feat: 增加项目管理功能

引入 SQLite 数据库(Drizzle ORM + bun:sqlite),实现项目 CRUD 与归档/恢复/删除
生命周期管理,新增项目管理前端页面,migration 嵌入单文件构建产物保持部署体验。

- src/server/db: schema、connection、migration 执行器、项目数据访问层
- src/server/routes/projects: 7 个 API 端点(列表/创建/详情/更新/归档/恢复/删除)
- src/web: 项目管理页面(TDesign Table/Tabs/Dialog/Form),TanStack Query hooks
- scripts: 构建时嵌入 migration SQL,开发期独立 generate-migrations-data 脚本
- tests: 60 个后端测试 + 27 个前端测试,覆盖 DB/migration/API/路由/页面
- docs: 更新架构、后端、发布、配置、部署、使用文档
This commit is contained in:
2026-05-27 18:54:44 +08:00
parent 348b35ef8c
commit d5a0ba9e9e
44 changed files with 2458 additions and 43 deletions

View File

@@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/no-empty-function, @typescript-eslint/require-await */
import { describe, expect, test } from "bun:test";
import { mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ResolvedConfig } from "../../src/server/config/types";
import { bootstrap, type BootstrapDependencies } from "../../src/server/bootstrap";
import { createMemoryLogger } from "../../src/server/logger";
function makeTempConfig(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
const base = join(tmpdir(), `bootstrap-db-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(base, { recursive: true });
return {
configDir: base,
dataDir: join(base, "data"),
host: "127.0.0.1",
logging: {
consoleLevel: "info",
fileLevel: "info",
filePath: join(base, "data", "logs", "test.log"),
rotationFrequency: "daily",
rotationMaxFiles: 14,
rotationSizeBytes: 52428800,
rotationSizeRaw: "50MB",
},
port: 0,
...overrides,
};
}
describe("bootstrap 数据库集成", () => {
test("启动时将数据库传递给 startServer", async () => {
let started = false;
let receivedDb: unknown = undefined;
const cfg = makeTempConfig();
const mockLoadConfig = (async () => cfg) as unknown as BootstrapDependencies["loadConfig"];
const mockOnSignal = (_signal: string, _handler: () => void) => {};
const mockStartServer = (options: { db: unknown }) => {
receivedDb = options.db;
started = true;
return { close: () => {} };
};
const deps: BootstrapDependencies = {
createLogger: async () => createMemoryLogger(),
loadConfig: mockLoadConfig,
onSignal: mockOnSignal,
startServer: mockStartServer,
};
await bootstrap({ configPath: join(cfg.configDir, "config.yaml"), mode: "production" }, deps);
expect(started).toBe(true);
expect(receivedDb).not.toBeUndefined();
expect(typeof (receivedDb as { close?: unknown }).close).toBe("function");
});
});

View File

@@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
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;
}
describe("数据库初始化", () => {
test("创建数据库文件并设置 PRAGMA", () => {
const dir = makeTempDir();
const logger = 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");
const foreignKeys = db.query("PRAGMA foreign_keys").get() as { foreign_keys: number };
expect(foreignKeys.foreign_keys).toBe(1);
// PRAGMA busy_timeout was set (Bun SQLite may return different formats)
const timeoutResult = db.query("PRAGMA busy_timeout").get() as Record<string, unknown>;
expect(timeoutResult).not.toBeNull();
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("在空数据目录中创建 alfred.db", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
db.close();
expect(existsSync(join(dir, "alfred.db"))).toBe(true);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("数据库连接可执行查询", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
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();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});

View File

@@ -0,0 +1,153 @@
import { describe, expect, test } from "bun:test";
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
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;
}
const MIGRATION_001: MigrationRecord = {
checksum: "fake-checksum-001",
id: "0001_initial",
sql: `
CREATE TABLE test_table (id TEXT PRIMARY KEY, name TEXT NOT NULL);
`,
};
const MIGRATION_002: MigrationRecord = {
checksum: "fake-checksum-002",
id: "0002_add_desc",
sql: `
ALTER TABLE test_table ADD COLUMN description TEXT DEFAULT '';
`,
};
describe("migration 执行器", () => {
test("应用待执行 migration 并记录", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
runMigrations(db, [MIGRATION_001], dir, logger);
const rows = db.query("SELECT id, checksum FROM schema_migrations").all() as Array<{
checksum: string;
id: string;
}>;
expect(rows.length).toBe(1);
expect(rows[0]!.id).toBe("0001_initial");
expect(rows[0]!.checksum).toBe("fake-checksum-001");
db.exec("INSERT INTO test_table (id, name) VALUES ('1', 'test')");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("跳过已应用的 migration", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
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();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("按顺序应用多个 migration", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
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 }>;
expect(rows.length).toBe(2);
expect(rows[0]!.id).toBe("0001_initial");
expect(rows[1]!.id).toBe("0002_add_desc");
db.exec("INSERT INTO test_table (id, name, description) VALUES ('1', 'test', 'desc')");
db.close();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("无待执行 migration 时不做变更", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
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();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("执行 migration 前创建备份", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
try {
const db = createDatabase(dir, logger);
db.exec("CREATE TABLE existing (id TEXT)");
db.exec("INSERT INTO existing (id) VALUES ('x')");
db.close();
const db2 = createDatabase(dir, logger);
runMigrations(db2, [MIGRATION_001], dir, logger);
db2.close();
const backupsDir = join(dir, "backups");
expect(existsSync(backupsDir)).toBe(true);
const backupFiles = readdirSync(backupsDir);
expect(backupFiles.length).toBe(1);
expect(backupFiles[0]!).toMatch(/^alfred-.*\.db$/);
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
test("失败的 migration 不留下部分记录", () => {
const dir = makeTempDir();
const logger = createMemoryLogger();
const BAD_MIGRATION: MigrationRecord = {
checksum: "bad",
id: "0003_bad",
sql: "INVALID SQL STATEMENT;",
};
try {
const db = createDatabase(dir, logger);
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();
} finally {
rmSync(dir, { force: true, recursive: true });
}
});
});

View File

@@ -0,0 +1,303 @@
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,
deleteProject,
getProject,
listProjects,
restoreProject,
updateProject,
} from "../../../src/server/db/projects";
import { createMemoryLogger } from "../../../src/server/logger";
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;
}
describe("项目数据访问层", () => {
test("创建项目", () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const result = createProject(db, { description: "测试描述", name: "测试项目" });
expect("error" in result).toBe(false);
expect((result as { project: unknown }).project).toBeDefined();
const row = db.query("SELECT name, description, status FROM projects WHERE name = '测试项目'").get() as {
description: string;
name: string;
status: string;
};
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);
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);
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);
createProject(db, { description: "descA", name: "项目A" });
createProject(db, { description: "descB", name: "项目B" });
createProject(db, { name: "其他" });
const result1 = listProjects(db, { page: 1, pageSize: 20 });
expect(result1.total).toBe(3);
expect(result1.items.length).toBe(3);
const result2 = listProjects(db, { keyword: "项目", page: 1, pageSize: 20 });
expect(result2.total).toBe(2);
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);
const created = createProject(db, { description: "详情", name: "详情项目" });
const id = (created as { project: { id: string } }).project.id;
const result = getProject(db, id);
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);
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);
const created = createProject(db, { name: "原名" });
const id = (created as { project: { id: string } }).project.id;
const result = updateProject(db, id, { description: "新描述", name: "新名" });
expect("error" in result).toBe(false);
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);
const created = createProject(db, { name: "待归档" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
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);
const created = createProject(db, { name: "待归档" });
const id = (created as { project: { id: string } }).project.id;
const result = archiveProject(db, id);
expect("error" in result).toBe(false);
const archived = (result as { project: { archivedAt: null | string; status: string } }).project;
expect(archived.status).toBe("archived");
expect(archived.archivedAt).not.toBeNull();
const row = db.query("SELECT status, archived_at FROM projects WHERE id = ?").get(id) as {
archived_at: null | string;
status: string;
};
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);
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);
const created = createProject(db, { name: "恢复测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = restoreProject(db, id);
expect("error" in result).toBe(false);
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);
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);
const created = createProject(db, { name: "删除测试" });
const id = (created as { project: { id: string } }).project.id;
archiveProject(db, id);
const result = deleteProject(db, id);
expect("error" in result).toBe(false);
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);
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 });
}
});
});

View File

@@ -0,0 +1,261 @@
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";
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);
}
// Inline imports for actual route handler tests (each handler is in separate file)
async function createProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleCreateProject: h } = await import("../../../src/server/routes/projects/create");
return h(req, db, MODE);
}
function createTestProject(db: Database, name = "测试项目"): Project {
const result = createProject(db, { name });
if ("error" in result) throw new Error(result.error);
return result.project;
}
async function deleteProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleDeleteProject: h } = await import("../../../src/server/routes/projects/delete");
return h(req, db, MODE);
}
async function getProjectViaHandler(req: Request, db: Database): Promise<Response> {
const { handleGetProject: h } = await import("../../../src/server/routes/projects/get");
return h(req, db, MODE);
}
async function listProjectsViaHandler(req: Request, db: Database): Promise<Response> {
const { handleListProjects: h } = await import("../../../src/server/routes/projects/list");
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);
}
// Need db/projects for setup
import { archiveProject, createProject, getProject } from "../../../src/server/db/projects";
describe("项目 API 路由", () => {
test("POST /api/projects 创建项目", async () => {
const dir = makeTempDir();
try {
const db = setupDb(dir);
const req = new Request("http://localhost/api/projects", {
body: JSON.stringify({ description: "路由测试", name: "路由项目" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
const res = await createProjectViaHandler(req, db);
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);
createTestProject(db, "A项目");
createTestProject(db, "B项目");
const req = new Request("http://localhost/api/projects?page=1&pageSize=20");
const res = await listProjectsViaHandler(req, db);
expect(res.status).toBe(200);
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);
const project = createTestProject(db, "详情路由");
const req = new Request(`http://localhost/api/projects/${project.id}`);
const res = await getProjectViaHandler(req, db);
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);
const project = createTestProject(db, "更新路由");
const req = new Request(`http://localhost/api/projects/${project.id}`, {
body: JSON.stringify({ name: "已更新" }),
headers: { "Content-Type": "application/json" },
method: "PATCH",
});
const res = await updateProjectViaHandler(req, db);
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);
const project = createTestProject(db, "归档路由");
const req = new Request(`http://localhost/api/projects/${project.id}/archive`, { method: "POST" });
const res = await archiveProjectViaHandler(req, db);
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);
const project = createTestProject(db, "恢复路由");
archiveProject(db, project.id);
const req = new Request(`http://localhost/api/projects/${project.id}/restore`, { method: "POST" });
const res = await restoreProjectViaHandler(req, db);
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);
const project = createTestProject(db, "删除路由");
archiveProject(db, project.id);
const req = new Request(`http://localhost/api/projects/${project.id}`, { method: "DELETE" });
const res = await deleteProjectViaHandler(req, db);
expect(res.status).toBe(204);
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);
const req1 = new Request("http://localhost/api/projects", {
body: JSON.stringify({ name: "重复名" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
await createProjectViaHandler(req1, db);
const req2 = new Request("http://localhost/api/projects", {
body: JSON.stringify({ name: "重复名" }),
headers: { "Content-Type": "application/json" },
method: "POST",
});
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);
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 });
}
});
});

