- 抽取 ConsoleShell 共享外壳(Layout/Header/Sider/主题切换/侧边栏折叠) - Sidebar 纯化为接受 menuItems prop 的展示组件 - Admin 管理台:/ 总览 + /projects 项目管理 - Workbench 工作台:/workbench/:projectId 项目作用域 - WorkbenchProjectGate 入口守卫(loading/error/archived/不存在拦截) - ProjectContext 提供当前项目上下文 - 项目管理表格 active 行增加'进入工作台'按钮 - 项目名称 trim 后最多 10 字符(前后端一致) - Workbench 总览页展示项目 Descriptions - Header 区分:管理台显示副标题,工作台显示项目名 + 返回管理台按钮 - 28/28 前端测试通过 - 文档更新:frontend.md ConsoleShell 规范、usage.md 双入口说明
363 lines
12 KiB
TypeScript
363 lines
12 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 { 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 });
|
|
}
|
|
});
|
|
|
|
test("创建项目名称超过 10 个字符失败", () => {
|
|
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("不能超过 10 个字符");
|
|
db.close();
|
|
} finally {
|
|
rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
test("创建项目名称刚好 10 个字符成功", () => {
|
|
const dir = makeTempDir();
|
|
try {
|
|
const db = setupDb(dir);
|
|
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);
|
|
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("不能超过 10 个字符");
|
|
db.close();
|
|
} finally {
|
|
rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
test("更新项目名称 trim 后为空失败", () => {
|
|
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, { 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 });
|
|
}
|
|
});
|
|
});
|