import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; const frontendRoot = fileURLToPath(new URL("../", import.meta.url)); const otherId = "11111111-1111-4111-8111-111111111111"; const selfRow = { name: "虚构甲", birth_date: "1991-04-07", reported_birth_time: "09:15:00", birth_time_status: "reported", latitude: 31.2, longitude: 121.4, timezone_offset: 8, timezone_id: "Asia/Shanghai", ayanamsa: "lahiri", }; const otherRow = { id: otherId, user_id: "user-1", role: "other", name: "虚构乙", birth_date: "1988-03-04", reported_birth_time: "07:40:00", birth_time_status: "reported", latitude: 22.3, longitude: 114.1, timezone_offset: 8, timezone_id: "Asia/Shanghai", ayanamsa: "raman", }; type RouteResult = { status: number; body: Record & { selfIds?: string[]; otherIds?: string[] }; upstream: Array<{ url: string; body: Record }>; charged: boolean; filters: string[]; missingStatus: number; missingBody: { billed?: boolean; status?: string }; upstreamBeforeMissing: number; selfReportIds: string[]; otherReportIds: string[]; }; function runRoute(script: string): RouteResult { const result = spawnSync(process.execPath, [ "--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script, ], { cwd: frontendRoot, encoding: "utf8" }); assert.equal(result.status, 0, result.stderr || result.stdout); const line = result.stdout.trim().split("\n").at(-1) ?? "{}"; return JSON.parse(line) as RouteResult; } test("chart view sends the other person's birth to the engine and fails closed without billing", () => { const script = String.raw` import { mock } from "node:test"; import { pathToFileURL } from "node:url"; const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; const selfRow = ${JSON.stringify(selfRow)}; const otherRow = ${JSON.stringify(otherRow)}; const otherId = ${JSON.stringify(otherId)}; mock.module("server-only", { namedExports: {} }); mock.module("@/lib/request-rate-limit", { namedExports: { consumeRequestRateLimit: () => ({ ok: true }), consumeUserRequestRateLimit: () => ({ ok: true }), } }); mock.module("@/lib/consultation-billing", { namedExports: { authorizeUsage() { throw new Error("must not charge"); }, completeUsage() { throw new Error("must not charge"); }, releaseUsage() {}, } }); let mode = "found"; mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => ({ auth: { getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }) }, from: (table) => ({ select() { return this; }, eq() { return this; }, async maybeSingle() { return { data: table === "chart_profiles" ? (mode === "found" ? otherRow : null) : selfRow, error: null }; }, }), }), } }); const upstream = []; globalThis.fetch = async (inputUrl, init) => { upstream.push({ url: String(inputUrl), body: init?.body ? JSON.parse(String(init.body)) : {} }); return new Response("{}", { status: 500, headers: { "content-type": "application/json" } }); }; const { GET } = await import(moduleUrl("src/app/api/chart-view/route.ts")); const found = await GET(new Request("https://staging.jyotisha.chat/api/chart-view?subject=" + otherId)); const foundBody = await found.json(); mode = "missing"; const upstreamBeforeMissing = upstream.length; const missing = await GET(new Request("https://staging.jyotisha.chat/api/chart-view?subject=" + otherId)); const missingBody = await missing.json(); console.log(JSON.stringify({ status: found.status, body: foundBody, upstream, charged: false, filters: [], missingStatus: missing.status, missingBody, upstreamBeforeMissing, })); `; const result = runRoute(script); const natal = result.upstream.find((item) => item.body.year === 1988); assert.ok(natal); assert.equal(natal?.body.lat, 22.3); assert.equal(result.upstream.some((item) => item.body.year === 1991), false); assert.equal(result.upstream.length, result.upstreamBeforeMissing); assert.equal(result.missingStatus, 409); assert.equal(result.missingBody.billed, false); assert.equal(result.missingBody.status, "chart_unavailable"); }); test("session and report lists keep legacy self rows and hide another person", () => { const script = String.raw` import { mock } from "node:test"; import { pathToFileURL } from "node:url"; const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; const otherId = ${JSON.stringify(otherId)}; const rows = [ { id: "legacy", chart_profile_id: null, title: "本人存量" }, { id: "self-text", chart_profile_id: "self", title: "本人文本" }, { id: "other-row", chart_profile_id: otherId, title: "虚构乙" }, ]; const filters = []; function query() { let pinned = false; let subject = "self"; const q = { select() { return q; }, eq(column, value) { filters.push("eq:" + column + "=" + String(value)); if (column === "pinned") pinned = value === true; if (column === "chart_profile_id") subject = String(value); return q; }, is(column, value) { filters.push("is:" + column + "=" + String(value)); if (column === "chart_profile_id" && value == null) subject = "self-null"; return q; }, or(expression) { filters.push("or:" + expression); if (String(expression).includes("chart_profile_id.is.null")) subject = "self"; return q; }, order() { return q; }, limit() { return q; }, in() { return q; }, then(resolve, reject) { const data = pinned ? [] : rows.filter((row) => { if (subject === "self") return row.chart_profile_id == null || row.chart_profile_id === "self"; if (subject === "self-null") return row.chart_profile_id == null; return row.chart_profile_id === subject; }); return Promise.resolve({ data, error: null }).then(resolve, reject); }, }; return q; } mock.module("server-only", { namedExports: {} }); mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => ({ auth: { getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }) }, from: () => query(), }), } }); const sessions = await import(moduleUrl("src/app/api/sessions/route.ts")); const selfResponse = await sessions.GET(new Request("https://staging.jyotisha.chat/api/sessions?subject=self")); const selfBody = await selfResponse.json(); const otherResponse = await sessions.GET(new Request("https://staging.jyotisha.chat/api/sessions?subject=" + otherId)); const otherBody = await otherResponse.json(); console.log(JSON.stringify({ status: selfResponse.status, body: { selfIds: selfBody.sessions.map((row) => row.id), otherIds: otherBody.sessions.map((row) => row.id) }, upstream: [], charged: false, filters, })); `; const result = runRoute(script); assert.deepEqual(result.body.selfIds, ["legacy", "self-text"]); assert.deepEqual(result.body.otherIds, ["other-row"]); assert.ok(result.filters.some((item) => item.includes("chart_profile_id.is.null"))); assert.ok(result.filters.some((item) => item === `eq:chart_profile_id=${otherId}`)); }); test("a missing report subject fails closed before billing", () => { const script = String.raw` import { mock } from "node:test"; import { pathToFileURL } from "node:url"; const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; const otherId = ${JSON.stringify(otherId)}; let charged = false; mock.module("server-only", { namedExports: {} }); mock.module("@/mastra", { namedExports: { runConsultationWorkflow() { throw new Error("mastra"); } } }); mock.module("@/mastra/personal-report", { namedExports: { PersonalReportAgentOutputError: class PersonalReportAgentOutputError extends Error {}, createPersonalReportAgent() { throw new Error("agent"); }, } }); mock.module("@/lib/product-access", { namedExports: { isProductEnabled: async () => true } }); mock.module("@/lib/consultation-billing", { namedExports: { authorizeUsage() { charged = true; throw new Error("must not charge"); }, completeUsage() { charged = true; }, releaseUsage() {}, } }); mock.module("@/lib/supabase/admin", { namedExports: { createAdminSupabaseClient: () => ({}) } }); mock.module("@/lib/personal-report-service", { namedExports: { createSupabasePersonalReportService: () => ({}), createPersonalReportDataClient: () => ({}), } }); const reports = [ { id: "legacy-report", chart_profile_id: null, status: "ready", request_id: "r1", created_at: "2026-09-01T00:00:00Z" }, { id: "other-report", chart_profile_id: otherId, status: "ready", request_id: "r2", created_at: "2026-09-02T00:00:00Z" }, ]; function reportQuery() { let subject = "self"; const q = { select() { return q; }, eq(column, value) { if (column === "chart_profile_id") subject = String(value); return q; }, is(column, value) { if (column === "chart_profile_id" && value == null) subject = "self-null"; return q; }, order() { return q; }, limit() { return q; }, in() { return q; }, maybeSingle() { return Promise.resolve({ data: null, error: null }); }, then(resolve, reject) { const data = reports.filter((row) => subject === "self-null" ? row.chart_profile_id == null : row.chart_profile_id === subject); return Promise.resolve({ data, error: null }).then(resolve, reject); }, }; return q; } mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => ({ auth: { getUser: async () => ({ data: { user: { id: "user-1" } }, error: null }) }, from: () => reportQuery(), }), } }); const reportsRoute = await import(moduleUrl("src/app/api/reports/route.ts")); const selfList = await reportsRoute.GET(new Request("https://staging.jyotisha.chat/api/reports?subject=self")); const selfListBody = await selfList.json(); const otherList = await reportsRoute.GET(new Request("https://staging.jyotisha.chat/api/reports?subject=" + otherId)); const otherListBody = await otherList.json(); const { POST } = reportsRoute; const response = await POST(new Request("https://staging.jyotisha.chat/api/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chartProfileId: otherId, depth: "standard", themes: ["general"] }), })); console.log(JSON.stringify({ status: response.status, body: await response.json(), upstream: [], charged, filters: [], selfReportIds: (selfListBody.reports ?? []).map((row) => row.id), otherReportIds: (otherListBody.reports ?? []).map((row) => row.id), })); `; const result = runRoute(script); assert.deepEqual(result.selfReportIds, ["legacy-report"]); assert.deepEqual(result.otherReportIds, ["other-report"]); assert.equal(result.status, 409); assert.equal(result.charged, false); assert.match(JSON.stringify(result.body), /不会扣点/); });