Files
Jyotisha/frontend/tests/birth-time-dynamic-idempotence.test.ts
2026-07-19 09:12:45 +08:00

135 lines
5.5 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import { dynamicCase, ownerId, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
import { dynamicJobStore } from "./birth-time-dynamic-job-memory-store.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
const answerId = "9a921af8-ddcc-4d20-b4c8-fbbb3e6a814d";
const actionId = "700406ad-1ca6-437d-9f77-61354ba8e36a";
function flow(initial = dynamicCase()) {
const memory = memoryStore(initial);
const jobs = dynamicJobStore(memory.store, () => {
const current = memory.savedCase();
return current?.journeyProtocol === "dynamic-choice-v2" ? current : null;
});
const service = createBirthTimeJourneyService({
store: jobs.store,
engine: {
async scan() { throw new Error("unexpected scan"); },
async score() { throw new Error("unexpected score"); },
async scoreEvents() { throw new Error("unexpected event score"); },
},
});
return { memory, service };
}
test("unmatched context replays only the identical action and payload", async () => {
const { service } = flow();
const unmatched = persistedQuestion.options.find((option) => option.kind === "unmatched");
if (!unmatched) throw new Error("missing unmatched option");
const clarification = await service.answerDynamicChoice(ownerId, {
caseId: dynamicCase().id,
actionId: answerId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: unmatched.optionId,
});
const command = {
caseId: dynamicCase().id,
actionId,
turnVersion: clarification.turnVersion,
questionId: persistedQuestion.questionId,
note: "更像是 2017 年",
};
const saved = await service.submitUnmatchedContext(ownerId, command);
const replay = await service.submitUnmatchedContext(ownerId, command);
assert.deepEqual(replay.nextAction, saved.nextAction);
assert.equal(replay.turnVersion, saved.turnVersion);
await assert.rejects(
service.submitUnmatchedContext(ownerId, { ...command, note: "其实是 2018 年" }),
StaleJourneyTurnError,
);
await assert.rejects(
service.pauseDynamic(ownerId, dynamicCase().id, actionId, command.turnVersion),
StaleJourneyTurnError,
);
});
test("pause replays after a lost response but cannot impersonate finish", async () => {
const { service } = flow();
const saved = await service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7);
const replay = await service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7);
assert.deepEqual(replay.nextAction, saved.nextAction);
assert.equal(replay.turnVersion, saved.turnVersion);
await assert.rejects(
service.finishDynamic(ownerId, dynamicCase().id, actionId, 7),
StaleJourneyTurnError,
);
});
test("finish replays after a lost response but cannot impersonate pause", async () => {
const { service } = flow();
const saved = await service.finishDynamic(ownerId, dynamicCase().id, actionId, 7);
const replay = await service.finishDynamic(ownerId, dynamicCase().id, actionId, 7);
assert.deepEqual(replay.nextAction, saved.nextAction);
assert.equal(replay.turnVersion, saved.turnVersion);
await assert.rejects(
service.pauseDynamic(ownerId, dynamicCase().id, actionId, 7),
StaleJourneyTurnError,
);
});
test("answer replay is bound to the exact question and option", async () => {
const { memory, service } = flow();
const unknown = persistedQuestion.options.find((option) => option.kind === "unknown");
const unmatched = persistedQuestion.options.find((option) => option.kind === "unmatched");
if (!unknown || !unmatched) throw new Error("missing special options");
const command = {
caseId: dynamicCase().id,
actionId,
turnVersion: 7,
questionId: persistedQuestion.questionId,
optionId: unknown.optionId,
};
const saved = await service.answerDynamicChoice(ownerId, command);
const replay = await service.answerDynamicChoice(ownerId, command);
assert.equal(replay.turnVersion, saved.turnVersion);
assert.equal(memory.savedCase()?.dynamicControl?.lastActionReceipt?.kind, "answer_choice");
await assert.rejects(
service.answerDynamicChoice(ownerId, { ...command, optionId: unmatched.optionId }),
StaleJourneyTurnError,
);
});
test("question commit replay is bound to the submitted fingerprints", async () => {
const initial = {
...dynamicCase(),
currentChoiceQuestion: null,
dynamicTurnState: {
...dynamicCase().dynamicTurnState,
nextAction: { kind: "generate_dynamic_question" as const },
},
};
const { memory, service } = flow(initial);
const question = {
...persistedQuestion,
questionId: "af34edbf-b4b0-4ebf-9a07-5c177bc73add",
questionFingerprint: "fresh-question",
candidatePartitionFingerprint: "fresh-partition",
};
const command = { caseId: initial.id, actionId, turnVersion: 7, unmatchedNote: null };
const saved = await service.commitDynamicQuestion(ownerId, command, question);
const replay = await service.commitDynamicQuestion(ownerId, command, question);
assert.deepEqual(replay.nextAction, saved.nextAction);
assert.equal(memory.savedCase()?.dynamicControl?.lastActionReceipt?.kind, "commit_question");
await assert.rejects(
service.commitDynamicQuestion(ownerId, command, {
...question, questionFingerprint: "changed-question",
}),
StaleJourneyTurnError,
);
});