Files
Jyotisha/frontend/tests/api-service-unavailable-20260904.test.ts
T
Jesse_ChenandClaude Opus 5.5 84eb23152a feat(rectification): remove the rectification regenerate button and endpoint (BUG-1056)
T2 of TASK-rectification-grounding-20260927 (product decision P1).
The rectification 「重试(重新生成)」 was a blind agent.generate rewrite that
persisted unchecked text (invented range / fit rate reproduced). Removed:
the regenerate route, regenerate-turn.ts, the regeneration agent and its
read-only tool set, the client regenerate action/state and the
regenerating/canRegenerate props. ChatMessageActions renders the regenerate
button only when onRegenerate is passed; ordinary consultation is unchanged.
The DB function regenerate_agentic_rectification_turn is kept (AGENTS §7.6,
retire in a later round). DESIGN.md / VOICE.md updated; source-contract
tests follow with 原值/新值/原因 notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 03:28:27 +08:00

223 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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",
// 原值: 清单含 cases/[caseId]/turns/[turnId]/regenerate/route.ts。新值: 移出清单。
// 原因: BUG-1056(TASK-rectification-grounding-20260927 P1)删除生时校正的重新生成接口与按钮,该路由文件已不存在。
"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<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 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`,
);
}
});