fix: verify onboarding cache ownership end to end

This commit is contained in:
Jesse_Chen
2026-07-19 23:54:27 +08:00
parent e13d595d6b
commit 346ec02fd2
9 changed files with 802 additions and 196 deletions
@@ -59,6 +59,18 @@ test("accepts a matching low-confidence result from the rectifying state", () =>
}), "04:53");
});
test("accepts a persisted candidate-saved compatibility action", () => {
assert.equal(candidateWorkingTime({
...terminalCase,
turn_state: {
nextAction: {
kind: "candidate_saved",
resultId: terminalCase.candidate_result_id,
},
},
}, completionRequest), "04:53");
});
test("does not accept a medium terminal action from the rectifying state", () => {
assert.equal(candidateWorkingTime({
...lowTerminalCase,
@@ -94,6 +106,16 @@ const rejectedCompletions = [
stored: terminalCase,
request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
},
{
name: "missing case owner",
stored: { ...terminalCase, user_id: null },
request: completionRequest,
},
{
name: "empty case owner",
stored: { ...terminalCase, user_id: "" },
request: completionRequest,
},
{
name: "wrong case ID",
stored: terminalCase,
@@ -4,6 +4,7 @@ import {
createOnboardingCacheIdentity,
createOnboardingCompletionTransition,
decideOnboardingCache,
ONBOARDING_CLAIM_TTL_MS,
} from "../src/lib/onboarding-cache-policy.ts";
const profileA = {
@@ -132,3 +133,72 @@ test("invalid ready content and expired pending claims are reclaimed", () => {
});
}
});
test("every selected profile input participates in the cache identity", () => {
// Given: the exact eight profile inputs used by onboarding generation/completeness.
const baseIdentity = createOnboardingCacheIdentity(profileA);
const mutations = [
{ field: "name", profile: { ...profileA, name: "周宁" } },
{ field: "birthDate", profile: { ...profileA, birthDate: "1991-06-15" } },
{ field: "birthTime", profile: { ...profileA, birthTime: "12:31" } },
{ field: "activeBirthTime", profile: { ...profileA, activeBirthTime: "12:45" } },
{ field: "birthTimeStatus", profile: { ...profileA, birthTimeStatus: "candidate" } },
{ field: "countryCode", profile: { ...profileA, countryCode: "TW" } },
{ field: "provinceCode", profile: { ...profileA, provinceCode: "310000" } },
{ field: "cityCode", profile: { ...profileA, cityCode: "310100" } },
] as const;
// When/Then: mutating any one input changes both ready and pending identities.
for (const mutation of mutations) {
const changed = createOnboardingCacheIdentity(mutation.profile);
assert.notEqual(changed.readyVersion, baseIdentity.readyVersion, mutation.field);
assert.notEqual(changed.pendingVersion, baseIdentity.pendingVersion, mutation.field);
}
});
test("pending claim TTL is active through TTL minus one and reclaimable at TTL", () => {
// Given: the current profile owns the pending identity at a fixed time.
const identity = createOnboardingCacheIdentity(profileA);
const nowMs = Date.parse("2026-07-19T10:03:00.000Z");
// When/Then: the exact boundary preserves the existing strict-less-than policy.
assert.deepEqual(decideOnboardingCache({
identity,
observedVersion: identity.pendingVersion,
generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS + 1,
nowMs,
cachedPayload: null,
}), { kind: "pending" });
assert.deepEqual(decideOnboardingCache({
identity,
observedVersion: identity.pendingVersion,
generatedAtMs: nowMs - ONBOARDING_CLAIM_TTL_MS,
nowMs,
cachedPayload: null,
}), {
kind: "claim",
expectedVersion: identity.pendingVersion,
pendingVersion: identity.pendingVersion,
});
});
test("pending claims with null or invalid generation timestamps are reclaimed", () => {
// Given: null and invalid timestamps have both been normalized to non-finite milliseconds.
const identity = createOnboardingCacheIdentity(profileA);
const observations = [Number.NaN, Date.parse("not-a-timestamp")];
// When/Then: neither timestamp can keep a pending claim active.
for (const generatedAtMs of observations) {
assert.deepEqual(decideOnboardingCache({
identity,
observedVersion: identity.pendingVersion,
generatedAtMs,
nowMs: Date.parse("2026-07-19T10:03:00.000Z"),
cachedPayload: null,
}), {
kind: "claim",
expectedVersion: identity.pendingVersion,
pendingVersion: identity.pendingVersion,
});
}
});
+87
View File
@@ -0,0 +1,87 @@
import type {
OnboardingClaimCommand,
OnboardingCompletionCommand,
OnboardingProfileRepository,
OnboardingProfileRow,
} from "../src/lib/onboarding-post.ts";
type ProfilePatch = Partial<Omit<OnboardingProfileRow, "id">>;
export class StatefulOnboardingProfileRepository implements OnboardingProfileRepository {
private row: OnboardingProfileRow;
private nextClaimInterference: ProfilePatch | null = null;
constructor(row: OnboardingProfileRow) {
this.row = structuredClone(row);
}
setProfile(patch: ProfilePatch): void {
this.row = { ...this.row, ...structuredClone(patch) };
}
interfereBeforeNextClaim(patch: ProfilePatch): void {
this.nextClaimInterference = structuredClone(patch);
}
snapshot(): OnboardingProfileRow {
return structuredClone(this.row);
}
async loadProfile(userId: string) {
return this.row.id === userId
? { data: this.snapshot(), error: null }
: { data: null, error: null };
}
async claimProfile(command: OnboardingClaimCommand) {
if (this.nextClaimInterference) {
this.setProfile(this.nextClaimInterference);
this.nextClaimInterference = null;
}
const ownsObservedRow = this.row.id === command.userId
&& this.row.onboarding_version === command.expectedVersion
&& this.row.onboarding_generated_at === command.expectedGeneratedAt;
if (!ownsObservedRow) return { data: null, error: null };
this.row = {
...this.row,
onboarding_version: command.pendingVersion,
onboarding_generated_at: command.claimedAt,
};
return { data: { id: this.row.id }, error: null };
}
async completeProfile(command: OnboardingCompletionCommand) {
const ownsPendingRow = this.row.id === command.userId
&& this.row.onboarding_version === command.expectedPendingVersion;
if (!ownsPendingRow) return { data: null, error: null };
this.row = {
...this.row,
onboarding_payload: structuredClone(command.payload),
onboarding_version: command.readyVersion,
onboarding_generated_at: command.generatedAt,
};
return { data: { id: this.row.id }, error: null };
}
}
export function completeProfileRow(
patch: ProfilePatch = {},
): OnboardingProfileRow {
return {
id: "07e583fc-90b9-4fcb-a9d3-8de654eeac9a",
name: "林遥",
birth_date: "1990-06-15",
birth_time: "12:30",
active_birth_time: "12:30",
birth_time_status: "confirmed",
country_code: "CN",
province_code: "110000",
city_code: "110100",
onboarding_payload: null,
onboarding_version: null,
onboarding_generated_at: null,
...structuredClone(patch),
};
}
+172
View File
@@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import test from "node:test";
import { z } from "zod";
import { createOnboardingCacheIdentity } from "../src/lib/onboarding-cache-policy.ts";
import { createOnboardingPost } from "../src/lib/onboarding-post.ts";
import {
completeProfileRow,
StatefulOnboardingProfileRepository,
} from "./onboarding-route-fake.ts";
const payloadA = {
greeting: "林遥,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "林遥的事业方向是什么?" },
{ theme: "marriage", text: "林遥的关系模式是什么?" },
{ theme: "timing", text: "林遥何时适合采取行动?" },
],
} as const;
const payloadB = {
greeting: "周宁,欢迎开始今天的咨询。",
suggestions: [
{ theme: "career", text: "周宁的事业方向是什么?" },
{ theme: "marriage", text: "周宁的关系模式是什么?" },
{ theme: "timing", text: "周宁何时适合采取行动?" },
],
} as const;
function generatedText(payload: typeof payloadA | typeof payloadB): string {
return JSON.stringify(payload);
}
function deferred<Value>() {
let settle: (value: Value) => void = () => undefined;
const promise = new Promise<Value>((resolve) => {
settle = resolve;
});
return { promise, resolve: settle } as const;
}
const responseBodySchema = z.object({
greeting: z.string(),
suggestions: z.array(z.object({ theme: z.string(), text: z.string() })),
source: z.enum(["agent", "cache", "fallback", "pending"]),
});
async function responseBody(response: Response): Promise<z.infer<typeof responseBodySchema>> {
return responseBodySchema.parse(await response.json());
}
function createPost(
repository: StatefulOnboardingProfileRepository,
generateText: (name: string) => Promise<string | null>,
) {
return createOnboardingPost({
openSession: async () => ({
userId: repository.snapshot().id,
authError: false,
repository,
}),
generateText,
now: () => new Date("2026-07-19T10:00:00.000Z"),
warn: () => undefined,
});
}
test("stale A generation returns pending after profile B replaces its claim", async () => {
// Given: A owns a claim whose generation remains in flight.
const repository = new StatefulOnboardingProfileRepository(completeProfileRow());
const generationA = deferred<string | null>();
const generationAStarted = deferred<void>();
const post = createPost(
repository,
async (name) => {
if (name === "林遥") {
generationAStarted.resolve();
return generationA.promise;
}
return generatedText(payloadB);
},
);
const responseA = post();
await generationAStarted.promise;
// When: the persisted profile changes to B, B claims/completes, then A finishes.
repository.setProfile({ name: "周宁" });
const bodyB = await responseBody(await post());
generationA.resolve(generatedText(payloadA));
const bodyA = await responseBody(await responseA);
// Then: B remains cached and A is provisional, never a stale terminal payload.
assert.deepEqual(bodyB, { ...payloadB, source: "agent" });
assert.equal(bodyA.source, "pending");
assert.doesNotMatch(JSON.stringify(bodyA), /林遥/);
assert.deepEqual(repository.snapshot().onboarding_payload, payloadB);
});
test("profile B replaces profile A ready cache instead of returning A content", async () => {
// Given: A has a ready cache, then the persisted profile changes to B.
const identityA = createOnboardingCacheIdentity({
name: "林遥", birthDate: "1990-06-15", birthTime: "12:30", activeBirthTime: "12:30",
birthTimeStatus: "confirmed", countryCode: "CN", provinceCode: "110000", cityCode: "110100",
});
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
onboarding_payload: payloadA,
onboarding_version: identityA.readyVersion,
onboarding_generated_at: "2026-07-19T09:59:00.000Z",
}));
repository.setProfile({ name: "周宁" });
const post = createPost(repository, async () => generatedText(payloadB));
// When: B requests onboarding through the real handler seam.
const body = await responseBody(await post());
// Then: B is generated and cached; A's ready payload is never returned.
assert.deepEqual(body, { ...payloadB, source: "agent" });
assert.deepEqual(repository.snapshot().onboarding_payload, payloadB);
});
test("profile B replaces profile A active pending claim instead of waiting on A", async () => {
// Given: A has a fresh pending claim, then B changes the active birth time.
const profile = completeProfileRow();
const identityA = createOnboardingCacheIdentity({
name: profile.name, birthDate: profile.birth_date, birthTime: profile.birth_time,
activeBirthTime: profile.active_birth_time, birthTimeStatus: profile.birth_time_status,
countryCode: profile.country_code, provinceCode: profile.province_code, cityCode: profile.city_code,
});
const repository = new StatefulOnboardingProfileRepository({
...profile,
onboarding_version: identityA.pendingVersion,
onboarding_generated_at: "2026-07-19T09:59:30.000Z",
});
repository.setProfile({ name: "周宁", active_birth_time: "12:45" });
let generatedFor = "";
const post = createPost(repository, async (name) => {
generatedFor = name;
return generatedText(payloadB);
});
// When: B requests onboarding within A's TTL.
const body = await responseBody(await post());
// Then: B claims and completes immediately rather than receiving pending for A.
assert.equal(generatedFor, "周宁");
assert.deepEqual(body, { ...payloadB, source: "agent" });
});
for (const interference of [
{ name: "observed version", patch: { onboarding_version: "concurrent-version" } },
{ name: "observed timestamp", patch: { onboarding_generated_at: "2026-07-19T09:58:00.000Z" } },
] as const) {
test(`claim loses when a concurrent writer changes the ${interference.name}`, async () => {
// Given: another writer changes one observed CAS field just before the claim.
const repository = new StatefulOnboardingProfileRepository(completeProfileRow({
onboarding_version: "legacy-ready",
onboarding_generated_at: "2026-07-19T09:59:00.000Z",
}));
repository.interfereBeforeNextClaim(interference.patch);
let generationCount = 0;
const post = createPost(repository, async () => {
generationCount += 1;
return generatedText(payloadA);
});
// When: the handler attempts its observed-row claim.
const body = await responseBody(await post());
// Then: compare-and-set loses provisionally and generation never starts.
assert.equal(body.source, "pending");
assert.equal(generationCount, 0);
});
}