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.10", }; } 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.10"); 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.10"); 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); });