import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; 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 { fallbackOnboardingPayload, onboardingSuggestionThemes, parseOnboardingPayload, } from "../src/lib/onboarding-payload.ts"; import { completeProfileRow, StatefulOnboardingProfileRepository, } from "./onboarding-route-fake.ts"; // Both payloads cover every consultation domain; the marker is what tells one // profile's generated content from the other's when checking for stale leaks. function payloadCovering(marker: string) { return { suggestions: onboardingSuggestionThemes.map((theme) => ({ theme, text: `请帮我看看${theme}方向的${marker}重点。`, })), }; } const payloadA = payloadCovering("甲组"); const payloadB = payloadCovering("乙组"); function generatedText(payload: ReturnType): string { return JSON.stringify(payload); } function deferred() { let settle: (value: Value) => void = () => undefined; const promise = new Promise((resolve) => { settle = resolve; }); return { promise, resolve: settle } as const; } const responseBodySchema = z.object({ suggestions: z.array(z.object({ theme: z.string(), text: z.string() })), source: z.enum(["agent", "cache", "fallback", "pending"]), }).strict(); async function responseBody(response: Response): Promise> { return responseBodySchema.parse(await response.json()); } function createPost( repository: StatefulOnboardingProfileRepository, generateText: (name: string) => Promise, ) { return createOnboardingPost({ openSession: async () => ({ userId: repository.snapshot().id, authError: false, repository, }), generateText, now: () => new Date("2026-07-19T10:00:00.000Z"), warn: () => undefined, }); } test("the safe fallback satisfies the payload schema it is meant to replace", () => { // The fallback is built from the domain registry, so a registry prompt that stops // being first-person would otherwise ship a payload the schema itself rejects. assert.notEqual(parseOnboardingPayload(fallbackOnboardingPayload), null); assert.deepEqual( fallbackOnboardingPayload.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes], ); }); test("no onboarding surface generates or transports a greeting", () => { const instructions = readFileSync(new URL("../src/mastra/index.ts", import.meta.url), "utf8"); const onboardingPrompt = instructions.slice( instructions.indexOf("const onboardingInstructions"), instructions.indexOf("const onboardingAgents")); const route = readFileSync(new URL("../src/app/api/onboarding/route.ts", import.meta.url), "utf8"); const payload = readFileSync(new URL("../src/lib/onboarding-payload.ts", import.meta.url), "utf8"); const client = readFileSync(new URL("../src/lib/onboarding-client.ts", import.meta.url), "utf8"); // Given: the hero renders a locally composed salutation, so a generated greeting has // nowhere to go and must not cost tokens. assert.doesNotMatch(onboardingPrompt, /"greeting"/); assert.match(onboardingPrompt, /Do not add a greeting/); assert.doesNotMatch(payload, /greeting/); assert.doesNotMatch(client, /greeting: (?:parsed|z\.string)/); // And: the route names the required themes so the Agent covers the whole grid. assert.match(route, /onboardingSuggestionThemes/); }); test("slow Agent generation is aborted and a terminal fallback is cached before the client deadline", async () => { const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); let generationAborted = false; const dependencies = { openSession: async () => ({ userId: repository.snapshot().id, authError: false, repository, }), generateText: (_name: string, signal?: AbortSignal) => new Promise((_resolve, reject) => { signal?.addEventListener("abort", () => { generationAborted = true; reject(signal.reason); }, { once: true }); }), generationTimeoutMs: 5, now: () => new Date("2026-07-19T10:00:00.000Z"), warn: () => undefined, }; const deadline = new Promise<"deadline">((resolve) => { setTimeout(() => resolve("deadline"), 50); }); const outcome = await Promise.race([createOnboardingPost(dependencies)(), deadline]); assert.notEqual(outcome, "deadline"); assert.ok(outcome instanceof Response); assert.equal(generationAborted, true); const body = await responseBody(outcome); assert.equal(body.source, "fallback"); assert.deepEqual(repository.snapshot().onboarding_payload, { suggestions: body.suggestions }); }); test("detached Agent questions are rejected in favor of user-centered fallbacks", async () => { const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); const post = createPost(repository, async () => JSON.stringify({ suggestions: onboardingSuggestionThemes.map((theme) => ({ theme, text: `印度占星一般会从哪些因素理解${theme}?`, })), })); const body = await responseBody(await post()); assert.equal(body.source, "fallback"); assert.ok(body.suggestions.every((item) => /我/.test(item.text))); assert.ok(body.suggestions.every((item) => !/印度占星|一般如何|通常|哪些因素|证据层/.test(item.text))); }); test("the home receives one question per consultation domain, never a partial set", async () => { // Given: the Agent answers with only the three themes the old contract required. const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); const post = createPost(repository, async () => JSON.stringify({ suggestions: [ { theme: "career", text: "请帮我看看事业方向。" }, { theme: "marriage", text: "请帮我看看关系模式。" }, { theme: "timing", text: "请帮我看看时机安排。" }, ], })); // When: the partial answer crosses the payload boundary. const body = await responseBody(await post()); // Then: it is rejected wholesale, and the fallback still covers every domain in order // so no home card is left without a question. assert.equal(body.source, "fallback"); assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]); }); test("a reordered set is rejected so questions cannot land under the wrong theme", async () => { // Given: every domain is present, but two themes are swapped against their questions. const repository = new StatefulOnboardingProfileRepository(completeProfileRow()); const swapped = [...onboardingSuggestionThemes]; [swapped[0], swapped[1]] = [swapped[1]!, swapped[0]!]; const post = createPost(repository, async () => JSON.stringify({ suggestions: swapped.map((theme) => ({ theme, text: `请帮我看看${theme}方向的重点。` })), })); const body = await responseBody(await post()); assert.equal(body.source, "fallback"); assert.deepEqual(body.suggestions.map((item) => item.theme), [...onboardingSuggestionThemes]); }); test("PostgreSQL Date birth_date is normalized before onboarding validation", async () => { const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ birth_date: new Date(1990, 5, 15), })); const post = createPost(repository, async () => generatedText(payloadA)); const response = await post(); const body = await responseBody(response); assert.equal(response.status, 200); assert.deepEqual(body, { ...payloadA, source: "agent" }); }); test("period-only birth declaration can generate the home starter questions without a concrete minute", async () => { const repository = new StatefulOnboardingProfileRepository({ ...completeProfileRow({ birth_time: null, active_birth_time: null, birth_time_status: "reported", }), reported_birth_time: null, birth_time_source: "period_only", birth_time_period: "early_morning", birth_time_clue: "家人只记得凌晨或清晨", uncertainty_before_minutes: null, uncertainty_after_minutes: null, }); let generationCount = 0; const post = createPost(repository, async () => { generationCount += 1; return generatedText(payloadA); }); const response = await post(); const body = await responseBody(response); assert.equal(response.status, 200); assert.equal(generationCount, 1); assert.deepEqual(body, { ...payloadA, source: "agent" }); }); test("global place with an IANA timezone can enter home before a numeric offset is resolved", async () => { const repository = new StatefulOnboardingProfileRepository(completeProfileRow({ name: "jesse", birth_date: "1955-02-24", birth_time: null, reported_birth_time: null, active_birth_time: null, birth_time_source: "period_only", birth_time_period: "evening", birth_time_clue: "大约晚上七点左右,可能前后差四十五分钟。", birth_time_status: null, country_code: "US", province_code: null, city_code: null, latitude: 37.7879363, longitude: -122.4075201, timezone_offset: null, birth_place_label: "旧金山, 加利福尼亚州, 美国", timezone_id: "America/Los_Angeles", })); let generationCount = 0; const response = await createPost(repository, async () => { generationCount += 1; return generatedText(payloadA); })(); assert.equal(response.status, 200); assert.equal(generationCount, 1); }); 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(); const generationAStarted = deferred(); 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 instanceof Date ? profile.birth_date.toISOString().slice(0, 10) : 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); }); }