fix: verify onboarding cache ownership end to end
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user