ai-chatbot/tests/routes/agent-project-recovery.test.ts
WuYongJie 70f954382a 减轻风格化入口加载并防止新建项目超时重复创建
风格化入口并发下载完整配置时,新建项目的响应可能超过前端超时限制,重复点击会产生多个空项目。现在未选项目时只加载轻量配置,进入项目后再加载完整风格配置;配置未变化时按更新时间返回 304,复用已加载数据。配置下载超时覆盖响应正文读取,完整配置保留 30 秒读取时间。

新建请求使用稳定的项目编号,数据库按主键去重;丢失响应后按编号核对结果,尚未确认时保留编号供重试和刷新恢复。已有项目的内容与账号归属检查保持有效。

验证:8 项专项测试通过,辅助测试 633 项通过、1 项原有跳过,TypeScript、改动文件 lint、日志脱敏、凭据、环境、元数据、测试和制品检查通过。全量 lint 仍有 1005 项历史诊断;发布构建和线上验收另行核对。
2026-11-04 09:46:45 +08:00

230 lines
11 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { readFileSync, existsSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import vm from "node:vm";
import { randomUUID, webcrypto } from "node:crypto";
import ts from "typescript";
const root = path.resolve(__dirname, "../..");
const id = randomUUID();
const owner = randomUUID();
function loadRuntime(mocks: Record<string, any> = {}, globals: Record<string, any> = {}) {
const modules = new Map<string, any>();
const context = vm.createContext({
console, Buffer, URL, Response, Request, Headers, DOMException,
AbortController, AbortSignal, crypto: webcrypto, setTimeout, clearTimeout,
process: { env: {} },
fetch: () => { throw new Error("Unexpected network request"); },
...globals,
});
const load = (relative: string): any => {
let filename = path.resolve(root, relative);
if (!existsSync(filename)) filename = path.join(filename.replace(/\.ts$/, ""), "index.ts");
if (modules.has(filename)) return modules.get(filename).exports;
const module = { exports: {} };
modules.set(filename, module);
const nativeRequire = createRequire(filename);
const requireMock = (name: string) => {
if (name === "server-only") return {};
if (Object.hasOwn(mocks, name)) return mocks[name];
if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`);
if (name.startsWith(".")) return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`)));
return nativeRequire(name);
};
const source = ts.transpileModule(readFileSync(filename, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
}).outputText;
context.module = module;
context.exports = module.exports;
context.require = requireMock;
const fn = new vm.Script(`(function(module,exports,require){${source}\n})`, { filename }).runInContext(context);
fn(module, module.exports, requireMock);
return module.exports;
};
return load;
}
function memoryStorage() {
const values = new Map<string, string>();
return { getItem: (key: string) => values.get(key) || null, setItem: (key: string, value: string) => values.set(key, value), removeItem: (key: string) => values.delete(key) };
}
function projectRoute() {
const projects = new Map<string, any>();
let userId: string | null = owner;
const db = {
insert: () => ({ values: (value: any) => ({ onConflictDoNothing: () => ({ returning: async () => {
const row = { ...value, id: value.id || randomUUID(), createdAt: new Date(), updatedAt: new Date() };
if (projects.has(row.id)) return [];
projects.set(row.id, row);
return [row];
} }) }) }),
select: () => ({ from: () => ({ where: (conditions: any[]) => ({ limit: async () => [...projects.values()].filter((row) => conditions.every(([key, value]) => row[key] === value)) }) }) }),
};
const load = loadRuntime({
"@/lib/db": { db },
"@/lib/db/schema": { canvasProject: { id: "id", userId: "userId", workspaceType: "workspaceType" } },
"@/app/(auth)/auth": { auth: async () => userId ? { user: { id: userId } } : null },
"drizzle-orm": { eq: (key: string, value: unknown) => [key, value], and: (...parts: unknown[]) => parts, desc: (value: unknown) => value },
});
return { route: load("app/api/agent-projects/route.ts"), projects, setUser: (value: string | null) => { userId = value; } };
}
test("duplicate POSTs return one project without overwriting saved state", async () => {
const { route, projects } = projectRoute();
const post = (title: string) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify({ id, title }) }));
const responses = await Promise.all([post("原项目"), post("重试标题")]);
expect(projects.size).toBe(1);
for (const response of responses) {
expect(response.status).toBe(200);
expect((await response.json()).data).toMatchObject({ id, title: "原项目", userId: owner });
}
projects.get(id).title = "已编辑项目";
expect((await (await post("迟到的重试")).json()).data.title).toBe("已编辑项目");
});
test("creation retains account ownership and accepts existing clients", async () => {
const { route, projects, setUser } = projectRoute();
const post = (body: unknown) => route.POST(new Request("http://localhost/api/agent-projects", { method: "POST", body: JSON.stringify(body) }));
expect((await post({ id })).status).toBe(200);
setUser(randomUUID());
const conflict = await post({ id });
expect(conflict.status).toBe(409);
expect((await conflict.json()).data).toBeUndefined();
expect(projects.size).toBe(1);
expect((await post({ title: "旧客户端" })).status).toBe(200);
expect((await post({ id: "invalid" })).status).toBe(400);
setUser(null);
expect((await post({ id: randomUUID() })).status).toBe(401);
});
test("a lost create response recovers the inserted project", async () => {
let project: any;
let posts = 0;
const fetchMock = async (_url: string, init?: RequestInit) => {
if (init?.method === "POST") {
posts++;
project = JSON.parse(String(init.body));
throw new DOMException("Aborted", "AbortError");
}
return Response.json({ success: true, data: project });
};
const { agentProjectService } = loadRuntime({}, { fetch: fetchMock, sessionStorage: memoryStorage() })("services/agent-projects/index.ts");
const result = await agentProjectService.createProject({ title: "新项目" }, { creationKey: "blank:test" });
expect(result.success).toBe(true);
expect(result.data.id).toBe(project.id);
expect(posts).toBe(1);
});
test("unconfirmed creation survives reload and retries with the same project ID", async () => {
const storage = memoryStorage();
const ids: string[] = [];
let offline = true;
const fetchMock = async (_url: string, init?: RequestInit) => {
if (init?.method === "POST") {
const body = JSON.parse(String(init.body));
ids.push(body.id);
if (!offline) return Response.json({ success: true, data: body });
throw new TypeError("Connection closed");
}
return Response.json({ success: false }, { status: 404 });
};
const service = () => loadRuntime({}, { fetch: fetchMock, sessionStorage: storage })("services/agent-projects/index.ts").agentProjectService;
const first = await service().createProject({ title: "09:18" }, { creationKey: "blank:test" });
expect(first.error).toContain("尚未确认");
offline = false;
const reloaded = service();
expect((await reloaded.createProject({ title: "09:21" }, { creationKey: "blank:test" })).success).toBe(true);
expect(ids[1]).toBe(ids[0]);
await reloaded.createProject({ title: "新的独立项目" }, { creationKey: "blank:test" });
expect(ids[2]).not.toBe(ids[0]);
});
test("simultaneous clicks share one create request", async () => {
let posts = 0;
let finish!: () => void;
const wait = new Promise<void>((resolve) => { finish = resolve; });
const service = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => {
posts++;
await wait;
return Response.json({ success: true, data: JSON.parse(String(init.body)) });
} })("services/agent-projects/index.ts").agentProjectService;
const first = service.createProject({}, { creationKey: "blank:double" });
const second = service.createProject({}, { creationKey: "blank:double" });
finish();
const results = await Promise.all([first, second]);
expect(posts).toBe(1);
expect(results[0].data.id).toBe(results[1].data.id);
});
test("conditional settings requests reuse the full config after 304", async () => {
const headers: Headers[] = [];
const config = { steps: [{ key: "style", promptFragment: "保留提示词" }] };
const { fetchCanvasSiteSettings } = loadRuntime({}, { fetch: async (_url: string, init: RequestInit) => {
headers.push(new Headers(init.headers));
return headers.length === 1 ? Response.json(config, { headers: { ETag: 'W/"revision-1"' } }) : new Response(null, { status: 304 });
} })("lib/client/canvas-site-settings.ts");
const first = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas");
const second = await fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas");
expect(second).toBe(first);
expect(second).toEqual(config);
expect(headers[1].get("If-None-Match")).toBe('W/"revision-1"');
});
test("settings timeout also aborts a stalled response body", async () => {
const { fetchCanvasSiteSettings } = loadRuntime({}, {
setTimeout: (callback: () => void) => setTimeout(callback, 5),
fetch: async (_url: string, init: RequestInit) => ({ ok: true, status: 200, json: () => new Promise((_, reject) => {
init.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
}) }),
})("lib/client/canvas-site-settings.ts");
await expect(fetchCanvasSiteSettings("/api/site-settings?scope=agent-canvas")).rejects.toHaveProperty("name", "AbortError");
});
test("entry config stays small and unchanged config returns 304 before loading values", async () => {
let revision = new Date("2026-11-04T00:00:00Z");
let fullReads = 0;
const tables = { siteSettings: { id: "siteId", updatedAt: "siteUpdatedAt" }, systemSettings: { key: "key", updatedAt: "updatedAt" }, aiProvider: { id: "providerId", updatedAt: "providerUpdatedAt", isEnabled: "enabled", type: "type" } };
const ui = { steps: [{ key: "style", title: "风格", enabled: true, sortOrder: 0, options: [{ key: "test", buttonName: "风格一", promptFragment: "生成提示词".repeat(3000), enabled: true, sortOrder: 0 }] }] };
const db = { select: (columns: unknown) => ({ from: (table: unknown) => {
const result = () => {
if (table === tables.aiProvider) return [];
if (table === tables.siteSettings) return [{ id: "site", siteName: "由壹点AI", updatedAt: revision }];
if (!columns) fullReads++;
return [{ key: "agent_workflow_ui_config", value: ui, updatedAt: revision }];
};
const query: any = Object.assign(Promise.resolve(result()), {
where: () => query,
limit: () => query,
});
return query;
} }) };
const route = loadRuntime({
"@/lib/db": { db }, "@/lib/db/schema": tables,
"@/app/(auth)/auth": { auth: async () => null },
})("app/api/site-settings/route.ts");
const request = (scope: string, etag?: string) => {
const req = new Request(`http://localhost/api/site-settings?scope=${scope}`, { headers: etag ? { "If-None-Match": etag } : {} });
return Object.assign(req, { nextUrl: new URL(req.url) });
};
const entry = await route.GET(request("agent-entry"));
expect(entry.status).toBe(200);
const entryData = await entry.json();
expect(entryData.agentWorkflowUiConfig.steps).toEqual([]);
expect(JSON.stringify(entryData).length).toBeLessThan(20_000);
const full = await route.GET(request("agent-canvas"));
expect((await full.json()).agentWorkflowUiConfig.steps[0].options[0].promptFragment).toBe("生成提示词".repeat(3000).slice(0, 20_000));
const etag = full.headers.get("ETag");
const reads = fullReads;
const unchanged = await route.GET(request("agent-canvas", etag));
expect(unchanged.status).toBe(304);
expect(await unchanged.text()).toBe("");
expect(fullReads).toBe(reads);
revision = new Date("2026-11-04T00:01:00Z");
const changed = await route.GET(request("agent-canvas", etag));
expect(changed.status).toBe(200);
expect(changed.headers.get("ETag")).not.toBe(etag);
});