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.
308 lines
13 KiB
TypeScript
308 lines
13 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { spawnSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import test from "node:test";
|
|
|
|
import { parseEphemerisOkResponse } from "../src/lib/ephemeris-contract.ts";
|
|
import {
|
|
eventsFromEngine,
|
|
natalHouseFromSigns,
|
|
panchangaFactorsFromEngine,
|
|
transitsFromEngine,
|
|
} from "../src/lib/ephemeris-view.ts";
|
|
|
|
const routeSource = readFileSync(new URL("../src/app/api/ephemeris/route.ts", import.meta.url), "utf8");
|
|
const viewSource = readFileSync(new URL("../src/lib/ephemeris-view.ts", import.meta.url), "utf8");
|
|
const frontendRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
const panchangaGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-panchanga-range.golden.json", import.meta.url), "utf8"));
|
|
const natalGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-natal.golden.json", import.meta.url), "utf8"));
|
|
const transitGolden = JSON.parse(readFileSync(new URL("./fixtures/ephemeris-chart-transit-2026-09-15.golden.json", import.meta.url), "utf8"));
|
|
|
|
const completeRow = {
|
|
name: "Example",
|
|
birth_date: "1990-04-09",
|
|
reported_birth_time: "13:24",
|
|
active_birth_time: "13:24",
|
|
birth_time: "13:24",
|
|
birth_time_status: "confirmed",
|
|
latitude: 31.19,
|
|
longitude: 121.44,
|
|
timezone_offset: 8,
|
|
timezone_id: "Asia/Shanghai",
|
|
ayanamsa: "raman",
|
|
};
|
|
|
|
function executeRoute(input: {
|
|
readonly user: boolean;
|
|
readonly row: Record<string, unknown> | null;
|
|
readonly other?: Record<string, unknown> | null;
|
|
readonly subject?: string;
|
|
readonly date?: string;
|
|
readonly dates?: readonly string[];
|
|
readonly eventsStatus?: number;
|
|
readonly abortOnFetch?: boolean;
|
|
}) {
|
|
const script = String.raw`
|
|
import { mock } from "node:test";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href;
|
|
const user = ${input.user ? "true" : "false"};
|
|
const row = ${JSON.stringify(input.row)};
|
|
const other = ${JSON.stringify(input.other ?? null)};
|
|
const subject = ${JSON.stringify(input.subject ?? "")};
|
|
const dates = ${JSON.stringify(input.dates ?? [input.date ?? "2026-09-15"])};
|
|
const eventsStatus = ${input.eventsStatus ?? 404};
|
|
const abortOnFetch = ${input.abortOnFetch ? "true" : "false"};
|
|
const fixture = (name) => JSON.parse(readFileSync(join(process.cwd(), "tests/fixtures", name), "utf8"));
|
|
const panchanga = fixture("ephemeris-panchanga-range.golden.json");
|
|
const natal = fixture("ephemeris-chart-natal.golden.json");
|
|
const transit = fixture("ephemeris-chart-transit-2026-09-15.golden.json");
|
|
|
|
let billingImported = false;
|
|
mock.module("@/lib/consultation-billing", {
|
|
namedExports: {
|
|
charge() { billingImported = true; throw new Error("billing must not run"); },
|
|
},
|
|
});
|
|
mock.module("@/mastra", {
|
|
namedExports: {
|
|
getAgent() { throw new Error("mastra must not run"); },
|
|
},
|
|
});
|
|
mock.module("@/lib/request-rate-limit", {
|
|
namedExports: {
|
|
consumeUserRequestRateLimit: () => ({ ok: true }),
|
|
},
|
|
});
|
|
mock.module("@/lib/daily-starlanguage", {
|
|
namedExports: {
|
|
calendarDateInTimeZone: () => "2026-09-15",
|
|
},
|
|
});
|
|
mock.module("@/lib/supabase/server", {
|
|
namedExports: {
|
|
createServerSupabaseClient: async () => ({
|
|
auth: { getUser: async () => ({ data: { user: user ? { id: "user-1" } : null }, error: null }) },
|
|
from: (table) => ({
|
|
select() { return this; },
|
|
eq() { return this; },
|
|
async maybeSingle() { return { data: table === "chart_profiles" ? other : row, error: null }; },
|
|
}),
|
|
}),
|
|
},
|
|
});
|
|
|
|
const upstream = [];
|
|
let signalAborted = null;
|
|
const controller = new AbortController();
|
|
globalThis.fetch = async (inputUrl, init) => {
|
|
const url = String(inputUrl);
|
|
const body = init?.body ? JSON.parse(String(init.body)) : {};
|
|
if (abortOnFetch && signalAborted === null) {
|
|
controller.abort();
|
|
signalAborted = Boolean(init?.signal?.aborted);
|
|
}
|
|
upstream.push({ url, body, signalAborted: Boolean(init?.signal?.aborted) });
|
|
if (url.endsWith("/api/panchanga_range")) {
|
|
return new Response(JSON.stringify(panchanga), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
if (url.endsWith("/api/chart")) {
|
|
const packet = Number(body.year) === 1990 ? natal : transit;
|
|
return new Response(JSON.stringify(packet), { status: 200, headers: { "content-type": "application/json" } });
|
|
}
|
|
if (url.endsWith("/api/ephemeris_events")) {
|
|
return new Response(JSON.stringify({ error: "not_found" }), { status: eventsStatus, headers: { "content-type": "application/json" } });
|
|
}
|
|
throw new Error("unexpected_upstream " + url);
|
|
};
|
|
|
|
const { GET } = await import(moduleUrl("src/app/api/ephemeris/route.ts"));
|
|
const responses = [];
|
|
for (const date of dates) {
|
|
const response = await GET(new Request("https://staging.jyotisha.chat/api/ephemeris?date=" + date + (subject ? "&subject=" + encodeURIComponent(subject) : ""), abortOnFetch ? { signal: controller.signal } : undefined));
|
|
responses.push({ status: response.status, body: await response.json() });
|
|
}
|
|
console.log(JSON.stringify({
|
|
status: responses[0].status,
|
|
body: responses[0].body,
|
|
responses,
|
|
upstream,
|
|
billingImported,
|
|
signalAborted,
|
|
}));
|
|
`;
|
|
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 {
|
|
status: number;
|
|
body: Record<string, unknown>;
|
|
responses: Array<{ status: number; body: Record<string, unknown> }>;
|
|
upstream: Array<{ url: string; body: Record<string, unknown>; signalAborted: boolean }>;
|
|
billingImported: boolean;
|
|
signalAborted: boolean | null;
|
|
};
|
|
}
|
|
|
|
test("the ephemeris BFF does not import billing or mastra", () => {
|
|
assert.match(routeSource, /createServerSupabaseClient/);
|
|
// 原值: the route selected ACCOUNT_BIRTH_SELECT itself.
|
|
// 新值: birth is loaded only through loadSubjectBirth.
|
|
// 原因: D13. One lookup for chart, ephemeris, reports, daily language and chat.
|
|
assert.match(routeSource, /loadSubjectBirth/);
|
|
assert.match(routeSource, /globalBirthProfileFromAccountRow/);
|
|
assert.match(routeSource, /consumeUserRequestRateLimit\("ephemeris"/);
|
|
assert.match(routeSource, /"\/api\/panchanga_range"/);
|
|
assert.match(routeSource, /"\/api\/chart"/);
|
|
assert.match(routeSource, /"\/api\/ephemeris_events"/);
|
|
assert.match(routeSource, /eventsFromEngine\(eventsRaw\)/);
|
|
assert.doesNotMatch(routeSource, /consultation-billing|@\/mastra/);
|
|
assert.doesNotMatch(viewSource, /consultation-billing|@\/mastra/);
|
|
});
|
|
|
|
test("ephemeris natal and transit chart requests skip VedAstro overview", () => {
|
|
assert.equal((routeSource.match(/skip_vedastro_main_entry_overview:\s*true/g) ?? []).length, 2);
|
|
const result = executeRoute({ user: true, row: completeRow });
|
|
const chartCalls = result.upstream.filter((item) => item.url.endsWith("/api/chart"));
|
|
assert.ok(chartCalls.length >= 2);
|
|
for (const call of chartCalls) {
|
|
assert.equal(call.body.skip_vedastro_main_entry_overview, true);
|
|
}
|
|
});
|
|
|
|
test("unauthenticated ephemeris requests are 401", () => {
|
|
const result = executeRoute({ user: false, row: null });
|
|
assert.equal(result.status, 401);
|
|
assert.equal(result.body.status, "unauthenticated");
|
|
assert.equal(result.upstream.length, 0);
|
|
assert.equal(result.billingImported, false);
|
|
});
|
|
|
|
test("another person's ephemeris natal request uses that person's birth, and a missing person fails closed", () => {
|
|
const otherId = "11111111-1111-4111-8111-111111111111";
|
|
const other = {
|
|
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",
|
|
};
|
|
const found = executeRoute({ user: true, row: completeRow, other, subject: otherId });
|
|
const natal = found.upstream.find((item) => item.url.endsWith("/api/chart") && item.body.year === 1988);
|
|
assert.ok(natal);
|
|
assert.equal(natal?.body.lat, 22.3);
|
|
assert.equal(natal?.body.lon, 114.1);
|
|
assert.equal(found.upstream.some((item) => item.body.year === 1990), false);
|
|
assert.equal(found.billingImported, false);
|
|
|
|
const missing = executeRoute({ user: true, row: completeRow, other: null, subject: otherId });
|
|
assert.equal(missing.status, 409);
|
|
assert.equal(missing.body.reason, "subject_not_found");
|
|
assert.equal(missing.upstream.some((item) => item.url.endsWith("/api/chart")), false);
|
|
assert.equal(missing.billingImported, false);
|
|
});
|
|
|
|
test("panchanga still returns without a birth profile, and natal-relative stays empty", () => {
|
|
const result = executeRoute({ user: true, row: { timezone_id: "Asia/Shanghai" } });
|
|
assert.equal(result.status, 200);
|
|
const parsed = parseEphemerisOkResponse(result.body);
|
|
assert.ok(parsed);
|
|
assert.equal(parsed?.panchanga.available, true);
|
|
assert.equal(parsed?.panchanga.factors.length, 5);
|
|
assert.equal(parsed?.transits.available, false);
|
|
assert.equal(parsed?.transits.natalRelativeAvailable, false);
|
|
assert.equal(parsed?.transits.natalRelativeNote, "missing_natal");
|
|
assert.equal(parsed?.events.status, "unavailable");
|
|
assert.equal(result.upstream.some((item) => item.url.endsWith("/api/chart")), false);
|
|
assert.equal(result.billingImported, false);
|
|
});
|
|
|
|
test("ephemeris_events failure degrades instead of failing the page", () => {
|
|
const result = executeRoute({ user: true, row: completeRow, eventsStatus: 404 });
|
|
assert.equal(result.status, 200);
|
|
const parsed = parseEphemerisOkResponse(result.body);
|
|
assert.ok(parsed);
|
|
assert.equal(parsed?.panchanga.available, true);
|
|
assert.equal(parsed?.transits.available, true);
|
|
assert.equal(parsed?.transits.natalRelativeAvailable, true);
|
|
assert.equal(parsed?.events.status, "unavailable");
|
|
assert.equal(result.billingImported, false);
|
|
assert.ok(result.upstream.some((item) => item.url.endsWith("/api/ephemeris_events")));
|
|
});
|
|
|
|
test("golden engine packets pass the narrow response contract", () => {
|
|
const factors = panchangaFactorsFromEngine(panchangaGolden, "2026-09-15");
|
|
assert.equal(factors.length, 5);
|
|
assert.equal(factors.find((item) => item.key === "tithi")?.value, "Shukla Panchami");
|
|
assert.equal(factors.find((item) => item.key === "nakshatra")?.detail, "第 4 足");
|
|
|
|
const transits = transitsFromEngine({ transitChart: transitGolden, natalChart: natalGolden });
|
|
assert.equal(transits.bodies.length, 9);
|
|
assert.equal(transits.natalRelativeAvailable, true);
|
|
const sun = transits.bodies.find((item) => item.planet === "Sun");
|
|
assert.equal(sun?.signLabel, "狮子座");
|
|
assert.equal(sun?.natalHouse, natalHouseFromSigns("Leo", "Cancer"));
|
|
assert.equal(sun?.natalHouse, 2);
|
|
|
|
const parsed = parseEphemerisOkResponse({
|
|
status: "ok",
|
|
date: "2026-09-15",
|
|
today: "2026-09-15",
|
|
timezoneId: "Asia/Shanghai",
|
|
panchanga: { available: true, ayanamsaNote: "lahiri_not_account", factors },
|
|
transits: {
|
|
available: true,
|
|
natalRelativeAvailable: true,
|
|
natalRelativeNote: "shown",
|
|
ayanamsa: "raman",
|
|
bodies: transits.bodies,
|
|
},
|
|
events: { status: "unavailable" },
|
|
});
|
|
assert.ok(parsed);
|
|
assert.equal(eventsFromEngine(null).status, "unavailable");
|
|
assert.equal(eventsFromEngine({ error: "not_found" }).status, "unavailable");
|
|
});
|
|
|
|
test("the same account loads the natal chart once across two dates and forwards the request signal", () => {
|
|
assert.match(routeSource, /request\.signal/);
|
|
assert.match(routeSource, /readChartViewEngineCache/);
|
|
assert.match(routeSource, /writeChartViewEngineCache/);
|
|
const result = executeRoute({
|
|
user: true,
|
|
row: completeRow,
|
|
dates: ["2026-09-15", "2026-09-16"],
|
|
});
|
|
assert.equal(result.responses.length, 2);
|
|
assert.equal(result.responses[0]?.body.date, "2026-09-15");
|
|
assert.equal(result.responses[1]?.body.date, "2026-09-16");
|
|
const charts = result.upstream.filter((item) => item.url.endsWith("/api/chart"));
|
|
const natal = charts.filter((item) => item.body.year === 1990);
|
|
const transit = charts.filter((item) => item.body.year !== 1990);
|
|
assert.equal(natal.length, 1);
|
|
assert.equal(transit.length, 2);
|
|
assert.equal(result.billingImported, false);
|
|
|
|
const aborted = executeRoute({ user: true, row: completeRow, abortOnFetch: true });
|
|
assert.equal(aborted.signalAborted, true);
|
|
assert.equal(aborted.upstream[0]?.signalAborted, true);
|
|
});
|