import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import { readdirSync, readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import test from "node:test"; const frontendRoot = fileURLToPath(new URL("../", import.meta.url)); const srcRoot = join(frontendRoot, "src"); const NOT_CONFIGURED = "服务尚未配置"; const ROUTES_USING_HELPER = [ "src/app/api/rectification/agent/route.ts", "src/app/api/rectification/cases/open/route.ts", "src/app/api/rectification/cases/entry-summary/route.ts", "src/app/api/rectification/cases/[caseId]/route.ts", "src/app/api/rectification/cases/[caseId]/close/route.ts", "src/app/api/rectification/cases/[caseId]/adopt-skill/route.ts", "src/app/api/rectification/cases/[caseId]/upgrade-skill/route.ts", "src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts", "src/app/api/rectification/cases/[caseId]/turns/[turnId]/regenerate/route.ts", "src/app/api/consult/route.ts", "src/app/api/consult/status/route.ts", "src/app/api/consult/cancel/route.ts", "src/app/api/models/route.ts", "src/app/api/birth-time-journey/route.ts", "src/app/api/birth-time-guide/route.ts", ] as const; function walk(directory: string): string[] { const files: string[] = []; for (const name of readdirSync(directory)) { const path = join(directory, name); if (statSync(path).isDirectory()) { files.push(...walk(path)); continue; } if (path.endsWith(".ts") || path.endsWith(".tsx")) files.push(path); } return files; } type RouteProbe = { configStatus: number; configBody: Record; unavailableStatus: number; unavailableBody: Record; unavailableLogs: string[][]; }; function executeRouteProbe(kind: "agent" | "cases"): RouteProbe { const script = String.raw` import { mock } from "node:test"; import { pathToFileURL } from "node:url"; import { SupabaseConfigurationError } from "@/lib/supabase/config"; const kind = ${JSON.stringify(kind)}; const moduleUrl = (path) => pathToFileURL(process.cwd() + "/" + path).href; mock.module("server-only", { namedExports: {} }); let calls = 0; mock.module("@/lib/supabase/server", { namedExports: { createServerSupabaseClient: async () => { calls += 1; if (calls === 1) throw new SupabaseConfigurationError(["APP_DATABASE_URL"]); throw new Error("connection refused"); }, }}); mock.module("@/lib/supabase/admin", { namedExports: { createAdminSupabaseClient: () => { throw new Error("admin unused in this unit test"); }, }}); const unavailableLogs = []; const originalError = console.error; console.error = (...args) => { unavailableLogs.push(args.map((value) => { if (typeof value === "string") return value; if (value instanceof Error) return value.stack ?? value.message; return String(value); })); }; async function invoke() { if (kind === "agent") { const { POST } = await import(moduleUrl("src/app/api/rectification/agent/route.ts")); return POST(new Request("https://staging.jyotisha.chat/api/rectification/agent", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", })); } const { GET } = await import(moduleUrl("src/app/api/rectification/cases/[caseId]/route.ts")); return GET( new Request("https://staging.jyotisha.chat/api/rectification/cases/123e4567-e89b-12d3-a456-426614174000?sessionId=123e4567-e89b-12d3-a456-426614174001"), { params: Promise.resolve({ caseId: "123e4567-e89b-12d3-a456-426614174000" }) }, ); } const configResponse = await invoke(); const configBody = await configResponse.json(); const unavailableResponse = await invoke(); const unavailableBody = await unavailableResponse.json(); console.error = originalError; console.log("AYANAM_RESULT " + JSON.stringify({ configStatus: configResponse.status, configBody, unavailableStatus: unavailableResponse.status, unavailableBody, unavailableLogs, })); `; const result = spawnSync(process.execPath, [ "--experimental-test-module-mocks", "--import", "tsx", "--input-type=module", "--eval", script, ], { cwd: frontendRoot, encoding: "utf8", env: { ...process.env, NODE_NO_WARNINGS: "1" }, }); assert.equal(result.status, 0, result.stderr || result.stdout); const line = result.stdout .split("\n") .map((row) => row.trim()) .find((row) => row.startsWith("AYANAM_RESULT ")); assert.ok(line, result.stdout); return JSON.parse(line.slice("AYANAM_RESULT ".length)) as RouteProbe; } function assertConfigMislabel(body: Record) { assert.equal(body.error, NOT_CONFIGURED); assert.equal(body.code, undefined); } function assertUnavailable(body: Record, logs: string[][]) { assert.equal(body.error, "服务暂时不可用,请稍后重试"); assert.equal(body.code, "service_unavailable"); assert.equal(JSON.stringify(body).includes("尚未配置"), false); assert.ok(logs.some((entry) => entry.includes("Error"))); assert.equal(JSON.stringify(logs).includes("connection refused"), false); assert.equal(JSON.stringify(logs).includes("APP_DATABASE_URL"), false); } test("shared helper classifies configuration errors and connection failures", async () => { const { jsonForSupabaseSetupFailure } = await import("../src/lib/api/service-unavailable.ts"); const { SupabaseConfigurationError } = await import("../src/lib/supabase/config.ts"); const logged: unknown[][] = []; const originalError = console.error; console.error = (...args: unknown[]) => { logged.push(args); }; try { const missing = await jsonForSupabaseSetupFailure( new SupabaseConfigurationError(["APP_DATABASE_URL"]), "POST /api/rectification/agent", ); assert.equal(missing.status, 503); assert.deepEqual(await missing.json(), { error: NOT_CONFIGURED, message: "请先配置数据库环境变量。", }); assert.deepEqual(logged, []); const unavailable = await jsonForSupabaseSetupFailure( new Error("connection refused"), "GET /api/rectification/cases/[caseId]", ); assert.equal(unavailable.status, 503); assert.deepEqual(await unavailable.json(), { error: "服务暂时不可用,请稍后重试", code: "service_unavailable", }); assert.deepEqual(logged, [["GET /api/rectification/cases/[caseId]", "Error"]]); } finally { console.error = originalError; } }); test("rectification agent maps setup failures without calling the rest of the handler", () => { const probe = executeRouteProbe("agent"); assert.equal(probe.configStatus, 503); assertConfigMislabel(probe.configBody); assert.equal(probe.unavailableStatus, 503); assertUnavailable(probe.unavailableBody, probe.unavailableLogs); assert.ok(probe.unavailableLogs.some((entry) => entry.some((value) => value.includes("/api/rectification/agent")), )); }); test("rectification case refresh maps setup failures without calling the rest of the handler", () => { const probe = executeRouteProbe("cases"); assert.equal(probe.configStatus, 503); assertConfigMislabel(probe.configBody); assert.equal(probe.unavailableStatus, 503); assertUnavailable(probe.unavailableBody, probe.unavailableLogs); assert.ok(probe.unavailableLogs.some((entry) => entry.some((value) => value.includes("/api/rectification/cases/")), )); }); test("not-configured copy lives only in the shared helper, and the listed routes call it", () => { // 原值: 测试名写 16 条,清单含 src/lib/onboarding-post.ts。 // 新值: 15 条,建议问题接口删除。 // 原因: /api/onboarding 下线。 const hits = walk(srcRoot) .filter((path) => readFileSync(path, "utf8").includes(NOT_CONFIGURED)) .map((path) => path.slice(srcRoot.length + 1).split("\\").join("/")); assert.deepEqual(hits, ["lib/api/service-unavailable.ts"]); for (const relative of ROUTES_USING_HELPER) { const source = readFileSync(join(frontendRoot, relative), "utf8"); assert.match( source, /jsonForSupabaseSetupFailure/, `${relative} must use jsonForSupabaseSetupFailure`, ); } });