fix: bind onboarding cache to profile identity
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
# Onboarding cache identity final-review fix
|
||||
|
||||
## Outcome
|
||||
|
||||
- Status: `DONE_WITH_CONCERNS`
|
||||
- Base SHA: `308e5d532ae10846ef196cf21eea3efc49a843dd`
|
||||
- Commit: `fix: bind onboarding cache to profile identity`
|
||||
- Resulting commit SHA: reported in the task handoff. A Git commit cannot embed its own resulting SHA without changing that SHA.
|
||||
|
||||
The onboarding cache now derives deterministic SHA-256 ready and pending versions from every
|
||||
profile field used by the route's completeness/generation decision. The database version contains
|
||||
no raw name, birth date/time, or location value. A ready value is accepted only for the current
|
||||
profile identity, a fresh pending value blocks only that same identity, and a completion can update
|
||||
the cache only while its exact pending identity still owns the row.
|
||||
|
||||
The claim write compares both the observed old version and the observed
|
||||
`onboarding_generated_at`. The timestamp predicate preserves the existing two-minute TTL while
|
||||
ensuring concurrent reclaimers cannot both acquire an expired deterministic pending identity.
|
||||
|
||||
The requested candidate-completion negative matrix exposed one real policy gap: the pure validator
|
||||
accepted any nonempty stored owner. It now receives the authenticated user ID and requires exact
|
||||
owner equality in addition to the route's owner-scoped query. No confidence, status, or billing
|
||||
policy changed.
|
||||
|
||||
## Files
|
||||
|
||||
- `frontend/src/app/api/onboarding/route.ts`
|
||||
- `frontend/src/lib/onboarding-cache-policy.ts`
|
||||
- `frontend/tests/onboarding-cache-policy.test.ts`
|
||||
- `frontend/src/app/api/birth-time-candidate-completion/route.ts`
|
||||
- `frontend/src/lib/birth-time-candidate-completion.ts`
|
||||
- `frontend/tests/birth-time-candidate-completion.test.ts`
|
||||
- `.superpowers/sdd/onboarding-cache-fix-report.md`
|
||||
|
||||
Unrelated `.superpowers/sdd/task-1-report.md` and `.omo/` changes were preserved and excluded from
|
||||
the staged commit.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
### RED — profile-aware cache policy
|
||||
|
||||
The shell's bare `node` command first failed with exit 127, so it was not counted as behavioral
|
||||
evidence. Using the installed Node 24.14.0 runtime:
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-cache-policy.test.ts
|
||||
```
|
||||
|
||||
Result before the policy module existed: exit 1, `ERR_MODULE_NOT_FOUND` for
|
||||
`src/lib/onboarding-cache-policy.ts`; 0 passed, 1 failed. The missing public policy seam was the
|
||||
expected RED.
|
||||
|
||||
### RED — candidate owner matrix
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-candidate-completion.test.ts
|
||||
```
|
||||
|
||||
Result before owner binding: exit 1; 13 passed, 2 failed. Both failures returned `"04:53"` instead
|
||||
of `null` for (1) a case owned by another user and (2) a request authenticated as another user.
|
||||
|
||||
### GREEN — focused behavior
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-cache-policy.test.ts tests/birth-time-candidate-completion.test.ts
|
||||
```
|
||||
|
||||
Result: exit 0; 20 passed, 0 failed. Coverage includes cached A to B, active-pending A to B, stale A
|
||||
completion after B claims, current ready/pending behavior, TTL reclaim, wrong case/owner, all three
|
||||
result-ID positions, missing winner/time, nonterminal action, and illegal status/action pairings.
|
||||
|
||||
## Final verification
|
||||
|
||||
Full frontend tests:
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts
|
||||
```
|
||||
|
||||
Result: exit 0; 444 passed, 0 failed.
|
||||
|
||||
Changed-file ESLint:
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node node_modules/eslint/bin/eslint.js src/app/api/onboarding/route.ts src/lib/onboarding-cache-policy.ts tests/onboarding-cache-policy.test.ts src/app/api/birth-time-candidate-completion/route.ts src/lib/birth-time-candidate-completion.ts tests/birth-time-candidate-completion.test.ts
|
||||
```
|
||||
|
||||
Result: exit 0; zero diagnostics.
|
||||
|
||||
Production build with the repository's CI public placeholders and the webpack fallback:
|
||||
|
||||
```text
|
||||
NEXT_PUBLIC_SUPABASE_URL=https://ci-placeholder.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=ci-placeholder PATH=/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH ./node_modules/.bin/next build --webpack
|
||||
```
|
||||
|
||||
Result: exit 0. Next 16.2.10 compiled successfully, finished TypeScript, generated 22/22 pages,
|
||||
and listed `/api/onboarding` and `/api/birth-time-candidate-completion` as dynamic routes.
|
||||
|
||||
Direct TypeScript diagnostic:
|
||||
|
||||
```text
|
||||
PATH=/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH ./node_modules/.bin/tsc --noEmit
|
||||
```
|
||||
|
||||
Result: exit 2 only for eight pre-existing ES2018 regexp-flag diagnostics in unrelated
|
||||
`tests/consultation-entrypoint.test.ts` and `tests/profile-persistence.test.ts`. No changed file was
|
||||
reported. The successful Next production build separately completed its TypeScript phase.
|
||||
|
||||
Quality checks:
|
||||
|
||||
```text
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Result: exit 0, no output. Pure LOC counts are 182, 82, 107, 62, 38, and 173 for the six changed
|
||||
TypeScript files respectively; every file is below the 200-line healthy ceiling.
|
||||
|
||||
Staged audit:
|
||||
|
||||
```text
|
||||
git diff --cached --check
|
||||
git diff --cached --name-status
|
||||
git diff --cached --stat
|
||||
```
|
||||
|
||||
Result: the diff check passed and the staged set contained exactly this report plus the six
|
||||
TypeScript implementation/test files listed above (`539 insertions, 39 deletions`). The unrelated
|
||||
modified `.superpowers/sdd/task-1-report.md` and untracked `.omo/` tree remained unstaged.
|
||||
|
||||
## Self-review
|
||||
|
||||
- Single responsibility: the new module owns only onboarding cache identity and transition policy.
|
||||
- Boundary purity: the route continues parsing cache payloads with Zod before returning them; the
|
||||
policy receives the parsed payload or `null`.
|
||||
- Variant discrimination: the route exhaustively switches over ready, pending, and claim.
|
||||
- Privacy: persisted identities contain a version prefix plus SHA-256 only.
|
||||
- Atomicity: claims compare the observed version/timestamp; completions compare the exact pending
|
||||
identity and cannot overwrite a newer profile claim.
|
||||
- Inputs: the route selects all eight fields included in the fingerprint.
|
||||
- Candidate policy: only the newly exposed owner mismatch changed; existing terminal/confidence
|
||||
pairings and representative-time rules remain intact.
|
||||
|
||||
## Concerns
|
||||
|
||||
- The mandatory repository pre-work command remains blocked by known host issues: system Python
|
||||
3.9 cannot import a PEP 604 annotation, system Python has no pytest, and terminal remote visibility
|
||||
is blocked. No remote-synchronization claim is made.
|
||||
- Default Turbopack rejects this worktree's externally pointed `frontend/node_modules` symlink. The
|
||||
webpack production build with the exact CI public placeholders passed completely.
|
||||
- The programming skill's standalone no-excuse checker could not resolve its own `typescript`
|
||||
dependency from the external skill cache. Changed-file ESLint, direct pattern audit, full tests,
|
||||
and the production TypeScript build were run instead.
|
||||
@@ -33,7 +33,7 @@ export async function POST(request: Request) {
|
||||
.eq("id", parsed.data.caseId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
const time = candidateWorkingTime(stored, parsed.data);
|
||||
const time = candidateWorkingTime(stored, { ...parsed.data, userId: user.id });
|
||||
if (caseError || !time) {
|
||||
return NextResponse.json(
|
||||
{ error: "候选结果已变化", message: "请使用当前评估结果继续。" },
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createOnboardingCacheIdentity,
|
||||
createOnboardingCompletionTransition,
|
||||
decideOnboardingCache,
|
||||
} from "@/lib/onboarding-cache-policy";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getOnboardingAgent } from "@/mastra";
|
||||
@@ -8,10 +13,6 @@ import { defaultLanguageModel } from "@/mastra/model";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
|
||||
const ONBOARDING_VERSION = "ayanam-onboarding-v3";
|
||||
const ONBOARDING_PENDING_VERSION = `${ONBOARDING_VERSION}:pending`;
|
||||
const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
greeting: z.string().trim().min(8).max(180),
|
||||
suggestions: z.tuple([
|
||||
@@ -95,34 +96,55 @@ export async function POST() {
|
||||
);
|
||||
}
|
||||
|
||||
if (profile.onboarding_version === ONBOARDING_VERSION) {
|
||||
const cached = onboardingSchema.safeParse(profile.onboarding_payload);
|
||||
if (cached.success) {
|
||||
return NextResponse.json({ ...cached.data, source: "cache" });
|
||||
}
|
||||
}
|
||||
|
||||
const generatedAt = typeof profile.onboarding_generated_at === "string"
|
||||
const identity = 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 cached = onboardingSchema.safeParse(profile.onboarding_payload);
|
||||
const generatedAtMs = typeof profile.onboarding_generated_at === "string"
|
||||
? Date.parse(profile.onboarding_generated_at)
|
||||
: 0;
|
||||
const activeClaim = profile.onboarding_version === ONBOARDING_PENDING_VERSION
|
||||
&& Number.isFinite(generatedAt)
|
||||
&& Date.now() - generatedAt < ONBOARDING_CLAIM_TTL_MS;
|
||||
if (activeClaim) {
|
||||
return NextResponse.json({ ...fallbackPayload, source: "pending" });
|
||||
const cacheDecision = decideOnboardingCache({
|
||||
identity,
|
||||
observedVersion: profile.onboarding_version,
|
||||
generatedAtMs,
|
||||
nowMs: Date.now(),
|
||||
cachedPayload: cached.success ? cached.data : null,
|
||||
});
|
||||
|
||||
switch (cacheDecision.kind) {
|
||||
case "ready":
|
||||
return NextResponse.json({ ...cacheDecision.payload, source: "cache" });
|
||||
case "pending":
|
||||
return NextResponse.json({ ...fallbackPayload, source: "pending" });
|
||||
case "claim":
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveDecision: never = cacheDecision;
|
||||
throw exhaustiveDecision;
|
||||
}
|
||||
}
|
||||
|
||||
const claimTime = new Date().toISOString();
|
||||
let claim = admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_version: ONBOARDING_PENDING_VERSION,
|
||||
onboarding_version: cacheDecision.pendingVersion,
|
||||
onboarding_generated_at: claimTime,
|
||||
})
|
||||
.eq("id", user.id);
|
||||
claim = profile.onboarding_version === null
|
||||
claim = cacheDecision.expectedVersion === null
|
||||
? claim.is("onboarding_version", null)
|
||||
: claim.eq("onboarding_version", profile.onboarding_version);
|
||||
: claim.eq("onboarding_version", cacheDecision.expectedVersion);
|
||||
claim = profile.onboarding_generated_at === null
|
||||
? claim.is("onboarding_generated_at", null)
|
||||
: claim.eq("onboarding_generated_at", profile.onboarding_generated_at);
|
||||
const { data: claimedProfile, error: claimError } = await claim.select("id").maybeSingle();
|
||||
if (claimError) {
|
||||
return NextResponse.json(
|
||||
@@ -160,15 +182,16 @@ export async function POST() {
|
||||
}
|
||||
}
|
||||
|
||||
const completionTransition = createOnboardingCompletionTransition(identity);
|
||||
const { error: cacheError } = await admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_payload: payload,
|
||||
onboarding_version: ONBOARDING_VERSION,
|
||||
onboarding_version: completionTransition.readyVersion,
|
||||
onboarding_generated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", user.id)
|
||||
.eq("onboarding_version", ONBOARDING_PENDING_VERSION);
|
||||
.eq("onboarding_version", completionTransition.expectedVersion);
|
||||
|
||||
if (cacheError) {
|
||||
console.warn("[onboarding] unable to cache generated content", cacheError.message);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
type CandidateCompletionRequest = {
|
||||
readonly userId: string;
|
||||
readonly caseId: string;
|
||||
readonly resultId: string;
|
||||
readonly time: string;
|
||||
@@ -29,8 +30,7 @@ export function candidateWorkingTime(
|
||||
&& assessment?.status === "candidate";
|
||||
|
||||
return assessment?.id === request.caseId
|
||||
&& typeof assessment?.user_id === "string"
|
||||
&& assessment.user_id.length > 0
|
||||
&& assessment?.user_id === request.userId
|
||||
&& terminalStatusMatches
|
||||
&& assessment.candidate_result_id === request.resultId
|
||||
&& action?.resultId === request.resultId
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const ONBOARDING_VERSION = "ayanam-onboarding-v3";
|
||||
export const ONBOARDING_CLAIM_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
type OnboardingProfileInput = {
|
||||
readonly name: string | null;
|
||||
readonly birthDate: string | null;
|
||||
readonly birthTime: string | null;
|
||||
readonly activeBirthTime: string | null;
|
||||
readonly birthTimeStatus: string | null;
|
||||
readonly countryCode: string | null;
|
||||
readonly provinceCode: string | null;
|
||||
readonly cityCode: string | null;
|
||||
};
|
||||
|
||||
export type OnboardingCacheIdentity = {
|
||||
readonly readyVersion: string;
|
||||
readonly pendingVersion: string;
|
||||
};
|
||||
|
||||
export type OnboardingCompletionTransition = {
|
||||
readonly expectedVersion: string;
|
||||
readonly readyVersion: string;
|
||||
};
|
||||
|
||||
type OnboardingCacheObservation<Payload> = {
|
||||
readonly identity: OnboardingCacheIdentity;
|
||||
readonly observedVersion: string | null;
|
||||
readonly generatedAtMs: number;
|
||||
readonly nowMs: number;
|
||||
readonly cachedPayload: Payload | null;
|
||||
};
|
||||
|
||||
type OnboardingCacheDecision<Payload> =
|
||||
| { readonly kind: "ready"; readonly payload: Payload }
|
||||
| { readonly kind: "pending" }
|
||||
| {
|
||||
readonly kind: "claim";
|
||||
readonly expectedVersion: string | null;
|
||||
readonly pendingVersion: string;
|
||||
};
|
||||
|
||||
export function createOnboardingCacheIdentity(
|
||||
profile: OnboardingProfileInput,
|
||||
): OnboardingCacheIdentity {
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(JSON.stringify([
|
||||
profile.name,
|
||||
profile.birthDate,
|
||||
profile.birthTime,
|
||||
profile.activeBirthTime,
|
||||
profile.birthTimeStatus,
|
||||
profile.countryCode,
|
||||
profile.provinceCode,
|
||||
profile.cityCode,
|
||||
]))
|
||||
.digest("hex");
|
||||
|
||||
return {
|
||||
readyVersion: `${ONBOARDING_VERSION}:${fingerprint}`,
|
||||
pendingVersion: `${ONBOARDING_VERSION}:pending:${fingerprint}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function decideOnboardingCache<Payload>(
|
||||
observation: OnboardingCacheObservation<Payload>,
|
||||
): OnboardingCacheDecision<Payload> {
|
||||
if (observation.observedVersion === observation.identity.readyVersion
|
||||
&& observation.cachedPayload !== null) {
|
||||
return { kind: "ready", payload: observation.cachedPayload };
|
||||
}
|
||||
|
||||
if (observation.observedVersion === observation.identity.pendingVersion
|
||||
&& Number.isFinite(observation.generatedAtMs)
|
||||
&& observation.nowMs - observation.generatedAtMs < ONBOARDING_CLAIM_TTL_MS) {
|
||||
return { kind: "pending" };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "claim",
|
||||
expectedVersion: observation.observedVersion,
|
||||
pendingVersion: observation.identity.pendingVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function createOnboardingCompletionTransition(
|
||||
identity: OnboardingCacheIdentity,
|
||||
): OnboardingCompletionTransition {
|
||||
return {
|
||||
expectedVersion: identity.pendingVersion,
|
||||
readyVersion: identity.readyVersion,
|
||||
};
|
||||
}
|
||||
@@ -35,25 +35,27 @@ const lowTerminalCase = {
|
||||
},
|
||||
};
|
||||
|
||||
const completionRequest = {
|
||||
userId: terminalCase.user_id,
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
} as const;
|
||||
|
||||
test("candidate completion only accepts the persisted terminal representative time", () => {
|
||||
assert.equal(candidateWorkingTime(terminalCase, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
...completionRequest,
|
||||
}), "04:53");
|
||||
|
||||
assert.equal(candidateWorkingTime(terminalCase, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
...completionRequest,
|
||||
time: "04:54",
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("accepts a matching low-confidence result from the rectifying state", () => {
|
||||
assert.equal(candidateWorkingTime(lowTerminalCase, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
...completionRequest,
|
||||
}), "04:53");
|
||||
});
|
||||
|
||||
@@ -68,9 +70,7 @@ test("does not accept a medium terminal action from the rectifying state", () =>
|
||||
},
|
||||
},
|
||||
}, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
...completionRequest,
|
||||
}), null);
|
||||
});
|
||||
|
||||
@@ -79,8 +79,105 @@ test("non-terminal cases cannot be adopted for consultation", () => {
|
||||
...terminalCase,
|
||||
turn_state: { nextAction: { kind: "ask_dynamic_choice" } },
|
||||
}, {
|
||||
caseId: terminalCase.id,
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
time: "04:53",
|
||||
...completionRequest,
|
||||
}), null);
|
||||
});
|
||||
|
||||
const rejectedCompletions = [
|
||||
{
|
||||
name: "case owned by another user",
|
||||
stored: { ...terminalCase, user_id: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "request from another user",
|
||||
stored: terminalCase,
|
||||
request: { ...completionRequest, userId: "f6cf99a5-9af7-4980-93ea-0298ee1dc95e" },
|
||||
},
|
||||
{
|
||||
name: "wrong case ID",
|
||||
stored: terminalCase,
|
||||
request: { ...completionRequest, caseId: "c84052ca-bcea-40a8-a32a-56980bbf7b22" },
|
||||
},
|
||||
{
|
||||
name: "wrong persisted result ID",
|
||||
stored: { ...terminalCase, candidate_result_id: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "wrong action result ID",
|
||||
stored: {
|
||||
...terminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
...terminalCase.turn_state.nextAction,
|
||||
resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0",
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "wrong requested result ID",
|
||||
stored: terminalCase,
|
||||
request: { ...completionRequest, resultId: "a3e41512-9fa0-4866-a187-e3b3aa07aee0" },
|
||||
},
|
||||
{
|
||||
name: "missing winning segment",
|
||||
stored: { ...terminalCase, candidate_result: { confidence: "medium" } },
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "missing representative time",
|
||||
stored: {
|
||||
...terminalCase,
|
||||
candidate_result: { confidence: "medium", winningSegment: {} },
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "low-result action paired with candidate status",
|
||||
stored: {
|
||||
...terminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "present_low_result",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "medium-result action paired with rectifying status",
|
||||
stored: {
|
||||
...lowTerminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "present_medium_result",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
{
|
||||
name: "candidate-saved action paired with rectifying status",
|
||||
stored: {
|
||||
...lowTerminalCase,
|
||||
turn_state: {
|
||||
nextAction: {
|
||||
kind: "candidate_saved",
|
||||
resultId: terminalCase.candidate_result_id,
|
||||
},
|
||||
},
|
||||
},
|
||||
request: completionRequest,
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const scenario of rejectedCompletions) {
|
||||
test(`rejects candidate completion with ${scenario.name}`, () => {
|
||||
assert.equal(candidateWorkingTime(scenario.stored, scenario.request), null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
createOnboardingCacheIdentity,
|
||||
createOnboardingCompletionTransition,
|
||||
decideOnboardingCache,
|
||||
} from "../src/lib/onboarding-cache-policy.ts";
|
||||
|
||||
const profileA = {
|
||||
name: "林遥",
|
||||
birthDate: "1990-06-15",
|
||||
birthTime: "12:30",
|
||||
activeBirthTime: "12:30",
|
||||
birthTimeStatus: "confirmed",
|
||||
countryCode: "CN",
|
||||
provinceCode: "110000",
|
||||
cityCode: "110100",
|
||||
} as const;
|
||||
|
||||
test("changed profile cannot reuse a ready cache from the previous profile", () => {
|
||||
// Given: profile A has a valid ready cache.
|
||||
const identityA = createOnboardingCacheIdentity(profileA);
|
||||
const identityB = createOnboardingCacheIdentity({ ...profileA, name: "周宁" });
|
||||
|
||||
// When: profile B observes A's ready version.
|
||||
const decision = decideOnboardingCache({
|
||||
identity: identityB,
|
||||
observedVersion: identityA.readyVersion,
|
||||
generatedAtMs: Date.parse("2026-07-19T10:00:00.000Z"),
|
||||
nowMs: Date.parse("2026-07-19T10:00:01.000Z"),
|
||||
cachedPayload: {},
|
||||
});
|
||||
|
||||
// Then: B must claim its own pending identity from the exact observed version.
|
||||
assert.deepEqual(decision, {
|
||||
kind: "claim",
|
||||
expectedVersion: identityA.readyVersion,
|
||||
pendingVersion: identityB.pendingVersion,
|
||||
});
|
||||
assert.notEqual(identityA.readyVersion, identityB.readyVersion);
|
||||
assert.doesNotMatch(identityB.readyVersion, /周宁|1990|12:30|110000/);
|
||||
});
|
||||
|
||||
test("changed profile cannot wait on the previous profile's active pending claim", () => {
|
||||
// Given: profile A has a fresh pending claim.
|
||||
const identityA = createOnboardingCacheIdentity(profileA);
|
||||
const identityB = createOnboardingCacheIdentity({ ...profileA, activeBirthTime: "12:45" });
|
||||
|
||||
// When: profile B observes A's pending version within the claim TTL.
|
||||
const decision = decideOnboardingCache({
|
||||
identity: identityB,
|
||||
observedVersion: identityA.pendingVersion,
|
||||
generatedAtMs: Date.parse("2026-07-19T10:00:00.000Z"),
|
||||
nowMs: Date.parse("2026-07-19T10:00:01.000Z"),
|
||||
cachedPayload: null,
|
||||
});
|
||||
|
||||
// Then: B claims immediately instead of returning A's pending response.
|
||||
assert.deepEqual(decision, {
|
||||
kind: "claim",
|
||||
expectedVersion: identityA.pendingVersion,
|
||||
pendingVersion: identityB.pendingVersion,
|
||||
});
|
||||
});
|
||||
|
||||
test("stale profile completion loses ownership after the current profile claims", () => {
|
||||
// Given: B has replaced A's pending identity in the row.
|
||||
const identityA = createOnboardingCacheIdentity(profileA);
|
||||
const identityB = createOnboardingCacheIdentity({ ...profileA, cityCode: "310100" });
|
||||
const rowVersionAfterBClaims = identityB.pendingVersion;
|
||||
|
||||
// When: each completion prepares an exact compare-and-set transition.
|
||||
const completionA = createOnboardingCompletionTransition(identityA);
|
||||
const completionB = createOnboardingCompletionTransition(identityB);
|
||||
|
||||
// Then: A cannot match the row, while B can commit its own ready identity.
|
||||
assert.notEqual(rowVersionAfterBClaims, completionA.expectedVersion);
|
||||
assert.equal(rowVersionAfterBClaims, completionB.expectedVersion);
|
||||
assert.equal(completionB.readyVersion, identityB.readyVersion);
|
||||
});
|
||||
|
||||
test("current profile accepts only valid ready content and an active current pending claim", () => {
|
||||
// Given: one current profile identity and a valid cached payload.
|
||||
const identity = createOnboardingCacheIdentity(profileA);
|
||||
const payload = { greeting: "current" };
|
||||
const nowMs = Date.parse("2026-07-19T10:01:00.000Z");
|
||||
|
||||
// When/Then: exact ready and fresh pending identities retain their existing behavior.
|
||||
assert.deepEqual(decideOnboardingCache({
|
||||
identity,
|
||||
observedVersion: identity.readyVersion,
|
||||
generatedAtMs: nowMs - 60_000,
|
||||
nowMs,
|
||||
cachedPayload: payload,
|
||||
}), { kind: "ready", payload });
|
||||
assert.deepEqual(decideOnboardingCache({
|
||||
identity,
|
||||
observedVersion: identity.pendingVersion,
|
||||
generatedAtMs: nowMs - 60_000,
|
||||
nowMs,
|
||||
cachedPayload: null,
|
||||
}), { kind: "pending" });
|
||||
});
|
||||
|
||||
test("invalid ready content and expired pending claims are reclaimed", () => {
|
||||
// Given: the current profile sees unusable ready content or an expired pending claim.
|
||||
const identity = createOnboardingCacheIdentity(profileA);
|
||||
const nowMs = Date.parse("2026-07-19T10:03:00.000Z");
|
||||
const observations = [
|
||||
{
|
||||
identity,
|
||||
observedVersion: identity.readyVersion,
|
||||
generatedAtMs: nowMs - 1_000,
|
||||
nowMs,
|
||||
cachedPayload: null,
|
||||
},
|
||||
{
|
||||
identity,
|
||||
observedVersion: identity.pendingVersion,
|
||||
generatedAtMs: nowMs - 180_000,
|
||||
nowMs,
|
||||
cachedPayload: null,
|
||||
},
|
||||
] as const;
|
||||
|
||||
// When/Then: each stale state becomes a compare-and-set claim for the current identity.
|
||||
for (const observation of observations) {
|
||||
assert.deepEqual(decideOnboardingCache(observation), {
|
||||
kind: "claim",
|
||||
expectedVersion: observation.observedVersion,
|
||||
pendingVersion: identity.pendingVersion,
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user