fix: verify onboarding cache ownership end to end
This commit is contained in:
@@ -150,3 +150,116 @@ modified `.superpowers/sdd/task-1-report.md` and untracked `.omo/` tree remained
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## Integration-review follow-up
|
||||
|
||||
### Outcome
|
||||
|
||||
- Status: `DONE_WITH_CONCERNS`
|
||||
- Follow-up base SHA: `e13d595d6b3130ee20647ab95b0be3e0af8bdcac`
|
||||
- Follow-up commit: `fix: verify onboarding cache ownership end to end`
|
||||
- Resulting follow-up SHA: reported in the task handoff because a Git commit cannot embed its own
|
||||
resulting hash without changing that hash.
|
||||
|
||||
`POST` now delegates to the injectable `createOnboardingPost` handler. Its stateful repository seam
|
||||
executes load, version-and-timestamp claim CAS, deterministic generation, and pending-version
|
||||
completion CAS as one observable HTTP workflow. The production adapter retains the real Supabase
|
||||
PostgREST filters; the test fake models the same mutable single-row compare-and-set behavior.
|
||||
|
||||
Completion now calls `select("id").maybeSingle()` and observes the returned row. A lost pending
|
||||
identity returns the safe provisional `pending` response, never the stale generated Agent payload.
|
||||
Database errors retain the prior warning and terminal fallback/Agent response behavior.
|
||||
|
||||
### RED/GREEN evidence
|
||||
|
||||
Initial route-integration RED:
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-route.test.ts
|
||||
```
|
||||
|
||||
Result before the injectable handler existed: exit 1, `ERR_MODULE_NOT_FOUND` for
|
||||
`src/lib/onboarding-post.ts`; 0 passed, 1 failed.
|
||||
|
||||
The first GREEN exercised two concurrent calls: A claimed and blocked in deterministic generation,
|
||||
the fake profile changed to B, B claimed and completed, and A then lost its exact pending-version
|
||||
completion. The handler returned B with `source=agent`, returned A with `source=pending`, and the
|
||||
repository retained B's payload.
|
||||
|
||||
Regression-sensitivity proof temporarily removed the completion result branch and returned the
|
||||
generated payload unconditionally. The focused stale test failed with actual `agent`, expected
|
||||
`pending`. Restoring the ownership-result branch made the same test pass 1/1.
|
||||
|
||||
The stale root contract RED used the available repository environment:
|
||||
|
||||
```text
|
||||
/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_agent_chat_contract.py
|
||||
```
|
||||
|
||||
Result before updating the contract: exit 1; 1 failed and 1 passed. The old contract still expected
|
||||
the onboarding fetch in `page.tsx` and later expected removed inline version logic. The updated
|
||||
contract follows the client/factory/cache modules and pins the production version CAS, timestamp
|
||||
CAS, pending-version completion CAS, completion-row observation, SHA-256 identity, and default POST
|
||||
delegation. Final result: 2 passed.
|
||||
|
||||
### Expanded matrices
|
||||
|
||||
- Route integration: ready A to B, active-pending A to B, stale A completion after B wins, observed
|
||||
version race, and observed timestamp race.
|
||||
- Cache policy: each of the eight selected profile inputs independently changes ready and pending
|
||||
identities; TTL minus one remains pending while exact TTL reclaims; null and invalid generated
|
||||
timestamps reclaim.
|
||||
- Candidate completion: missing owner, empty owner, and legal `candidate_saved` compatibility were
|
||||
added without changing confidence/status policy.
|
||||
|
||||
### Follow-up verification
|
||||
|
||||
Focused frontend tests:
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/onboarding-route.test.ts tests/onboarding-cache-policy.test.ts tests/birth-time-candidate-completion.test.ts
|
||||
```
|
||||
|
||||
Result: exit 0; 31 passed, 0 failed.
|
||||
|
||||
Full frontend suite:
|
||||
|
||||
```text
|
||||
/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts
|
||||
```
|
||||
|
||||
Result: exit 0; 455 passed, 0 failed.
|
||||
|
||||
Changed-file ESLint: exit 0 with zero diagnostics across the route, cache/payload/handler modules,
|
||||
candidate modules, stateful fake, and focused tests.
|
||||
|
||||
Affected Python contract: 2 passed. Changed-file Ruff initially found one extra blank line at the
|
||||
existing import boundary; it was removed before final verification.
|
||||
|
||||
Production build:
|
||||
|
||||
```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; compiled in 5.9 seconds, completed TypeScript in 10.3 seconds, and generated 22/22
|
||||
pages. `/api/onboarding` remains a dynamic route.
|
||||
|
||||
Direct `tsc --noEmit` still reports only the same eight unrelated ES2018 regexp-flag diagnostics;
|
||||
no follow-up file is reported. Pure LOC is 80 (production adapter), 184 (handler), 33 (payload), 82
|
||||
(cache policy), 141 (route integration test), 75 (stateful fake), 165 (policy test), 194 (candidate
|
||||
test), and 95 (root contract): every touched file remains below 200 pure LOC.
|
||||
|
||||
### Follow-up concerns
|
||||
|
||||
- The same pre-work Python 3.9/pytest and remote-visibility environment blockers remain; no remote
|
||||
synchronization is claimed.
|
||||
- Default Turbopack still rejects the worktree's external dependency symlink. The complete webpack
|
||||
production build with repository-standard CI placeholders passed.
|
||||
|
||||
Staged follow-up audit ran `git diff --cached --check`, `--name-status`, and `--stat`. The check
|
||||
passed, and the staged set contained exactly nine follow-up paths: this report, the production
|
||||
route adapter, handler, payload boundary, route fake/test, policy test, candidate test, and root CI
|
||||
contract. Unrelated `.superpowers/sdd/task-1-report.md` and `.omo/` remained unstaged.
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createOnboardingCacheIdentity,
|
||||
createOnboardingCompletionTransition,
|
||||
decideOnboardingCache,
|
||||
} from "@/lib/onboarding-cache-policy";
|
||||
createOnboardingPost,
|
||||
type OnboardingProfileRepository,
|
||||
} from "@/lib/onboarding-post";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { getOnboardingAgent } from "@/mastra";
|
||||
@@ -13,189 +10,74 @@ import { defaultLanguageModel } from "@/mastra/model";
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 30;
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
greeting: z.string().trim().min(8).max(180),
|
||||
suggestions: z.tuple([
|
||||
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
|
||||
]),
|
||||
function createProfileRepository(
|
||||
admin: ReturnType<typeof createAdminSupabaseClient>,
|
||||
): OnboardingProfileRepository {
|
||||
return {
|
||||
async loadProfile(userId) {
|
||||
return admin
|
||||
.from("profiles")
|
||||
.select("id,name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
|
||||
.eq("id", userId)
|
||||
.maybeSingle();
|
||||
},
|
||||
async claimProfile(command) {
|
||||
let claim = admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_version: command.pendingVersion,
|
||||
onboarding_generated_at: command.claimedAt,
|
||||
})
|
||||
.eq("id", command.userId);
|
||||
claim = command.expectedVersion === null
|
||||
? claim.is("onboarding_version", null)
|
||||
: claim.eq("onboarding_version", command.expectedVersion);
|
||||
claim = command.expectedGeneratedAt === null
|
||||
? claim.is("onboarding_generated_at", null)
|
||||
: claim.eq("onboarding_generated_at", command.expectedGeneratedAt);
|
||||
return claim.select("id").maybeSingle();
|
||||
},
|
||||
async completeProfile(command) {
|
||||
return admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_payload: command.payload,
|
||||
onboarding_version: command.readyVersion,
|
||||
onboarding_generated_at: command.generatedAt,
|
||||
})
|
||||
.eq("id", command.userId)
|
||||
.eq("onboarding_version", command.expectedPendingVersion)
|
||||
.select("id")
|
||||
.maybeSingle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const POST = createOnboardingPost({
|
||||
openSession: async () => {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return {
|
||||
userId: user?.id ?? null,
|
||||
authError: Boolean(error),
|
||||
repository: createProfileRepository(admin),
|
||||
};
|
||||
},
|
||||
generateText: async (name) => {
|
||||
const model = defaultLanguageModel();
|
||||
if (!model) return null;
|
||||
const result = await getOnboardingAgent(model).generate([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
name ? `用户称呼:${name.slice(0, 80)}` : "用户未填写称呼。",
|
||||
"请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。",
|
||||
].join("\n"),
|
||||
},
|
||||
]);
|
||||
return result.text;
|
||||
},
|
||||
now: () => new Date(),
|
||||
warn: (message, detail) => console.warn(message, detail),
|
||||
});
|
||||
|
||||
type OnboardingPayload = z.infer<typeof onboardingSchema>;
|
||||
|
||||
const fallbackPayload: OnboardingPayload = {
|
||||
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "我的事业优势更适合怎样发挥?" },
|
||||
{ theme: "marriage", text: "我在关系里容易重复什么模式?" },
|
||||
{ theme: "timing", text: "未来一年有哪些阶段值得提前准备?" },
|
||||
],
|
||||
};
|
||||
|
||||
function parseJsonObject(text: string) {
|
||||
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const start = normalized.indexOf("{");
|
||||
const end = normalized.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new Error("onboarding_json_missing");
|
||||
return JSON.parse(normalized.slice(start, end + 1));
|
||||
}
|
||||
|
||||
function hasCompleteBirthProfile(profile: Record<string, unknown>) {
|
||||
return Boolean(
|
||||
profile.name
|
||||
&& profile.birth_date
|
||||
&& (profile.active_birth_time || profile.birth_time)
|
||||
&& (profile.birth_time_status === "confirmed"
|
||||
|| profile.birth_time_status === "candidate"
|
||||
|| (!profile.birth_time_status && profile.birth_time))
|
||||
&& profile.country_code
|
||||
&& profile.province_code
|
||||
&& profile.city_code,
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
|
||||
let admin: ReturnType<typeof createAdminSupabaseClient>;
|
||||
try {
|
||||
supabase = await createServerSupabaseClient();
|
||||
admin = createAdminSupabaseClient();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: "请先登录", message: "登录后才能准备初始问题。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await admin
|
||||
.from("profiles")
|
||||
.select("name,birth_date,birth_time,active_birth_time,birth_time_status,country_code,province_code,city_code,onboarding_payload,onboarding_version,onboarding_generated_at")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError || !profile) {
|
||||
return NextResponse.json(
|
||||
{ error: "无法读取用户档案", message: profileError?.message || "请重新登录后再试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasCompleteBirthProfile(profile)) {
|
||||
return NextResponse.json(
|
||||
{ error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
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 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: cacheDecision.pendingVersion,
|
||||
onboarding_generated_at: claimTime,
|
||||
})
|
||||
.eq("id", user.id);
|
||||
claim = cacheDecision.expectedVersion === null
|
||||
? claim.is("onboarding_version", null)
|
||||
: 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(
|
||||
{ error: "暂时无法准备初始问题", message: claimError.message },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!claimedProfile) {
|
||||
return NextResponse.json({ ...fallbackPayload, source: "pending" });
|
||||
}
|
||||
|
||||
let payload = fallbackPayload;
|
||||
let source: "agent" | "fallback" = "fallback";
|
||||
|
||||
const onboardingModel = defaultLanguageModel();
|
||||
if (onboardingModel) {
|
||||
try {
|
||||
const result = await getOnboardingAgent(onboardingModel).generate([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
profile.name ? `用户称呼:${String(profile.name).slice(0, 80)}` : "用户未填写称呼。",
|
||||
"请生成首次欢迎语和三个入门问题。欢迎语直接邀请用户提问,不要提到出生资料、资料准备或系统处理过程。",
|
||||
].join("\n"),
|
||||
},
|
||||
]);
|
||||
const parsed = onboardingSchema.safeParse(parseJsonObject(result.text));
|
||||
if (parsed.success) {
|
||||
payload = parsed.data;
|
||||
source = "agent";
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "unknown error";
|
||||
console.warn("[onboarding] agent generation failed; using safe fallback", message);
|
||||
}
|
||||
}
|
||||
|
||||
const completionTransition = createOnboardingCompletionTransition(identity);
|
||||
const { error: cacheError } = await admin
|
||||
.from("profiles")
|
||||
.update({
|
||||
onboarding_payload: payload,
|
||||
onboarding_version: completionTransition.readyVersion,
|
||||
onboarding_generated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", user.id)
|
||||
.eq("onboarding_version", completionTransition.expectedVersion);
|
||||
|
||||
if (cacheError) {
|
||||
console.warn("[onboarding] unable to cache generated content", cacheError.message);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ...payload, source });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const onboardingSchema = z.object({
|
||||
greeting: z.string().trim().min(8).max(180),
|
||||
suggestions: z.tuple([
|
||||
z.object({ theme: z.literal("career"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("marriage"), text: z.string().trim().min(4).max(80) }),
|
||||
z.object({ theme: z.literal("timing"), text: z.string().trim().min(4).max(80) }),
|
||||
]),
|
||||
});
|
||||
|
||||
export type OnboardingPayload = z.infer<typeof onboardingSchema>;
|
||||
|
||||
export const fallbackOnboardingPayload: OnboardingPayload = {
|
||||
greeting: "我们从你此刻最关心的事情开始。可以选择下面的方向,也可以直接说出你的问题。",
|
||||
suggestions: [
|
||||
{ theme: "career", text: "我的事业优势更适合怎样发挥?" },
|
||||
{ theme: "marriage", text: "我在关系里容易重复什么模式?" },
|
||||
{ theme: "timing", text: "未来一年有哪些阶段值得提前准备?" },
|
||||
],
|
||||
};
|
||||
|
||||
class OnboardingJsonError extends Error {
|
||||
readonly name = "OnboardingJsonError";
|
||||
}
|
||||
|
||||
export function parseOnboardingPayload(value: unknown): OnboardingPayload | null {
|
||||
const parsed = onboardingSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function parseOnboardingText(text: string): OnboardingPayload | null {
|
||||
const normalized = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
|
||||
const start = normalized.indexOf("{");
|
||||
const end = normalized.lastIndexOf("}");
|
||||
if (start < 0 || end <= start) throw new OnboardingJsonError("onboarding_json_missing");
|
||||
const parsed: unknown = JSON.parse(normalized.slice(start, end + 1));
|
||||
return parseOnboardingPayload(parsed);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
createOnboardingCacheIdentity,
|
||||
createOnboardingCompletionTransition,
|
||||
decideOnboardingCache,
|
||||
} from "./onboarding-cache-policy.ts";
|
||||
import {
|
||||
fallbackOnboardingPayload,
|
||||
type OnboardingPayload,
|
||||
parseOnboardingPayload,
|
||||
parseOnboardingText,
|
||||
} from "./onboarding-payload.ts";
|
||||
|
||||
export type OnboardingProfileRow = {
|
||||
readonly id: string;
|
||||
readonly name: string | null;
|
||||
readonly birth_date: string | null;
|
||||
readonly birth_time: string | null;
|
||||
readonly active_birth_time: string | null;
|
||||
readonly birth_time_status: string | null;
|
||||
readonly country_code: string | null;
|
||||
readonly province_code: string | null;
|
||||
readonly city_code: string | null;
|
||||
readonly onboarding_payload: unknown;
|
||||
readonly onboarding_version: string | null;
|
||||
readonly onboarding_generated_at: string | null;
|
||||
};
|
||||
|
||||
type RepositoryError = { readonly message: string };
|
||||
type RepositoryResult<Value> = {
|
||||
readonly data: Value | null;
|
||||
readonly error: RepositoryError | null;
|
||||
};
|
||||
|
||||
export type OnboardingClaimCommand = {
|
||||
readonly userId: string;
|
||||
readonly expectedVersion: string | null;
|
||||
readonly expectedGeneratedAt: string | null;
|
||||
readonly pendingVersion: string;
|
||||
readonly claimedAt: string;
|
||||
};
|
||||
|
||||
export type OnboardingCompletionCommand = {
|
||||
readonly userId: string;
|
||||
readonly expectedPendingVersion: string;
|
||||
readonly readyVersion: string;
|
||||
readonly payload: OnboardingPayload;
|
||||
readonly generatedAt: string;
|
||||
};
|
||||
|
||||
export interface OnboardingProfileRepository {
|
||||
loadProfile(userId: string): Promise<RepositoryResult<OnboardingProfileRow>>;
|
||||
claimProfile(command: OnboardingClaimCommand): Promise<RepositoryResult<{ readonly id: string }>>;
|
||||
completeProfile(command: OnboardingCompletionCommand): Promise<RepositoryResult<{ readonly id: string }>>;
|
||||
}
|
||||
|
||||
type OnboardingSession = {
|
||||
readonly userId: string | null;
|
||||
readonly authError: boolean;
|
||||
readonly repository: OnboardingProfileRepository;
|
||||
};
|
||||
|
||||
type OnboardingPostDependencies = {
|
||||
readonly openSession: () => Promise<OnboardingSession>;
|
||||
readonly generateText: (name: string) => Promise<string | null>;
|
||||
readonly now: () => Date;
|
||||
readonly warn: (message: string, detail: string) => void;
|
||||
};
|
||||
|
||||
function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean {
|
||||
return Boolean(
|
||||
profile.name
|
||||
&& profile.birth_date
|
||||
&& (profile.active_birth_time || profile.birth_time)
|
||||
&& (profile.birth_time_status === "confirmed"
|
||||
|| profile.birth_time_status === "candidate"
|
||||
|| (!profile.birth_time_status && profile.birth_time))
|
||||
&& profile.country_code
|
||||
&& profile.province_code
|
||||
&& profile.city_code,
|
||||
);
|
||||
}
|
||||
|
||||
export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise<Response> {
|
||||
return async function onboardingPost(): Promise<Response> {
|
||||
let session: OnboardingSession;
|
||||
try {
|
||||
session = await dependencies.openSession();
|
||||
} catch { // no-excuse-ok: catch -- route boundary converts missing configuration.
|
||||
return Response.json(
|
||||
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (session.authError || !session.userId) {
|
||||
return Response.json(
|
||||
{ error: "请先登录", message: "登录后才能准备初始问题。" },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const loaded = await session.repository.loadProfile(session.userId);
|
||||
if (loaded.error || !loaded.data) {
|
||||
return Response.json(
|
||||
{ error: "无法读取用户档案", message: loaded.error?.message || "请重新登录后再试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
const profile = loaded.data;
|
||||
if (!hasCompleteBirthProfile(profile)) {
|
||||
return Response.json(
|
||||
{ error: "出生资料尚未完成", message: "请先填写称呼、出生日期、时间和地点。" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
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 cachedPayload = parseOnboardingPayload(profile.onboarding_payload);
|
||||
const generatedAtMs = profile.onboarding_generated_at === null
|
||||
? Number.NaN
|
||||
: Date.parse(profile.onboarding_generated_at);
|
||||
const now = dependencies.now();
|
||||
const decision = decideOnboardingCache({
|
||||
identity,
|
||||
observedVersion: profile.onboarding_version,
|
||||
generatedAtMs,
|
||||
nowMs: now.getTime(),
|
||||
cachedPayload,
|
||||
});
|
||||
|
||||
switch (decision.kind) {
|
||||
case "ready":
|
||||
return Response.json({ ...decision.payload, source: "cache" });
|
||||
case "pending":
|
||||
return Response.json({ ...fallbackOnboardingPayload, source: "pending" });
|
||||
case "claim":
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveDecision: never = decision;
|
||||
throw exhaustiveDecision;
|
||||
}
|
||||
}
|
||||
|
||||
const claim = await session.repository.claimProfile({
|
||||
userId: session.userId,
|
||||
expectedVersion: decision.expectedVersion,
|
||||
expectedGeneratedAt: profile.onboarding_generated_at,
|
||||
pendingVersion: decision.pendingVersion,
|
||||
claimedAt: now.toISOString(),
|
||||
});
|
||||
if (claim.error) {
|
||||
return Response.json(
|
||||
{ error: "暂时无法准备初始问题", message: claim.error.message },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!claim.data) return Response.json({ ...fallbackOnboardingPayload, source: "pending" });
|
||||
|
||||
let payload = fallbackOnboardingPayload;
|
||||
let source: "agent" | "fallback" = "fallback";
|
||||
try {
|
||||
const text = await dependencies.generateText(profile.name ?? "");
|
||||
const parsed = text === null ? null : parseOnboardingText(text);
|
||||
if (parsed) {
|
||||
payload = parsed;
|
||||
source = "agent";
|
||||
}
|
||||
} catch (error) { // no-excuse-ok: catch -- generation failure intentionally uses safe fallback.
|
||||
dependencies.warn(
|
||||
"[onboarding] agent generation failed; using safe fallback",
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
}
|
||||
|
||||
const completion = createOnboardingCompletionTransition(identity);
|
||||
const completed = await session.repository.completeProfile({
|
||||
userId: session.userId,
|
||||
expectedPendingVersion: completion.expectedVersion,
|
||||
readyVersion: completion.readyVersion,
|
||||
payload,
|
||||
generatedAt: dependencies.now().toISOString(),
|
||||
});
|
||||
if (completed.error) {
|
||||
dependencies.warn("[onboarding] unable to cache generated content", completed.error.message);
|
||||
return Response.json({ ...payload, source });
|
||||
}
|
||||
return completed.data
|
||||
? Response.json({ ...payload, source })
|
||||
: Response.json({ ...fallbackOnboardingPayload, source: "pending" });
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PAGE = ROOT / "frontend" / "src" / "app" / "page.tsx"
|
||||
AGENT = ROOT / "frontend" / "src" / "mastra" / "index.ts"
|
||||
ONBOARDING_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "onboarding" / "route.ts"
|
||||
ONBOARDING_CLIENT = ROOT / "frontend" / "src" / "lib" / "onboarding-client.ts"
|
||||
ONBOARDING_POST = ROOT / "frontend" / "src" / "lib" / "onboarding-post.ts"
|
||||
ONBOARDING_CACHE_POLICY = (
|
||||
ROOT / "frontend" / "src" / "lib" / "onboarding-cache-policy.ts"
|
||||
)
|
||||
CONSULT_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "consult" / "route.ts"
|
||||
MODELS_ROUTE = ROOT / "frontend" / "src" / "app" / "api" / "models" / "route.ts"
|
||||
MODEL_SELECTION = ROOT / "frontend" / "src" / "lib" / "consultation-model-selection.ts"
|
||||
@@ -24,6 +28,9 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
|
||||
page = PAGE.read_text(encoding="utf-8")
|
||||
agent = AGENT.read_text(encoding="utf-8")
|
||||
route = ONBOARDING_ROUTE.read_text(encoding="utf-8")
|
||||
onboarding_client = ONBOARDING_CLIENT.read_text(encoding="utf-8")
|
||||
onboarding_post = ONBOARDING_POST.read_text(encoding="utf-8")
|
||||
cache_policy = ONBOARDING_CACHE_POLICY.read_text(encoding="utf-8")
|
||||
consult_route = CONSULT_ROUTE.read_text(encoding="utf-8")
|
||||
migration = ONBOARDING_MIGRATION.read_text(encoding="utf-8")
|
||||
|
||||
@@ -39,7 +46,7 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
|
||||
assert "<BirthTimeIntakeFields value={value}" in page
|
||||
assert "<BirthLocationFields value={profileDraft}" in page
|
||||
assert "Enter 确认称呼" in page
|
||||
assert 'fetch("/api/onboarding"' in page
|
||||
assert 'fetch("/api/onboarding"' in onboarding_client
|
||||
assert "onboarding?.suggestions" in page
|
||||
assert "parseAgentReply(answer, theme)" in page
|
||||
assert "suggestions: reply.suggestions" in page
|
||||
@@ -56,9 +63,23 @@ def test_onboarding_and_agent_suggestion_contract() -> None:
|
||||
assert "function currentTimeContext(now = new Date())" in consult_route
|
||||
assert "currentTimeContext(requestTime)," in consult_route
|
||||
assert "中国标准时间(UTC+8)" in consult_route
|
||||
assert 'profile.onboarding_version === ONBOARDING_VERSION' in route
|
||||
assert "getOnboardingAgent(onboardingModel).generate" in route
|
||||
assert 'source: "cache"' in route
|
||||
assert "export const POST = createOnboardingPost" in route
|
||||
assert 'claim.is("onboarding_version", null)' in route
|
||||
assert '.eq("onboarding_version", command.expectedVersion)' in route
|
||||
assert 'claim.is("onboarding_generated_at", null)' in route
|
||||
assert '.eq("onboarding_generated_at", command.expectedGeneratedAt)' in route
|
||||
assert (
|
||||
'.eq("onboarding_version", command.expectedPendingVersion)\n'
|
||||
' .select("id")\n'
|
||||
" .maybeSingle();"
|
||||
) in route
|
||||
assert "createOnboardingCacheIdentity" in onboarding_post
|
||||
assert "expectedGeneratedAt: profile.onboarding_generated_at" in onboarding_post
|
||||
assert "completed.data" in onboarding_post
|
||||
assert 'source: "cache"' in onboarding_post
|
||||
assert 'source: "pending"' in onboarding_post
|
||||
assert 'createHash("sha256")' in cache_policy
|
||||
assert "getOnboardingAgent(model).generate" in route
|
||||
assert "onboarding_payload" in migration
|
||||
assert "to service_role" in migration
|
||||
assert "to authenticated" not in migration
|
||||
|
||||
Reference in New Issue
Block a user