Files
Jyotisha/frontend/tests/api-service-unavailable-20260904.test.ts
T
Jesse_ChenandCursor 5483649bc1 fix(api): stop labeling database blips as unconfigured (BUG-542)
Deploy-window connection errors were translated into "服务尚未配置".
Only real configuration errors keep that copy; other 503s use service_unavailable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 12:37:21 +08:00

220 lines
8.1 KiB
TypeScript

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",
"src/lib/onboarding-post.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<string, unknown>;
unavailableStatus: number;
unavailableBody: Record<string, unknown>;
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<string, unknown>) {
assert.equal(body.error, NOT_CONFIGURED);
assert.equal(body.code, undefined);
}
function assertUnavailable(body: Record<string, unknown>, 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 16 routes call it", () => {
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`,
);
}
});