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,12 +1,7 @@
|
||||
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 { createDatabase } from "../../../src/server/db/connection";
|
||||
import { runMigrations } from "../../../src/server/db/migrate";
|
||||
import {
|
||||
archiveProject,
|
||||
createProject,
|
||||
@@ -16,44 +11,21 @@ import {
|
||||
restoreProject,
|
||||
updateProject,
|
||||
} from "../../../src/server/db/projects";
|
||||
import { createMemoryLogger } from "../../../src/server/logger";
|
||||
import { createMigratedTestDatabase } from "../../helpers";
|
||||
|
||||
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
|
||||
);
|
||||
`;
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = join(tmpdir(), `projects-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
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;
|
||||
function withProjectsDb(callback: (db: Database) => void): void {
|
||||
const handle = createMigratedTestDatabase("projects-test");
|
||||
try {
|
||||
callback(handle.db);
|
||||
handle.close();
|
||||
} finally {
|
||||
handle.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
describe("项目数据访问层", () => {
|
||||
test("创建项目", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const result = createProject(db, { description: "测试描述", name: "测试项目" });
|
||||
expect("error" in result).toBe(false);
|
||||
expect((result as { project: unknown }).project).toBeDefined();
|
||||
@@ -66,43 +38,28 @@ describe("项目数据访问层", () => {
|
||||
expect(row.name).toBe("测试项目");
|
||||
expect(row.description).toBe("测试描述");
|
||||
expect(row.status).toBe("active");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("项目名称全局唯一(含归档项目)", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
createProject(db, { name: "唯一名称" });
|
||||
const result2 = createProject(db, { name: "唯一名称" });
|
||||
expect("error" in result2).toBe(true);
|
||||
expect((result2 as unknown as { error: string }).error).toContain("已存在");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("trim 后名称为空时创建失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const result = createProject(db, { name: " " });
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { error: string }).error).toContain("不能为空");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("列表查询(分页和关键字)", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
createProject(db, { description: "descA", name: "项目A" });
|
||||
createProject(db, { description: "descB", name: "项目B" });
|
||||
createProject(db, { name: "其他" });
|
||||
@@ -117,16 +74,11 @@ describe("项目数据访问层", () => {
|
||||
const result3 = listProjects(db, { page: 1, pageSize: 1 });
|
||||
expect(result3.total).toBe(3);
|
||||
expect(result3.items.length).toBe(1);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("获取项目详情", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { description: "详情", name: "详情项目" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
|
||||
@@ -134,29 +86,19 @@ describe("项目数据访问层", () => {
|
||||
expect("error" in result).toBe(false);
|
||||
const projectResult = result as { project: { description: string } };
|
||||
expect(projectResult.project.description).toBe("详情");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("获取不存在的项目返回 404 错误", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const result = getProject(db, "nonexistent");
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { status: number }).status).toBe(404);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("更新项目名称和描述", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "原名" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
|
||||
@@ -166,16 +108,11 @@ describe("项目数据访问层", () => {
|
||||
const updated = result as { project: { description: string; name: string } };
|
||||
expect(updated.project.name).toBe("新名");
|
||||
expect(updated.project.description).toBe("新描述");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("更新已归档项目失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "待归档" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
archiveProject(db, id);
|
||||
@@ -183,16 +120,11 @@ describe("项目数据访问层", () => {
|
||||
const result = updateProject(db, id, { name: "新名称" });
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { status: number }).status).toBe(409);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("归档项目", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "待归档" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
|
||||
@@ -209,32 +141,22 @@ describe("项目数据访问层", () => {
|
||||
};
|
||||
expect(row.status).toBe("archived");
|
||||
expect(row.archived_at).not.toBeNull();
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("对已归档项目重复归档失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "测试" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
archiveProject(db, id);
|
||||
const result = archiveProject(db, id);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { status: number }).status).toBe(409);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("恢复已归档项目", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "恢复测试" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
archiveProject(db, id);
|
||||
@@ -245,31 +167,21 @@ describe("项目数据访问层", () => {
|
||||
const restored = (result as { project: { archivedAt: null | string; status: string } }).project;
|
||||
expect(restored.status).toBe("active");
|
||||
expect(restored.archivedAt).toBeNull();
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("恢复 active 项目失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "活跃项目" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
const result = restoreProject(db, id);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { status: number }).status).toBe(409);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("永久删除已归档项目", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "删除测试" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
archiveProject(db, id);
|
||||
@@ -279,84 +191,56 @@ describe("项目数据访问层", () => {
|
||||
|
||||
const after = getProject(db, id);
|
||||
expect("error" in after).toBe(true);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("删除 active 项目失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "活跃项目" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
|
||||
const result = deleteProject(db, id);
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { status: number }).status).toBe(409);
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("创建项目名称超过 10 个字符失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
const result = createProject(db, { name: "这是一个很长的名字" });
|
||||
withProjectsDb((db) => {
|
||||
const result = createProject(db, { name: "这是一个非常非常长的名字" });
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("创建项目名称刚好 10 个字符成功", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const result = createProject(db, { name: "一二三四五六七八九十" });
|
||||
expect("error" in result).toBe(false);
|
||||
const project = (result as { project: { name: string } }).project;
|
||||
expect(project.name).toBe("一二三四五六七八九十");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("更新项目名称超过 10 个字符失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "短名" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
|
||||
const result = updateProject(db, id, { name: "这是一个很长的名字" });
|
||||
const result = updateProject(db, id, { name: "这是一个非常非常长的名字" });
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { error: string }).error).toContain("不能超过 10 个字符");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("更新项目名称 trim 后为空失败", () => {
|
||||
const dir = makeTempDir();
|
||||
try {
|
||||
const db = setupDb(dir);
|
||||
withProjectsDb((db) => {
|
||||
const created = createProject(db, { name: "原名" });
|
||||
const id = (created as { project: { id: string } }).project.id;
|
||||
|
||||
const result = updateProject(db, id, { name: " " });
|
||||
expect("error" in result).toBe(true);
|
||||
expect((result as unknown as { error: string }).error).toContain("不能为空");
|
||||
db.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user