Files
Jyotisha/frontend/tests/birth-time-scoring-recovery.test.ts
T
2026-07-18 18:40:23 +08:00

223 lines
8.5 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { candidateResultSchema } from "../src/lib/birth-time-evidence.ts";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import {
confirmActionId,
draftActionId,
existingCareerEvent,
existingEducationEvent,
guidedCase,
journeyCaseId,
lowCandidate,
memoryStore,
secondActionId,
thirdActionId,
unusedJourneyEngine,
} from "./birth-time-journey-test-support.ts";
import {
pendingScoringFlow,
scoringTestUserId as userId,
} from "./birth-time-scoring-test-support.ts";
const mediumCandidate = candidateResultSchema.parse({
...lowCandidate,
resultId: "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7",
confidence: "medium",
winningSegment: {
startTime: "05:18",
endTime: "05:24",
representativeTime: "05:21",
widthMinutes: 7,
},
});
test("a completion write failure is reclaimed only after the processing lease", async () => {
let now = new Date("2026-07-18T00:00:00.000Z");
let failCompletion = true;
const flow = await pendingScoringFlow({
score: async () => lowCandidate,
now: () => now,
transformStore: (store) => ({
...store,
async completeScoringJob(...args) {
if (failCompletion) throw new TypeError("database unavailable");
return store.completeScoringJob(...args);
},
}),
});
await assert.rejects(flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId));
const freshLease = await flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId);
assert.equal(freshLease.nextAction.kind, "score_pending");
assert.equal(flow.calls(), 1);
failCompletion = false;
now = new Date("2026-07-18T00:01:01.000Z");
const recovered = await flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId);
assert.equal(recovered.nextAction.kind, "ask_adaptive_evidence");
assert.equal(flow.calls(), 2);
});
test("expired pending and failed jobs renew while completed jobs replay", async () => {
let now = new Date("2026-07-18T00:00:00.000Z");
let fail = false;
const pending = await pendingScoringFlow({
now: () => now,
score: async () => {
if (fail) throw new TypeError("offline");
return lowCandidate;
},
});
now = new Date("2026-07-18T01:00:00.000Z");
const completed = await pending.service.pollScoringJob(userId, journeyCaseId, pending.jobId);
const replay = await pending.service.pollScoringJob(userId, journeyCaseId, pending.jobId);
assert.deepEqual(replay.nextAction, completed.nextAction);
assert.equal(pending.calls(), 1);
now = new Date("2026-07-18T00:00:00.000Z");
fail = true;
const failed = await pendingScoringFlow({
now: () => now,
score: async () => {
if (fail) throw new TypeError("offline");
return lowCandidate;
},
});
await failed.service.pollScoringJob(userId, journeyCaseId, failed.jobId);
fail = false;
now = new Date("2026-07-18T01:00:00.000Z");
const retried = await failed.service.pollScoringJob(userId, journeyCaseId, failed.jobId);
assert.equal(retried.nextAction.kind, "ask_adaptive_evidence");
assert.equal(retried.lifeEvents.length, 3);
assert.equal(failed.calls(), 2);
});
test("a completed medium job replays after its candidate range is saved", async () => {
const flow = await pendingScoringFlow({ score: async () => mediumCandidate });
const completed = await flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId);
if (completed.nextAction.kind !== "present_medium_result") {
throw new TypeError("test setup did not produce a medium result");
}
const saved = await flow.service.saveGuidedCandidate({
userId,
caseId: journeyCaseId,
actionId: secondActionId,
expectedVersion: completed.turnVersion,
resultId: mediumCandidate.resultId,
});
assert.equal(saved.nextAction.kind, "candidate_saved");
const replay = await flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId);
assert.deepEqual(replay.nextAction, saved.nextAction);
assert.equal(flow.calls(), 1);
});
test("concurrent duplicate confirmation replays one job and one evidence event", async () => {
const memory = memoryStore(guidedCase({
version: 4,
domain: "relationship",
askedDomains: ["education", "career"],
lifeEvents: [existingEducationEvent, existingCareerEvent],
}));
const service = createBirthTimeJourneyService({ store: memory.store, engine: unusedJourneyEngine });
const proposed = await service.proposeEvidenceDraft(
userId, journeyCaseId, draftActionId, 4,
{ domain: "relationship", precision: "year", date: "2021" },
);
const confirm = () => service.confirmEvidenceDraft(
userId, journeyCaseId, confirmActionId, proposed.turnVersion,
proposed.evidenceDraft?.draftId ?? "",
);
const [first, duplicate] = await Promise.all([confirm(), confirm()]);
assert.deepEqual(duplicate.nextAction, first.nextAction);
assert.equal(duplicate.lifeEvents.length, 3);
assert.equal(memory.scoringJobCount(), 1);
});
test("algorithm mismatches never mutate pending, processing, or completed jobs", async () => {
const pending = await pendingScoringFlow({ score: async () => lowCandidate });
pending.memory.setScoringJobAlgorithm(pending.jobId, "wrong-version");
await assert.rejects(pending.service.pollScoringJob(userId, journeyCaseId, pending.jobId));
assert.equal(pending.memory.scoringJobStatus(pending.jobId), "pending");
assert.equal(pending.calls(), 0);
const processing = await pendingScoringFlow({
score: async () => lowCandidate,
transformStore: (store) => ({
...store,
async completeScoringJob() { throw new TypeError("offline"); },
}),
});
await assert.rejects(processing.service.pollScoringJob(userId, journeyCaseId, processing.jobId));
processing.memory.setScoringJobAlgorithm(processing.jobId, "wrong-version");
await assert.rejects(processing.service.pollScoringJob(userId, journeyCaseId, processing.jobId));
assert.equal(processing.memory.scoringJobStatus(processing.jobId), "processing");
assert.equal(processing.calls(), 1);
const completed = await pendingScoringFlow({ score: async () => lowCandidate });
await completed.service.pollScoringJob(userId, journeyCaseId, completed.jobId);
completed.memory.setScoringJobAlgorithm(completed.jobId, "wrong-version");
await assert.rejects(completed.service.pollScoringJob(userId, journeyCaseId, completed.jobId));
assert.equal(completed.memory.scoringJobStatus(completed.jobId), "completed");
assert.equal(completed.calls(), 1);
});
test("completed replay rejects inconsistent persisted candidate state", async () => {
const flow = await pendingScoringFlow({ score: async () => lowCandidate });
await flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId);
const stored = flow.memory.savedCase();
if (!stored) throw new TypeError("missing completed test case");
flow.memory.replaceCase({ ...stored, candidateResult: null });
await assert.rejects(flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId));
assert.equal(flow.memory.scoringJobStatus(flow.jobId), "completed");
assert.equal(flow.calls(), 1);
});
test("old completed polls replay review but cannot replace the next active job", async () => {
let scoreCall = 0;
const flow = await pendingScoringFlow({
score: async () => {
scoreCall += 1;
return scoreCall === 1
? lowCandidate
: { ...lowCandidate, eventCount: 4, domainCount: 4 };
},
});
const scored = await flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId);
if (scored.nextAction.kind !== "ask_adaptive_evidence") {
throw new TypeError("missing adaptive question");
}
const draft = await flow.service.proposeEvidenceDraft(
userId,
journeyCaseId,
secondActionId,
scored.turnVersion,
{ domain: scored.nextAction.question.domain, precision: "year", date: "2022" },
);
const oldReplay = await flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId);
assert.equal(oldReplay.nextAction.kind, "review_evidence_draft");
const next = await flow.service.confirmEvidenceDraft(
userId,
journeyCaseId,
thirdActionId,
draft.turnVersion,
draft.evidenceDraft?.draftId ?? "",
);
if (next.nextAction.kind !== "score_pending") throw new TypeError("missing next job");
await assert.rejects(flow.service.pollScoringJob(userId, journeyCaseId, flow.jobId));
const nextResult = await flow.service.pollScoringJob(
userId,
journeyCaseId,
next.nextAction.jobId,
);
assert.equal(nextResult.progress.adaptiveRound, 2);
assert.equal(flow.calls(), 2);
});