fix: isolate dynamic rectification persistence
This commit is contained in:
@@ -7,6 +7,7 @@ import { createGuidedCandidateActions } from "../src/lib/birth-time-guided-candi
|
||||
import {
|
||||
createBirthTimeJourneyService,
|
||||
type LegacyBirthTimeJourneyEngine,
|
||||
type DynamicVersionedJourneyResponse,
|
||||
type StoredRectificationCase,
|
||||
type VersionedJourneyResponse,
|
||||
} from "../src/lib/birth-time-journey-service.ts";
|
||||
@@ -107,7 +108,12 @@ export function createHarness(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export function assertLegalTurn(turn: VersionedJourneyResponse): void {
|
||||
export function assertLegalTurn(
|
||||
turn: VersionedJourneyResponse | DynamicVersionedJourneyResponse,
|
||||
): asserts turn is VersionedJourneyResponse {
|
||||
if (turn.journeyProtocol === "dynamic-choice-v2") {
|
||||
assert.fail("legacy flow unexpectedly resumed a dynamic journey");
|
||||
}
|
||||
assert.ok(turn.nextAction, "every legal journey response has nextAction");
|
||||
assert.ok(turn.turnVersion >= 0);
|
||||
switch (turn.nextAction.kind) {
|
||||
|
||||
@@ -142,13 +142,16 @@ export const privateRow = {
|
||||
export function loadClient(
|
||||
privateState: typeof privateRow | null,
|
||||
omitProcessedActions = false,
|
||||
publicTurnVersion = publicRow.turn_version,
|
||||
) {
|
||||
const { processed_action_ids: ignoredReceipts, ...rowWithoutReceipts } = publicRow;
|
||||
void ignoredReceipts;
|
||||
return {
|
||||
from(table: string) {
|
||||
const row = table === "birth_time_rectification_cases"
|
||||
? omitProcessedActions ? rowWithoutReceipts : publicRow
|
||||
? omitProcessedActions
|
||||
? rowWithoutReceipts
|
||||
: { ...publicRow, turn_version: publicTurnVersion }
|
||||
: table === "birth_time_rectification_dynamic_state"
|
||||
? privateState
|
||||
: { latitude: 31.2304, longitude: 121.4737, timezone_offset: 8 };
|
||||
@@ -173,8 +176,11 @@ export function dynamicCase(): DynamicStoredRectificationCase {
|
||||
lifeEvents: [],
|
||||
candidateResult: null,
|
||||
turnVersion: 7,
|
||||
turnState: null,
|
||||
dynamicTurnState,
|
||||
evidenceDraft: null,
|
||||
processedActionIds: [],
|
||||
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
|
||||
candidateModel: privateRow.candidate_model,
|
||||
currentChoiceQuestion: persistedQuestion,
|
||||
choiceAnswers: [],
|
||||
@@ -215,6 +221,7 @@ export function legacyCase(active: boolean): LegacyStoredRectificationCase {
|
||||
return {
|
||||
id: caseId,
|
||||
userId: ownerId,
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot,
|
||||
questionnaire: null,
|
||||
answers: { q1: "A" },
|
||||
|
||||
@@ -75,6 +75,19 @@ test("unknown birth time initializes the full-day dynamic range", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("mixed-null reported ranges fail instead of inventing one boundary", () => {
|
||||
const mixedRange = {
|
||||
...snapshot,
|
||||
reportedRange: {
|
||||
label: "04:00—未知",
|
||||
startTime: "04:00",
|
||||
endTime: null,
|
||||
},
|
||||
};
|
||||
|
||||
assert.throws(() => createInitialDynamicState(mixedRange, "2026-07-18"));
|
||||
});
|
||||
|
||||
test("new v2 case creation crosses one atomic RPC boundary", async () => {
|
||||
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
|
||||
const savedId = await saveDynamicAssessment({
|
||||
@@ -100,6 +113,15 @@ test("new v2 case creation crosses one atomic RPC boundary", async () => {
|
||||
assert.equal(calls[0]?.name, "create_birth_time_dynamic_case");
|
||||
assert.equal(calls[0]?.args.p_user_id, ownerId);
|
||||
assert.equal(JSON.stringify(calls[0]?.args.p_public_case).includes("candidateModel"), false);
|
||||
assert.deepEqual(calls[0]?.args.p_profile, {
|
||||
reportedBirthTime: null,
|
||||
birthTimeSource: "period_only",
|
||||
birthTimePeriod: "early_morning",
|
||||
birthTimeClue: null,
|
||||
uncertaintyBeforeMinutes: null,
|
||||
uncertaintyAfterMinutes: null,
|
||||
birthTimeStatus: "rectifying",
|
||||
});
|
||||
assert.deepEqual(calls[0]?.args.p_private_state, createInitialDynamicState(
|
||||
snapshot,
|
||||
"2026-07-18",
|
||||
@@ -120,6 +142,13 @@ test("v2 load rejects missing required public persistence fields", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("v2 load rejects incoherent public row and JSON turn versions", async () => {
|
||||
await assert.rejects(
|
||||
loadStoredRectificationCase(loadClient(privateRow, false, 8), ownerId, caseId),
|
||||
BirthTimeJourneyStoreError,
|
||||
);
|
||||
});
|
||||
|
||||
test("v2 cases cannot fall through to legacy mutation paths", () => {
|
||||
assert.throws(() => assertLegacyJourneyMutation(dynamicCase()), {
|
||||
name: "GuidedJourneyLegacyMutationError",
|
||||
@@ -166,11 +195,12 @@ test("memory replay returns the stored advanced dynamic turn", async () => {
|
||||
const changed = { ...initial, agentContext: ["persisted context"] };
|
||||
|
||||
const saved = await memory.store.saveDynamicTurn(changed, 7, actionId);
|
||||
const replay = await memory.store.saveDynamicTurn(initial, 7, actionId);
|
||||
const replay = await memory.store.saveDynamicTurn(initial, 7, actionId.toUpperCase());
|
||||
|
||||
assert.deepEqual(replay, saved);
|
||||
assert.equal(replay.turnVersion, 8);
|
||||
assert.deepEqual(replay.agentContext, ["persisted context"]);
|
||||
assert.deepEqual(replay.processedActionIds, [actionId]);
|
||||
assert.equal(memory.committedTurnWrites(), 1);
|
||||
});
|
||||
|
||||
@@ -203,6 +233,7 @@ test("legacy active upgrade preserves evidence and starts v2 without legacy fing
|
||||
journeyProtocol: "dynamic-choice-v2",
|
||||
turnVersion: loaded.turnVersion ?? 0,
|
||||
processedActionIds: loaded.processedActionIds ?? [],
|
||||
persistedProgress: loaded.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] },
|
||||
dynamicTurnState: parsedTurn,
|
||||
turnState: null,
|
||||
evidenceDraft: null,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
|
||||
import {
|
||||
caseId,
|
||||
dynamicCase,
|
||||
ownerId,
|
||||
} from "./birth-time-dynamic-persistence-fixture.ts";
|
||||
import { memoryStore } from "./birth-time-journey-memory-store.ts";
|
||||
import { unusedJourneyEngine } from "./birth-time-journey-test-support.ts";
|
||||
|
||||
test("v2 resume returns the stored dynamic turn without legacy scoring writes", async () => {
|
||||
const stored = {
|
||||
...dynamicCase(),
|
||||
scoring: {
|
||||
answeredCount: 5,
|
||||
candidateClusterRankings: [],
|
||||
nextRound: null,
|
||||
nextRoundQuestions: [],
|
||||
raw: {},
|
||||
},
|
||||
};
|
||||
const memory = memoryStore(stored);
|
||||
const service = createBirthTimeJourneyService({ store: memory.store, engine: unusedJourneyEngine });
|
||||
|
||||
const resumed = await service.resume(ownerId, caseId);
|
||||
|
||||
assert.equal(resumed.journeyProtocol, "dynamic-choice-v2");
|
||||
assert.deepEqual({
|
||||
journeyProtocol: resumed.journeyProtocol,
|
||||
turnVersion: resumed.turnVersion,
|
||||
nextAction: resumed.nextAction,
|
||||
progress: resumed.progress,
|
||||
permissions: resumed.permissions,
|
||||
}, stored.dynamicTurnState);
|
||||
assert.equal(memory.legacyWrites(), 0);
|
||||
});
|
||||
@@ -44,6 +44,7 @@ function storedCase(): LegacyStoredRectificationCase {
|
||||
return {
|
||||
id: caseId,
|
||||
userId: "owner-1",
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot: {
|
||||
state: "rectifying",
|
||||
assistantIntent: "continue_rectification_questions",
|
||||
|
||||
@@ -53,6 +53,9 @@ export function memoryStore(
|
||||
if (!savedCase) {
|
||||
throw new MissingTestCaseError();
|
||||
}
|
||||
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
|
||||
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion);
|
||||
}
|
||||
const processedActionIds = savedCase.processedActionIds ?? [];
|
||||
if (processedActionIds.includes(actionId)) {
|
||||
return savedCase;
|
||||
@@ -70,8 +73,9 @@ export function memoryStore(
|
||||
},
|
||||
async saveDynamicTurn(value, expectedVersion, actionId) {
|
||||
if (!savedCase) throw new MissingTestCaseError();
|
||||
const receipt = actionId.toLowerCase();
|
||||
const receipts = savedCase.processedActionIds ?? [];
|
||||
if (receipts.includes(actionId)) {
|
||||
if (receipts.includes(receipt)) {
|
||||
if (!savedDynamicCase) throw new MissingTestCaseError();
|
||||
return savedDynamicCase;
|
||||
}
|
||||
@@ -82,7 +86,7 @@ export function memoryStore(
|
||||
...value,
|
||||
turnVersion: expectedVersion + 1,
|
||||
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
|
||||
processedActionIds: [...receipts, actionId],
|
||||
processedActionIds: [...receipts, receipt],
|
||||
};
|
||||
savedCase = savedDynamic;
|
||||
savedDynamicCase = savedDynamic;
|
||||
@@ -90,7 +94,7 @@ export function memoryStore(
|
||||
return savedDynamic;
|
||||
},
|
||||
async upgradeLegacyActiveCase(value) {
|
||||
if (value.journeyProtocol === "dynamic-choice-v2" || isTerminalLegacyCase(value)) {
|
||||
if (isTerminalLegacyCase(value)) {
|
||||
return value;
|
||||
}
|
||||
const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate);
|
||||
@@ -115,6 +119,13 @@ export function memoryStore(
|
||||
if (!savedCase) {
|
||||
throw new MissingTestCaseError();
|
||||
}
|
||||
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
|
||||
throw new StaleJourneyTurnError(
|
||||
savedCase.id,
|
||||
command.expectedVersion,
|
||||
savedCase.turnVersion,
|
||||
);
|
||||
}
|
||||
const receipt = command.actionId.toLowerCase();
|
||||
const receipts = savedCase.processedActionIds ?? [];
|
||||
if (receipts.includes(receipt)) {
|
||||
|
||||
@@ -48,6 +48,7 @@ test("legacy low-score resume displays adaptive round one exactly once", async (
|
||||
const first = await flow.service.resume("user-1", journeyCaseId);
|
||||
const second = await flow.service.resume("user-1", journeyCaseId);
|
||||
|
||||
if (first.journeyProtocol === "dynamic-choice-v2") assert.fail("expected legacy response");
|
||||
assert.equal(first.nextAction.kind, "ask_adaptive_evidence");
|
||||
assert.equal(first.progress.adaptiveRound, 1);
|
||||
assert.deepEqual(second.nextAction, first.nextAction);
|
||||
|
||||
@@ -78,6 +78,7 @@ test("journey service accumulates legacy answers while preserving the applicatio
|
||||
const storedCase: StoredRectificationCase = {
|
||||
id: journeyCaseId,
|
||||
userId: "user-1",
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot: assessed.snapshot,
|
||||
questionnaire,
|
||||
answers: { education_environment_shift: "A" },
|
||||
@@ -121,6 +122,7 @@ test("journey service resumes an owner-scoped unfinished legacy case", async ()
|
||||
const storedCase: StoredRectificationCase = {
|
||||
id: journeyCaseId,
|
||||
userId: "user-1",
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot: {
|
||||
state: "rectifying",
|
||||
assistantIntent: "continue_rectification_questions",
|
||||
@@ -150,6 +152,7 @@ test("journey service heals a completed legacy questionnaire into life-event col
|
||||
const storedCase: StoredRectificationCase = {
|
||||
id: journeyCaseId,
|
||||
userId: "user-1",
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot: {
|
||||
state: "candidate",
|
||||
assistantIntent: "present_saved_candidate_range",
|
||||
@@ -184,6 +187,7 @@ test("journey service resumes a fail-closed case without a questionnaire", async
|
||||
const storedCase: StoredRectificationCase = {
|
||||
id: journeyCaseId,
|
||||
userId: "user-1",
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot: {
|
||||
state: "rectifying",
|
||||
assistantIntent: "explain_assessment_unavailable",
|
||||
|
||||
@@ -128,6 +128,7 @@ export function guidedCase(input: GuidedCaseInput = {}): LegacyStoredRectificati
|
||||
return {
|
||||
id: journeyCaseId,
|
||||
userId: "user-1",
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot: {
|
||||
state: "rectifying",
|
||||
assistantIntent: candidateResult?.confidence === "low"
|
||||
|
||||
@@ -11,6 +11,7 @@ const actionId = "45857b75-4718-4590-aaf5-7113a03ea765";
|
||||
const storedCase = {
|
||||
id: "case-1",
|
||||
userId: "user-1",
|
||||
journeyProtocol: "legacy-guided-v1",
|
||||
snapshot: { state: "rectifying" },
|
||||
answers: {},
|
||||
turnState: { nextAction: { kind: "paused" } },
|
||||
@@ -79,6 +80,7 @@ test("saveTurn uses one owner-and-version-constrained update", async () => {
|
||||
}],
|
||||
["id", "case-1"],
|
||||
["user_id", "user-1"],
|
||||
["journey_protocol", "legacy-guided-v1"],
|
||||
["turn_version", 4],
|
||||
["not", "processed_action_ids", "cs", `{${actionId}}`],
|
||||
["select", "id"],
|
||||
@@ -127,7 +129,25 @@ test("saveTurn treats an uppercase UUID replay as the stored lowercase receipt",
|
||||
|
||||
assert.equal(saved, current);
|
||||
assert.equal(fake.calls[0][1].processed_action_ids[0], actionId);
|
||||
assert.deepEqual(fake.calls[4], ["not", "processed_action_ids", "cs", `{${actionId}}`]);
|
||||
assert.deepEqual(fake.calls[5], ["not", "processed_action_ids", "cs", `{${actionId}}`]);
|
||||
});
|
||||
|
||||
test("saveTurn cannot overwrite a case upgraded between load and write", async () => {
|
||||
const fake = updateClient({ data: null, error: null });
|
||||
const upgraded = {
|
||||
...storedCase,
|
||||
journeyProtocol: "dynamic-choice-v2",
|
||||
turnVersion: 4,
|
||||
processedActionIds: [],
|
||||
};
|
||||
const persistence = createJourneyTurnPersistence(fake.client, async () => upgraded);
|
||||
|
||||
await assert.rejects(
|
||||
persistence.saveTurn(storedCase, 4, actionId),
|
||||
StaleJourneyTurnError,
|
||||
);
|
||||
|
||||
assert.deepEqual(fake.calls[3], ["journey_protocol", "legacy-guided-v1"]);
|
||||
});
|
||||
|
||||
test("saveTurn rejects a non-UUID action receipt before writing", async () => {
|
||||
@@ -181,6 +201,7 @@ test("load accepts only the exact empty legacy turn state", async () => {
|
||||
const value = await loadStoredRectificationCase(loadClient(storedRow()), "12dc56f0-1f17-4a2f-86bf-1056ab78def9", "45857b75-4718-4590-aaf5-7113a03ea765");
|
||||
|
||||
assert.equal(value?.turnState, null);
|
||||
assert.equal(value?.journeyProtocol, "legacy-guided-v1");
|
||||
});
|
||||
|
||||
test("load rejects a malformed nonempty persisted turn state", async () => {
|
||||
|
||||
Reference in New Issue
Block a user