fix: reject stale onboarding recovery

This commit is contained in:
Jesse_Chen
2026-07-19 21:38:58 +08:00
parent 6a34a843ec
commit 28d58d4b6f
4 changed files with 121 additions and 31 deletions
+52
View File
@@ -3,6 +3,9 @@ import test from "node:test";
import {
OnboardingAuthenticationError,
OnboardingRequestError,
isCurrentOnboardingRequest,
onboardingProfileFingerprint,
onboardingRequestIdentity,
requestOnboardingWithRecovery,
} from "../src/lib/onboarding-client.ts";
@@ -16,6 +19,26 @@ const personalizedOnboarding = {
source: "cache",
} as const;
const completeProfile = {
name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30",
birthTimeSource: "hospital_record", birthTimePeriod: "", birthTimeClue: "出生证明", birthTimeStatus: "confirmed",
uncertaintyBeforeMinutes: 0, uncertaintyAfterMinutes: 0, rectificationCaseId: "", countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101",
} as const;
test("rejects a stale completion after a complete profile changes", () => {
// Given: one account starts onboarding for a complete persisted profile.
const firstIdentity = onboardingRequestIdentity("account-1", onboardingProfileFingerprint(completeProfile));
// When: a presentation-affecting profile field changes before that request completes.
const changedFingerprint = onboardingProfileFingerprint({ ...completeProfile, name: "周宁" });
const currentIdentity = onboardingRequestIdentity("account-1", changedFingerprint);
// Then: the new profile has a distinct request identity and the old completion is stale.
assert.notEqual(currentIdentity, firstIdentity);
assert.equal(isCurrentOnboardingRequest(currentIdentity, firstIdentity), false);
assert.equal(isCurrentOnboardingRequest(currentIdentity, currentIdentity), true);
});
test("returns personalized cache content after a timeout and pending response", async () => {
// Given: the first request times out, the second is provisional, and the third is terminal.
const originalFetch = globalThis.fetch;
@@ -256,3 +279,32 @@ test("parent cancellation interrupts response body parsing", async () => {
globalThis.fetch = originalFetch;
}
});
test("parent cancellation interrupts a pending-response retry delay", async () => {
// Given: the first response is pending and the next retry has a long delay.
const originalFetch = globalThis.fetch;
const parent = new AbortController();
let requestCount = 0;
let markFetched: (() => void) | null = null;
const fetched = new Promise<void>((resolve) => { markFetched = resolve; });
globalThis.fetch = () => {
requestCount += 1; markFetched?.();
return Promise.resolve(Response.json({ ...personalizedOnboarding, source: "pending" }));
};
try {
// When: the page unmounts after the provisional response enters its retry wait.
const request = requestOnboardingWithRecovery(parent.signal, () => undefined, {
requestTimeoutMs: 10_000, retryDelayMs: 10_000, maxAttempts: 3,
});
await fetched;
await new Promise<void>((resolve) => setImmediate(resolve));
parent.abort(new DOMException("page unmounted", "AbortError"));
// Then: the delay is cancelled and no second request starts.
await assert.rejects(request, (error: unknown) => error instanceof DOMException && error.name === "AbortError");
assert.equal(requestCount, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
-19
View File
@@ -28,25 +28,6 @@ test("keeps starter questions visible while the user edits a draft", () => {
assert.doesNotMatch(starterVisibilityGuard, /\bdraft\b/);
});
test("keeps onboarding recovery alive after safe defaults become visible", () => {
// Given: the homepage owns one recovery sequence per completed account profile.
const recoveryEffect = sourceBetween(
pageSource,
"const onboardingRequestIdentity =",
" useEffect(() => {\n if (!hydrated || !profileComplete || birthTimeDisplayState(profile)) return;",
);
// When: the request identity, client call, and effect dependencies are inspected.
// Then: fallback state cannot cancel recovery and only authentication triggers login.
assert.match(pageSource, /requestOnboardingWithRecovery/);
assert.match(pageSource, /OnboardingAuthenticationError/);
assert.match(recoveryEffect, /onboardingRequestIdentity\.current === requestIdentity/);
assert.match(recoveryEffect, /requestOnboardingWithRecovery\(controller\.signal,/);
assert.match(recoveryEffect, /caught instanceof OnboardingAuthenticationError/);
assert.match(recoveryEffect, /\}, \[accountId, hydrated, profileComplete\]\);/);
assert.doesNotMatch(pageSource, /function readOnboarding\(/);
});
test("keeps follow-up suggestions visible while the user edits a draft", () => {
// Given: the follow-up suggestion block and its render guard.
const suggestionGuard = sourceBetween(