223 lines
8.5 KiB
TypeScript
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);
|
|
});
|