Files
Jyotisha/frontend/tests/conversational-rectification-e2e.test.ts
2026-07-27 09:57:14 +08:00

911 lines
39 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import test from "node:test";
import { createBirthTimeConversationPostHandler } from "../src/app/api/birth-time-conversation/handler.ts";
import {
clearBirthTimeConsultationConsent,
createBirthTimeConsultationConsentState,
grantBirthTimeConsultationConsent,
resolveBirthTimeConsultationRoute,
} from "../src/lib/birth-time-consultation-consent.ts";
import { isDeclaredBirthProfileComplete } from "../src/lib/birth-time-intake-model.ts";
import {
CONVERSATIONAL_RECTIFICATION_UNAVAILABLE,
sendConversationalRectificationCommand,
} from "../src/lib/conversational-rectification/client.ts";
import {
conversationalRectificationTurnSchema,
type ConversationalRectificationTurn,
} from "../src/lib/conversational-rectification/contracts.ts";
import { ConversationalRectificationError } from "../src/lib/conversational-rectification/errors.ts";
import {
createConversationalRectificationService,
type ConversationalRectificationServicePorts,
} from "../src/lib/conversational-rectification/orchestrator.ts";
import type { RectificationTechnicalPacket } from "../src/lib/conversational-rectification/technical-packet.ts";
import type {
LoadedConversationalRectificationCase,
StoredConversationalRectificationCase,
} from "../src/lib/conversational-rectification/store.ts";
import type { ConversationalRectificationTelemetryPayload } from "../src/lib/birth-time-journey-telemetry.ts";
import { createConversationalRectificationTelemetry } from "../src/lib/birth-time-journey-telemetry.ts";
import { conversationalRectificationCreationPolicy } from "../src/lib/conversational-rectification/creation-policy.ts";
const userId = "00000000-0000-4000-8000-000000009001";
const caseId = "00000000-0000-4000-8000-000000009002";
const originalQuestion = "我下一次适合换工作的时间是什么时候?";
const deploymentSha = "0123456789abcdef0123456789abcdef01234567";
const openingNarrative = "根据你填写的出生时间信息,当前先核对 05:00–06:00。这只是待核对范围,还不能把其中某一分钟当作已确认出生时间。你可以按自己的节奏讲已经发生的人生经历,一次说一件或连续说多件都可以;记得的年月可以自然地带上,不确定也没关系。";
const intermediateFallbackNarrative = "我听到了。你愿意接着说说这件事之后发生了什么吗?";
const declaredBirthInput = {
source: "approximate" as const,
birthDate: "1990-01-01",
reportedTime: "05:30",
uncertaintyBeforeMinutes: 30 as const,
uncertaintyAfterMinutes: 30 as const,
birthTimeClue: "家人只记得天刚亮",
birthplace: {
countryCode: "CN",
provinceCode: "130000",
cityCode: "130400",
districtCode: "130406",
latitude: 36.420487,
longitude: 114.209936,
timezoneOffset: 8,
},
};
const onboardingDraft = {
date: "1990-01-01",
time: "05:30",
reportedTime: "05:30",
birthTimeStatus: "reported" as const,
birthTimeSource: "approximate" as const,
birthTimePeriod: "" as const,
birthTimeClue: "家人只记得天刚亮",
uncertaintyBeforeMinutes: 30,
uncertaintyAfterMinutes: 30,
countryCode: "CN",
provinceCode: "130000",
cityCode: "130400",
districtCode: "130406",
city: "合成测试城市",
latitude: 36.420487,
longitude: 114.209936,
timezoneOffset: 8,
};
function technicalPacket(ready: boolean): RectificationTechnicalPacket {
return {
calculationVersion: "synthetic-rectification-v3",
candidate: {
status: ready ? "ready_for_confirmation" : "pending_validation",
representativeTime: ready ? "05:18" : "05:30",
range: ready
? { startTime: "05:16", endTime: "05:20" }
: { startTime: "05:00", endTime: "06:00" },
},
useBoundary: ready
? "候选已达到确认门槛,但明确确认前仍使用旧的账户排盘时间。"
: "当前时间只是待验证候选,不能当作已经校正完成的出生分钟。",
candidateModelRefs: ["synthetic-model-ref"],
candidateDifferenceRefs: ["synthetic-d1", "synthetic-d9", "synthetic-d10", "synthetic-d24"],
candidateWeights: ready ? { "05:18": 0.75, "05:19": 0.25 } : { "05:30": 0.5 },
partitionIds: ["private-synthetic-partition"],
d1Stability: "stable",
boundaryDistanceMinutes: ready ? 2 : 30,
sensitivityScope: {
source: "time_linked_candidate_scan_samples",
rangeStart: ready ? "05:16" : "05:00",
rangeEnd: ready ? "05:20" : "06:00",
sampleTimes: ready ? ["05:16", "05:18", "05:20"] : ["05:00", "05:30", "06:00"],
},
stableLayers: [{ layer: "D1", values: ["Cancer"], referenceIds: ["synthetic-d1"] }],
sensitiveLayers: [
{ layer: "D9", values: ["Aries", "Leo"], referenceIds: ["synthetic-d9"] },
{ layer: "D10", values: ["Taurus", "Libra"], referenceIds: ["synthetic-d10"] },
{ layer: "D24", values: ["Gemini", "Virgo"], referenceIds: ["synthetic-d24"] },
],
supportedSensitiveLayers: ["D9", "D10", "D24"],
scoredHistoricalEvidence: ready ? [{
evidenceId: "00000000-0000-4000-8000-000000009099",
domain: "career",
candidateTime: "05:18",
score: 8,
ruleRefs: ["synthetic-history-rule"],
}] : [],
suggestedDomains: [
{ domain: "career", layer: "D10", reason: "D10 在候选范围内变化,已发生的事业事件可以区分候选。" },
{ domain: "education", layer: "D24", reason: "D24 在候选范围内变化,已发生的学业事件可以区分候选。" },
{ domain: "relocation", layer: "D9", reason: "D9 在候选范围内变化,已发生的搬迁事件可以区分候选。" },
],
referenceIds: ["synthetic-d1", "synthetic-d9", "synthetic-d10", "synthetic-d24", "synthetic-history-rule"],
futureWindows: [{
label: "未来背景窗口",
startDate: "2027-01-01",
endDate: "2027-03-31",
scoreable: false,
}],
};
}
function narrativeGenerator() {
return {
modelId: "synthetic-grounded-narrator",
async generate(prompt: string) {
const request = JSON.parse(prompt) as {
phase: "first" | "intermediate" | "final";
packet: ReturnType<typeof technicalPacket> & {
candidate: ReturnType<typeof technicalPacket>["candidate"] & {
rangeStart: string;
rangeEnd: string;
};
};
};
const packet = request.packet;
const final = request.phase === "final";
return { text: JSON.stringify({
narrative: [
`当前仍在核对 ${packet.candidate.rangeStart}${packet.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`,
final ? "现有已发生事件支持进入候选确认。" : "先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。",
].join(""),
evidenceRequest: final ? null : {
domains: ["career"],
datePrecision: "month_preferred",
prompt: "请提供已经发生的真实事件,并写明哪一年、哪一月以及发生了什么。",
},
}) };
},
};
}
type Receipt = Readonly<{
kind: "save_turn" | "pause" | "abandon" | "confirm";
expectedVersion: number;
fingerprint: string;
row: StoredConversationalRectificationCase;
}>;
function createSyntheticBackend(options: {
legacy?: boolean;
allowNewCaseCreation?: boolean;
packetFailure?: boolean;
createFailure?: boolean;
initialReady?: boolean;
packetEvidenceCalls?: string[][];
} = {}) {
const cases = new Map<string, LoadedConversationalRectificationCase>();
const receipts = new Map<string, Receipt>();
let activeTime = "04:58";
let billingState: LoadedConversationalRectificationCase["billingState"] = null;
let reserveCount = 0;
let chargeCount = 0;
let releaseCount = 0;
const legacyCaseId = "00000000-0000-4000-8000-000000009080";
const withBilling = (state: LoadedConversationalRectificationCase["billingState"]) => {
billingState = state;
for (const [id, row] of cases) cases.set(id, { ...row, billingState: state });
};
const save = (input: {
row?: LoadedConversationalRectificationCase;
userId: string;
caseId: string;
turn: unknown;
privateCandidate: LoadedConversationalRectificationCase["privateCandidate"];
evidence?: LoadedConversationalRectificationCase["eventEvidence"];
receipt?: LoadedConversationalRectificationCase["validationReceipts"][number];
importedFromCaseId?: string | null;
}): LoadedConversationalRectificationCase => {
const prior = input.row;
const turn = conversationalRectificationTurnSchema.parse(input.turn);
const next = {
caseId: input.caseId,
userId: input.userId,
status: turn.status,
turnVersion: turn.turnVersion,
revisionOfCaseId: prior?.revisionOfCaseId ?? "00000000-0000-4000-8000-000000009000",
importedFromCaseId: input.importedFromCaseId ?? prior?.importedFromCaseId ?? null,
baselineActiveTime: "04:58",
pendingConsultationQuestion: turn.pendingConsultationQuestion,
billingState,
latestTurn: structuredClone(turn),
declaredBirthInput,
privateCandidate: structuredClone(input.privateCandidate),
eventEvidence: structuredClone(input.evidence ?? prior?.eventEvidence ?? []),
validationReceipts: [
...(prior?.validationReceipts ?? []),
...(input.receipt ? [structuredClone(input.receipt)] : []),
],
} satisfies LoadedConversationalRectificationCase;
cases.set(input.caseId, next);
return next;
};
const replay = (input: {
userId: string;
caseId: string;
expectedVersion: number;
actionId: string;
commandFingerprint: string;
}, kind: Receipt["kind"], mutate: () => LoadedConversationalRectificationCase) => {
const prior = receipts.get(input.actionId);
if (prior) {
if (prior.kind !== kind || prior.expectedVersion !== input.expectedVersion
|| prior.fingerprint !== input.commandFingerprint) throw new ConversationalRectificationError("action_conflict");
return prior.row;
}
const row = mutate();
receipts.set(input.actionId, { kind, expectedVersion: input.expectedVersion, fingerprint: input.commandFingerprint, row });
return row;
};
const store: ConversationalRectificationServicePorts["store"] = {
async loadActionReceipt(input) {
const prior = receipts.get(input.actionId);
if (!prior) return null;
if (prior.kind !== input.actionKind || prior.expectedVersion !== input.expectedVersion
|| prior.fingerprint !== input.commandFingerprint) throw new ConversationalRectificationError("action_conflict");
return prior.row;
},
async loadCase(input) {
const row = input.caseId ? cases.get(input.caseId) : [...cases.values()].at(-1);
return row?.userId === input.userId ? structuredClone(row) : null;
},
async createCaseWithFirstTurn(input) {
if (options.createFailure) throw new Error("synthetic create failure");
return save({
userId: input.userId,
caseId: input.caseId,
turn: input.firstTurn,
privateCandidate: input.privateCandidate,
receipt: input.validationReceipt,
});
},
async saveTurn(input) {
return replay(input, "save_turn", () => {
const row = cases.get(input.caseId);
if (!row || row.userId !== input.userId) throw new ConversationalRectificationError("case_not_found");
if (row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
return save({
row,
userId: input.userId,
caseId: input.caseId,
turn: input.turn,
privateCandidate: input.privateCandidate,
evidence: [...row.eventEvidence, ...input.evidence],
receipt: input.validationReceipt,
});
});
},
async pause(input) {
return replay(input, "pause", () => {
const row = cases.get(input.caseId);
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
privateCandidate: row.privateCandidate, receipt: input.validationReceipt });
});
},
async abandon(input) {
return replay(input, "abandon", () => {
const row = cases.get(input.caseId);
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
privateCandidate: row.privateCandidate, receipt: input.validationReceipt });
});
},
async confirm(input) {
return replay(input, "confirm", () => {
const row = cases.get(input.caseId);
if (!row || row.turnVersion !== input.expectedVersion) throw new ConversationalRectificationError("stale_turn");
if (row.privateCandidate.resultId !== input.resultId
|| row.privateCandidate.representativeTime !== input.time
|| row.privateCandidate.calculationVersion !== input.calculationVersion) {
throw new ConversationalRectificationError("candidate_changed");
}
activeTime = input.time;
return save({ row, userId: input.userId, caseId: input.caseId, turn: input.turn,
privateCandidate: { ...row.privateCandidate, workingState: { phase: "confirmed", iteration: 4, notes: [] } },
receipt: input.validationReceipt });
});
},
async importLegacy(input) {
if (cases.has(input.caseId)) return cases.get(input.caseId)!;
withBilling("migration_waived");
return save({
userId: input.userId,
caseId: input.caseId,
turn: input.firstTurn,
privateCandidate: input.privateCandidate,
evidence: [...input.evidence],
receipt: input.validationReceipt,
importedFromCaseId: input.legacyCaseId,
});
},
};
const service = createConversationalRectificationService({
store,
billing: {
async reserve() { reserveCount += 1; withBilling("reserved"); return { success: true, credits: 97, billingState: "reserved" }; },
async complete() { chargeCount += 1; withBilling("charged"); return { success: true, credits: 97, billingState: "charged" }; },
async release() { releaseCount += 1; withBilling("released"); return { success: true, credits: 100, billingState: "released" }; },
},
rectificationPriceCredits: 3,
allowNewCaseCreation: options.allowNewCaseCreation,
async loadDeclaredProfile() {
return {
declaredBirthInput,
revisionOfCaseId: "00000000-0000-4000-8000-000000009000",
legacyCaseId: options.legacy ? legacyCaseId : null,
};
},
async loadLegacyCase(receivedUserId, receivedCaseId) {
if (!options.legacy || receivedUserId !== userId || receivedCaseId !== legacyCaseId) return null;
return {
caseId: legacyCaseId,
userId,
journeyProtocol: "dynamic-choice-v2",
status: "rectifying",
turnVersion: 4,
declaredBirthInput,
currentRange: { startTime: "05:10", endTime: "05:50" },
lifeEvents: [{
id: "00000000-0000-4000-8000-000000009081",
domain: "career",
precision: "month",
date: "2014-07",
}],
currentChoicePrompt: "2006-2011 还是 2011-2016",
choiceAnswers: ["A"],
};
},
async buildTechnicalPacket(input) {
if (options.packetFailure) throw new Error("synthetic packet failure");
options.packetEvidenceCalls?.push(input.evidence.map((item) => item.id));
const ready = options.initialReady === true
|| input.evidence.filter((item) => item.scoreable === true && item.extractionStatus !== "needs_clarification").length >= 3;
const packet = technicalPacket(ready);
if (input.preserveCandidateRange && input.privateCandidate?.rangeStart && input.privateCandidate.rangeEnd) {
return {
packet: {
...packet,
candidate: {
...packet.candidate,
status: "pending_validation",
range: { startTime: input.privateCandidate.rangeStart, endTime: input.privateCandidate.rangeEnd },
},
sensitivityScope: {
...packet.sensitivityScope,
rangeStart: input.privateCandidate.rangeStart,
rangeEnd: input.privateCandidate.rangeEnd,
},
},
resultId: null,
};
}
return { packet, resultId: ready ? "00000000-0000-4000-8000-000000009099" : null };
},
narrativeGenerator: narrativeGenerator(),
asOfDate: () => "2026-07-21",
});
return {
service,
cases,
billing: () => ({ reserveCount, chargeCount, releaseCount, state: billingState }),
activeTime: () => activeTime,
legacyCaseId,
};
}
test("technical readiness cannot bypass the three-event confirmation gate on the first turn", async () => {
const backend = createSyntheticBackend({ initialReady: true });
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
deploymentSha,
});
const turn = await post(handler, {
type: "start",
actionId: caseId,
pendingConsultationQuestion: originalQuestion,
});
assert.equal(turn.status, "active");
assert.equal(turn.candidate.status, "pending_validation");
assert.equal(turn.actions.includes("confirm"), false);
assert.equal(backend.cases.get(caseId)?.privateCandidate.resultId, null);
assert.equal(backend.cases.get(caseId)?.privateCandidate.workingState?.phase, "collecting_evidence");
});
async function post(
handler: (request: Request) => Promise<Response>,
command: Record<string, unknown>,
): Promise<ConversationalRectificationTurn> {
const response = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(command),
}));
const payload = await response.json();
assert.equal(response.status, 200, JSON.stringify(payload));
return payload as ConversationalRectificationTurn;
}
test("authenticated synthetic flow covers soft entry, rich evidence, resume, and safety gates", async () => {
assert.equal(isDeclaredBirthProfileComplete(onboardingDraft), true, "onboarding may finish without rectification");
let consent = createBirthTimeConsultationConsentState();
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
kind: "consult", mode: "unverified_birth_time", time: "05:30",
});
consent = grantBirthTimeConsultationConsent(consent, "chat-a", "unverified_birth_time");
assert.deepEqual(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a"), {
kind: "consult", mode: "unverified_birth_time", time: "05:30",
});
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-b").kind, "consult");
consent = clearBirthTimeConsultationConsent(consent, "chat-a");
assert.equal(resolveBirthTimeConsultationRoute(onboardingDraft, consent, "chat-a").kind, "consult");
const packetEvidenceCalls: string[][] = [];
const backend = createSyntheticBackend({ packetEvidenceCalls });
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
deploymentSha,
telemetry: (payload) => telemetry.push(payload),
now: (() => { let value = 0; return () => (value += 25); })(),
});
let turn = await post(handler, { type: "start", actionId: caseId, pendingConsultationQuestion: originalQuestion });
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
assert.equal(turn.status, "active");
assert.equal(
turn.narrative,
openingNarrative,
"the first visible reply must use the deterministic fast opening",
);
assert.doesNotMatch(turn.narrative, /\bD\d+\b/);
assert.equal(turn.evidenceRequest, null);
assert.equal(JSON.stringify(turn).includes("candidateWeights"), false);
assert.equal(JSON.stringify(turn).includes("private-synthetic-partition"), false);
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
assert.equal(telemetry.at(-1)?.billingState, "charged");
assert.equal(backend.activeTime(), "04:58", "revision must retain the old active minute");
turn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009003",
turnVersion: turn.turnVersion, domain: "career", answer: "都不符合,我想换一个方向",
});
assert.equal(turn.status, "active");
assert.equal(
turn.narrative,
intermediateFallbackNarrative,
"a direction change must show the model reply instead of a program-authored redirect template",
);
assert.doesNotMatch(turn.narrative, /不沿用不符合|自由描述另一件已经发生/);
assert.equal(backend.billing().chargeCount, 1);
turn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009004",
turnVersion: turn.turnVersion, domain: "career", answer: "后来工作压力很大",
});
assert.equal(
turn.narrative,
intermediateFallbackNarrative,
);
assert.doesNotMatch(turn.narrative, /我先按你的原话记下|我已保存你的原话|还差时间定位/);
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "日期待补充");
const futureEvidenceAction = "00000000-0000-4000-8000-000000009050";
turn = await post(handler, {
type: "answer", caseId, actionId: futureEvidenceAction,
turnVersion: turn.turnVersion, domain: "career", answer: "2027年计划换工作",
});
assert.equal(
turn.narrative,
intermediateFallbackNarrative,
"future evidence must remain non-scoreable without replacing the model's visible answer",
);
assert.doesNotMatch(turn.narrative, /未来事件只能作为背景|不能用于校正评分/);
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2027(未来,仅作背景)");
const futureEvidenceId = turn.evidenceRecap.at(-1)?.id;
assert.ok(futureEvidenceId);
const historicalEvidenceIds: string[] = [];
turn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009005",
turnVersion: turn.turnVersion, domain: "career", answer: "2014年7月第一次正式入职",
});
assert.equal(turn.evidenceRecap.at(-1)?.dateLabel, "2014-07");
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
turn = await post(handler, {
type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009006",
turnVersion: turn.turnVersion,
});
assert.equal(turn.status, "paused");
const secondDevice = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
deploymentSha,
telemetry: (payload) => telemetry.push(payload),
});
turn = await post(secondDevice, {
type: "resume", caseId, actionId: "00000000-0000-4000-8000-000000009007",
turnVersion: turn.turnVersion,
});
assert.equal(turn.status, "paused");
assert.equal(turn.pendingConsultationQuestion, originalQuestion);
turn = await post(secondDevice, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009008",
turnVersion: turn.turnVersion, domain: "education", answer: "2011年6月大学毕业",
});
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
turn = await post(secondDevice, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009009",
turnVersion: turn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
});
historicalEvidenceIds.push(turn.evidenceRecap.at(-1)!.id);
assert.equal(turn.status, "active");
assert.equal(turn.candidate.status, "pending_validation");
assert.doesNotMatch(turn.actions.join(","), /confirm/);
assert.equal(backend.activeTime(), "04:58", "evidence collection must not replace the active minute");
assert.equal(packetEvidenceCalls.some((ids) => ids.includes(futureEvidenceId)), false);
assert.deepEqual(packetEvidenceCalls.at(-1), historicalEvidenceIds);
assert.equal(backend.billing().chargeCount, 1);
const allowedTelemetryKeys = [
"protocol", "phase", "actionKind", "resultCategory", "latencyBucket",
"billingState", "errorCategory", "deploymentSha",
].sort();
assert.ok(telemetry.length >= 8);
for (const payload of telemetry) {
assert.deepEqual(Object.keys(payload).sort(), allowedTelemetryKeys);
assert.equal(payload.protocol, "conversational-evidence-v3");
assert.equal(payload.deploymentSha, deploymentSha);
const serialized = JSON.stringify(payload);
for (const forbidden of [originalQuestion, "1990-01-01", userId, caseId, "05:18", "05:30"]) {
assert.equal(serialized.includes(forbidden), false);
}
}
});
test("legacy unfinished work imports once with migration waiver and no questionnaire or charge", async () => {
const backend = createSyntheticBackend({ legacy: true });
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: (payload) => telemetry.push(payload),
});
const first = await post(handler, { type: "start", actionId: caseId });
const replay = await backend.service.start(userId, { type: "start", actionId: caseId });
assert.deepEqual(replay, first);
assert.equal(backend.cases.get(caseId)?.importedFromCaseId, backend.legacyCaseId);
assert.equal(backend.cases.get(caseId)?.billingState, "migration_waived");
assert.deepEqual(backend.billing(), { reserveCount: 0, chargeCount: 0, releaseCount: 0, state: "migration_waived" });
assert.deepEqual(first.candidate.rangeStart, "05:10");
assert.deepEqual(first.candidate.rangeEnd, "05:50");
assert.doesNotMatch(first.narrative, /2006-2011|2011-2016|哪个时间段/);
assert.equal(JSON.stringify(first).includes("choiceAnswers"), false);
assert.equal(telemetry.at(-1)?.billingState, "migration_waived");
});
test("v3 telemetry drops invalid payloads without affecting the product request", () => {
const emitted: unknown[] = [];
const record = createConversationalRectificationTelemetry((payload) => emitted.push(payload));
const valid = {
protocol: "conversational-evidence-v3",
phase: "collecting_evidence",
actionKind: "answer",
resultCategory: "success",
latencyBucket: "lt_100ms",
billingState: "unchanged",
errorCategory: "none",
deploymentSha,
} as const;
record(valid);
for (const forbidden of [
"narrative", "eventText", "birthDate", "birthTime", "email", "userId",
"accessToken", "refreshToken", "modelPrompt", "caseId", "actionId",
]) {
assert.doesNotThrow(() => record({ ...valid, [forbidden]: "private" } as never));
}
assert.deepEqual(emitted, [valid]);
});
test("telemetry reports released reservations and authentication rejects before service creation", async () => {
const backend = createSyntheticBackend({ createFailure: true });
const telemetry: ConversationalRectificationTelemetryPayload[] = [];
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: (payload) => telemetry.push(payload),
});
const failed = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: caseId }),
}));
assert.equal(failed.status, 503);
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 0, releaseCount: 1, state: "released" });
assert.equal(telemetry.at(-1)?.billingState, "released");
assert.equal(telemetry.at(-1)?.errorCategory, "dependency");
let serviceCreations = 0;
const unauthenticated = createBirthTimeConversationPostHandler({
authenticate: async () => null,
createService: async () => { serviceCreations += 1; return backend.service; },
telemetry: () => undefined,
});
const rejected = await unauthenticated(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: caseId }),
}));
assert.equal(rejected.status, 401);
assert.equal(serviceCreations, 0);
});
test("authenticated route remains owner-bound", async () => {
const backend = createSyntheticBackend();
const started = await backend.service.start(userId, { type: "start", actionId: caseId });
const foreign = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId: "00000000-0000-4000-8000-000000009999", context: {} }),
createService: async () => backend.service,
telemetry: () => undefined,
});
const response = await foreign(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
type: "resume",
caseId,
actionId: "00000000-0000-4000-8000-000000009998",
turnVersion: started.turnVersion,
}),
}));
assert.equal(response.status, 404);
assert.deepEqual(backend.billing(), { reserveCount: 1, chargeCount: 1, releaseCount: 0, state: "charged" });
});
test("transient 502 replays the same command and terminal failures expose only stable Chinese copy", async () => {
const originalFetch = globalThis.fetch;
const bodies: string[] = [];
let attempts = 0;
globalThis.fetch = async (_input, init) => {
attempts += 1;
bodies.push(String(init?.body));
if (attempts === 1) return new Response("Bad gateway", { status: 502 });
return Response.json({
caseId,
journeyProtocol: "conversational-evidence-v3",
status: "active",
turnVersion: 0,
narrative: "合成安全响应。",
candidate: { status: "pending_validation", representativeTime: "05:30", rangeStart: "05:00", rangeEnd: "06:00" },
technicalReceipt: { calculationVersion: "synthetic-v1", stableLayers: ["D1"], sensitiveLayers: ["D9"], candidateDifferenceRefs: [] },
evidenceRequest: { domains: ["career", "education"], datePrecision: "month_preferred", freeTextAllowed: true },
evidenceRecap: [],
actions: ["answer", "pause", "abandon"],
pendingConsultationQuestion: null,
});
};
try {
const turn = await sendConversationalRectificationCommand({ type: "start", actionId: caseId });
assert.equal(turn.status, "active");
assert.equal(attempts, 2);
assert.equal(bodies[0], bodies[1]);
globalThis.fetch = async () => new Response("The string did not match the expected pattern", { status: 502 });
await assert.rejects(
sendConversationalRectificationCommand({ type: "start", actionId: caseId }),
(error: unknown) => error instanceof Error
&& error.message === CONVERSATIONAL_RECTIFICATION_UNAVAILABLE
&& !error.message.includes("expected pattern"),
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("health exposes deployment identity and explicit v3 rollout readiness without environment secrets", async () => {
const originalFetch = globalThis.fetch;
const prior = {
GITHUB_SHA: process.env.GITHUB_SHA,
RECTIFICATION_V3_CREATE_ENABLED: process.env.RECTIFICATION_V3_CREATE_ENABLED,
RECTIFICATION_V3_MIGRATIONS_READY: process.env.RECTIFICATION_V3_MIGRATIONS_READY,
RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA,
RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS: process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS,
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
};
process.env.GITHUB_SHA = deploymentSha;
process.env.RECTIFICATION_V3_CREATE_ENABLED = "true";
process.env.RECTIFICATION_V3_MIGRATIONS_READY = "true";
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA = deploymentSha;
process.env.RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS = userId;
process.env.NEXT_PUBLIC_SUPABASE_URL = "https://example.invalid";
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = "synthetic-public-key";
process.env["SUPABASE_SERVICE_ROLE_KEY"] = "synthetic-runtime-secret-never-return";
process.env.OPENAI_API_KEY = "synthetic-model-key";
globalThis.fetch = async () => Response.json({ status: "ok" });
try {
const { GET: healthGet } = await import(`../src/app/api/health/route.ts?e2e=${Date.now()}`);
const response = await healthGet();
const body = await response.json() as Record<string, unknown>;
assert.deepEqual(body.deployment, { gitCommit: deploymentSha });
assert.deepEqual(body.rollout, {
conversationalRectificationV3: {
protocol: "conversational-evidence-v3",
newCaseCreation: "enabled",
creationAudience: "public",
migrations: "ready",
syntheticSmoke: "matched",
readyForNewCases: true,
},
});
assert.equal(JSON.stringify(body).includes("synthetic-runtime-secret-never-return"), false);
assert.equal(JSON.stringify(body).includes(userId), false);
} finally {
globalThis.fetch = originalFetch;
for (const [key, value] of Object.entries(prior)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
test("pending smoke policy blocks ordinary paid and legacy starts before billing", async () => {
const ordinaryUser = "00000000-0000-4000-8000-000000009099";
const policyInput = {
creationEnabled: "true",
migrationsReady: "true",
deploymentSha,
smokeSha: "",
syntheticSmokeUserIds: userId,
} as const;
const ordinaryPolicy = conversationalRectificationCreationPolicy({
...policyInput,
userId: ordinaryUser,
});
assert.equal(ordinaryPolicy.audience, "smoke_only");
assert.equal(ordinaryPolicy.allowNewCaseCreation, false);
for (const legacy of [false, true]) {
const backend = createSyntheticBackend({
legacy,
allowNewCaseCreation: ordinaryPolicy.allowNewCaseCreation,
});
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId: ordinaryUser, context: {} }),
createService: async () => backend.service,
telemetry: () => undefined,
});
const response = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: caseId }),
}));
assert.equal(response.status, 503);
assert.deepEqual(backend.billing(), {
reserveCount: 0,
chargeCount: 0,
releaseCount: 0,
state: null,
});
assert.equal(backend.cases.size, 0);
}
const smokePolicy = conversationalRectificationCreationPolicy({ ...policyInput, userId });
const smokeBackend = createSyntheticBackend({
allowNewCaseCreation: smokePolicy.allowNewCaseCreation,
});
await smokeBackend.service.start(userId, { type: "start", actionId: caseId });
assert.deepEqual(smokeBackend.billing(), {
reserveCount: 1,
chargeCount: 1,
releaseCount: 0,
state: "charged",
});
const publicPolicy = conversationalRectificationCreationPolicy({
...policyInput,
userId: ordinaryUser,
smokeSha: deploymentSha,
syntheticSmokeUserIds: "",
});
const publicBackend = createSyntheticBackend({
allowNewCaseCreation: publicPolicy.allowNewCaseCreation,
});
await publicBackend.service.start(ordinaryUser, { type: "start", actionId: caseId });
assert.equal(publicBackend.billing().chargeCount, 1);
});
test("rollback flag stops only new cases while existing v3 resume stays readable", async () => {
const enabled = createSyntheticBackend();
await enabled.service.start(userId, { type: "start", actionId: caseId });
const pendingPolicy = conversationalRectificationCreationPolicy({
userId,
creationEnabled: "true",
migrationsReady: "true",
deploymentSha,
smokeSha: "",
syntheticSmokeUserIds: "00000000-0000-4000-8000-000000009099",
});
assert.equal(pendingPolicy.audience, "smoke_only");
const backend = createSyntheticBackend({
allowNewCaseCreation: pendingPolicy.allowNewCaseCreation,
});
backend.cases.set(caseId, enabled.cases.get(caseId)!);
const blockedHandler = createBirthTimeConversationPostHandler({
authenticate: async () => ({
userId: "00000000-0000-4000-8000-000000009098",
context: {},
}),
createService: async () => backend.service,
telemetry: () => undefined,
});
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: () => undefined,
});
const blockedStart = await blockedHandler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: "00000000-0000-4000-8000-000000009050" }),
}));
assert.equal(blockedStart.status, 503);
const lostStartReplay = await post(handler, { type: "start", actionId: caseId });
assert.equal(lostStartReplay.caseId, caseId, "an existing start identity remains replayable");
const resumed = await post(handler, {
type: "resume", caseId, actionId: "00000000-0000-4000-8000-000000009051", turnVersion: 0,
});
assert.equal(resumed.caseId, caseId);
let existingTurn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009052",
turnVersion: resumed.turnVersion, domain: "career", answer: "2014年7月第一次正式入职",
});
existingTurn = await post(handler, {
type: "pause", caseId, actionId: "00000000-0000-4000-8000-000000009053",
turnVersion: existingTurn.turnVersion,
});
assert.equal(existingTurn.status, "paused");
existingTurn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009054",
turnVersion: existingTurn.turnVersion, domain: "education", answer: "2011年6月大学毕业",
});
existingTurn = await post(handler, {
type: "answer", caseId, actionId: "00000000-0000-4000-8000-000000009055",
turnVersion: existingTurn.turnVersion, domain: "relocation", answer: "2018年9月搬到外地生活",
});
assert.equal(existingTurn.status, "active");
assert.equal(existingTurn.caseId, caseId);
assert.equal(backend.activeTime(), "04:58");
});
test("a throwing injected telemetry sink cannot turn a committed request into failure", async () => {
const backend = createSyntheticBackend();
const handler = createBirthTimeConversationPostHandler({
authenticate: async () => ({ userId, context: {} }),
createService: async () => backend.service,
telemetry: () => { throw new Error("synthetic telemetry outage"); },
});
const response = await handler(new Request("https://example.invalid/api/birth-time-conversation", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "start", actionId: caseId }),
}));
assert.equal(response.status, 200);
assert.equal(backend.cases.get(caseId)?.status, "active");
assert.equal(backend.billing().chargeCount, 1);
});