View File

@@ -41,6 +41,7 @@ describe("App", () => {
renderWithProviders(createElement(App));
expect(screen.getAllByText("仪表盘").length).toBeGreaterThan(0);
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
expect(screen.getAllByText("用户管理").length).toBeGreaterThan(0);
expect(screen.getAllByText("系统设置").length).toBeGreaterThan(0);
});

View File

@@ -11,6 +11,7 @@ describe("Sidebar", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("项目管理")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
@@ -19,10 +20,21 @@ describe("Sidebar", () => {
renderWithProviders(createElement(Sidebar, { collapsed: true, onToggleCollapsed: () => {} }));
expect(screen.getByText("仪表盘")).not.toBeNull();
expect(screen.getByText("项目管理")).not.toBeNull();
expect(screen.getByText("用户管理")).not.toBeNull();
expect(screen.getByText("系统设置")).not.toBeNull();
});
test("项目管理菜单项可导航到 /projects", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
initialRoute: "/projects",
});
const activeItem = document.querySelector(".t-is-active");
expect(activeItem).not.toBeNull();
expect(activeItem?.textContent).toContain("项目管理");
});
test("高亮当前路由对应的菜单项", () => {
renderWithProviders(createElement(Sidebar, { collapsed: false, onToggleCollapsed: () => {} }), {
initialRoute: "/users",

View File

@@ -0,0 +1,41 @@
import { screen, waitFor } from "@testing-library/react";
import { describe, expect, test } from "bun:test";
import { createElement } from "react";
import { ProjectsPage } from "../../../src/web/pages/projects";
import { renderWithProviders } from "../test-utils";
describe("ProjectsPage", () => {
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
renderWithProviders(createElement(ProjectsPage));
// Tabs, search, create button always visible
expect(screen.getByText("进行中")).not.toBeNull();
expect(screen.getByText("已归档")).not.toBeNull();
expect(screen.getByText("搜索")).not.toBeNull();
expect(screen.getByText("新建项目")).not.toBeNull();
// Table renders (shows loading or empty state)
await waitFor(
() => {
const body = document.body.textContent ?? "";
expect(body).toContain("项目名称");
},
{ timeout: 10000 },
);
});
test("新建按钮点击打开弹窗", async () => {
renderWithProviders(createElement(ProjectsPage));
const createBtn = screen.getByText("新建项目");
createBtn.click();
await waitFor(
() => {
expect(screen.getByText("确定")).not.toBeNull();
},
{ timeout: 5000 },
);
});
});