import assert from "node:assert/strict"; import test from "node:test"; import { createBirthTimeConversationPostHandler } from "../src/app/api/birth-time-conversation/route.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 { createRectificationQuestionHandoffCoordinator } from "../src/lib/rectification-question-handoff.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 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 & { candidate: ReturnType["candidate"] & { rangeStart: string; rangeEnd: string; }; }; }; const packet = request.packet; const final = request.phase === "final"; const nextDomain = packet.suggestedDomains[0]?.domain ?? "career"; return { text: JSON.stringify({ narrative: [ `当前仍在核对 ${packet.candidate.rangeStart}–${packet.candidate.rangeEnd} 的候选范围,不能视为已经确认的出生分钟。`, final ? "现有已发生事件支持进入候选确认。" : "先说一件已经发生的重要经历好吗?请注明哪一年、哪一月以及发生了什么。", ].join(""), candidateStatus: packet.candidate.status, representativeTime: packet.candidate.representativeTime, rangeStart: packet.candidate.rangeStart, rangeEnd: packet.candidate.rangeEnd, useBoundary: packet.useBoundary, stableLayers: packet.stableLayers.map((item) => item.layer), sensitiveLayers: packet.sensitiveLayers.map((item) => item.layer), referenceIds: [], domainReasons: packet.suggestedDomains.map((item) => ({ ...item })), evidenceRequest: final ? null : { domains: [nextDomain], 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; initialReady?: boolean; packetEvidenceCalls?: string[][]; } = {}) { const cases = new Map(); const receipts = new Map(); 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) { 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, command: Record, ): Promise { 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, atomic confirmation, and handoff", 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.match(turn.narrative, /05:00–06:00.*不能/); assert.doesNotMatch(turn.narrative, /\bD\d+\b/); assert.equal((turn.narrative.match(/[??]/g) ?? []).length, 1); assert.match(turn.narrative, /哪一年、哪一月/); assert.deepEqual(turn.evidenceRequest?.domains, ["career"]); assert.equal(turn.evidenceRequest?.freeTextAllowed, true); 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.match(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.match(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.match(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, "confirming"); assert.equal(turn.candidate.representativeTime, "05:18"); assert.equal(backend.activeTime(), "04:58"); assert.equal(packetEvidenceCalls.some((ids) => ids.includes(futureEvidenceId)), false); assert.deepEqual(packetEvidenceCalls.at(-1), historicalEvidenceIds); const wrong = await handler(new Request("https://example.invalid/api/birth-time-conversation", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009010", turnVersion: turn.turnVersion, time: "05:17", }), })); assert.equal(wrong.status, 409); assert.equal(backend.activeTime(), "04:58", "a failed confirmation must be atomic"); assert.equal(telemetry.at(-1)?.phase, "confirming"); assert.equal(telemetry.at(-1)?.billingState, "unchanged"); turn = await post(secondDevice, { type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009011", turnVersion: turn.turnVersion, time: "05:18", }); assert.equal(turn.status, "completed"); assert.deepEqual(turn.actions, ["continue_original_question"]); assert.equal(backend.activeTime(), "05:18"); assert.equal(backend.billing().chargeCount, 1); let ordinaryReservations = 0; let ordinaryAnswers = 0; const handoff = createRectificationQuestionHandoffCoordinator<"timing">(); const continued = await handoff.continueOriginalQuestion( turn.pendingConsultationQuestion ?? "", { sessionId: "new-device-chat", theme: "timing" }, async (context) => { ordinaryReservations += 1; ordinaryAnswers += 1; assert.equal(context.question, originalQuestion); assert.equal(backend.activeTime(), "05:18"); return true; }, ); assert.equal(continued, true); assert.equal(ordinaryReservations, 1); assert.equal(ordinaryAnswers, 1); const chats = new Set(["new-device-chat"]); chats.delete("new-device-chat"); assert.equal(chats.size, 0); const caseAfterChatDeletion = backend.cases.get(caseId); assert.equal(caseAfterChatDeletion?.status, "completed", "chat deletion must not cascade to the account case"); const allowedTelemetryKeys = [ "protocol", "phase", "actionKind", "resultCategory", "latencyBucket", "billingState", "errorCategory", "deploymentSha", ].sort(); assert.ok(telemetry.length >= 10); 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({ packetFailure: 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; 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 handler = createBirthTimeConversationPostHandler({ authenticate: async () => ({ userId, context: {} }), createService: async () => backend.service, telemetry: () => undefined, }); const blockedStart = await handler(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, "confirming"); existingTurn = await post(handler, { type: "confirm", caseId, actionId: "00000000-0000-4000-8000-000000009056", turnVersion: existingTurn.turnVersion, time: "05:18", }); assert.equal(existingTurn.status, "completed"); assert.equal(backend.activeTime(), "05:18"); }); 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); });