Files
16gagent/test/backend-builder-agent.test.mjs
2026-06-06 10:40:48 +08:00

568 lines
24 KiB
JavaScript

/**
* Backend Builder Agent Test — SF-04
*/
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, rmSync, readFileSync } from "node:fs";
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
const WORKSPACE = resolve(__dirname, "..");
let buildBackend, loadJSON, writeBackend;
before(async () => {
const mod = await import("../scripts/backend-builder-agent.mjs");
buildBackend = mod.buildBackend;
loadJSON = mod.loadJSON;
writeBackend = mod.writeBackend;
});
function loadPRD(name) {
return JSON.parse(readFileSync(resolve(WORKSPACE, `test/fixtures/architecture-agent/${name}-prd.json`), "utf-8"));
}
function loadArch() {
return JSON.parse(readFileSync(resolve(WORKSPACE, "architecture-example.json"), "utf-8"));
}
// Demo RequirementPackage that exercises all generators
const DEMO_PRD = {
projectName: "PetCare",
chineseName: "宠物管理",
domain: "pet",
summary: "一款宠物健康管理应用",
features: [
{ name: "宠物档案", priority: "P0" },
{ name: "健康日程", priority: "P0" },
{ name: "日常记录", priority: "P1" },
{ name: "附近医院", priority: "P1" },
],
userStories: [
{ id: "US-001", as: "宠物主人", want: "记录宠物信息", soThat: "管理宠物档案", priority: "P0" },
],
personas: [{ name: "宠物主人" }],
};
const DEMO_ARCH = {
projectName: "PetCare",
databaseSchema: [
{
table: "pets",
description: "宠物档案表",
fields: [
{ name: "id", type: "TEXT", constraints: "PK" },
{ name: "owner_id", type: "TEXT", constraints: "NOT NULL, FK → users.id" },
{ name: "name", type: "TEXT", constraints: "NOT NULL" },
{ name: "species", type: "TEXT" },
{ name: "breed", type: "TEXT" },
{ name: "birth_date", type: "TEXT" },
{ name: "weight_kg", type: "REAL" },
{ name: "avatar_url", type: "TEXT" },
{ name: "created_at", type: "TEXT", constraints: "NOT NULL, DEFAULT (datetime('now'))" },
{ name: "updated_at", type: "TEXT", constraints: "NOT NULL, DEFAULT (datetime('now'))" },
],
indexes: ["CREATE INDEX IF NOT EXISTS idx_pets_owner ON pets(owner_id)"],
},
{
table: "schedules",
description: "健康日程表",
fields: [
{ name: "id", type: "TEXT", constraints: "PK" },
{ name: "pet_id", type: "TEXT", constraints: "NOT NULL, FK → pets.id" },
{ name: "type", type: "TEXT", constraints: "NOT NULL" },
{ name: "title", type: "TEXT", constraints: "NOT NULL" },
{ name: "scheduled_at", type: "TEXT", constraints: "NOT NULL" },
{ name: "completed", type: "INTEGER", constraints: "DEFAULT 0" },
{ name: "created_at", type: "TEXT", constraints: "NOT NULL, DEFAULT (datetime('now'))" },
],
},
{
table: "daily_logs",
description: "日常记录表",
fields: [
{ name: "id", type: "TEXT", constraints: "PK" },
{ name: "pet_id", type: "TEXT", constraints: "NOT NULL, FK → pets.id" },
{ name: "category", type: "TEXT", constraints: "NOT NULL" },
{ name: "value", type: "TEXT", constraints: "NOT NULL" },
{ name: "logged_at", type: "TEXT", constraints: "NOT NULL, DEFAULT (datetime('now'))" },
],
},
],
apiDesign: [
{
resource: "pets",
basePath: "/api/pets",
endpoints: [
{ method: "GET", path: "/api/pets", description: "获取宠物列表" },
{ method: "GET", path: "/api/pets/:id", description: "获取宠物详情" },
{ method: "POST", path: "/api/pets", description: "创建宠物" },
{ method: "PUT", path: "/api/pets/:id", description: "更新宠物" },
{ method: "DELETE", path: "/api/pets/:id", description: "删除宠物" },
],
},
{
resource: "schedules",
basePath: "/api/schedules",
endpoints: [
{ method: "GET", path: "/api/schedules", description: "获取日程列表" },
{ method: "POST", path: "/api/schedules", description: "创建日程" },
],
},
{
resource: "daily-logs",
basePath: "/api/daily-logs",
endpoints: [
{ method: "POST", path: "/api/daily-logs", description: "记录日常" },
{ method: "GET", path: "/api/daily-logs", description: "查询日常记录" },
],
},
],
};
// ═══════════════════════════════════════════════════════
describe("1 — 目录结构完整性", () => {
it("生成完整后端项目目录", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(!result.error, `Unexpected error: ${result.error}`);
assert.ok(result.stats.totalFiles >= 15, `Expected >= 15 files, got ${result.stats.totalFiles}`);
});
it("生成 package.json", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["package.json"], "Missing package.json");
const pkg = JSON.parse(result.files["package.json"]);
assert.ok(pkg.dependencies.fastify, "fastify dependency missing");
assert.ok(pkg.dependencies["sql.js"], "sql.js dependency missing");
assert.ok(pkg.dependencies["@fastify/jwt"], "@fastify/jwt dependency missing");
assert.ok(pkg.dependencies.bcrypt, "bcrypt dependency missing");
assert.ok(pkg.devDependencies.typescript, "typescript devDependency missing");
assert.ok(pkg.scripts.build, "build script missing");
assert.ok(pkg.scripts.test, "test script missing");
assert.ok(pkg.scripts.dev, "dev script missing");
});
it("生成 tsconfig.json", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["tsconfig.json"], "Missing tsconfig.json");
const cfg = JSON.parse(result.files["tsconfig.json"]);
assert.equal(cfg.compilerOptions.strict, true);
assert.equal(cfg.compilerOptions.module, "ESNext");
assert.ok(cfg.compilerOptions.outDir);
});
it("生成 .env.example", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files[".env.example"]);
const env = result.files[".env.example"];
assert.ok(env.includes("JWT_SECRET"));
assert.ok(env.includes("DATABASE_URL"));
assert.ok(env.includes("PORT"));
});
it("生成 README.md", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["README.md"]);
const readme = result.files["README.md"];
assert.ok(readme.includes("Fastify"));
assert.ok(readme.includes("SQLite"));
assert.ok(readme.includes("npm install"));
assert.ok(readme.includes("## API Endpoints"));
});
it("生成 .gitignore", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files[".gitignore"]);
assert.ok(result.files[".gitignore"].includes("node_modules"));
});
});
// ═══════════════════════════════════════════════════════
describe("2 — src/index.ts(服务入口)", () => {
it("生成 src/index.ts", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["src/index.ts"]);
const content = result.files["src/index.ts"];
assert.ok(content.includes("Fastify"), "Missing Fastify import");
assert.ok(content.includes("buildApp"), "Missing buildApp function");
assert.ok(content.includes("@fastify/jwt"), "Missing JWT plugin");
assert.ok(content.includes("@fastify/cors"), "Missing CORS plugin");
assert.ok(content.includes("authRoutes"), "Missing auth routes");
assert.ok(content.includes("petsRoutes") || content.includes("/api/pets"), "Missing pets routes");
assert.ok(content.includes("/api/health"), "Missing health check");
});
});
// ═══════════════════════════════════════════════════════
describe("3 — src/db/(数据库层)", () => {
it("生成 src/db/schema.ts", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["src/db/schema.ts"]);
const content = result.files["src/db/schema.ts"];
assert.ok(content.includes("CREATE TABLE"), "Missing CREATE TABLE");
assert.ok(content.includes("users"), "Missing users table");
assert.ok(content.includes("pets"), "Missing pets table");
assert.ok(content.includes("schedules"), "Missing schedules table");
assert.ok(content.includes("daily_logs"), "Missing daily_logs table");
});
it("生成 src/db/client.ts", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["src/db/client.ts"]);
const content = result.files["src/db/client.ts"];
assert.ok(content.includes("sql.js"), "Missing sql.js import");
assert.ok(content.includes("getDb"), "Missing getDb function");
assert.ok(content.includes("queryAll"), "Missing queryAll helper");
assert.ok(content.includes("queryOne"), "Missing queryOne helper");
assert.ok(content.includes("execute"), "Missing execute helper");
assert.ok(content.includes("initDb"), "Missing initDb function");
});
});
// ═══════════════════════════════════════════════════════
describe("4 — src/routes/(路由层)", () => {
it("生成 CRUD 路由文件", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.stats.routes >= 3, `Expected >= 3 routes, got ${result.stats.routes}`);
assert.ok(result.files["src/routes/pets.ts"], "Missing pets routes");
assert.ok(result.files["src/routes/schedules.ts"], "Missing schedules routes");
assert.ok(result.files["src/routes/daily_logs.ts"], "Missing daily_logs routes");
});
it("生成 auth 路由", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["src/routes/auth.ts"]);
const content = result.files["src/routes/auth.ts"];
assert.ok(content.includes("register"), "Missing register route");
assert.ok(content.includes("login"), "Missing login route");
assert.ok(content.includes("/me"), "Missing /me route");
assert.ok(content.includes("bcrypt"), "Missing bcrypt import");
assert.ok(content.includes("jwt.sign"), "Missing JWT sign");
});
it("路由文件包含 CRUD 方法", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const petsRoute = result.files["src/routes/pets.ts"];
assert.ok(petsRoute.includes("authenticate"), "Missing auth middleware");
assert.ok(petsRoute.includes("app.get"), "Missing GET");
assert.ok(petsRoute.includes("app.post"), "Missing POST");
assert.ok(petsRoute.includes("app.put"), "Missing PUT");
assert.ok(petsRoute.includes("app.delete"), "Missing DELETE");
});
});
// ═══════════════════════════════════════════════════════
describe("5 — src/middleware/(中间件)", () => {
it("生成 JWT 认证中间件", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["src/middleware/auth.ts"]);
const content = result.files["src/middleware/auth.ts"];
assert.ok(content.includes("authenticate"), "Missing authenticate");
assert.ok(content.includes("requireAdmin"), "Missing requireAdmin");
assert.ok(content.includes("optionalAuth"), "Missing optionalAuth");
assert.ok(content.includes("jwtVerify"), "Missing jwtVerify call");
assert.ok(content.includes("status(401)"), "Missing 401 response");
});
});
// ═══════════════════════════════════════════════════════
describe("6 — src/services/(服务层)", () => {
it("生成服务文件", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.stats.services >= 3, `Expected >= 3 services, got ${result.stats.services}`);
assert.ok(result.files["src/services/pet.ts"], "Missing pet service");
assert.ok(result.files["src/services/schedule.ts"], "Missing schedule service");
});
it("服务文件包含 CRUD 方法", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const svc = result.files["src/services/pet.ts"];
assert.ok(svc.includes("list()"), "Missing list()");
assert.ok(svc.includes("getById"), "Missing getById()");
assert.ok(svc.includes("create"), "Missing create()");
assert.ok(svc.includes("update"), "Missing update()");
assert.ok(svc.includes("delete"), "Missing delete()");
assert.ok(svc.includes("class"), "Missing class definition");
});
});
// ═══════════════════════════════════════════════════════
describe("7 — src/types/(类型定义)", () => {
it("生成类型文件", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.files["src/types/index.ts"]);
const types = result.files["src/types/index.ts"];
assert.ok(types.includes("export interface"), "Missing interfaces");
});
it("包含数据模型类型", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const types = result.files["src/types/index.ts"];
assert.ok(types.includes("interface Pet"), "Missing Pet type");
assert.ok(types.includes("interface Schedule"), "Missing Schedule type");
assert.ok(types.includes("interface User"), "Missing User type");
});
it("包含 Create/Update 输入类型", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const types = result.files["src/types/index.ts"];
assert.ok(types.includes("CreatePetInput"), "Missing CreatePetInput");
assert.ok(types.includes("UpdatePetInput"), "Missing UpdatePetInput");
});
it("包含 Auth 类型", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const types = result.files["src/types/index.ts"];
assert.ok(types.includes("LoginInput"), "Missing LoginInput");
assert.ok(types.includes("RegisterInput"), "Missing RegisterInput");
assert.ok(types.includes("AuthResponse"), "Missing AuthResponse");
});
it("包含 JWT payload 类型", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const types = result.files["src/types/index.ts"];
assert.ok(types.includes("JwtPayload"), "Missing JwtPayload");
});
it("包含 API 响应类型", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const types = result.files["src/types/index.ts"];
assert.ok(types.includes("ApiResponse"), "Missing ApiResponse");
assert.ok(types.includes("PaginatedResponse"), "Missing PaginatedResponse");
assert.ok(types.includes("ErrorResponse"), "Missing ErrorResponse");
});
});
// ═══════════════════════════════════════════════════════
describe("8 — 测试生成", () => {
it("生成测试文件", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
assert.ok(result.stats.tests >= 2, `Expected >= 2 test files, got ${result.stats.tests}`);
assert.ok(result.files["src/__tests__/auth.test.ts"], "Missing auth test");
});
it("auth 测试包含 register/login/me", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const test = result.files["src/__tests__/auth.test.ts"];
assert.ok(test.includes("registers a new user"), "Missing register test");
assert.ok(test.includes("logs in with correct credentials"), "Missing login test");
assert.ok(test.includes("returns current user"), "Missing me test");
assert.ok(test.includes("rejects without token"), "Missing auth fail test");
});
it("CRUD 测试包含完整生命周期", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const test = result.files["src/__tests__/pets.test.ts"];
if (!test) return; // pets test may not exist depending on naming
assert.ok(test.includes("creates"), "Missing create test");
assert.ok(test.includes("updates"), "Missing update test");
assert.ok(test.includes("deletes"), "Missing delete test");
assert.ok(test.includes("returns 404 after delete"), "Missing 404-after-delete test");
});
});
// ═══════════════════════════════════════════════════════
describe("9 — 错误处理", () => {
it("invalid PRD 返回错误", () => {
const result = buildBackend({ error: "test" }, {});
assert.equal(result.error, "INVALID_PRD");
});
it("invalid Architecture 返回错误", () => {
const result = buildBackend(DEMO_PRD, { error: "test" });
assert.equal(result.error, "INVALID_ARCH");
});
it("null PRD 返回错误", () => {
const result = buildBackend(null, {});
assert.equal(result.error, "INVALID_PRD");
});
it("空的 databaseSchema 仍然生成基础项目", () => {
const result = buildBackend(DEMO_PRD, { ...DEMO_ARCH, databaseSchema: [] });
assert.ok(!result.error);
assert.ok(result.files["src/index.ts"]);
assert.ok(result.files["src/routes/auth.ts"]);
assert.ok(result.stats.totalFiles >= 8, `Expected >= 8 files with empty schema, got ${result.stats.totalFiles}`);
});
});
// ═══════════════════════════════════════════════════════
describe("10 — 文件写入 I/O", () => {
it("writeBackend 写入完整项目", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const tmpDir = resolve(WORKSPACE, ".tmp-backend-test");
writeBackend(result, tmpDir);
assert.ok(existsSync(resolve(tmpDir, "package.json")));
assert.ok(existsSync(resolve(tmpDir, "tsconfig.json")));
assert.ok(existsSync(resolve(tmpDir, ".env.example")));
assert.ok(existsSync(resolve(tmpDir, "README.md")));
assert.ok(existsSync(resolve(tmpDir, "src/index.ts")));
assert.ok(existsSync(resolve(tmpDir, "src/db/schema.ts")));
assert.ok(existsSync(resolve(tmpDir, "src/db/client.ts")));
assert.ok(existsSync(resolve(tmpDir, "src/middleware/auth.ts")));
assert.ok(existsSync(resolve(tmpDir, "src/routes/auth.ts")));
assert.ok(existsSync(resolve(tmpDir, "src/types/index.ts")));
// Check at least one route & service file
const routesDir = resolve(tmpDir, "src/routes");
const servicesDir = resolve(tmpDir, "src/services");
assert.ok(existsSync(routesDir));
assert.ok(existsSync(servicesDir));
rmSync(tmpDir, { recursive: true, force: true });
});
it("loadJSON 读取文件", () => {
const { data } = loadJSON(resolve(WORKSPACE, "prd-example.json"));
assert.ok(data.projectName);
});
it("loadJSON 不存在的文件返回 error", () => {
const { data, error } = loadJSON("/nonexistent.json");
assert.equal(data, null);
assert.ok(error);
});
});
// ═══════════════════════════════════════════════════════
describe("11 — npm install & build", () => {
it("npm install 成功", async () => {
const generateAndInstall = async () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const tmpDir = resolve(WORKSPACE, ".tmp-backend-build");
writeBackend(result, tmpDir);
const { execSync } = await import("node:child_process");
try {
execSync("npm install --no-audit --no-fund --prefer-offline", {
cwd: tmpDir,
encoding: "utf-8",
timeout: 120_000,
stdio: "pipe",
});
return { success: true, tmpDir };
} catch (e) {
return { success: false, error: e.stderr || e.message, tmpDir };
}
};
const { success, error, tmpDir } = await generateAndInstall();
if (!success) {
console.error("npm install failed:", error?.slice(0, 500));
}
assert.ok(success, `npm install failed: ${error?.slice(0, 200)}`);
// Cleanup
rmSync(tmpDir, { recursive: true, force: true });
});
it("npm run build 成功", async () => {
const generateAndBuild = async () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const tmpDir = resolve(WORKSPACE, ".tmp-backend-build");
writeBackend(result, tmpDir);
const { execSync } = await import("node:child_process");
try {
execSync("npm install --no-audit --no-fund --prefer-offline", {
cwd: tmpDir,
encoding: "utf-8",
timeout: 120_000,
stdio: "pipe",
});
execSync("npx tsc --noEmit", {
cwd: tmpDir,
encoding: "utf-8",
timeout: 60_000,
stdio: "pipe",
});
return { success: true, tmpDir };
} catch (e) {
return { success: false, error: e.stderr || e.message, tmpDir };
}
};
const { success, error, tmpDir } = await generateAndBuild();
if (!success) {
console.error("Build failed:", error?.slice(0, 1000));
}
assert.ok(success, `Build failed: ${error?.slice(0, 300)}`);
// Cleanup
rmSync(tmpDir, { recursive: true, force: true });
});
});
// ═══════════════════════════════════════════════════════
describe("12 — CLI 集成", () => {
it("--prd + --arch CLI 模式", async () => {
const result = buildBackend(
loadPRD("ecommerce"),
loadArch()
);
writeBackend(result, resolve(WORKSPACE, ".tmp-cli-backend"));
assert.ok(!result.error);
rmSync(resolve(WORKSPACE, ".tmp-cli-backend"), { recursive: true, force: true });
});
it("--help 显示帮助", async () => {
const { execSync } = await import("node:child_process");
const output = execSync("node scripts/backend-builder-agent.mjs --help", {
cwd: WORKSPACE,
encoding: "utf-8",
});
assert.ok(output.includes("Backend Builder"));
assert.ok(output.includes("Usage"));
});
});
// ═══════════════════════════════════════════════════════
describe("13 — 多领域覆盖", () => {
const domains = ["ecommerce", "enterprise", "education", "note"];
for (const domain of domains) {
it(`${domain} 域生成成功`, () => {
const prd = loadPRD(domain);
const result = buildBackend(prd, loadArch());
assert.ok(!result.error, `${domain}: ${result.message}`);
assert.ok(result.stats.totalFiles >= 8, `${domain}: expected >= 8 files, got ${result.stats.totalFiles}`);
assert.ok(result.stats.routes >= 2, `${domain}: expected >= 2 routes, got ${result.stats.routes}`);
assert.ok(result.stats.services >= 2, `${domain}: expected >= 2 services, got ${result.stats.services}`);
});
}
});
// ═══════════════════════════════════════════════════════
describe("14 — 生成的内容非空且有意义", () => {
it("所有生成的文件内容非空(>50 字符)", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
for (const [path, content] of Object.entries(result.files)) {
assert.ok(content.length > 50, `${path}: too short (${content.length} chars)`);
}
});
it("package.json 可解析为合法 JSON", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const pkg = JSON.parse(result.files["package.json"]);
assert.ok(pkg.name);
assert.ok(pkg.dependencies);
assert.ok(pkg.scripts);
});
it("tsconfig.json 可解析为合法 JSON", () => {
const result = buildBackend(DEMO_PRD, DEMO_ARCH);
const cfg = JSON.parse(result.files["tsconfig.json"]);
assert.ok(cfg.compilerOptions);
});
});