Files
Jyotisha/frontend/tests/conversational-rectification-store.test.ts
T
2026-07-20 22:24:43 +08:00

663 lines
22 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import {
ConversationalRectificationError,
toConversationalRectificationPublicError,
} from "../src/lib/conversational-rectification/errors.ts";
import {
ConversationalRectificationStore,
mapConversationalRectificationStoreError,
type ConversationalRectificationRpcClient,
} from "../src/lib/conversational-rectification/store.ts";
import { ConversationalRectificationBilling } from "../src/lib/conversational-rectification/billing.ts";
import {
conversationalRectificationActionReceiptRequestSchema,
conversationalRectificationActionReceiptResponseSchema,
declaredBirthInputSchema,
privateCandidateSchema,
validationReceiptSchema,
} from "../src/lib/conversational-rectification/persistence-contracts.ts";
import { postgresJsonbTextBytes } from "../src/lib/conversational-rectification/json-bounds.ts";
const userId = "00000000-0000-4000-8000-000000000101";
const caseId = "00000000-0000-4000-8000-000000000102";
const actionId = "00000000-0000-4000-8000-000000000103";
const resultId = "00000000-0000-4000-8000-000000000104";
const importCaseId = "00000000-0000-4000-8000-000000000107";
const firstTurn = {
caseId,
journeyProtocol: "conversational-evidence-v3" as const,
status: "active" as const,
turnVersion: 0,
narrative: "我们会用已经发生的人生事件验证当前候选范围。",
candidate: {
status: "pending_validation" as const,
representativeTime: "05:21",
rangeStart: "05:10",
rangeEnd: "05:30",
},
technicalReceipt: {
calculationVersion: "rectification-v3.1",
stableLayers: ["D1"],
sensitiveLayers: ["D9"],
candidateDifferenceRefs: ["difference-1"],
},
evidenceRequest: {
domains: ["career", "relocation"] as const,
datePrecision: "month_preferred" as const,
freeTextAllowed: true as const,
},
evidenceRecap: [],
actions: ["answer", "pause", "abandon"] as const,
pendingConsultationQuestion: null,
};
const storedRow = {
case_id: caseId,
user_id: userId,
status: "active",
turn_version: 0,
revision_of_case_id: null,
imported_from_case_id: null,
baseline_active_time: "04:58",
pending_consultation_question: null,
billing_state: null,
latest_turn: firstTurn,
declared_birth_input: {
birthDate: "1990-01-01",
reportedTime: "05:20",
source: "approximate",
birthTimeClue: "家人记得天刚亮",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthplace: {
countryCode: "TW",
provinceCode: "TPE",
cityCode: "TPE-CITY",
districtCode: "DAAN",
latitude: 25.0268,
longitude: 121.5434,
timezoneOffset: 8,
},
},
private_candidate: {
resultId,
calculationVersion: "rectification-v3.1",
candidateWeights: [0.6, 0.4],
},
event_evidence: [],
validation_receipts: [{ modelId: "synthetic-model", schemaValidated: true }],
};
const validationReceipt = {
modelId: "synthetic-model",
schemaValidated: true,
};
function rpcClient(
handler: (name: string, args: Readonly<Record<string, unknown>>) => unknown,
): ConversationalRectificationRpcClient {
return {
async rpc(name, args) {
return { data: handler(name, args), error: null };
},
};
}
test("creates an account-level case and first public turn through one RPC", async () => {
let call: { name: string; args: Readonly<Record<string, unknown>> } | undefined;
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
call = { name, args };
return storedRow;
}));
const result = await store.createCaseWithFirstTurn({
userId,
caseId,
actionId: caseId,
expectedVersion: 0,
revisionOfCaseId: null,
pendingConsultationQuestion: null,
declaredBirthInput: {
birthDate: "1990-01-01",
reportedTime: "05:20",
source: "approximate",
birthTimeClue: "家人记得天刚亮",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthplace: {
countryCode: "TW",
provinceCode: "TPE",
cityCode: "TPE-CITY",
districtCode: "DAAN",
latitude: 25.0268,
longitude: 121.5434,
timezoneOffset: 8,
},
},
firstTurn,
validationReceipt,
privateCandidate: {
resultId,
representativeTime: "05:21",
calculationVersion: "rectification-v3.1",
candidateWeights: [0.6, 0.4],
},
});
assert.equal(call?.name, "create_conversational_rectification_case");
assert.deepEqual(call?.args, {
p_user_id: userId,
p_case_id: caseId,
p_expected_version: 0,
p_action_id: caseId,
p_revision_of_case_id: null,
p_pending_consultation_question: null,
p_declared_birth_input: {
birthDate: "1990-01-01",
reportedTime: "05:20",
source: "approximate",
birthTimeClue: "家人记得天刚亮",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
birthplace: {
countryCode: "TW",
provinceCode: "TPE",
cityCode: "TPE-CITY",
districtCode: "DAAN",
latitude: 25.0268,
longitude: 121.5434,
timezoneOffset: 8,
},
},
p_first_turn: firstTurn,
p_validation_receipt: validationReceipt,
p_private_candidate: {
resultId,
representativeTime: "05:21",
calculationVersion: "rectification-v3.1",
candidateWeights: [0.6, 0.4],
},
});
assert.equal(result.caseId, caseId);
assert.equal(result.baselineActiveTime, "04:58");
assert.deepEqual(result.declaredBirthInput, storedRow.declared_birth_input);
assert.deepEqual(result.latestTurn, firstTurn);
assert.deepEqual(result.privateCandidate, storedRow.private_candidate);
assert.deepEqual(result.eventEvidence, []);
assert.deepEqual(result.validationReceipts, storedRow.validation_receipts);
});
test("loads the latest unfinished case by account without a chat identifier", async () => {
const calls: unknown[] = [];
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
calls.push([name, args]);
return storedRow;
}));
const loaded = await store.loadCase({ userId });
assert.equal(loaded?.caseId, caseId);
assert.deepEqual(calls, [["load_conversational_rectification_case", {
p_user_id: userId,
p_case_id: null,
}]]);
});
test("binds every paid start to the public action as its recoverable case id", async () => {
let calls = 0;
const client = rpcClient(() => {
calls += 1;
return storedRow;
});
const store = new ConversationalRectificationStore(client);
const billing = new ConversationalRectificationBilling(client);
await assert.rejects(
billing.reserve({ userId, caseId, actionId, expectedVersion: 0, price: 3 }),
(error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "action_conflict",
);
await assert.rejects(
store.createCaseWithFirstTurn({
userId,
caseId,
actionId,
expectedVersion: 0,
revisionOfCaseId: null,
pendingConsultationQuestion: null,
declaredBirthInput: storedRow.declared_birth_input as never,
firstTurn,
validationReceipt,
privateCandidate: storedRow.private_candidate,
}),
(error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "action_conflict",
);
assert.equal(calls, 0);
});
test("save, pause, abandon, confirm, and import carry owner/version/action guards", async () => {
const calls: Array<[string, Readonly<Record<string, unknown>>]> = [];
const store = new ConversationalRectificationStore(rpcClient((name, args) => {
calls.push([name, args]);
return storedRow;
}));
const common = { userId, caseId, actionId, expectedVersion: 0 };
const evidence = [{
id: "00000000-0000-4000-8000-000000000105",
rawText: "2019 年 7 月换工作",
domain: "career" as const,
eventSummary: "换工作",
dateValue: "2019-07",
datePrecision: "month" as const,
extractionStatus: "clear" as const,
}];
await store.saveTurn({
...common,
turn: { ...firstTurn, turnVersion: 1 },
evidence,
validationReceipt,
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
});
await store.pause({
...common,
turn: { ...firstTurn, status: "paused", turnVersion: 1 },
validationReceipt,
});
await store.abandon({
...common,
turn: { ...firstTurn, status: "abandoned", turnVersion: 1 },
validationReceipt,
});
await store.confirm({
...common,
resultId,
time: "05:21",
calculationVersion: "rectification-v3.1",
validationReceipt,
turn: {
...firstTurn,
status: "completed",
turnVersion: 1,
candidate: { ...firstTurn.candidate, status: "confirmed" },
evidenceRequest: null,
actions: ["continue_original_question"],
},
});
await store.importLegacy({
userId,
caseId: importCaseId,
actionId: importCaseId,
expectedVersion: 0,
legacyCaseId: "00000000-0000-4000-8000-000000000106",
price: 3,
pendingConsultationQuestion: null,
firstTurn: { ...firstTurn, caseId: importCaseId },
validationReceipt,
privateCandidate: { resultId, calculationVersion: "rectification-v3.1" },
});
assert.deepEqual(calls.map(([name]) => name), [
"save_conversational_rectification_turn",
"pause_conversational_rectification_case",
"abandon_conversational_rectification_case",
"confirm_conversational_rectification_candidate",
"import_legacy_conversational_rectification_case",
]);
for (const [, args] of calls.slice(0, 4)) {
assert.equal(args.p_user_id, userId);
assert.equal(args.p_case_id, caseId);
assert.equal(args.p_action_id, actionId);
assert.equal(args.p_expected_version, 0);
assert.deepEqual(args.p_validation_receipt, validationReceipt);
}
assert.equal(calls[4]?.[1].p_case_id, importCaseId);
assert.equal(calls[4]?.[1].p_action_id, importCaseId);
assert.deepEqual(calls[4]?.[1].p_validation_receipt, validationReceipt);
assert.deepEqual(calls[0]?.[1].p_evidence, evidence);
assert.equal(calls[0]?.[1].p_turn && typeof calls[0]?.[1].p_turn, "object");
assert.equal("outputValidationReceipt" in (calls[0]?.[1].p_turn as object), false);
});
test("maps only exact allowlisted database failures to stable domain codes", () => {
const exact = [
["conversational_case_not_found", "case_not_found"],
["conversational_stale_turn", "stale_turn"],
["conversational_action_conflict", "action_conflict"],
["conversational_candidate_changed", "candidate_changed"],
["conversational_billing_failed", "billing_failed"],
] as const;
for (const [message, code] of exact) {
const mapped = mapConversationalRectificationStoreError({ code: "P0001", message });
assert.ok(mapped instanceof ConversationalRectificationError);
assert.equal(mapped.code, code);
}
assert.equal(
mapConversationalRectificationStoreError({ code: "08006", message: "conversational_stale_turn" }).code,
"store_unavailable",
);
assert.equal(
mapConversationalRectificationStoreError({ code: "P0001", message: "raw database detail" }).code,
"store_unavailable",
);
});
test("unknown Supabase errors never survive in the store or public error", async () => {
const rawMessage = "WebKit SQL password=model-secret";
const store = new ConversationalRectificationStore({
async rpc() {
return { data: null, error: { code: "08006", message: rawMessage } };
},
});
let caught: unknown;
try {
await store.loadCase({ userId, caseId });
} catch (error) {
caught = error;
}
assert.ok(caught instanceof ConversationalRectificationError);
assert.equal(caught.code, "store_unavailable");
assert.equal(JSON.stringify(caught).includes(rawMessage), false);
assert.equal(JSON.stringify(toConversationalRectificationPublicError(caught)).includes(rawMessage), false);
});
test("billing sends the server fee and never converts completion into a debit", async () => {
const calls: Array<[string, Readonly<Record<string, unknown>>]> = [];
const billing = new ConversationalRectificationBilling(rpcClient((name, args) => {
calls.push([name, args]);
return {
success: true,
credits: 7,
billing_state: name.includes("complete") ? "charged" : name.includes("release") ? "released" : "reserved",
error_code: null,
};
}));
const common = { userId, caseId, actionId: caseId, expectedVersion: 0 };
assert.equal((await billing.reserve({ ...common, price: 3 })).billingState, "reserved");
assert.equal((await billing.complete(common)).billingState, "charged");
assert.equal((await billing.release({ ...common, price: 3 })).billingState, "released");
assert.deepEqual(calls.map(([name]) => name), [
"reserve_conversational_rectification_fee",
"complete_conversational_rectification_fee",
"release_conversational_rectification_fee",
]);
assert.equal(calls[0]?.[1].p_price, 3);
assert.equal("p_price" in (calls[1]?.[1] ?? {}), false);
assert.equal(calls[2]?.[1].p_price, 3);
});
test("billing rejections use stable domain errors instead of raw RPC messages", async () => {
const insufficient = new ConversationalRectificationBilling(rpcClient(() => ({
success: false,
credits: 0,
billing_state: null,
error_code: "insufficient_credits",
})));
await assert.rejects(
insufficient.reserve({ userId, caseId, actionId: caseId, expectedVersion: 0, price: 3 }),
(error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "insufficient_credits",
);
const failed = new ConversationalRectificationBilling(rpcClient(() => ({
success: false,
credits: 7,
billing_state: "released",
error_code: "already_charged",
})));
await assert.rejects(
failed.release({ userId, caseId, actionId: caseId, expectedVersion: 0, price: 3 }),
(error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "billing_failed",
);
});
test("declared birth input is strict, source-aware, bounded, and location-complete", () => {
assert.equal(declaredBirthInputSchema.safeParse(storedRow.declared_birth_input).success, true);
const commonDeclaration = {
birthDate: "1990-01-01",
birthTimeClue: null,
birthplace: {
city: "Taipei",
latitude: 25.03,
longitude: 121.56,
timezoneOffset: 8,
},
};
for (const value of [
{ ...commonDeclaration, source: "hospital_record", reportedTime: "05:20", uncertaintyBeforeMinutes: 2, uncertaintyAfterMinutes: 2 },
{ ...commonDeclaration, source: "family_exact", reportedTime: "05:20", uncertaintyBeforeMinutes: 10, uncertaintyAfterMinutes: 10 },
{ ...commonDeclaration, source: "period_only", reportedPeriod: "morning" },
{ ...commonDeclaration, source: "unknown" },
{ ...commonDeclaration, source: "legacy_import", reportedTime: "05:20", uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0 },
]) {
assert.equal(declaredBirthInputSchema.safeParse(value).success, true);
}
const invalid = [
{ ...storedRow.declared_birth_input, unexpected: true },
{ ...storedRow.declared_birth_input, birthDate: "2023-02-30" },
{ ...storedRow.declared_birth_input, birthTimeClue: undefined },
{ ...storedRow.declared_birth_input, birthplace: undefined },
{ ...storedRow.declared_birth_input, source: "unrecognized" },
{
...storedRow.declared_birth_input,
uncertaintyAfterMinutes: 60,
},
{
...storedRow.declared_birth_input,
reportedPeriod: "morning",
},
{
...storedRow.declared_birth_input,
birthplace: { ...storedRow.declared_birth_input.birthplace, latitude: 91 },
},
{
...storedRow.declared_birth_input,
birthplace: { timezoneOffset: 8 },
},
];
for (const value of invalid) {
assert.equal(declaredBirthInputSchema.safeParse(value).success, false);
}
const exactClue = declaredBirthInputSchema.parse({
...storedRow.declared_birth_input,
birthTimeClue: " exact clue spacing ",
});
assert.equal(exactClue.birthTimeClue, " exact clue spacing ");
});
test("durable private and receipt schemas accept boundaries and reject oversize or unknown fields", () => {
assert.equal(postgresJsonbTextBytes({ a: [1, 2] }), 13);
assert.equal(validationReceiptSchema.safeParse({
modelId: "m".repeat(120),
schemaValidated: true,
validatorVersion: "v".repeat(80),
retryCount: 2,
fallbackUsed: false,
issues: ["i".repeat(240)],
}).success, true);
assert.equal(validationReceiptSchema.safeParse({
modelId: "m".repeat(121),
schemaValidated: true,
}).success, false);
assert.equal(validationReceiptSchema.safeParse({
modelId: `${"m".repeat(120)} `,
schemaValidated: true,
}).success, false);
assert.equal(validationReceiptSchema.safeParse({
modelId: "model",
schemaValidated: true,
validatedAt: "2026-07-20T12:00:00.123456789012345678901Z",
}).success, false);
const candidate = {
resultId,
representativeTime: "05:21",
calculationVersion: "rectification-v3.1",
candidateWeights: Array.from({ length: 1_440 }, () => 0.5),
candidateModelRefs: ["model-ref"],
suggestedDomains: ["career", "relocation"],
};
assert.equal(privateCandidateSchema.safeParse(candidate).success, true);
assert.equal(privateCandidateSchema.safeParse({
...candidate,
candidateWeights: [...candidate.candidateWeights, 0.5],
}).success, false);
assert.equal(privateCandidateSchema.safeParse({
calculationVersion: "v1",
rangeStart: null,
}).success, false);
assert.equal(privateCandidateSchema.safeParse({ ...candidate, secretExtra: true }).success, false);
const request = {
kind: "save_turn",
userId,
caseId,
expectedVersion: 0,
actionId,
requestFingerprint: "a".repeat(64),
};
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse(request).success, true);
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({
...request,
extra: "not durable",
}).success, false);
assert.equal(conversationalRectificationActionReceiptRequestSchema.safeParse({
...request,
requestFingerprint: "a".repeat(65),
}).success, false);
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
success: false,
credits: 7,
billing_state: null,
error_code: "e".repeat(80),
}).success, true);
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
success: false,
credits: 7,
billing_state: null,
error_code: "e".repeat(81),
}).success, false);
});
test("public turn JSON fields reject field and byte boundary violations", () => {
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
...storedRow,
declared_birth_input: undefined,
private_candidate: undefined,
event_evidence: undefined,
validation_receipts: undefined,
}).success, true);
const exact = {
...firstTurn,
technicalReceipt: {
...firstTurn.technicalReceipt,
calculationVersion: "v".repeat(80),
},
evidenceRecap: [{
id: "00000000-0000-4000-8000-000000000108",
summary: "事".repeat(1_000),
dateLabel: "d".repeat(80),
}],
};
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
...storedRow,
latest_turn: exact,
declared_birth_input: undefined,
private_candidate: undefined,
event_evidence: undefined,
validation_receipts: undefined,
}).success, true);
const nearTurnLimit = {
...exact,
narrative: "n".repeat(12_000),
evidenceRecap: Array.from({ length: 16 }, (_, index) => ({
id: `00000000-0000-4000-8000-${(300 + index).toString().padStart(12, "0")}`,
summary: "事".repeat(1_000),
dateLabel: "d".repeat(80),
})),
};
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
...storedRow,
latest_turn: nearTurnLimit,
declared_birth_input: undefined,
private_candidate: undefined,
event_evidence: undefined,
validation_receipts: undefined,
}).success, true);
for (const latest_turn of [
{
...exact,
candidate: { ...exact.candidate, unknown: true },
},
{
...exact,
technicalReceipt: { ...exact.technicalReceipt, calculationVersion: "v".repeat(81) },
},
{
...exact,
evidenceRecap: [{ ...exact.evidenceRecap[0], summary: "事".repeat(1_001) }],
},
{
...exact,
narrative: "n".repeat(12_000),
evidenceRecap: Array.from({ length: 20 }, (_, index) => ({
id: `00000000-0000-4000-8000-${(200 + index).toString().padStart(12, "0")}`,
summary: "事".repeat(1_000),
dateLabel: "d".repeat(80),
})),
},
]) {
assert.equal(conversationalRectificationActionReceiptResponseSchema.safeParse({
...storedRow,
latest_turn,
declared_birth_input: undefined,
private_candidate: undefined,
event_evidence: undefined,
validation_receipts: undefined,
}).success, false);
}
});
test("store rejects invalid durable inputs before issuing an RPC", async () => {
let calls = 0;
const store = new ConversationalRectificationStore(rpcClient(() => {
calls += 1;
return storedRow;
}));
await assert.rejects(store.createCaseWithFirstTurn({
userId,
caseId,
actionId: caseId,
expectedVersion: 0,
revisionOfCaseId: null,
pendingConsultationQuestion: null,
declaredBirthInput: {
birthDate: "1990-01-01",
source: "approximate",
reportedTime: "05:20",
birthTimeClue: null,
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
} as never,
firstTurn,
validationReceipt,
privateCandidate: {
resultId,
calculationVersion: "rectification-v3.1",
},
}), (error: unknown) => error instanceof ConversationalRectificationError
&& error.code === "action_conflict");
assert.equal(calls, 0);
});