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>
220 lines
8.1 KiB
TypeScript
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`,
|
|
);
|
|
}
|
|
});
|