Files
Jyotisha/frontend/tests/rectification-saved-civil-fields.test.ts
T
jesse-uxandJesse_Chen 6e16dc5e16 fix(rectification): keep one delivery and adopt with the birth date
Accept and GET now decide with the same birth snapshot as the answer, so an under-age probe cannot flip can_adopt. The exit skips a second write when this reply already delivered. The card stays without an adopt button when adoption is not allowed, and a refusal shows the Chinese sentence.
2026-10-06 16:03:57 +08:00

127 lines
7.3 KiB
TypeScript

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { acceptV9Candidate, confirmV9BirthTime } from "../src/lib/rectification-agentic/v9/tool-service.ts";
import { CASE_ID, SESSION_ID, RESULT_ID, CANDIDATE_ID, TURN_ID, USER_ID,
candidateSnapshotFixture, dossierFixture, fakeAccounting } from "./rectification-v9-test-support.ts";
// Synthetic RPC output boundary fixtures, not engine confirmation evidence.
const civil = { saved_date: "2000-06-14", saved_timezone_offset: -4, day_offset: -1, date_changed: true };
const malformed = [
{ ...civil, saved_date: "2000-02-30" },
{ ...civil, saved_timezone_offset: "-4" },
{ ...civil, saved_timezone_offset: null },
{ ...civil, saved_timezone_offset: 15 },
{ saved_date: civil.saved_date },
{ ...civil, day_offset: 2 },
{ ...civil, date_changed: false },
];
for (const kind of ["accept", "confirm"] as const) {
test(`${kind} validates saved civil output and preserves date-less legacy`, async () => {
async function invoke(fields: Record<string, unknown>) {
const accounting = fakeAccounting({
get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }),
[`${kind}_agentic_rectification_candidate_for_case_v2`]: () => ({
success: true, saved_time: "23:55", status: kind === "accept" ? "accepted" : "confirmed",
result_id: RESULT_ID, ...fields,
}),
});
return kind === "accept"
? acceptV9Candidate(accounting.client, USER_ID, CASE_ID, RESULT_ID, CANDIDATE_ID, TURN_ID)
: confirmV9BirthTime(accounting.client, USER_ID, CASE_ID, {
resultId: RESULT_ID, candidateId: CANDIDATE_ID, requestId: TURN_ID,
consentQuote: "Confirm synthetic boundary", sourceTurnId: TURN_ID,
});
}
const valid = await invoke(civil);
assert.equal(valid.savedDate, civil.saved_date);
assert.equal(valid.savedTimezoneOffset, civil.saved_timezone_offset);
assert.equal(valid.dayOffset, -1);
assert.equal(valid.dateChanged, true);
assert.equal(Object.hasOwn(await invoke({}), "savedDate"), false);
for (const fields of malformed) await assert.rejects(invoke(fields), /invalid_saved_civil_date/);
});
}
function executeRouteRows() {
const script = `
import { mock } from 'node:test';
import { pathToFileURL } from 'node:url';
let fields = {};
const accounting = { rpc: async (name) => ({ error: null, data: name === 'get_agentic_rectification_case'
? { session_id: ${JSON.stringify(SESSION_ID)} }
: name === 'get_agentic_rectification_case_compute'
? { case_id: ${JSON.stringify(CASE_ID)}, skill_version: '9.0.0', baseline_profile_fingerprint: '${"a".repeat(64)}', baseline_birth_snapshot: { birth_date: '1900-06-15' }, candidate_range: { start_time: '04:00', end_time: '06:00' } }
: { success: true, saved_time: '23:55', status: 'accepted', result_id: ${JSON.stringify(RESULT_ID)}, ...fields } }) };
mock.module('server-only', { namedExports: {} });
mock.module('@/lib/supabase/server', { namedExports: { createServerSupabaseClient: async () => ({
auth: { getUser: async () => ({ data: { user: { id: ${JSON.stringify(USER_ID)} } }, error: null }) }
}) } });
mock.module('@/lib/supabase/admin', { namedExports: { createAdminSupabaseClient: () => accounting } });
mock.module('@/lib/product-access', { namedExports: { isProductEnabled: async () => true } });
mock.module('@/lib/rectification-agentic/v9/request-cache', { namedExports: { withRectificationRequestCache: x => x } });
mock.module('@/lib/rectification-agentic/v9/tool-service', { namedExports: {
RectificationToolServiceError: class RectificationToolServiceError extends Error {
constructor(code) { super('Rectification tool service error: ' + code); this.name = 'RectificationToolServiceError'; this.code = code; }
},
loadV9CaseDossier: async () => ({ evidence: [], latestResult: null }),
evidenceLedgerFingerprint: () => 'synthetic',
// 原值: 桩只导出档案读取,采用路由不读出生快照。
// 新值: 同时导出 loadV9CaseCompute,返回虚构 1900-06-15。
// 原因: 采用前共用决策入口会加载这个函数;别名桩替换相对导入后,缺导出就在决断前抛错,路由变成 503。
loadV9CaseCompute: async () => ({
caseId: ${JSON.stringify(CASE_ID)},
skillVersion: '9.0.0',
baselineProfileFingerprint: '${"a".repeat(64)}',
baselineBirthSnapshot: { birth_date: '1900-06-15' },
candidateRange: { start_time: '04:00', end_time: '06:00' },
}),
} });
mock.module('@/lib/rectification-agentic/v9/result-identity', { namedExports: { assertV9ResultWritable: async () => {} } });
mock.module('@/lib/rectification-agentic/v9/interview-state', { namedExports: {
decideFromDossier: () => ({}), overlayPublicDecision: () => ({ can_adopt: true })
} });
mock.module('@/lib/rectification-agentic/core/rectification-decision', { namedExports: {
publicDecisionFields: () => ({ can_adopt: true })
} });
mock.module('@/lib/rectification-agentic/v9/answer-choice', { namedExports: { persistNextInterviewIfIdle: async () => {} } });
const { POST } = await import(pathToFileURL(process.cwd() + '/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts').href);
const results = [];
for (fields of ${JSON.stringify([civil, {}, ...malformed])}) {
const response = await POST(new Request('https://example.invalid/api/rectification/cases/synthetic/candidates/accept', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(${JSON.stringify({
sessionId: SESSION_ID, resultId: RESULT_ID, candidateId: CANDIDATE_ID, requestId: TURN_ID,
})})
}), { params: Promise.resolve({ caseId: ${JSON.stringify(CASE_ID)} }) });
results.push({ status: response.status, body: await response.json() });
}
console.log(JSON.stringify(results));
`;
const result = spawnSync(process.execPath, ["--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script], {
cwd: fileURLToPath(new URL("../", import.meta.url)), encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr || result.stdout);
return JSON.parse(result.stdout.trim().split("\n").at(-1)!) as { status: number; body: Record<string, unknown> }[];
}
let routeRows: ReturnType<typeof executeRouteRows>;
test("UI adoption route preserves complete civil output and date-less legacy", () => {
routeRows = executeRouteRows();
assert.equal(routeRows[0].status, 200, JSON.stringify(routeRows[0]?.body));
assert.equal(routeRows[0].body.ok, true);
for (const [key, value] of Object.entries(civil)) assert.equal(routeRows[0].body[key], value, key);
assert.equal(routeRows[1].status, 200);
assert.equal(routeRows[1].body.ok, true);
assert.equal(Object.hasOwn(routeRows[1].body, "saved_date"), false);
});
test("UI adoption route cannot report success for malformed or partial saved civil output", () => {
routeRows ??= executeRouteRows();
for (const row of routeRows.slice(2)) {
assert.ok(row.status >= 400, JSON.stringify(row));
assert.notEqual(row.body.ok, true);
}
});