Files
Jyotisha/frontend/tests/rectification-v9-entry-routing.test.ts

259 lines
8.9 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
entrySummaryFromResponse,
isResumableRectificationStatus,
isTerminalRectificationStatus,
openRectificationRequestBody,
openResponseFromPayload,
rectificationEntryLabels,
resolveRectificationEntryAction,
} from "../src/lib/rectification-entry.ts";
import {
openRectificationCaseRequestSchema,
shouldStartOpening,
} from "../src/lib/rectification-agentic/v9/open-request.ts";
import {
mapRectificationRpcError,
openRectificationCase,
RectificationCaseServiceError,
} from "../src/lib/rectification-agentic/v9/case-service.ts";
import {
CASE_ID,
SESSION_ID,
fakeAccounting,
type FakeRpcHandler,
} from "./rectification-v9-test-support.ts";
const uuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
test("homepage entry requests a new case and shows 开始新的生时校正", () => {
const body = openRectificationRequestBody("homepage", null);
assert.equal(body.intent, "homepage");
assert.ok(typeof body.requestId === "string" && body.requestId.length > 0);
assert.equal("sessionId" in body, false);
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: false,
latest_resumable: null,
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
});
test("homepage entry with a resumable case still offers a separate new correction", () => {
const summary = entrySummaryFromResponse({
has_resumable_case: true,
has_terminal_case_with_time: false,
latest_resumable: {
case_id: CASE_ID,
status: "collecting_evidence",
last_activity_at: "2026-08-12T10:00:00.000Z",
},
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.equal(rectificationEntryLabels.start, "开始新的生时校正");
const opened = openResponseFromPayload({
disposition: "created",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "draft",
shouldStartOpening: true,
skillVersion: "10.0.0",
});
assert.equal(opened?.disposition, "created");
assert.equal(opened?.shouldStartOpening, true);
assert.equal(opened?.caseId, CASE_ID);
assert.equal(opened?.sessionId, SESSION_ID);
});
test("homepage entry with only a terminal case shows 再次校正 and creates a new case", () => {
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: true,
latest_resumable: null,
latest_terminal: {
case_id: CASE_ID,
status: "confirmed",
has_usable_time: true,
},
});
assert.equal(resolveRectificationEntryAction(summary), "restart");
assert.equal(rectificationEntryLabels.restart, "再次校正");
});
test("legacy completed sessions never derive 继续上次 from the session list", () => {
// The CTA must be driven by the server entry summary, not by scanning
// sessionType in the client session list.
const summary = entrySummaryFromResponse({
has_resumable_case: false,
has_terminal_case_with_time: false,
latest_resumable: null,
latest_terminal: null,
});
assert.equal(resolveRectificationEntryAction(summary), "start");
assert.notEqual(rectificationEntryLabels.start, "继续上次校正");
});
test("sidebar click passes the exact sessionId and opens exactly that session", () => {
const body = openRectificationRequestBody("session", SESSION_ID);
assert.equal(body.intent, "session");
assert.equal(body.sessionId, SESSION_ID);
});
test("sidebar click on a terminal session opens read-only history", () => {
const opened = openResponseFromPayload({
disposition: "readonly",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "confirmed",
shouldStartOpening: false,
skillVersion: "9.0.0",
});
assert.equal(opened?.disposition, "readonly");
assert.equal(isTerminalRectificationStatus(opened?.status ?? ""), true);
assert.equal(isResumableRectificationStatus(opened?.status ?? ""), false);
assert.equal(opened?.shouldStartOpening, false);
});
test("double-click sends an idempotency key and never business state", () => {
const first = openRectificationRequestBody("homepage", null);
const second = openRectificationRequestBody("homepage", null);
// Both requests carry a requestId; the server serializes on the user and
// dedupes via the open ledger. No caseId/birth data/range is ever sent.
assert.ok(first.requestId);
assert.ok(second.requestId);
for (const body of [first, second]) {
assert.equal("caseId" in body, false);
assert.equal("birth_date" in body, false);
assert.equal("candidate_range" in body, false);
assert.equal("userId" in body, false);
}
});
test("two tabs produce distinct request ids; the open schema stays minimal", () => {
const tabA = openRectificationRequestBody("homepage", null);
const tabB = openRectificationRequestBody("homepage", null);
assert.notEqual(tabA.requestId, tabB.requestId);
for (const parsed of [tabA, tabB]) {
const result = openRectificationCaseRequestSchema.safeParse(parsed);
assert.equal(result.success, true);
}
});
test("non-owner sessionId maps to a 404 business error, never a leak", () => {
const view = mapRectificationRpcError(
new Error("agentic_rectification_session_not_found"),
);
assert.equal(view.status, 404);
assert.equal(view.code, "case_session_not_found");
});
test("profile incomplete prevents case creation with an explicit business error", async () => {
const accounting = fakeAccounting({});
// loadV9RectificationProfile throws profile_incomplete before the open RPC
// runs when the profile row is missing required fields.
const profileClient = {
...accounting.client,
from: () => ({
select: () => ({
eq: () => ({
single: async () => ({ data: { birth_date: null, latitude: null }, error: null }),
}),
}),
}),
} as never;
await assert.rejects(
openRectificationCase(profileClient, "user-1", {
intent: "homepage",
requestId: uuid,
}),
(error: unknown) => error instanceof RectificationCaseServiceError && error.code === "profile_incomplete",
);
});
test("shouldStartOpening is server-owned: only freshly created never-started cases", () => {
assert.equal(shouldStartOpening("created", 0), true);
assert.equal(shouldStartOpening("created", 1), false);
assert.equal(shouldStartOpening("resumed", 0), false);
assert.equal(shouldStartOpening("readonly", 3), false);
});
test("empty client message cache never repeats opening when the case has turns", () => {
// The client messages array may be empty after refresh, but exact-session
// recovery stays resumed: shouldStartOpening is false, so the opening turn
// is not re-emitted.
const opened = openResponseFromPayload({
disposition: "resumed",
caseId: CASE_ID,
sessionId: SESSION_ID,
status: "collecting_evidence",
shouldStartOpening: false,
skillVersion: "9.0.0",
});
assert.equal(opened?.disposition, "resumed");
assert.equal(opened?.shouldStartOpening, false);
});
test("open RPC passes the pinned skill and server-derived baseline only", async () => {
const rpc: FakeRpcHandler = (fn) => {
if (fn === "open_agentic_rectification_case_v2") {
return {
disposition: "created",
case_id: CASE_ID,
session_id: SESSION_ID,
status: "draft",
should_start_opening: true,
skill_version: "10.0.0",
};
}
return null;
};
const profile = {
birth_date: "1997-08-08",
birth_place_label: "河北省邯郸市",
reported_birth_time: "05:00",
active_birth_time: null,
birth_time_source: "family_exact",
birth_time_period: null,
uncertainty_before_minutes: 10,
uncertainty_after_minutes: 10,
latitude: 36.420487,
longitude: 114.209936,
timezone_id: "Asia/Shanghai",
timezone_offset: 8,
};
const accounting = fakeAccounting({
open_agentic_rectification_case_v2: rpc,
});
const profileClient = {
...accounting.client,
from: () => ({
select: () => ({
eq: () => ({
single: async () => ({ data: profile, error: null }),
}),
}),
}),
} as never;
const response = await openRectificationCase(profileClient, "user-1", {
intent: "homepage",
requestId: uuid,
});
assert.equal(response.disposition, "created");
assert.equal(response.shouldStartOpening, true);
assert.equal(response.skillVersion, "10.0.0");
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
assert.ok(openCall);
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
assert.equal(openCall.args.p_skill_version, "10.0.0");
assert.equal(openCall.args.p_user_id, "user-1");
// The server derives the baseline; the request never carries it from the browser.
assert.equal("birth_date" in openCall.args, false);
});