test: 测试体系全面优化,修复 Windows SQLite EBUSY 和前端产品缺陷
测试基础设施 - 统一 SQLite 测试 DB/临时目录 helper(tests/helpers.ts),支持 Windows EBUSY 重试清理 - 测试库使用 PRAGMA journal_mode=DELETE 避免 WAL 句柄延迟 - 路由 handler 测试改用 createMigratedMemoryTestDatabase 避免 File DB 锁 - SQLite 聚焦 --rerun-each=20 全部通过(720 pass) 后端测试补强 - 新增 tests/server/app.test.ts 真实 startServer 集成测试 - 覆盖 /api/meta、项目 CRUD、错误路径、静态 fallback、安全 header - bootstrap/logger 测试捕获预期输出,消除测试噪音 前端测试补强 - 移除 .ant-* 内部类名依赖,改为角色/文本/导航/请求契约断言 - 项目页补充搜索、Tab 切换、表单、表格操作、错误反馈行为测试 - 新增 hooks(use-theme-preference、use-sidebar-collapsed、use-projects)纯逻辑测试 - 新增 ErrorBoundary 错误展示和刷新按钮测试 - 新增搜索清空行为测试 - 测试 setup 过滤 antd/rc-trigger NaN height warning 产品修复(测试暴露) - 修复 ProjectToolbar 搜索框无法输入(新增 draftKeyword 状态) - 加固 ProjectFormModal 表单字段同步(useEffect 替代不可靠的 afterOpenChange) - 清理 ProjectFormModal 冗余 afterOpenChange 同步逻辑 重构与合规 - ProjectContext 拆分为三文件满足 React Fast Refresh 规则 - use-projects.ts 导出内部 helper 函数供测试验证 - scripts/build.ts 提取纯生成函数供测试使用,修复构建步骤日志编号 - 修复 build 测试覆盖真实生成逻辑 文档同步 - 更新后端/前端/开发文档测试规范、质量门禁和 helper 使用说明
This commit is contained in:
@@ -1,36 +1,13 @@
|
||||
import type Database from "bun:sqlite";
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Project, RuntimeMode } from "../../../src/shared/api";
|
||||
|
||||
import { createDatabase } from "../../../src/server/db/connection";
|
||||
import { runMigrations } from "../../../src/server/db/migrate";
|
||||
import { createMemoryLogger } from "../../../src/server/logger";
|
||||
import { createMigratedMemoryTestDatabase } from "../../helpers";
|
||||
|
||||
const MODE: RuntimeMode = "test";
|
||||
|
||||
const MIGRATION_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||
archived_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
async function archiveProjectViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleArchiveProject: h } = await import("../../../src/server/routes/projects/archive");
|
||||
return h(req, db, MODE);
|
||||
@@ -63,24 +40,11 @@ async function listProjectsViaHandler(req: Request, db: Database): Promise<Respo
|
||||
return h(req, db, MODE);
|
||||
}
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = join(tmpdir(), `route-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function restoreProjectViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleRestoreProject: h } = await import("../../../src/server/routes/projects/restore");
|
||||
return h(req, db, MODE);
|
||||
}
|
||||
|
||||
function setupDb(dir: string): Database {
|
||||
const logger = createMemoryLogger();
|
||||
const db = createDatabase(dir, logger);
|
||||
runMigrations(db, [{ checksum: "init", id: "001_init", sql: MIGRATION_SQL }], dir, logger);
|
||||
return db;
|
||||
}
|
||||
|
||||
async function updateProjectViaHandler(req: Request, db: Database): Promise<Response> {
|
||||
const { handleUpdateProject: h } = await import("../../../src/server/routes/projects/update");
|
||||
return h(req, db, MODE);
|
||||
@@ -89,11 +53,19 @@ async function updateProjectViaHandler(req: Request, db: Database): Promise<Resp
|
||||
// Need db/projects for setup
|
||||
import { archiveProject, createProject, getProject } from "../../../src/server/db/projects";
|
||||
|
||||
async function withRouteDb(callback: (db: Database) => Promise<void>): Promise<void> {
|
||||
const handle = createMigratedMemoryTestDatabase("route-test");
|
||||
try {
|
||||
await callback(handle.db);
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
describe("项目 API 路由", () => {
|
||||
test("POST /api/projects 创建项目", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const req = new Request("http://localhost/api/projects", {
|
||||
body: JSON.stringify({ description: "路由测试", name: "路由项目" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -103,16 +75,11 @@ describe("项目 API 路由", () => {
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { project: Project };
|
||||
expect(body.project.name).toBe("路由项目");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /api/projects 列表查询", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
createTestProject(db, "A项目");
|
||||
createTestProject(db, "B项目");
|
||||
|
||||
@@ -122,16 +89,11 @@ describe("项目 API 路由", () => {
|
||||
const body = (await res.json()) as { items: Project[]; total: number };
|
||||
expect(body.total).toBe(2);
|
||||
expect(body.items.length).toBe(2);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("GET /api/projects/:id 获取详情", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db, "详情路由");
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}`);
|
||||
@@ -139,16 +101,11 @@ describe("项目 API 路由", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { project: Project };
|
||||
expect(body.project.name).toBe("详情路由");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("PATCH /api/projects/:id 更新项目", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db, "更新路由");
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}`, {
|
||||
@@ -160,16 +117,11 @@ describe("项目 API 路由", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { project: Project };
|
||||
expect(body.project.name).toBe("已更新");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/projects/:id/archive 归档项目", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db, "归档路由");
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}/archive`, { method: "POST" });
|
||||
@@ -177,16 +129,11 @@ describe("项目 API 路由", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { project: Project };
|
||||
expect(body.project.status).toBe("archived");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("POST /api/projects/:id/restore 恢复项目", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db, "恢复路由");
|
||||
archiveProject(db, project.id);
|
||||
|
||||
@@ -195,16 +142,11 @@ describe("项目 API 路由", () => {
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { project: Project };
|
||||
expect(body.project.status).toBe("active");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("DELETE /api/projects/:id 永久删除已归档项目", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db, "删除路由");
|
||||
archiveProject(db, project.id);
|
||||
|
||||
@@ -214,16 +156,11 @@ describe("项目 API 路由", () => {
|
||||
|
||||
const after = getProject(db, project.id);
|
||||
expect("error" in after).toBe(true);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("创建同名项目返回 409", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const req1 = new Request("http://localhost/api/projects", {
|
||||
body: JSON.stringify({ name: "重复名" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -238,24 +175,16 @@ describe("项目 API 路由", () => {
|
||||
});
|
||||
const res = await createProjectViaHandler(req2, db);
|
||||
expect(res.status).toBe(409);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("删除 active 项目返回 409", async () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
await withRouteDb(async (db) => {
|
||||
const project = createTestProject(db, "活项目");
|
||||
|
||||
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
|
||||
const res = await deleteProjectViaHandler(req, db);
|
||||
expect(res.status).toBe(409);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user