fix(chart): keep subject-scoped failures visible and follow the new routes
Independent Staging Quality Gate / validate (push) Successful in 10m5s
Independent Staging Quality Gate / publish (push) Successful in 3m23s

A 401 for the current person was stored, then hidden because the subject stamp only happened on success. Session, report, and birth contracts now follow the subject query and the shared birth lookup. Admin reset fixtures can no longer insert a retired self mirror.
This commit is contained in:
jesse-ux
2026-09-25 04:51:49 +08:00
parent 45842361f3
commit fabe0114dd
12 changed files with 28 additions and 15 deletions
+12 -3
View File
@@ -255,7 +255,10 @@ const ephemerisCache = createMemoryCache<EphemerisPageSnapshot>(async () => {
headers: { Accept: "application/json" },
cache: "no-store",
});
if (response.status === 401) return { kind: "unauthorized" };
if (response.status === 401) {
ephemerisSubject = subjectId;
return { kind: "unauthorized" };
}
const json: unknown = await response.json().catch(() => null);
const payload = parseEphemerisOkResponse(json);
if (!payload) throw new Error("ephemeris_page_bad_payload");
@@ -304,8 +307,14 @@ const reportsCache = createMemoryCache<ReportsPageSnapshot>(async () => {
});
const json: unknown = await response.json().catch(() => null);
if (readCurrentSubjectId() !== subjectId) throw new Error("reports_subject_changed");
if (response.status === 401) return { kind: "unauthorized" };
if (!response.ok) return { kind: "error", reports: [] };
if (response.status === 401) {
reportsSubject = subjectId;
return { kind: "unauthorized" };
}
if (!response.ok) {
reportsSubject = subjectId;
return { kind: "error", reports: [] };
}
reportsSubject = subjectId;
return { kind: "ready", reports: readReports(json) };
});
+1 -1
View File
@@ -442,6 +442,6 @@ test("the (app) layout mounts one sidebar for home and the four secondary routes
assert.equal(layout.match(/<SidebarProvider/g)?.length, 1);
assert.match(layout, /controls=\{registration\?\.controls\}/);
const provider = readFileSync(new URL("../src/lib/session-list-context.tsx", import.meta.url), "utf8");
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}&subject=\$\{encodeURIComponent\(readCurrentSubjectId\(\)\)\}`/);
assert.match(provider, /fetch\("\/api\/account"/);
});
+2 -1
View File
@@ -92,7 +92,8 @@ test("the chart-view BFF never bills and never calls a model", () => {
}
assert.match(routeSource, /export async function GET/);
assert.match(serviceSource, /createServerSupabaseClient/);
assert.match(serviceSource, /ACCOUNT_BIRTH_SELECT/);
assert.match(serviceSource, /loadSubjectBirth/);
assert.doesNotMatch(serviceSource, /clientBirth|body\.profile/);
assert.match(loadSource, /\/api\/chart/);
assert.match(loadSource, /\/api\/dasha\/chara/);
assert.match(loadSource, /\/api\/varga_full/);
@@ -320,7 +320,7 @@ test("rectification mutations report pending state while session-level return co
// 新值:onPendingChange: setRectificationMutationPending 写在 hook 的 panel
// 原因:mutationPending 仍由外壳对象持有,但 setter 经 panel 交给子树
assert.match(source, /onPendingChange: setRectificationMutationPending/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending\}/);
assert.match(source, /disabled=\{productEntrypointsDisabled \|\| rectificationLoading \|\| rectificationMutationPending \|\| !rectificationForSelf\}/);
assert.doesNotMatch(source, /重试恢复/);
assert.doesNotMatch(source, /返回并恢复原问题|返回首页/);
});
@@ -82,8 +82,9 @@ test("admin customer reset clears only rebuildable application state", async ()
insert into public.chat_sessions (user_id, title, theme, messages)
values ('${targetId}', 'Reset Chat', 'general', '[]'::jsonb);
-- 原值: role self。新值: other。原因: 本人镜像已停写,重置仍要清掉可重建的他人档案。
insert into public.chart_profiles (user_id, role, profile)
values ('${targetId}', 'self', '{}'::jsonb);
values ('${targetId}', 'other', '{}'::jsonb);
insert into public.synastry_reports (user_id, partner_name, report)
values ('${targetId}', 'Partner', '{}'::jsonb);
insert into public.credit_transactions (user_id, transaction_type, amount, balance_after, request_id)
+2 -1
View File
@@ -162,7 +162,8 @@ test("bootstrap enters the prepare phase instead of revealing after the account
// 原值: 3 个 prepare 守卫(onboarding / 今日星语 / 入口摘要)。
// 新值: 2 个(今日星语 / 入口摘要)。
// 原因: 建议问题请求已删除。
const guards = page.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? [];
const binder = readFileSync(new URL("../src/components/daily-starlanguage-binder.tsx", import.meta.url), "utf8");
const guards = `${page}\n${binder}`.match(/if \(bootstrapPhase === "account" \|\| !accountId/g) ?? [];
assert.equal(guards.length, 2, "daily starlanguage and entry summary must both key off bootstrapPhase");
assert.match(page, /setRectificationEntrySummarySettled\(true\);/);
assert.match(page, /bootstrapRevealDelayMs\(Date\.now\(\), prepareStartedAt\.current, bootstrapPrepareReady\)/);
+2 -2
View File
@@ -1298,7 +1298,7 @@ function seedRecord(): PersonalReportRecord {
test("POST route uses dual clients: authenticated reads + admin persistence", () => {
assert.match(createRoute, /createServerSupabaseClient\(\)/);
assert.match(createRoute, /\.from\("profiles"\)/);
assert.match(createRoute, /loadSubjectBirth/);
assert.match(createRoute, /createAdminSupabaseClient\(\)/);
assert.match(createRoute, /createSupabasePersonalReportService\(admin\)/);
assert.match(createRoute, /createPersonalReportDataClient\(admin\)/);
@@ -1358,7 +1358,7 @@ test("POST enqueues durable work without Next.js after and GET lists metadata wi
assert.match(createRoute, /jobs:\s*createSupabasePersonalReportJobService\(admin\)/);
assert.doesNotMatch(createRoute, /\bafter\s*\(/);
assert.doesNotMatch(createRoute, /deferGeneration/);
assert.match(createRoute, /export async function GET\(\)/);
assert.match(createRoute, /export async function GET\(request: Request\)/);
assert.match(createRoute, /REPORT_LIST_COLUMNS/);
const listColumns = createRoute.slice(
createRoute.indexOf("const REPORT_LIST_COLUMNS"),
+1 -1
View File
@@ -80,7 +80,7 @@ test("mounted report center removes deleted row and an older GET cannot resurrec
writeReportsPage({ kind: "ready", reports: [row] });
globalThis.fetch = async (url, options) => {
if (options?.method === "DELETE") return new Response(null, { status: 204 });
if (url === "/api/reports") { gets += 1; return stale.promise; }
if (String(url).startsWith("/api/reports")) { gets += 1; return stale.promise; }
return Response.json({}, { status: 401 });
};
try {
+1 -1
View File
@@ -97,7 +97,7 @@ test("a warm chart cache does not go through a null waiting state", async () =>
assert.notEqual(warm, null);
const secondMountView = peekChartPage()?.kind === "view" ? peekChartPage() : null;
assert.equal(secondMountView?.kind, "view");
assert.match(chartHook, /useState<ChartViewResponse \| null>\(\(\) => viewFromSnapshot\(peekChartPage\(\)\)\)/);
assert.match(chartHook, /useState<ChartViewResponse \| null>\(\(\) => viewMatchingSubject\(subjectId\)\)/);
});
test("chart / ephemeris / reports caches share one inflight fetch and keep the last result", async () => {
@@ -91,7 +91,7 @@ test("daily and synastry routes share a literal account-birth select string", ()
const subjectBirth = readFileSync(new URL("../src/lib/subject-birth.ts", import.meta.url), "utf8");
assert.match(subjectBirth, /select\(ACCOUNT_BIRTH_SELECT\)/);
assert.match(daily, /loadSubjectBirth/);
assert.match(synastry, /select\(ACCOUNT_BIRTH_SELECT\)/);
assert.match(synastry, /loadSubjectBirth/);
assert.doesNotMatch(daily, /accountBirthColumns/);
assert.doesNotMatch(synastry, /accountBirthColumns/);
assert.doesNotMatch(daily, /\.join\(",\"\)/);
+1 -1
View File
@@ -18,7 +18,7 @@ test("the app layout owns one SessionListProvider and one SidebarProvider", () =
});
test("the provider fetches the session list once and home does not fetch it again", () => {
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}&subject=\$\{encodeURIComponent\(readCurrentSubjectId\(\)\)\}`/);
assert.match(provider, /fetch\("\/api\/account"/);
// 原值: page.tsx 里 `await sessionListReady;`
// 新值: page.tsx 把 sessionListReady 交给 runHomeBootstrap,函数里 `await deps.sessionListReady`。
+2 -1
View File
@@ -8,7 +8,8 @@ const overlay = readFileSync(new URL("../src/components/account-dialog-overlay.t
test("account menu exposes the four MVP settings sections", () => {
assert.match(sidebar, /<span>个人资料<\/span>/);
assert.match(sidebar, /<span>星盘资料<\/span>/);
assert.match(sidebar, /href: "\/people", label: "星盘档案"/);
assert.doesNotMatch(sidebar, /<span>星盘资料<\/span>/);
assert.match(sidebar, /<span>通用设置<\/span>/);
assert.match(sidebar, /<span>账户与点数<\/span>/);
assert.match(sidebar, /onOpenGeneral/);