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:
169
tests/server/app.test.ts
Normal file
169
tests/server/app.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import type { StaticAssets } from "../../src/server/static";
|
||||
|
||||
import { startServer } from "../../src/server/server";
|
||||
import { createMigratedTestDatabase } from "../helpers";
|
||||
|
||||
function createStaticAssets(): StaticAssets {
|
||||
return {
|
||||
files: {
|
||||
"/assets/app.js": new Blob(["console.log('app')"], { type: "text/javascript" }),
|
||||
},
|
||||
indexHtml: new Blob(["<!doctype html><main>Alfred</main>"], { type: "text/html" }),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestApp() {
|
||||
const handle = createMigratedTestDatabase("app-test");
|
||||
const server = startServer({
|
||||
config: { host: "127.0.0.1", port: 0 },
|
||||
db: handle.db,
|
||||
logger: handle.logger,
|
||||
mode: "production",
|
||||
staticAssets: createStaticAssets(),
|
||||
version: "9.8.7-test",
|
||||
});
|
||||
|
||||
return {
|
||||
baseUrl: server.url.toString().replace(/\/$/, ""),
|
||||
close: () => {
|
||||
void server.stop(true);
|
||||
handle.cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("后端应用集成", () => {
|
||||
test("/api/meta 返回服务元信息和生产安全 header", async () => {
|
||||
const app = createTestApp();
|
||||
try {
|
||||
const res = await fetch(`${app.baseUrl}/api/meta`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(res.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin");
|
||||
|
||||
const body = (await res.json()) as { ok: boolean; service: string; timestamp: string; version: string };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.service).toBe("alfred");
|
||||
expect(body.version).toBe("9.8.7-test");
|
||||
expect(Number.isNaN(Date.parse(body.timestamp))).toBe(false);
|
||||
} finally {
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("未知 API 返回 JSON 404", async () => {
|
||||
const app = createTestApp();
|
||||
try {
|
||||
const res = await fetch(`${app.baseUrl}/api/missing`);
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/json; charset=utf-8");
|
||||
expect(await res.json()).toEqual({ error: "API route not found", status: 404 });
|
||||
} finally {
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("静态资源和 SPA fallback 由真实服务返回", async () => {
|
||||
const app = createTestApp();
|
||||
try {
|
||||
const assetRes = await fetch(`${app.baseUrl}/assets/app.js`);
|
||||
expect(assetRes.status).toBe(200);
|
||||
expect(assetRes.headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
expect(await assetRes.text()).toBe("console.log('app')");
|
||||
|
||||
const fallbackRes = await fetch(`${app.baseUrl}/projects`);
|
||||
expect(fallbackRes.status).toBe(200);
|
||||
expect(fallbackRes.headers.get("Cache-Control")).toBe("no-cache");
|
||||
expect(await fallbackRes.text()).toContain("Alfred");
|
||||
} finally {
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("项目 API 覆盖创建、列表、详情、更新、归档、恢复和删除", async () => {
|
||||
const app = createTestApp();
|
||||
try {
|
||||
const createdRes = await fetch(`${app.baseUrl}/api/projects`, {
|
||||
body: JSON.stringify({ description: "集成测试", name: "集成项目" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
expect(createdRes.status).toBe(201);
|
||||
const createdBody = (await createdRes.json()) as { project: { id: string; name: string; status: string } };
|
||||
const id = createdBody.project.id;
|
||||
expect(createdBody.project.name).toBe("集成项目");
|
||||
|
||||
const listRes = await fetch(`${app.baseUrl}/api/projects?keyword=集成&page=1&pageSize=20`);
|
||||
expect(listRes.status).toBe(200);
|
||||
const listBody = (await listRes.json()) as { items: Array<{ id: string }>; total: number };
|
||||
expect(listBody.total).toBe(1);
|
||||
expect(listBody.items[0]!.id).toBe(id);
|
||||
|
||||
const getRes = await fetch(`${app.baseUrl}/api/projects/${id}`);
|
||||
expect(getRes.status).toBe(200);
|
||||
|
||||
const updateRes = await fetch(`${app.baseUrl}/api/projects/${id}`, {
|
||||
body: JSON.stringify({ name: "更新项目" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(((await updateRes.json()) as { project: { name: string } }).project.name).toBe("更新项目");
|
||||
|
||||
const archiveRes = await fetch(`${app.baseUrl}/api/projects/${id}/archive`, { method: "POST" });
|
||||
expect(archiveRes.status).toBe(200);
|
||||
expect(((await archiveRes.json()) as { project: { status: string } }).project.status).toBe("archived");
|
||||
|
||||
const restoreRes = await fetch(`${app.baseUrl}/api/projects/${id}/restore`, { method: "POST" });
|
||||
expect(restoreRes.status).toBe(200);
|
||||
expect(((await restoreRes.json()) as { project: { status: string } }).project.status).toBe("active");
|
||||
|
||||
await fetch(`${app.baseUrl}/api/projects/${id}/archive`, { method: "POST" });
|
||||
const deleteRes = await fetch(`${app.baseUrl}/api/projects/${id}`, { method: "DELETE" });
|
||||
expect(deleteRes.status).toBe(204);
|
||||
} finally {
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("项目 API 覆盖关键错误路径", async () => {
|
||||
const app = createTestApp();
|
||||
try {
|
||||
const invalidIdRes = await fetch(`${app.baseUrl}/api/projects/-bad`);
|
||||
expect(invalidIdRes.status).toBe(400);
|
||||
|
||||
const createOnce = await fetch(`${app.baseUrl}/api/projects`, {
|
||||
body: JSON.stringify({ name: "错误项目" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
const id = ((await createOnce.json()) as { project: { id: string } }).project.id;
|
||||
|
||||
const duplicateRes = await fetch(`${app.baseUrl}/api/projects`, {
|
||||
body: JSON.stringify({ name: "错误项目" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
expect(duplicateRes.status).toBe(409);
|
||||
|
||||
const deleteActiveRes = await fetch(`${app.baseUrl}/api/projects/${id}`, { method: "DELETE" });
|
||||
expect(deleteActiveRes.status).toBe(409);
|
||||
|
||||
await fetch(`${app.baseUrl}/api/projects/${id}/archive`, { method: "POST" });
|
||||
const updateArchivedRes = await fetch(`${app.baseUrl}/api/projects/${id}`, {
|
||||
body: JSON.stringify({ name: "不可改" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
expect(updateArchivedRes.status).toBe(409);
|
||||
|
||||
const missingRes = await fetch(`${app.baseUrl}/api/projects/00000000-0000-4000-8000-000000000000`);
|
||||
expect(missingRes.status).toBe(404);
|
||||
} finally {
|
||||
app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,22 @@ import type { StartServerOptions } from "../../src/server/server";
|
||||
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
|
||||
import { createMemoryLogger } from "../../src/server/logger";
|
||||
|
||||
async function captureConsoleError(callback: () => Promise<void>): Promise<string[]> {
|
||||
const originalError = console.error;
|
||||
const errors: string[] = [];
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
await callback();
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
||||
const base = join(tmpdir(), `bootstrap-test-${Date.now()}`);
|
||||
mkdirSync(base, { recursive: true });
|
||||
@@ -106,13 +122,17 @@ describe("bootstrap", () => {
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||
} catch {
|
||||
// expected - exit threw
|
||||
}
|
||||
const errors = await captureConsoleError(async () => {
|
||||
try {
|
||||
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
|
||||
} catch {
|
||||
// expected - exit threw
|
||||
}
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(1);
|
||||
expect(errors).toContain("日志初始化失败: pino import failed");
|
||||
expect(errors).toContain("启动失败: exit called");
|
||||
});
|
||||
|
||||
test("启动失败时调用 logger.fatal 并 flush", async () => {
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { createDatabase } from "../../../src/server/db/connection";
|
||||
import { createMemoryLogger } from "../../../src/server/logger";
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = join(tmpdir(), `db-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
import { closeSqliteForTest, createTestDatabase, makeTempDir, rmRetrySync } from "../../helpers";
|
||||
|
||||
describe("数据库初始化", () => {
|
||||
test("创建数据库文件并设置 PRAGMA", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const dir = makeTempDir("db-test");
|
||||
const db = createDatabase(dir, createMemoryLogger());
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const journalMode = db.query("PRAGMA journal_mode").get() as { journal_mode: string };
|
||||
expect(journalMode.journal_mode).toBe("wal");
|
||||
|
||||
@@ -28,37 +21,34 @@ describe("数据库初始化", () => {
|
||||
const timeoutResult = db.query("PRAGMA busy_timeout").get() as Record<string, unknown>;
|
||||
expect(timeoutResult).not.toBeNull();
|
||||
|
||||
db.close();
|
||||
closeSqliteForTest(db);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
rmRetrySync(dir);
|
||||
}
|
||||
});
|
||||
|
||||
test("在空数据目录中创建 alfred.db", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("db-test");
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
db.close();
|
||||
handle.close();
|
||||
|
||||
expect(existsSync(join(dir, "alfred.db"))).toBe(true);
|
||||
expect(existsSync(join(handle.dir, "alfred.db"))).toBe(true);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("数据库连接可执行查询", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("db-test");
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const { db } = handle;
|
||||
db.exec("CREATE TABLE test (id TEXT PRIMARY KEY)");
|
||||
db.exec("INSERT INTO test (id) VALUES ('1')");
|
||||
const row = db.query("SELECT id FROM test WHERE id = '1'").get() as { id: string };
|
||||
expect(row.id).toBe("1");
|
||||
db.close();
|
||||
handle.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { MigrationRecord } from "../../../src/server/db/load-migrations";
|
||||
|
||||
import { createDatabase } from "../../../src/server/db/connection";
|
||||
import { runMigrations } from "../../../src/server/db/migrate";
|
||||
import { createMemoryLogger } from "../../../src/server/logger";
|
||||
|
||||
function makeTempDir(): string {
|
||||
const dir = join(tmpdir(), `migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
import { createTestDatabase, openTestDatabase } from "../../helpers";
|
||||
|
||||
const MIGRATION_001: MigrationRecord = {
|
||||
checksum: "fake-checksum-001",
|
||||
@@ -33,10 +25,9 @@ const MIGRATION_002: MigrationRecord = {
|
||||
|
||||
describe("migration 执行器", () => {
|
||||
test("应用待执行 migration 并记录", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("migration-test");
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const { db, dir, logger } = handle;
|
||||
runMigrations(db, [MIGRATION_001], dir, logger);
|
||||
|
||||
const rows = db.query("SELECT id, checksum FROM schema_migrations").all() as Array<{
|
||||
@@ -48,33 +39,31 @@ describe("migration 执行器", () => {
|
||||
expect(rows[0]!.checksum).toBe("fake-checksum-001");
|
||||
|
||||
db.exec("INSERT INTO test_table (id, name) VALUES ('1', 'test')");
|
||||
db.close();
|
||||
handle.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("跳过已应用的 migration", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("migration-test");
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const { db, dir, logger } = handle;
|
||||
runMigrations(db, [MIGRATION_001], dir, logger);
|
||||
runMigrations(db, [MIGRATION_001], dir, logger);
|
||||
|
||||
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
|
||||
expect(rows.length).toBe(1);
|
||||
db.close();
|
||||
handle.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("按顺序应用多个 migration", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("migration-test");
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const { db, dir, logger } = handle;
|
||||
runMigrations(db, [MIGRATION_001, MIGRATION_002], dir, logger);
|
||||
|
||||
const rows = db.query("SELECT id FROM schema_migrations ORDER BY id").all() as Array<{ id: string }>;
|
||||
@@ -83,41 +72,39 @@ describe("migration 执行器", () => {
|
||||
expect(rows[1]!.id).toBe("0002_add_desc");
|
||||
|
||||
db.exec("INSERT INTO test_table (id, name, description) VALUES ('1', 'test', 'desc')");
|
||||
db.close();
|
||||
handle.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("无待执行 migration 时不做变更", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("migration-test");
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const { db, dir, logger } = handle;
|
||||
runMigrations(db, [], dir, logger);
|
||||
|
||||
const tableExists = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'")
|
||||
.get();
|
||||
expect(tableExists).toBeNull();
|
||||
db.close();
|
||||
handle.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("执行 migration 前创建备份", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("migration-test");
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const { db, dir } = handle;
|
||||
db.exec("CREATE TABLE existing (id TEXT)");
|
||||
db.exec("INSERT INTO existing (id) VALUES ('x')");
|
||||
db.close();
|
||||
handle.close();
|
||||
|
||||
const db2 = createDatabase(dir, logger);
|
||||
runMigrations(db2, [MIGRATION_001], dir, logger);
|
||||
db2.close();
|
||||
const reopened = openTestDatabase(dir);
|
||||
runMigrations(reopened.db, [MIGRATION_001], dir, reopened.logger);
|
||||
reopened.close();
|
||||
|
||||
const backupsDir = join(dir, "backups");
|
||||
expect(existsSync(backupsDir)).toBe(true);
|
||||
@@ -125,29 +112,28 @@ describe("migration 执行器", () => {
|
||||
expect(backupFiles.length).toBe(1);
|
||||
expect(backupFiles[0]!).toMatch(/^alfred-.*\.db$/);
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("失败的 migration 不留下部分记录", () => {
|
||||
const dir = makeTempDir();
|
||||
const logger = createMemoryLogger();
|
||||
const handle = createTestDatabase("migration-test");
|
||||
const BAD_MIGRATION: MigrationRecord = {
|
||||
checksum: "bad",
|
||||
id: "0003_bad",
|
||||
sql: "INVALID SQL STATEMENT;",
|
||||
};
|
||||
try {
|
||||
const db = createDatabase(dir, logger);
|
||||
const { db, dir, logger } = handle;
|
||||
expect(() => {
|
||||
runMigrations(db, [MIGRATION_001, BAD_MIGRATION], dir, logger);
|
||||
}).toThrow();
|
||||
|
||||
const rows = db.query("SELECT id FROM schema_migrations").all() as Array<{ id: string }>;
|
||||
expect(rows.length).toBe(0);
|
||||
db.close();
|
||||
handle.close();
|
||||
} finally {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
handle.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,35 @@ import type { Logger } from "../../src/server/logger";
|
||||
|
||||
import { createConsoleFallback, createMemoryLogger, createNoopLogger, REDACT_PATHS } from "../../src/server/logger";
|
||||
|
||||
function captureConsole(callback: () => void): { errors: string[]; logs: string[]; warns: string[] } {
|
||||
const originalError = console.error;
|
||||
const originalLog = console.log;
|
||||
const originalWarn = console.warn;
|
||||
const errors: string[] = [];
|
||||
const logs: string[] = [];
|
||||
const warns: string[] = [];
|
||||
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
console.log = (...args: unknown[]) => {
|
||||
logs.push(args.map(String).join(" "));
|
||||
};
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warns.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
callback();
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
console.log = originalLog;
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
return { errors, logs, warns };
|
||||
}
|
||||
|
||||
describe("NoopLogger", () => {
|
||||
test("所有方法不抛异常", () => {
|
||||
const logger = createNoopLogger();
|
||||
@@ -59,17 +88,33 @@ describe("MemoryLogger", () => {
|
||||
|
||||
describe("ConsoleFallbackLogger", () => {
|
||||
test("不抛异常", () => {
|
||||
const logger = createConsoleFallback();
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.flush();
|
||||
const child = logger.child({ component: "test" });
|
||||
const child = captureConsole(() => {
|
||||
const logger = createConsoleFallback();
|
||||
logger.trace("trace");
|
||||
logger.debug("debug");
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
logger.flush();
|
||||
return logger.child({ component: "test" });
|
||||
});
|
||||
expect(child).toBeDefined();
|
||||
});
|
||||
|
||||
test("按等级写入对应 console 通道", () => {
|
||||
const output = captureConsole(() => {
|
||||
const logger = createConsoleFallback();
|
||||
logger.info("info");
|
||||
logger.warn("warn");
|
||||
logger.error("error");
|
||||
logger.fatal("fatal");
|
||||
});
|
||||
|
||||
expect(output.logs).toContain("info");
|
||||
expect(output.warns).toContain("warn");
|
||||
expect(output.errors).toEqual(["error", "fatal"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Logger 接口契约", () => {
|
||||
@@ -94,7 +139,7 @@ describe("Logger 接口契约", () => {
|
||||
});
|
||||
|
||||
test("ConsoleFallbackLogger 满足 Logger 接口", () => {
|
||||
expect(() => assertLogger(createConsoleFallback())).not.toThrow();
|
||||
expect(() => captureConsole(() => assertLogger(createConsoleFallback()))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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