Files
Jyotisha/frontend/tests/subject-route-birth.test.ts
T
jesse-ux ab6c55f822 feat: keep up to five chart subjects on typed columns
Chart, ephemeris, reports, daily language, consult, and synastry read birth data only through resolveSubjectBirth. The current person is a request parameter outside Home(). /people replaces the settings chart pane, and deleting someone removes that person's chats and reports.
2026-09-25 04:19:05 +08:00

289 lines
12 KiB
TypeScript

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<string, unknown> & { selfIds?: string[]; otherIds?: string[] };
upstream: Array<{ url: string; body: Record<string, unknown> }>;
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), /不会扣点/);
});