import assert from "node:assert/strict"; import test from "node:test"; import { RectificationCaseServiceError, adoptLegacyRectificationSkill, closeRectificationCase, getRectificationCase, getRectificationSkillIdentityStatus, getRectificationEntrySummary, loadV9RectificationProfile, mapRectificationRpcError, openRectificationCase, upgradeRectificationSkill, } from "../src/lib/rectification-agentic/v9/case-service.ts"; import type { OpenRectificationCaseRequest } from "../src/lib/rectification-agentic/v9/open-request.ts"; type RpcHandler = (fn: string, args: Record) => Promise<{ data: unknown; error: { message: string } | null; }>; function fakeAccounting(overrides: { profile?: Record | null; rpc?: RpcHandler; }) { const rpc = overrides.rpc ?? (async () => ({ data: null, error: { message: "agentic_rectification_case_not_found" } })); return { from: (table: string) => { if (table !== "profiles") throw new Error(`unexpected table ${table}`); const builder = { select: () => builder, eq: () => builder, single: async () => { if (!overrides.profile) return { data: null, error: { message: "not found" } }; return { data: overrides.profile, error: null }; }, }; return builder; }, rpc, } as never; } const completeProfile = { birth_date: "1997-08-08", birth_place_label: "河北省邯郸市", reported_birth_time: "05:00", active_birth_time: "05:08", 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 requestId = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; const caseId = "11111111-1111-4111-8111-111111111111"; const sessionId = "22222222-2222-4222-8222-222222222222"; function openRpc(data: unknown): RpcHandler { return async () => ({ data, error: null }); } test("loadV9RectificationProfile uses a server-owned radius around reported time", async () => { const accounting = fakeAccounting({ profile: completeProfile }); const profile = await loadV9RectificationProfile(accounting, "user-1"); assert.equal(profile.userId, "user-1"); assert.equal(profile.baseline.birth_date, "1997-08-08"); assert.equal(profile.baseline.birth_place_label, "河北省邯郸市"); assert.equal(profile.baseline.timezone_id, "Asia/Shanghai"); assert.equal(profile.baseline.reported_birth_time, "05:00"); assert.equal(profile.baseline.active_birth_time, null); assert.equal(profile.baselineFingerprint.length, 64); assert.equal(profile.baseline.uncertainty_before_minutes, 10); assert.equal(profile.baseline.uncertainty_after_minutes, 10); assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" }); assert.ok(!("password" in profile.baseline)); }); test("loadV9RectificationProfile normalizes PostgreSQL Date birth dates", async () => { const dateProfile = { ...completeProfile, birth_date: new Date("1997-08-08T00:00:00.000Z"), }; const accounting = fakeAccounting({ profile: dateProfile }); const profile = await loadV9RectificationProfile(accounting, "user-1"); assert.equal(profile.baseline.birth_date, "1997-08-08"); }); test("family exact 0/0 remains profile truth while the Case keeps a movable search window", async () => { const accounting = fakeAccounting({ profile: { ...completeProfile, uncertainty_before_minutes: 0, uncertainty_after_minutes: 0, }, }); const profile = await loadV9RectificationProfile(accounting, "user-1"); assert.equal(profile.baseline.uncertainty_before_minutes, 0); assert.equal(profile.baseline.uncertainty_after_minutes, 0); assert.deepEqual(profile.candidateRange, { start_time: "04:45", end_time: "05:15" }); }); test("loadV9RectificationProfile rejects incomplete profiles without creating a case", async () => { for (const incomplete of [ { ...completeProfile, latitude: null, longitude: null, timezone_offset: null }, { ...completeProfile, birth_place_label: "" }, { ...completeProfile, timezone_id: "" }, ]) { const accounting = fakeAccounting({ profile: incomplete }); await assert.rejects( () => loadV9RectificationProfile(accounting, "user-1"), (error: unknown) => error instanceof RectificationCaseServiceError && error.code === "profile_incomplete", ); } }); test("period-only profiles open with their selected server-owned range", async () => { const cases = [ { intent: "homepage", period: "morning", range: { start_time: "08:00", end_time: "11:59" } }, { intent: "new", period: "late_night", range: { start_time: "23:00", end_time: "03:59" } }, ] as const; for (const item of cases) { const capturedArgs: { value: Record | null } = { value: null }; const accounting = fakeAccounting({ profile: { ...completeProfile, reported_birth_time: null, birth_time_source: "period_only", birth_time_period: item.period, }, rpc: async (_fn, args) => { capturedArgs.value = args; return { data: { disposition: "created", case_id: caseId, session_id: sessionId, status: "draft", should_start_opening: true, skill_version: "9.0.0", }, error: null, }; }, }); const response = await openRectificationCase(accounting, "user-1", { intent: item.intent, requestId, }); assert.equal(response.disposition, "created"); assert.deepEqual(capturedArgs.value?.p_candidate_range, item.range); assert.equal( (capturedArgs.value?.p_baseline_birth_snapshot as Record).reported_birth_time, null, ); } }); test("period-only profiles with an IANA timezone can open when the cached offset is missing", async () => { const originalFetch = globalThis.fetch; const capturedArgs: { value: Record | null } = { value: null }; let timezoneLookupCalled = false; globalThis.fetch = async (input, init) => { timezoneLookupCalled = true; assert.equal(String(input), "http://127.0.0.1:5200/api/location/timezone"); assert.deepEqual(JSON.parse(String(init?.body)), { latitude: 36.420487, longitude: 114.209936, birthDate: "1997-08-08", birthTime: "20:30", }); return new Response(JSON.stringify({ available: true, timezoneId: "Asia/Shanghai", timezoneOffset: 8, }), { status: 200 }); }; try { const accounting = fakeAccounting({ profile: { ...completeProfile, reported_birth_time: null, birth_time_source: "period_only", birth_time_period: "evening", timezone_offset: null, }, rpc: async (_fn, args) => { capturedArgs.value = args; return { data: { disposition: "created", case_id: caseId, session_id: sessionId, status: "draft", should_start_opening: true, skill_version: "9.0.0", }, error: null, }; }, }); const response = await openRectificationCase(accounting, "user-1", { intent: "homepage", requestId, }); assert.equal(response.disposition, "created"); assert.equal(timezoneLookupCalled, true); assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "18:00", end_time: "22:59" }); assert.equal( (capturedArgs.value?.p_baseline_birth_snapshot as Record).timezone_offset, 8, ); } finally { globalThis.fetch = originalFetch; } }); test("unknown-time profiles open with a full-day server-owned range", async () => { const capturedArgs: { value: Record | null } = { value: null }; const accounting = fakeAccounting({ profile: { ...completeProfile, reported_birth_time: null, birth_time_source: "unknown", birth_time_period: null, }, rpc: async (_fn, args) => { capturedArgs.value = args; return { data: { disposition: "created", case_id: caseId, session_id: sessionId, status: "draft", should_start_opening: true, skill_version: "9.0.0", }, error: null, }; }, }); const response = await openRectificationCase(accounting, "user-1", { intent: "homepage", requestId, }); assert.equal(response.disposition, "created"); assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "00:00", end_time: "23:59" }); }); test("fresh profiles still fail closed when their birth-time declaration is incomplete", async () => { const cases = [ { source: "period_only", period: null }, { source: "period_only", period: "not_a_period" }, { source: "family_exact", period: null }, { source: "approximate", period: null }, ] as const; for (const item of cases) { let rpcCalled = false; const accounting = fakeAccounting({ profile: { ...completeProfile, reported_birth_time: null, birth_time_source: item.source, birth_time_period: item.period, }, rpc: async () => { rpcCalled = true; return { data: null, error: null }; }, }); await assert.rejects( () => openRectificationCase(accounting, "user-1", { intent: "homepage", requestId }), (error: unknown) => error instanceof RectificationCaseServiceError && error.code === "profile_incomplete", ); assert.equal(rpcCalled, false); } }); test("homepage and new opens ignore active time and legacy uncertainty for their fresh range", async () => { for (const intent of ["homepage", "new"] as const) { const capturedArgs: { value: Record | null } = { value: null }; const accounting = fakeAccounting({ profile: completeProfile, rpc: async (_fn, args) => { capturedArgs.value = args; return { data: { disposition: "created", case_id: caseId, session_id: sessionId, status: "draft", should_start_opening: true, skill_version: "9.0.0", }, error: null, }; }, }); const request: OpenRectificationCaseRequest = { intent, requestId }; const response = await openRectificationCase(accounting, "user-1", request); assert.equal(response.disposition, "created"); assert.equal(response.caseId, caseId); assert.equal(response.sessionId, sessionId); assert.equal(response.shouldStartOpening, true); assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "04:45", end_time: "05:15" }); assert.deepEqual(capturedArgs.value?.p_baseline_birth_snapshot, { birth_date: "1997-08-08", birth_place_label: "河北省邯郸市", latitude: 36.420487, longitude: 114.209936, timezone_id: "Asia/Shanghai", timezone_offset: 8, birth_time_source: "family_exact", birth_time_period: null, reported_birth_time: "05:00", active_birth_time: null, uncertainty_before_minutes: 10, uncertainty_after_minutes: 10, }); } }); test("homepage open may create a new case even when resumable history exists", async () => { const accounting = fakeAccounting({ profile: completeProfile, rpc: openRpc({ disposition: "created", case_id: caseId, session_id: sessionId, status: "draft", should_start_opening: true, skill_version: "9.0.0", }), }); const response = await openRectificationCase(accounting, "user-1", { intent: "homepage", requestId, }); assert.equal(response.disposition, "created"); assert.equal(response.shouldStartOpening, true); }); test("session intent passes the exact sessionId and never the profile snapshot", async () => { const capturedArgs: { value: Record | null } = { value: null }; const accounting = fakeAccounting({ profile: null, rpc: async (fn, args) => { capturedArgs.value = args; return { data: { disposition: "readonly", case_id: caseId, session_id: sessionId, status: "closed", should_start_opening: false, skill_version: "9.0.0", }, error: null, }; }, }); const response = await openRectificationCase(accounting, "user-1", { intent: "session", requestId, sessionId, }); assert.equal(response.disposition, "readonly"); assert.equal(capturedArgs.value?.p_session_id, sessionId); assert.deepEqual(capturedArgs.value?.p_baseline_birth_snapshot, {}); }); test("non-owner session opens surface a safe not-found error", async () => { const accounting = fakeAccounting({ profile: null, rpc: async () => ({ data: null, error: { message: "agentic_rectification_session_not_found" } }), }); await assert.rejects( () => openRectificationCase(accounting, "user-1", { intent: "session", requestId, sessionId: "99999999-9999-4999-8999-999999999999", }), (error: unknown) => error instanceof RectificationCaseServiceError && error.code === "case_session_not_found", ); }); test("legacy active-case conflicts still map to a safe public error", async () => { const accounting = fakeAccounting({ profile: completeProfile, rpc: async () => ({ data: null, error: { message: "agentic_rectification_active_case_conflict" } }), }); await assert.rejects( () => openRectificationCase(accounting, "user-1", { intent: "new", requestId }), (error: unknown) => error instanceof RectificationCaseServiceError && error.code === "active_case_conflict", ); }); test("entry summary projects resumable history for homepage context", async () => { const accounting = fakeAccounting({ rpc: async (fn) => { assert.equal(fn, "get_agentic_rectification_entry_summary"); return { data: { has_resumable_case: true, has_terminal_case_with_time: false, latest_resumable: { case_id: caseId, status: "collecting_evidence", last_activity_at: "2026-08-12T00:00:00.000Z", }, latest_terminal: null, }, error: null, }; }, }); const summary = await getRectificationEntrySummary(accounting, "user-1"); assert.equal(summary.hasResumableCase, true); assert.equal(summary.hasTerminalCaseWithTime, false); assert.equal(summary.latestResumable?.caseId, caseId); }); test("get case returns a sanitized projection without the birth snapshot", async () => { const accounting = fakeAccounting({ rpc: async () => ({ data: { case_id: caseId, session_id: sessionId, status: "candidate_ready", skill_name: "jyotish-birth-time-rectification", skill_version: "9.0.0", candidate_range: { start_time: "04:50", end_time: "05:10" }, accepted_time: null, confirmed_time: null, created_at: "2026-08-12T00:00:00.000Z", last_activity_at: "2026-08-12T00:00:00.000Z", completed_at: null, closed_reason: null, evidence_count: 3, turn_count: 4, latest_result: null, }, error: null, }), }); const view = await getRectificationCase(accounting, "user-1", caseId); assert.equal(view.caseId, caseId); assert.equal(view.evidenceCount, 3); assert.deepEqual(view.candidateRange, { start_time: "04:50", end_time: "05:10" }); assert.ok(!("baseline_birth_snapshot" in view)); assert.ok(!("birth_date" in view)); }); test("close is idempotent and never wraps as engine confirmed", async () => { const accounting = fakeAccounting({ rpc: async (fn, args) => { assert.equal(args.p_reason, "completed_by_user"); return { data: { success: true, case_id: caseId, status: "closed", idempotent: false }, error: null }; }, }); const result = await closeRectificationCase(accounting, "user-1", caseId, "completed_by_user"); assert.equal(result.status, "closed"); assert.equal(result.idempotent, false); }); test("skill identity status preserves legacy adoption state", async () => { const accounting = fakeAccounting({ rpc: async (fn) => { assert.equal(fn, "get_agentic_rectification_skill_identity_status"); return { data: { skill_name: "jyotish-birth-time-rectification", skill_version: "8.9.0", skill_sha256: null, skill_source_commit: null, skill_identity_status: "legacy_unverifiable", requires_skill_adoption: true, }, error: null, }; }, }); const status = await getRectificationSkillIdentityStatus(accounting, "user-1", caseId); assert.equal(status.status, "legacy_unverifiable"); assert.equal(status.skillSha256, null); assert.equal(status.requiresSkillAdoption, true); }); test("legacy adoption calls the explicit RPC and keeps a null previous hash", async () => { const accounting = fakeAccounting({ rpc: async (fn, args) => { assert.equal(fn, "adopt_agentic_rectification_skill_v1"); assert.equal(args.p_skill_name, "jyotish-birth-time-rectification"); assert.equal(args.p_skill_version, "9.0.0"); assert.equal(args.p_skill_sha256, "5acb3103e80993ea611b93d2c1746e70b74aff8f8636bcffc80a837b954b470d"); return { data: { success: true, case_id: caseId, previous_skill_name: "jyotish-birth-time-rectification", previous_skill_version: "8.9.0", previous_skill_sha256: null, previous_source_commit: null, previous_identity_status: "legacy_unverifiable", upgrade_kind: "legacy_adoption", skill_name: "jyotish-birth-time-rectification", skill_version: "9.0.0", skill_sha256: "5acb3103e80993ea611b93d2c1746e70b74aff8f8636bcffc80a837b954b470d", source_commit: "0fd111d16b45796086a6c1d0945dbd3de6755d8a", receipt_id: "33333333-3333-4333-8333-333333333333", idempotent: false, }, error: null, }; }, }); const result = await adoptLegacyRectificationSkill(accounting, "user-1", caseId, "9.0.0"); assert.equal(result.previousSkillSha256, null); assert.equal(result.idempotent, false); }); test("upgrade skill resolves and passes the exact registered identity", async () => { const accounting = fakeAccounting({ rpc: async (fn, args) => { assert.equal(fn, "upgrade_agentic_rectification_skill_v2"); assert.equal(args.p_skill_name, "jyotish-birth-time-rectification"); assert.equal(args.p_skill_version, "9.0.0"); assert.equal( args.p_skill_sha256, "5acb3103e80993ea611b93d2c1746e70b74aff8f8636bcffc80a837b954b470d", ); assert.equal( args.p_skill_source_commit, "0fd111d16b45796086a6c1d0945dbd3de6755d8a", ); return { data: { success: true, case_id: caseId, previous_skill_version: "8.9.0", skill_version: "9.0.0", idempotent: false, }, error: null, }; }, }); const result = await upgradeRectificationSkill(accounting, "user-1", caseId, "9.0.0"); assert.equal(result.skillVersion, "9.0.0"); }); test("rpc errors map to safe public views without leaking database text", async () => { const generic = mapRectificationRpcError(new Error("syntax error at or near \"profiles\"")); assert.equal(generic.status, 500); assert.equal(generic.code, "rectification_service_failed"); assert.doesNotMatch(generic.message, /syntax error/); const conflict = mapRectificationRpcError(new Error("agentic_rectification_active_case_conflict")); assert.equal(conflict.status, 409); assert.equal(conflict.code, "active_case_conflict"); assert.doesNotMatch(conflict.message, /agentic_rectification/); const notFound = mapRectificationRpcError(new Error("agentic_rectification_case_not_found")); assert.equal(notFound.status, 404); assert.equal(notFound.code, "case_not_found"); const publicLegacy = mapRectificationRpcError(new Error("agentic_rectification_skill_identity_unverifiable")); assert.equal(publicLegacy.status, 409); assert.equal(publicLegacy.code, "skill_identity_unverifiable"); });