引入 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: 更新架构、后端、发布、配置、部署、使用文档
262 lines
8.9 KiB
TypeScript
262 lines
8.9 KiB
TypeScript
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 });
|
|
}
|
|
});
|
|
});
|