Files
Jyotisha/frontend/tests/membership-navigation-contract.test.ts
T
jesse-uxandClaude Code 7a00145698
Independent Staging Quality Gate / validate (push) Failing after 16m28s
Independent Staging Quality Gate / publish (push) Skipped
test(membership): update balance notification contract
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 22:18:25 +08:00

90 lines
5.6 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const hookSource = readFileSync(new URL("../src/hooks/use-billing-panel.ts", import.meta.url), "utf8");
const panelSource = readFileSync(new URL("../src/components/billing-panel.tsx", import.meta.url), "utf8");
const billingSource = `${hookSource}\n${panelSource}`;
const nextConfig = readFileSync(new URL("../next.config.ts", import.meta.url), "utf8");
function countMatches(source: string, pattern: RegExp) {
return source.match(pattern)?.length ?? 0;
}
test("packages load on mount without a redundant account request", () => {
// 原值: 挂载并发 fetchAccountData() 与 fetchPackages(),并锁定 Promise.allSettled。
// 新值: 挂载只执行 fetchPackages(),账户余额来自首页 bootstrap;成功兑换/支付使用接口返回 credits 通过共享事件同步。
// 原因: BUG-1022 / TASK-home-slow-network D5,删除无用的完整 /api/account 请求,避免重复数据库读取。
assert.match(hookSource, /setTimeout\(\(\) => void fetchPackages\(\), 0\)/);
assert.doesNotMatch(hookSource, /Promise\.allSettled\(\[fetchAccountData\(\), fetchPackages\(\)\]\)/);
assert.doesNotMatch(hookSource, /fetchAccountData/);
assert.doesNotMatch(hookSource, /fetch\("\/api\/account"/);
assert.match(hookSource, /notifyBalanceChanged\(result\.credits\)/);
// 原值: 套餐支付成功契约精确断言 `notifyBalanceChanged(payload.credits)`。
// 新值: 支付成功无条件调用 `notifyBalanceChanged(typeof payload.credits === "number" ? payload.credits : 0)`。
// 原因: 订阅响应可能缺少 credits,仍需广播余额刷新事件;未知载荷使用 0,服务端刷新保持权威。
assert.match(hookSource, /notifyBalanceChanged\(typeof payload\.credits === "number" \? payload\.credits : 0\)/);
});
test("a failing packages request cannot hide the account summary", () => {
// 原值: 账户与套餐并发加载,各自错误状态均需保留。
// 新值: 账户来自首页 bootstrap;账单挂载只请求套餐,失败仅显示套餐错误,账户摘要仍可见。
// 原因: BUG-1022 / T2 删除重复账户请求,避免为已不存在的请求保留错误契约。
assert.doesNotMatch(hookSource, /setAccountError\(caught instanceof Error/);
assert.match(hookSource, /setPackagesError\("套餐支付暂时不可用,请稍后重试"\)/);
// 原值: 摘要在整页 hero,失败时显示「正在读取…」
// 新值: 弹窗打开时账户已在内存,摘要直接渲染;套餐区失败只出 packagesError
// 原因: 决策 8,不得出现「正在加载」
assert.match(panelSource, /余额 \{credits\} 点/);
assert.match(panelSource, /\{packagesError && <p className="form-error" role="alert">\{packagesError\}<\/p>\}/);
assert.doesNotMatch(panelSource, /正在读取/);
});
test("membership and orders navigate through next/link instead of a document reload", () => {
// 原值: Link 在 /membership 与 /membership/orders 之间
// 新值: 页签切换;旧路径由 next.config redirects 接到 ?settings=billing
// 原因: 删除整页
assert.doesNotMatch(billingSource, /import Link from "next\/link"/);
assert.doesNotMatch(billingSource, /href="\/membership/);
assert.doesNotMatch(billingSource, /window\.location\.assign\("\/membership/);
assert.match(nextConfig, /source: "\/membership"/);
assert.match(panelSource, /value="orders"/);
});
test("401 redirects stay hard navigations so no client state survives the sign-out", () => {
assert.match(hookSource, /if \(response\.status === 401\) \{\s*window\.location\.assign\("\/login"\);/);
// 原值: 4 次认证硬跳转,包含已删除的挂载账户请求。
// 新值: 3 次,订单、兑换、支付状态仍在 401 时硬跳转登录。
// 原因: 删除重复 /api/account 请求后,认证行为保留在仍实际发起的请求上。
assert.equal(countMatches(hookSource, /window\.location\.assign\("\/login"\)/g), 3);
});
test("browser back and the external cashier keep their native behaviour", () => {
// 原值: goBack 的 history.back / assign("/")
// 新值: 无 goBack;收银台仍 window.open
// 原因: 账单在当前页,后退是浏览器原生;BUG-251 要求收银台新标签
assert.doesNotMatch(billingSource, /window\.history\.back\(\)/);
assert.match(hookSource, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
});
test("the hard navigation inventory does not grow", () => {
// 原值: 会员页 4 次 window.location. + 订单页 1 次。
// 新值: hook 仅 3 次 assign("/login"),面板 0 次。
// 原因: 删除重复账户请求后,仅订单、兑换、支付状态保留认证硬跳转。
assert.equal(countMatches(hookSource, /window\.location\./g), 3);
assert.equal(countMatches(panelSource, /window\.location\./g), 0);
});
test("payment polling pauses on a hidden tab and refreshes when it returns", () => {
assert.match(hookSource, /if \(!document\.hidden\) startPolling\(\);/);
assert.match(hookSource, /document\.addEventListener\("visibilitychange", onVisibilityChange\);/);
assert.match(hookSource, /if \(document\.hidden\) \{\s*stopPolling\(\);\s*return;\s*\}/);
assert.match(hookSource, /void checkPaymentStatus\(\);\s*startPolling\(\);/);
assert.match(hookSource, /window\.setInterval\(\(\) => void checkPaymentStatus\(\), 3000\);/);
});
test("polling cleanup releases both the timer and the visibility listener", () => {
assert.match(hookSource, /if \(timer\) window\.clearInterval\(timer\);/);
assert.match(hookSource, /stopPolling\(\);\s*document\.removeEventListener\("visibilitychange", onVisibilityChange\);/);
});