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.
289 lines
12 KiB
TypeScript
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), /不会扣点/);
|
|
});
|