220 lines
7.8 KiB
JavaScript
220 lines
7.8 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import {
|
|
BirthTimeJourneyStoreError,
|
|
createJourneyTurnPersistence,
|
|
loadStoredRectificationCase,
|
|
StaleJourneyTurnError,
|
|
} from "../src/lib/birth-time-journey-turn-persistence.ts";
|
|
|
|
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" } },
|
|
evidenceDraft: null,
|
|
processedActionIds: [],
|
|
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
|
|
};
|
|
|
|
function updateClient(result) {
|
|
const calls = [];
|
|
const filter = {
|
|
eq(column, value) {
|
|
calls.push([column, value]);
|
|
return filter;
|
|
},
|
|
not(column, operator, value) {
|
|
calls.push(["not", column, operator, value]);
|
|
return filter;
|
|
},
|
|
select(columns) {
|
|
calls.push(["select", columns]);
|
|
return filter;
|
|
},
|
|
async maybeSingle() {
|
|
return result;
|
|
},
|
|
};
|
|
return {
|
|
calls,
|
|
client: {
|
|
from(table) {
|
|
assert.equal(table, "birth_time_rectification_cases");
|
|
return {
|
|
update(values) {
|
|
calls.push(["update", values]);
|
|
return filter;
|
|
},
|
|
};
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
test("saveTurn uses one owner-and-version-constrained update", async () => {
|
|
const fake = updateClient({ data: { id: "case-1" }, error: null });
|
|
const persistence = createJourneyTurnPersistence(fake.client, async () => null);
|
|
|
|
const saved = await persistence.saveTurn(storedCase, 4, actionId);
|
|
|
|
assert.equal(saved.turnVersion, 5);
|
|
assert.deepEqual(saved.processedActionIds, [actionId]);
|
|
assert.deepEqual(fake.calls, [
|
|
["update", {
|
|
status: "rectifying",
|
|
journey_snapshot: storedCase.snapshot,
|
|
answers: {},
|
|
life_events: [],
|
|
candidate_result: {},
|
|
turn_version: 5,
|
|
turn_state: storedCase.turnState,
|
|
evidence_draft: null,
|
|
processed_action_ids: [actionId],
|
|
adaptive_round: 0,
|
|
asked_domains: [],
|
|
updated_at: fake.calls[0][1].updated_at,
|
|
}],
|
|
["id", "case-1"],
|
|
["user_id", "user-1"],
|
|
["journey_protocol", "legacy-guided-v1"],
|
|
["turn_version", 4],
|
|
["not", "processed_action_ids", "cs", `{${actionId}}`],
|
|
["select", "id"],
|
|
]);
|
|
});
|
|
|
|
test("saveTurn reloads only a conflicting write and returns duplicate receipt", async () => {
|
|
const fake = updateClient({ data: null, error: null });
|
|
const current = { ...storedCase, turnVersion: 5, processedActionIds: [actionId] };
|
|
const persistence = createJourneyTurnPersistence(fake.client, async () => current);
|
|
|
|
const saved = await persistence.saveTurn(storedCase, 4, actionId);
|
|
|
|
assert.equal(saved, current);
|
|
});
|
|
|
|
test("saveTurn throws a typed stale error after a conflicting unprocessed action", async () => {
|
|
const fake = updateClient({ data: null, error: null });
|
|
const current = { ...storedCase, turnVersion: 5 };
|
|
const persistence = createJourneyTurnPersistence(fake.client, async () => current);
|
|
|
|
await assert.rejects(
|
|
persistence.saveTurn(storedCase, 4, actionId),
|
|
StaleJourneyTurnError,
|
|
);
|
|
});
|
|
|
|
test("saveTurn returns a same-version replay without a second receipt", async () => {
|
|
const fake = updateClient({ data: null, error: null });
|
|
const current = { ...storedCase, turnVersion: 4, processedActionIds: [actionId] };
|
|
const persistence = createJourneyTurnPersistence(fake.client, async () => current);
|
|
|
|
const saved = await persistence.saveTurn(storedCase, 4, actionId);
|
|
|
|
assert.equal(saved, current);
|
|
assert.equal(saved.turnVersion, 4);
|
|
assert.deepEqual(saved.processedActionIds, [actionId]);
|
|
});
|
|
|
|
test("saveTurn treats an uppercase UUID replay as the stored lowercase receipt", async () => {
|
|
const fake = updateClient({ data: null, error: null });
|
|
const current = { ...storedCase, turnVersion: 4, processedActionIds: [actionId] };
|
|
const persistence = createJourneyTurnPersistence(fake.client, async () => current);
|
|
|
|
const saved = await persistence.saveTurn(storedCase, 4, actionId.toUpperCase());
|
|
|
|
assert.equal(saved, current);
|
|
assert.equal(fake.calls[0][1].processed_action_ids[0], 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 () => {
|
|
const fake = updateClient({ data: { id: "case-1" }, error: null });
|
|
const persistence = createJourneyTurnPersistence(fake.client, async () => null);
|
|
|
|
await assert.rejects(persistence.saveTurn(storedCase, 4, "not-a-uuid"));
|
|
|
|
assert.deepEqual(fake.calls, []);
|
|
});
|
|
|
|
test("saveTurn writes asked domains in canonical unique order", async () => {
|
|
const fake = updateClient({ data: { id: "case-1" }, error: null });
|
|
const persistence = createJourneyTurnPersistence(fake.client, async () => null);
|
|
const value = {
|
|
...storedCase,
|
|
persistedProgress: { adaptiveRound: 0, askedDomains: ["career", "education", "career"] },
|
|
};
|
|
|
|
const saved = await persistence.saveTurn(value, 4, actionId);
|
|
|
|
assert.deepEqual(fake.calls[0][1].asked_domains, ["education", "career"]);
|
|
assert.deepEqual(saved.persistedProgress.askedDomains, ["education", "career"]);
|
|
});
|
|
|
|
function storedRow(overrides = {}) {
|
|
return {
|
|
id: "45857b75-4718-4590-aaf5-7113a03ea765",
|
|
user_id: "12dc56f0-1f17-4a2f-86bf-1056ab78def9",
|
|
journey_snapshot: {
|
|
state: "rectifying", assistantIntent: "continue_rectification_questions", input: "rectification_questions",
|
|
route: "rectification", confidence: null, canApply: false, activeTime: null,
|
|
reportedRange: { label: "14:00—15:00", startTime: "14:00", endTime: "15:00" },
|
|
},
|
|
questionnaire: {}, answers: {}, scoring_result: {}, reported_date: "1993-04-17",
|
|
life_events: [], candidate_result: {}, turn_version: 4, turn_state: {}, evidence_draft: null,
|
|
processed_action_ids: [], adaptive_round: 0, asked_domains: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function loadClient(row) {
|
|
const filter = { eq() { return filter; }, async maybeSingle() { return { data: row, error: null }; } };
|
|
const profile = { eq() { return profile; }, async maybeSingle() {
|
|
return { data: { latitude: 31.2304, longitude: 121.4737, timezone_offset: 8 }, error: null };
|
|
} };
|
|
return { from(table) { return { select() { return table === "profiles" ? profile : filter; } }; } };
|
|
}
|
|
|
|
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 () => {
|
|
await assert.rejects(
|
|
loadStoredRectificationCase(loadClient(storedRow({ turn_state: { turnVersion: 1 } })), "12dc56f0-1f17-4a2f-86bf-1056ab78def9", "45857b75-4718-4590-aaf5-7113a03ea765"),
|
|
BirthTimeJourneyStoreError,
|
|
);
|
|
});
|
|
|
|
test("load rejects a malformed non-null persisted evidence draft", async () => {
|
|
await assert.rejects(
|
|
loadStoredRectificationCase(loadClient(storedRow({ evidence_draft: {} })), "12dc56f0-1f17-4a2f-86bf-1056ab78def9", "45857b75-4718-4590-aaf5-7113a03ea765"),
|
|
BirthTimeJourneyStoreError,
|
|
);
|
|
});
|