Files
Jyotisha/frontend/tests/ephemeris-route.test.ts
T

313 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 transitBodySource = readFileSync(new URL("../src/lib/transit-chart-body.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", () => {
// 原值: route.ts 里出现 2 次(natal + transit)
// 新值: route.ts 1 次(natal)+ 共享的 transit-chart-body.ts 1 次(transit)
// 原因: transitChartBody 挪到 lib 给星盘页行运盘共用(TASK-chart-types T4);下面按实际请求逐条断言不变
assert.equal((routeSource.match(/skip_vedastro_main_entry_overview:\s*true/g) ?? []).length, 1);
assert.equal((transitBodySource.match(/skip_vedastro_main_entry_overview:\s*true/g) ?? []).length, 1);
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);
});