2026-05-15 00:58:40 +08:00
import fs from "node:fs" ;
import path from "node:path" ;
function assert ( condition : unknown , message : string ) {
if ( ! condition ) throw new Error ( message );
}
export function runTests() {
const projectRoot = process . cwd ();
const accountCenterPath = path . join (
projectRoot ,
"components" ,
"account" ,
"AccountCenter.tsx" ,
);
const serviceWorkerPath = path . join ( projectRoot , "public" , "sw.js" );
const accountCenterSource = fs . readFileSync ( accountCenterPath , "utf8" );
2026-05-23 23:30:48 +08:00
const accountDir = path . dirname ( accountCenterPath );
const accountSplitFiles = [
"types.ts" ,
"constants.ts" ,
"formatters.ts" ,
"wallet.ts" ,
"payment-utils.ts" ,
"AccountInfoRow.tsx" ,
"account-copy.ts" ,
"usePaymentState.ts" ,
];
for ( const file of accountSplitFiles ) {
assert (
fs . existsSync ( path . join ( accountDir , file )),
`AccountCenter split file must exist: components/account/ ${ file } ` ,
);
}
const accountFeatureSource = [
"AccountCenter.tsx" ,
"account-copy.ts" ,
"wallet.ts" ,
"payment-utils.ts" ,
"usePaymentState.ts" ,
2026-05-25 03:05:39 +08:00
"useWalletBind.ts" ,
"usePaymentFlow.ts" ,
"useBilling.ts" ,
2026-05-23 23:30:48 +08:00
]
. map (( file ) => fs . readFileSync ( path . join ( accountDir , file ), "utf8" ))
. join ( "\n" );
assert (
accountCenterSource . split ( /\r?\n/ ). length < 3200 ,
"AccountCenter.tsx must stay below 3200 lines after extracting account helpers" ,
);
assert (
accountCenterSource . includes ( 'import { createAccountCopy } from "./account-copy";' ) &&
accountCenterSource . includes ( 'const copy = useMemo(() => createAccountCopy(isEn), [isEn]);' ),
"AccountCenter copy text must be centralized in account-copy.ts instead of an inline 170+ line object" ,
);
2026-05-25 02:38:20 +08:00
const hookPath = path . join ( accountDir , "useAccountPayment.ts" );
const hookSource = fs . existsSync ( hookPath )
? fs . readFileSync ( hookPath , "utf8" )
: "" ;
2026-05-30 14:09:57 +08:00
const paymentFlowSource = fs . readFileSync (
path . join ( accountDir , "usePaymentFlow.ts" ),
"utf8" ,
);
2026-05-23 23:30:48 +08:00
assert (
2026-05-25 02:38:20 +08:00
( accountCenterSource . includes ( 'import { usePaymentState } from "./usePaymentState";' ) ||
hookSource . includes ( 'import { usePaymentState } from "./usePaymentState";' )) &&
( accountCenterSource . includes ( "clearPaymentState" ) ||
hookSource . includes ( "clearPaymentState" )) &&
( accountCenterSource . includes ( "clearPaymentMessages" ) ||
hookSource . includes ( "clearPaymentMessages" )),
2026-05-23 23:30:48 +08:00
"payment UI state reset/message helpers must be centralized in usePaymentState.ts" ,
);
2026-05-15 00:58:40 +08:00
const serviceWorkerSource = fs . readFileSync ( serviceWorkerPath , "utf8" );
2026-05-20 20:13:18 +08:00
const appAnalyticsSource = fs . readFileSync (
path . join ( projectRoot , "lib" , "app-analytics.ts" ),
"utf8" ,
);
const analyticsRouteSource = fs . readFileSync (
path . join ( projectRoot , "app" , "api" , "analytics" , "events" , "route.ts" ),
"utf8" ,
);
2026-05-20 22:23:28 +08:00
const grantRouteSource = fs . readFileSync (
path . join (
projectRoot ,
"app" ,
"api" ,
"ops" ,
"subscriptions" ,
"grant" ,
"route.ts" ,
),
"utf8" ,
);
2026-05-28 12:18:43 +08:00
const authMeRouteSource = fs . readFileSync (
path . join ( projectRoot , "app" , "api" , "auth" , "me" , "route.ts" ),
"utf8" ,
);
2026-05-29 17:22:33 +08:00
const paymentConfigRouteSource = fs . readFileSync (
path . join ( projectRoot , "app" , "api" , "payments" , "config" , "route.ts" ),
"utf8" ,
);
const paymentRuntimeRouteSource = fs . readFileSync (
path . join ( projectRoot , "app" , "api" , "payments" , "runtime" , "route.ts" ),
"utf8" ,
);
const backendAuthSource = fs . readFileSync (
path . join ( projectRoot , "lib" , "backend-auth.ts" ),
"utf8" ,
);
const opsAdminSource = fs . readFileSync (
path . join ( projectRoot , "lib" , "ops-admin.ts" ),
"utf8" ,
);
const supabaseServerSource = fs . readFileSync (
path . join ( projectRoot , "lib" , "supabase" , "server.ts" ),
"utf8" ,
);
2026-05-20 21:08:54 +08:00
const subscriptionsPageSource = fs . readFileSync (
path . join (
projectRoot ,
"components" ,
"ops" ,
"subscriptions" ,
"SubscriptionsPageClient.tsx" ,
),
"utf8" ,
);
2026-05-29 17:22:33 +08:00
const membershipsPageSource = fs . readFileSync (
path . join (
projectRoot ,
"components" ,
"ops" ,
"memberships" ,
"MembershipsPageClient.tsx" ,
),
"utf8" ,
);
const opsOverviewSource = fs . readFileSync (
path . join (
projectRoot ,
"components" ,
"ops" ,
"overview" ,
"OverviewPageClient.tsx" ,
),
"utf8" ,
);
2026-05-15 00:58:40 +08:00
assert (
accountCenterSource . includes (
'import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";' ,
),
"checkout overlay must be in the account bundle, not lazy-loaded after the user clicks pay" ,
);
assert (
! /const\s+UnlockProOverlay\s*=\s*dynamic\s*\(/ . test ( accountCenterSource ),
"checkout overlay must not be dynamically imported; stale deployments can make the lazy chunk fail at pay time" ,
);
assert (
! /STATIC_ASSETS\s*=\s*\[[^\]]*["']\/_next\//s.test(serviceWorkerSource),
"service worker must not cache-first the whole /_next/ tree; stale chunks break checkout after deploys" ,
);
2026-05-20 19:48:13 +08:00
assert (
! /label\.toLowerCase\(\)\.includes\(["']binance["']\)\)\s*return/ . test (
2026-05-23 23:30:48 +08:00
accountFeatureSource ,
2026-05-20 19:48:13 +08:00
),
"Binance Web3 Wallet injected provider must remain available for browser-extension binding" ,
);
assert (
2026-05-23 23:30:48 +08:00
accountFeatureSource . includes ( "Binance 扩展已绑定" ) &&
accountFeatureSource . includes ( "如支付卡住,请优先使用 WalletConnect 扫码支付" ),
2026-05-20 19:48:13 +08:00
"Binance extension binding must show a WalletConnect fallback hint for payment stability" ,
);
2026-05-20 20:00:32 +08:00
assert (
2026-05-23 23:30:48 +08:00
accountFeatureSource . includes ( "钱包里需要少量 POL 作为 gas 手续费" ) &&
accountFeatureSource . includes ( "只有 USDC 可能无法完成授权或支付" ),
2026-05-21 13:09:21 +08:00
"payment wallet tab must warn users that Polygon POL gas is required in addition to USDC" ,
2026-05-20 20:00:32 +08:00
);
2026-05-29 21:23:12 +08:00
assert (
! accountCenterSource . includes ( "md:w-96" ),
"account payment management must not use a narrow fixed sidebar that creates an overlong column" ,
);
assert (
accountCenterSource . includes ( "items-start" ) &&
accountCenterSource . includes ( "xl:grid-cols-[minmax(0,0.9fr)_minmax(620px,1.1fr)]" ),
"account secondary sections must align cards to the top and give payment management a wider responsive column" ,
);
assert (
accountCenterSource . includes ( "data-testid=\"payment-management-grid\"" ) &&
2026-05-29 21:34:36 +08:00
accountCenterSource . includes ( 'hasTelegramPanel ? "" : "lg:grid-cols-[minmax(0,1fr)_minmax(320px,380px)]"' ) &&
accountCenterSource . includes ( "data-testid=\"payment-guard-grid\"" ) &&
accountCenterSource . includes ( 'hasTelegramPanel ? "" : "sm:grid-cols-2"' ),
"payment management must only split into internal columns when it is not already sharing the row with a Telegram panel" ,
2026-05-29 21:23:12 +08:00
);
2026-05-20 20:13:18 +08:00
assert (
! appAnalyticsSource . includes ( 'NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"' ) &&
! analyticsRouteSource . includes ( 'NEXT_PUBLIC_POLYWEATHER_APP_ANALYTICS === "true"' ),
"app analytics must be enabled by default so ops funnel can collect data without a fragile production env flag" ,
);
assert (
accountCenterSource . includes ( 'trackAppEvent("signup_completed"' ) &&
accountCenterSource . includes ( 'trackAppEvent("dashboard_active"' ),
"account center must emit signup_completed and dashboard_active so the ops funnel has top-of-funnel data" ,
);
2026-05-30 19:50:50 +08:00
assert (
appAnalyticsSource . includes ( '| "landing_view"' ) &&
appAnalyticsSource . includes ( '| "enter_terminal"' ) &&
appAnalyticsSource . includes ( '| "login_start"' ) &&
appAnalyticsSource . includes ( '| "signup_success"' ) &&
appAnalyticsSource . includes ( '| "trial_created"' ) &&
appAnalyticsSource . includes ( '| "payment_start"' ) &&
appAnalyticsSource . includes ( '| "payment_success"' ),
"app analytics must expose the standard growth funnel event names" ,
);
assert (
accountCenterSource . includes ( 'trackAppEvent("signup_success"' ) &&
accountCenterSource . includes ( 'trackAppEvent("trial_created"' ) &&
accountCenterSource . includes ( "subscription_is_trial" ),
"account center must emit signup_success and trial_created for the standard funnel" ,
);
assert (
paymentFlowSource . includes ( 'trackAppEvent("payment_start"' ) &&
paymentFlowSource . includes ( 'trackAppEvent("payment_success"' ),
"payment flow must emit payment_start and payment_success alongside legacy checkout events" ,
);
2026-05-27 12:14:07 +08:00
assert (
accountCenterSource . includes ( "isSubscriptionUnknown" ) &&
accountCenterSource . includes ( "subscriptionStatusLabel" ) &&
! accountCenterSource . includes (
'backend?.subscription_active);' ,
),
"account center must distinguish unknown subscription sync state from a confirmed unsubscribed account" ,
);
assert (
hookSource . includes ( "backendJson.authenticated === false" ) &&
hookSource . includes ( "refreshSession()" ) &&
hookSource . includes ( "retriedBackendJson" ),
"account snapshot loader must retry with a refreshed Supabase token when local user exists but /api/auth/me reports unauthenticated" ,
);
2026-05-30 16:26:32 +08:00
assert (
hookSource . includes ( "refreshEntitlementAfterPayment" ) &&
paymentFlowSource . includes ( "refreshEntitlementAfterPayment" ) &&
paymentFlowSource . includes ( "await refreshEntitlementAfterPayment();" ) &&
hookSource . includes ( "subscription_active === true" ),
"successful payment flows must automatically poll /api/auth/me until the paid subscription is visible instead of requiring logout or manual refresh" ,
);
2026-05-29 17:22:33 +08:00
assert (
! hookSource . includes ( ".auth.getUser()" ) &&
hookSource . includes ( ".auth.getSession()" ),
"account snapshot loader must use the local Supabase session instead of calling getUser before /api/auth/me validates the bearer" ,
);
2026-05-20 21:08:54 +08:00
assert (
subscriptionsPageSource . includes ( "getSupabaseBrowserClient" ) &&
2026-05-20 21:18:59 +08:00
subscriptionsPageSource . includes ( "refreshSession" ) &&
2026-05-20 21:08:54 +08:00
subscriptionsPageSource . includes ( "Authorization" ) &&
subscriptionsPageSource . includes ( "/api/ops/subscriptions/grant" ) &&
subscriptionsPageSource . includes ( "/api/ops/subscriptions/extend" ),
"ops manual subscription grant/extend must send the Supabase bearer token to avoid 401 when route cookies are stale" ,
);
2026-05-29 17:22:33 +08:00
assert (
membershipsPageSource . includes ( "opsApi.membershipsOverview" ) &&
! membershipsPageSource . includes ( 'fetch("/api/ops/memberships/growth?days=90"' ) &&
! membershipsPageSource . includes ( "Promise.all" ),
"ops memberships page must load memberships and growth through one overview proxy request to avoid duplicate Supabase session/subscription reads" ,
);
assert (
opsOverviewSource . includes ( "opsApi.membershipsOverview(200, 30)" ) &&
! opsOverviewSource . includes ( "opsApi.memberships()" ) &&
! opsOverviewSource . includes ( "opsApi.membershipsGrowth(30)" ),
"ops overview page must reuse membershipsOverview for table and growth data instead of issuing separate membership/growth proxy requests" ,
);
2026-05-20 22:23:28 +08:00
assert (
grantRouteSource . includes ( "grantSubscriptionDirectly" ) &&
grantRouteSource . includes ( "res.status === 404" ) &&
2026-05-29 17:22:33 +08:00
grantRouteSource . includes ( '"status": "active"' ) &&
grantRouteSource . includes ( "/rest/v1/profiles" ) &&
grantRouteSource . includes ( "select=id&email=eq." ) &&
grantRouteSource . indexOf ( "/rest/v1/profiles" ) <
grantRouteSource . indexOf ( "/auth/v1/admin/users" ) &&
grantRouteSource . includes ( 'Prefer: "return=minimal"' ),
"ops subscription grant route must fall back to direct Supabase grant and resolve users via indexed profiles before Auth Admin" ,
2026-05-20 22:23:28 +08:00
);
2026-05-28 12:18:43 +08:00
assert (
2026-05-30 21:01:48 +08:00
authMeRouteSource . includes ( "isSubscriptionRequiredBackendResponse(res.status, raw)" ) &&
authMeRouteSource . includes ( "subscriptionRequiredAuthProfileResponse" ) &&
2026-05-30 15:06:22 +08:00
authMeRouteSource . includes ( "degradedAuthProfileResponse" ) &&
authMeRouteSource . includes ( "reason: `backend_${res.status}`" ) &&
2026-05-28 12:18:43 +08:00
authMeRouteSource . includes ( "subscription_active: null" ),
2026-05-30 21:01:48 +08:00
"auth profile proxy must only convert explicit subscription-required 403 responses to inactive access while preserving unrelated backend 401/403 responses as unknown subscription" ,
2026-05-28 12:18:43 +08:00
);
2026-05-29 17:22:33 +08:00
assert (
( authMeRouteSource . match ( /buildBackendRequestHeaders\(req\)/g ) || []). length === 1 &&
authMeRouteSource . includes ( "let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null" ),
"auth profile proxy must build backend auth headers once and reuse them on timeout/error fallback" ,
);
assert (
authMeRouteSource . includes ( "hasSupabaseServerEnv()" ) &&
authMeRouteSource . includes ( "!auth.authUserId" ) &&
authMeRouteSource . includes ( 'req.headers.get("authorization")' ) &&
authMeRouteSource . indexOf ( "authenticated: false" ) <
authMeRouteSource . indexOf ( "await fetch(`${API_BASE}/api/auth/me`" ),
"auth profile proxy must return unauthenticated locally for no-session Supabase requests instead of forwarding the backend entitlement token" ,
);
2026-05-30 21:08:19 +08:00
assert (
authMeRouteSource . includes ( 'reason: "prefer_snapshot_fast_path"' ) &&
authMeRouteSource . indexOf ( 'reason: "prefer_snapshot_fast_path"' ) <
authMeRouteSource . indexOf ( "buildBackendRequestHeaders(req)" ),
"auth profile proxy must return a valid entitlement snapshot before reading Supabase session cookies on terminal cold start" ,
);
2026-05-29 17:22:33 +08:00
assert (
backendAuthSource . includes ( "if (incomingAuth) {" ) &&
backendAuthSource . indexOf ( "if (incomingAuth) {" ) <
backendAuthSource . indexOf ( "const supabase = createSupabaseRouteClient" ),
"backend proxy must forward caller bearer tokens before creating a Supabase route client to avoid duplicate getUser calls" ,
);
assert (
backendAuthSource . includes ( "headers.set(FORWARDED_SUPABASE_USER_ID_HEADER" ) &&
backendAuthSource . includes ( "headers.set(FORWARDED_SUPABASE_EMAIL_HEADER" ) &&
backendAuthSource . indexOf ( "headers.set(FORWARDED_SUPABASE_USER_ID_HEADER" ) >
backendAuthSource . indexOf ( "const sessionUser = session?.user" ),
"backend proxy must forward Supabase session user id/email with the backend token so Python can skip duplicate /auth/v1/user validation" ,
);
assert (
backendAuthSource . includes ( "function hasSupabaseSessionCookie" ) &&
backendAuthSource . includes ( "String(cookie.value || \"\").trim()" ) &&
backendAuthSource . includes ( "if (!hasSupabaseSessionCookie(request))" ) &&
backendAuthSource . indexOf ( "if (!hasSupabaseSessionCookie(request))" ) <
backendAuthSource . indexOf ( "const supabase = createSupabaseRouteClient" ),
"backend proxy must skip Supabase route client/session reads when no auth cookie is present" ,
);
assert (
! backendAuthSource . includes ( ".auth.getUser()" ) &&
backendAuthSource . includes ( ".auth.getSession()" ),
"backend proxy must not call Supabase getUser before forwarding a bearer token that the backend validates again" ,
);
assert (
supabaseServerSource . includes ( "export function hasSupabaseSessionCookieValues" ) &&
supabaseServerSource . includes ( 'name === "supabase-auth-token"' ) &&
supabaseServerSource . includes ( 'name.startsWith("sb-")' ) &&
supabaseServerSource . indexOf ( "if (!hasSupabaseSessionCookieValues" ) <
supabaseServerSource . indexOf ( "const supabase = createSupabaseServerClient" ),
"Supabase server helpers must expose one cookie detector and skip refresh getUser calls when no session cookie exists" ,
);
assert (
supabaseServerSource . includes ( ".auth.getClaims()" ) &&
! supabaseServerSource . includes ( ".auth.getUser()" ),
"Supabase middleware refresh must validate JWTs with getClaims so asymmetric JWT projects avoid per-request /auth/v1/user reads" ,
);
assert (
opsAdminSource . includes ( "hasSupabaseSessionCookieValues" ) &&
opsAdminSource . indexOf ( "if (!hasSupabaseSessionCookieValues" ) <
opsAdminSource . indexOf ( "const supabase = createSupabaseServerClient" ),
"ops admin page gate must redirect before creating a Supabase client/getUser call when no session cookie exists" ,
);
assert (
opsAdminSource . includes ( ".auth.getClaims()" ) &&
! opsAdminSource . includes ( ".auth.getUser()" ) &&
opsAdminSource . includes ( "claims?.email" ),
"ops admin page gate must use verified JWT claims instead of a per-page getUser auth lookup" ,
);
assert (
paymentConfigRouteSource . includes ( "includeSupabaseIdentity: false" ),
"payment config proxy must not read Supabase session cookies for public cached config" ,
);
assert (
paymentRuntimeRouteSource . includes ( "includeSupabaseIdentity: false" ),
"payment runtime proxy must not read Supabase session cookies because backend entitlement token already protects the runtime status payload" ,
);
2026-05-30 14:09:57 +08:00
assert (
( paymentFlowSource . match ( /await buildAuthedHeaders\(true, true\);/g ) || [])
. length >= 3 ,
"manual payment mutations must require a valid Supabase bearer token instead of forwarding unauthenticated requests that surface raw backend JSON" ,
);
2026-05-31 01:56:08 +08:00
assert (
paymentFlowSource . includes ( "verifyPaymentAuthReady" ) &&
paymentFlowSource . indexOf ( "await verifyPaymentAuthReady()" ) <
paymentFlowSource . indexOf ( "resolvePaymentProvider(" ) &&
paymentFlowSource . includes ( "auth_confirmed_at" ),
"wallet checkout must confirm a fresh Supabase bearer before opening wallet prompts or creating payment intents" ,
);
2026-05-15 00:58:40 +08:00
}