533 lines
18 KiB
TypeScript
533 lines
18 KiB
TypeScript
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<string, unknown>) => Promise<{
|
|
data: unknown;
|
|
error: { message: string } | null;
|
|
}>;
|
|
|
|
function fakeAccounting(overrides: {
|
|
profile?: Record<string, unknown> | 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<string, unknown> | 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<string, unknown>).reported_birth_time,
|
|
null,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("unknown-time profiles open with a full-day server-owned range", async () => {
|
|
const capturedArgs: { value: Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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");
|
|
});
|