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:
@@ -41,12 +41,13 @@
|
||||
|
||||
代码变更必须按影响范围执行验证。
|
||||
|
||||
| 变更类型 | 必跑命令 |
|
||||
| -------------------------- | --------------------------------------------------------- |
|
||||
| 常规代码变更 | `bun run check` |
|
||||
| 构建、部署、前后端集成变更 | `bun run verify` |
|
||||
| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
|
||||
| 变更类型 | 必跑命令 |
|
||||
| -------------------------- | ------------------------------------------------------------- |
|
||||
| 常规代码变更 | `bun run check` |
|
||||
| 构建、部署、前后端集成变更 | `bun run verify` |
|
||||
| 配置 schema 变化 | `bun run schema`、`bun run schema:check`、`bun run check` |
|
||||
| SQLite 测试基础设施变化 | 相关单文件测试 + SQLite 聚焦 `--rerun-each` + `bun run check` |
|
||||
| 仅文档变更 | 检查链接、索引和文档归属一致性 |
|
||||
|
||||
正式提交或影响构建产物时优先运行 `bun run verify`。如果因环境限制无法执行完整验证,必须在收尾说明中记录未执行项和原因。
|
||||
|
||||
|
||||
@@ -119,6 +119,13 @@ bun run version:set # 显式设置版本号
|
||||
| 配置 schema | schema 导出、合法/非法配置 |
|
||||
| helpers/middleware | 单元测试 |
|
||||
|
||||
后端测试约定:
|
||||
|
||||
- API 路由集成测试必须通过真实 `startServer` 覆盖路由注册、HTTP method、fallback、响应 header 和核心错误路径。
|
||||
- SQLite 相关测试必须复用 `tests/helpers.ts` 中的测试数据库 helper,不要在测试文件内分散实现临时目录清理或直接裸用 `rmSync(dir, { recursive: true })`。
|
||||
- DAO 和路由边界测试应优先使用真实 migration 初始化测试库,只有 migration 执行器单测可以使用最小 fake migration。
|
||||
- logger/bootstrap 中预期的 fallback 输出必须在测试中捕获并断言,正常通过的测试不应污染 stdout/stderr。
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
修改后端 API、共享类型、配置契约、日志模块、版本管理或后端测试规范时,必须更新本文档。
|
||||
|
||||
@@ -157,6 +157,9 @@ Workbench 项目上下文通过 `ProjectContext` 提供,在 `WorkbenchProjectG
|
||||
- 组件测试环境由 tests/setup.ts 和 bunfig.toml preload 提供
|
||||
- 断言优先基于用户可见文本、role、按钮和交互结果,不依赖 `.ant-*` 内部类名。
|
||||
- 对 antd 组件只断言本项目传入的可观察行为或配置结果,避免把 antd 内部 DOM 结构当作稳定契约。
|
||||
- fetch mock、路由、QueryClientProvider 等系统边界优先复用 tests/web/test-utils.tsx,避免在每个测试文件重复安装 `window.fetch`。
|
||||
- 项目页这类数据驱动页面至少覆盖请求 URL/query、method/body、成功后的用户可见结果,以及关键错误路径或失败后状态。
|
||||
- ErrorBoundary、hooks 纯逻辑和 fetch request helper 应使用单元测试覆盖异常回退,页面测试只保留真实用户路径。
|
||||
|
||||
## 更新触发条件
|
||||
|
||||
|
||||
114
scripts/build.ts
114
scripts/build.ts
@@ -11,6 +11,67 @@ const buildDir = join(projectRoot, ".build");
|
||||
const executablePath = join(projectRoot, `dist/${APP.name}`);
|
||||
const packageJsonPath = join(projectRoot, "package.json");
|
||||
|
||||
export function createMigrationsDataSource(records: Array<{ checksum: string; id: string; sql: string }>): string {
|
||||
return [
|
||||
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
|
||||
``,
|
||||
`export const MIGRATIONS: MigrationRecord[] = [`,
|
||||
...records.map(
|
||||
(r) =>
|
||||
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
|
||||
),
|
||||
`];`,
|
||||
``,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function createServerEntrySource(version: string): string {
|
||||
return [
|
||||
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||
`import { createConsoleFallback } from "../src/server/logger";`,
|
||||
`import { MIGRATIONS } from "./migrations-data";`,
|
||||
`import { staticAssets } from "./static-assets";`,
|
||||
"",
|
||||
`const APP_VERSION = "${version}" as const;`,
|
||||
"",
|
||||
`async function main() {`,
|
||||
` const { configPath } = parseRuntimeArgs();`,
|
||||
` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`,
|
||||
` process.exit(1);`,
|
||||
`});`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function createStaticAssetsSource({
|
||||
fileEntries,
|
||||
importLines,
|
||||
indexHtmlVar,
|
||||
}: {
|
||||
fileEntries: string[];
|
||||
importLines: string[];
|
||||
indexHtmlVar: string;
|
||||
}): string {
|
||||
return [
|
||||
`import type { StaticAssets } from "../src/server/static";`,
|
||||
"",
|
||||
...importLines,
|
||||
"",
|
||||
`export const staticAssets: StaticAssets = {`,
|
||||
` files: {`,
|
||||
...fileEntries,
|
||||
` },`,
|
||||
` indexHtml: Bun.file(${indexHtmlVar}),`,
|
||||
`};`,
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function build() {
|
||||
try {
|
||||
await viteBuild();
|
||||
@@ -98,42 +159,11 @@ async function codeGeneration() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const staticAssetsTs = [
|
||||
`import type { StaticAssets } from "../src/server/static";`,
|
||||
"",
|
||||
...importLines,
|
||||
"",
|
||||
`export const staticAssets: StaticAssets = {`,
|
||||
` files: {`,
|
||||
...fileEntries,
|
||||
` },`,
|
||||
` indexHtml: Bun.file(${indexHtmlVar}),`,
|
||||
`};`,
|
||||
"",
|
||||
].join("\n");
|
||||
const staticAssetsTs = createStaticAssetsSource({ fileEntries, importLines, indexHtmlVar });
|
||||
|
||||
await writeFile(join(buildDir, "static-assets.ts"), staticAssetsTs);
|
||||
|
||||
const serverEntryTs = [
|
||||
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||
`import { createConsoleFallback } from "../src/server/logger";`,
|
||||
`import { MIGRATIONS } from "./migrations-data";`,
|
||||
`import { staticAssets } from "./static-assets";`,
|
||||
"",
|
||||
`const APP_VERSION = "${version}" as const;`,
|
||||
"",
|
||||
`async function main() {`,
|
||||
` const { configPath } = parseRuntimeArgs();`,
|
||||
` await bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
` createConsoleFallback().fatal(\`启动失败: \${error instanceof Error ? error.message : String(error)}\`);`,
|
||||
` process.exit(1);`,
|
||||
`});`,
|
||||
"",
|
||||
].join("\n");
|
||||
const serverEntryTs = createServerEntrySource(version);
|
||||
|
||||
await writeFile(join(buildDir, "server-entry.ts"), serverEntryTs);
|
||||
}
|
||||
@@ -159,17 +189,7 @@ async function generateMigrationsData() {
|
||||
return { checksum, id, sql: sql.trim() };
|
||||
});
|
||||
|
||||
const lines = [
|
||||
`import type { MigrationRecord } from "../src/server/db/load-migrations";`,
|
||||
``,
|
||||
`export const MIGRATIONS: MigrationRecord[] = [`,
|
||||
...records.map(
|
||||
(r) =>
|
||||
` { id: ${JSON.stringify(r.id)}, sql: ${JSON.stringify(r.sql)}, checksum: ${JSON.stringify(r.checksum)} },`,
|
||||
),
|
||||
`];`,
|
||||
``,
|
||||
].join("\n");
|
||||
const lines = createMigrationsDataSource(records);
|
||||
|
||||
await writeFile(join(buildDir, "migrations-data.ts"), lines);
|
||||
console.log(`Embedded ${records.length} migration(s)`);
|
||||
@@ -195,7 +215,7 @@ function toImportSpecifier(fromDir: string, targetPath: string) {
|
||||
}
|
||||
|
||||
async function viteBuild() {
|
||||
console.log("Step 1/3: Vite build...");
|
||||
console.log("Step 1/4: Vite build...");
|
||||
const proc = Bun.spawn(["bunx", "--bun", "vite", "build"], {
|
||||
cwd: projectRoot,
|
||||
stderr: "inherit",
|
||||
@@ -208,4 +228,6 @@ async function viteBuild() {
|
||||
}
|
||||
}
|
||||
|
||||
await build();
|
||||
if (import.meta.main) {
|
||||
await build();
|
||||
}
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
const ProjectContext = createContext<null | Project>(null);
|
||||
import { ProjectContext } from "./ProjectContextValue";
|
||||
|
||||
export function ProjectProvider({ children, project }: { children: ReactNode; project: Project }) {
|
||||
return <ProjectContext.Provider value={project}>{children}</ProjectContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCurrentProject(): Project {
|
||||
const project = useContext(ProjectContext);
|
||||
if (!project) {
|
||||
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
|
||||
5
src/web/consoles/workbench/ProjectContextValue.ts
Normal file
5
src/web/consoles/workbench/ProjectContextValue.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
export const ProjectContext = createContext<null | Project>(null);
|
||||
@@ -5,8 +5,9 @@ import { useNavigate } from "react-router";
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
import { ConsoleShell } from "../../components/ConsoleShell/ConsoleShell";
|
||||
import { ProjectProvider, useCurrentProject } from "./ProjectContext";
|
||||
import { ProjectProvider } from "./ProjectContext";
|
||||
import { getWorkbenchMenuItems } from "./routes";
|
||||
import { useCurrentProject } from "./useCurrentProject";
|
||||
|
||||
interface WorkbenchConsoleLayoutProps {
|
||||
project: Project;
|
||||
|
||||
13
src/web/consoles/workbench/useCurrentProject.ts
Normal file
13
src/web/consoles/workbench/useCurrentProject.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import type { Project } from "../../../shared/api";
|
||||
|
||||
import { ProjectContext } from "./ProjectContextValue";
|
||||
|
||||
export function useCurrentProject(): Project {
|
||||
const project = useContext(ProjectContext);
|
||||
if (!project) {
|
||||
throw new Error("useCurrentProject 必须在 Workbench 项目上下文内使用");
|
||||
}
|
||||
return project;
|
||||
}
|
||||
@@ -11,6 +11,93 @@ import type {
|
||||
|
||||
const PROJECTS_KEY = ["projects"] as const;
|
||||
|
||||
export async function archiveProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function createProject(data: CreateProjectRequest): Promise<Project> {
|
||||
const response = await fetch("/api/projects", {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function fetchProjectList(params: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ProjectStatus;
|
||||
}): Promise<ProjectListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
||||
if (params.status) searchParams.set("status", params.status);
|
||||
const qs = searchParams.toString();
|
||||
const url = `/api/projects${qs ? `?${qs}` : ""}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<ProjectListResponse>;
|
||||
}
|
||||
|
||||
export async function restoreProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
export async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`, {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
export function useArchiveProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -75,90 +162,3 @@ export function useUpdateProject() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/archive`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
async function createProject(data: CreateProjectRequest): Promise<Project> {
|
||||
const response = await fetch("/api/projects", {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
async function deleteProject(id: string): Promise<void> {
|
||||
const response = await fetch(`/api/projects/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
async function fetchProjectList(params: {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: ProjectStatus;
|
||||
}): Promise<ProjectListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.page) searchParams.set("page", String(params.page));
|
||||
if (params.pageSize) searchParams.set("pageSize", String(params.pageSize));
|
||||
if (params.keyword) searchParams.set("keyword", params.keyword);
|
||||
if (params.status) searchParams.set("status", params.status);
|
||||
const qs = searchParams.toString();
|
||||
const url = `/api/projects${qs ? `?${qs}` : ""}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<ProjectListResponse>;
|
||||
}
|
||||
|
||||
async function restoreProject(id: string): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}/restore`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const data = (await response.json()) as ProjectResponse;
|
||||
return data.project;
|
||||
}
|
||||
|
||||
async function updateProject(id: string, data: UpdateProjectRequest): Promise<Project> {
|
||||
const response = await fetch(`/api/projects/${id}`, {
|
||||
body: JSON.stringify(data),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => null)) as null | { error?: string };
|
||||
throw new Error(body?.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
const result = (await response.json()) as ProjectResponse;
|
||||
return result.project;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { App as AntApp, Form, Input, Modal } from "antd";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { CreateProjectRequest, Project, UpdateProjectRequest } from "../../../../shared/api";
|
||||
|
||||
@@ -29,6 +30,15 @@ export function ProjectFormModal({
|
||||
const { message } = AntApp.useApp();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editingProject) {
|
||||
form.setFieldsValue({ description: editingProject.description, name: editingProject.name });
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [editingProject, form, open]);
|
||||
|
||||
const handleFinish = async (values: FormValues) => {
|
||||
try {
|
||||
if (editingProject) {
|
||||
@@ -52,15 +62,6 @@ export function ProjectFormModal({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
afterOpenChange={(visible) => {
|
||||
if (visible) {
|
||||
if (editingProject) {
|
||||
form.setFieldsValue({ description: editingProject.description, name: editingProject.name });
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}
|
||||
}}
|
||||
confirmLoading={submitting}
|
||||
destroyOnHidden
|
||||
okText="确定"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PlusOutlined } from "@ant-design/icons";
|
||||
import { Button, Flex, Input, Tabs } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ProjectStatus } from "../../../../shared/api";
|
||||
|
||||
@@ -25,6 +26,8 @@ export function ProjectToolbar({
|
||||
onTabChange,
|
||||
openCreateDialog,
|
||||
}: ProjectToolbarProps) {
|
||||
const [draftKeyword, setDraftKeyword] = useState(keyword);
|
||||
|
||||
return (
|
||||
<Flex align="center" gap="var(--ant-margin-lg)" justify="space-between" wrap="wrap">
|
||||
<Tabs activeKey={activeTab} items={STATUS_TAB_ITEMS} onChange={onTabChange} />
|
||||
@@ -32,10 +35,14 @@ export function ProjectToolbar({
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton="搜索"
|
||||
onClear={onSearchClear}
|
||||
onSearch={onSearch}
|
||||
onChange={(event) => setDraftKeyword(event.target.value)}
|
||||
onClear={() => {
|
||||
setDraftKeyword("");
|
||||
onSearchClear();
|
||||
}}
|
||||
onSearch={(value) => onSearch(value)}
|
||||
placeholder="搜索项目名称或描述"
|
||||
value={keyword}
|
||||
value={draftKeyword}
|
||||
/>
|
||||
{activeTab === "active" && (
|
||||
<Button icon={<PlusOutlined />} onClick={openCreateDialog} type="primary">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Card, Descriptions, Space, Typography } from "antd";
|
||||
|
||||
import { useCurrentProject } from "../../consoles/workbench/ProjectContext";
|
||||
import { useCurrentProject } from "../../consoles/workbench/useCurrentProject";
|
||||
|
||||
export function WorkbenchOverviewPage() {
|
||||
const project = useCurrentProject();
|
||||
|
||||
171
tests/helpers.ts
171
tests/helpers.ts
@@ -1,4 +1,130 @@
|
||||
import Database from "bun:sqlite";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
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 { loadMigrationsFromDir } from "../src/server/db/load-migrations";
|
||||
import { runMigrations } from "../src/server/db/migrate";
|
||||
import { createMemoryLogger } from "../src/server/logger";
|
||||
|
||||
const RETRYABLE_RM_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]);
|
||||
|
||||
export interface TestDatabaseHandle {
|
||||
cleanup: () => void;
|
||||
close: () => void;
|
||||
db: Database;
|
||||
dir: string;
|
||||
logger: ReturnType<typeof createMemoryLogger>;
|
||||
}
|
||||
|
||||
export function closeSqliteForTest(db: Database): void {
|
||||
try {
|
||||
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
} catch {
|
||||
// 关闭前的 checkpoint 是尽力操作;失败时仍继续 close,避免掩盖原测试断言。
|
||||
}
|
||||
|
||||
try {
|
||||
db.exec("PRAGMA journal_mode = DELETE");
|
||||
} catch {
|
||||
// Windows 上 WAL/SHM 句柄释放可能延迟,切回 DELETE 失败时仍继续关闭连接。
|
||||
}
|
||||
|
||||
db.close();
|
||||
forceBunGc();
|
||||
sleepSync(100);
|
||||
}
|
||||
|
||||
export function configureSqliteForTest(db: Database): void {
|
||||
// 生产库使用 WAL;测试库切回 DELETE,避免 Windows 上 WAL/SHM 句柄延迟导致清理 EBUSY。
|
||||
db.exec("PRAGMA journal_mode = DELETE");
|
||||
}
|
||||
|
||||
export function createMigratedMemoryTestDatabase(prefix: string): TestDatabaseHandle {
|
||||
const dir = makeTempDir(prefix);
|
||||
const logger = createMemoryLogger();
|
||||
const db = new Database(":memory:");
|
||||
configureSqliteForTest(db);
|
||||
let closed = false;
|
||||
|
||||
runMigrations(db, loadMigrationsFromDir(), dir, logger);
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closeSqliteForTest(db);
|
||||
closed = true;
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
close();
|
||||
rmRetrySync(dir);
|
||||
},
|
||||
close,
|
||||
db,
|
||||
dir,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMigratedTestDatabase(prefix: string): TestDatabaseHandle {
|
||||
return createTestDatabase(prefix, loadMigrationsFromDir());
|
||||
}
|
||||
|
||||
export function createTestDatabase(prefix: string, migrations: MigrationRecord[] = []): TestDatabaseHandle {
|
||||
const dir = makeTempDir(prefix);
|
||||
const logger = createMemoryLogger();
|
||||
const db = createDatabase(dir, logger);
|
||||
configureSqliteForTest(db);
|
||||
let closed = false;
|
||||
|
||||
if (migrations.length > 0) {
|
||||
runMigrations(db, migrations, dir, logger);
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (closed) return;
|
||||
closeSqliteForTest(db);
|
||||
closed = true;
|
||||
};
|
||||
|
||||
return {
|
||||
cleanup: () => {
|
||||
close();
|
||||
rmRetrySync(dir);
|
||||
},
|
||||
close,
|
||||
db,
|
||||
dir,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeTempDir(prefix: string): string {
|
||||
const dir = join(tmpdir(), `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export function openTestDatabase(dir: string): Pick<TestDatabaseHandle, "close" | "db" | "logger"> {
|
||||
const logger = createMemoryLogger();
|
||||
const db = createDatabase(dir, logger);
|
||||
configureSqliteForTest(db);
|
||||
let closed = false;
|
||||
return {
|
||||
close: () => {
|
||||
if (closed) return;
|
||||
closeSqliteForTest(db);
|
||||
closed = true;
|
||||
},
|
||||
db,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
|
||||
export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
@@ -6,8 +132,51 @@ export async function rmRetry(dir: string, retries = 10, delayMs = 500) {
|
||||
await rm(dir, { force: true, recursive: true });
|
||||
return;
|
||||
} catch (e) {
|
||||
if (i === retries - 1) throw e;
|
||||
const code = getErrorCode(e);
|
||||
if (i === retries - 1 || (code && !RETRYABLE_RM_CODES.has(code))) {
|
||||
throw withCleanupMessage(e, dir, i + 1);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function rmRetrySync(dir: string, retries = 300, delayMs = 50): void {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
forceBunGc();
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
return;
|
||||
} catch (e) {
|
||||
const code = getErrorCode(e);
|
||||
if (i === retries - 1 || (code && !RETRYABLE_RM_CODES.has(code))) {
|
||||
throw withCleanupMessage(e, dir, i + 1);
|
||||
}
|
||||
sleepSync(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function forceBunGc(): void {
|
||||
const bun = (globalThis as typeof globalThis & { Bun?: { gc?: (force?: boolean) => void } }).Bun;
|
||||
bun?.gc?.(true);
|
||||
}
|
||||
|
||||
function getErrorCode(error: unknown): string | undefined {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
||||
}
|
||||
|
||||
function withCleanupMessage(error: unknown, dir: string, attempts: number): Error {
|
||||
if (!(error instanceof Error)) {
|
||||
return new Error(`测试临时目录清理失败:${dir},已重试 ${attempts} 次,原始错误:${String(error)}`);
|
||||
}
|
||||
|
||||
error.message = `测试临时目录清理失败:${dir},已重试 ${attempts} 次。${error.message}`;
|
||||
return error;
|
||||
}
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { validateVersion } from "../../scripts/bump-version-logic";
|
||||
import { createMigrationsDataSource, createServerEntrySource, createStaticAssetsSource } from "../../scripts/build";
|
||||
|
||||
describe("build 版本注入", () => {
|
||||
test("validateVersion 接受有效版本", () => {
|
||||
expect(() => validateVersion("0.1.0")).not.toThrow();
|
||||
expect(() => validateVersion("1.2.3")).not.toThrow();
|
||||
});
|
||||
|
||||
test("validateVersion 拒绝无效版本", () => {
|
||||
expect(() => validateVersion("invalid")).toThrow();
|
||||
expect(() => validateVersion("1.0.0-beta.1")).toThrow();
|
||||
});
|
||||
|
||||
test("生成的 server-entry 包含版本字面量", () => {
|
||||
test("生成的 server-entry 包含真实 bootstrap 参数", () => {
|
||||
const version = "0.1.0";
|
||||
const serverEntryTs = [
|
||||
`import { bootstrap } from "../src/server/bootstrap";`,
|
||||
`import { parseRuntimeArgs } from "../src/server/config";`,
|
||||
`import { staticAssets } from "./static-assets";`,
|
||||
"",
|
||||
`const APP_VERSION = "${version}" as const;`,
|
||||
"",
|
||||
`async function main() {`,
|
||||
` const { configPath } = parseRuntimeArgs();`,
|
||||
` await bootstrap({ configPath, mode: "production", staticAssets, version: APP_VERSION });`,
|
||||
`}`,
|
||||
"",
|
||||
`void main().catch((error) => {`,
|
||||
` console.error("启动失败:", error instanceof Error ? error.message : error);`,
|
||||
` process.exit(1);`,
|
||||
`});`,
|
||||
"",
|
||||
].join("\n");
|
||||
const serverEntryTs = createServerEntrySource(version);
|
||||
|
||||
expect(serverEntryTs).toContain(`const APP_VERSION = "${version}"`);
|
||||
expect(serverEntryTs).toContain("version: APP_VERSION");
|
||||
expect(serverEntryTs).toContain(`import { MIGRATIONS } from "./migrations-data";`);
|
||||
expect(serverEntryTs).toContain(`import { staticAssets } from "./static-assets";`);
|
||||
expect(serverEntryTs).toContain(
|
||||
`bootstrap({ configPath, migrations: MIGRATIONS, mode: "production", staticAssets, version: APP_VERSION })`,
|
||||
);
|
||||
expect(serverEntryTs).toContain("createConsoleFallback().fatal");
|
||||
});
|
||||
|
||||
test("版本字面量不依赖外部 package.json", () => {
|
||||
const serverEntryTs = [`const APP_VERSION = "0.1.0" as const;`].join("\n");
|
||||
test("生成的 static-assets 使用 indexHtml 与文件映射", () => {
|
||||
const staticAssetsTs = createStaticAssetsSource({
|
||||
fileEntries: [` "/assets/app.js": Bun.file(f1),`],
|
||||
importLines: [
|
||||
`import f0 from "./../dist/web/index.html" with { type: "file" };`,
|
||||
`import f1 from "./../dist/web/assets/app.js" with { type: "file" };`,
|
||||
],
|
||||
indexHtmlVar: "f0",
|
||||
});
|
||||
|
||||
expect(serverEntryTs).not.toContain("package.json");
|
||||
expect(serverEntryTs).not.toContain("Bun.file");
|
||||
expect(serverEntryTs).toContain('"0.1.0"');
|
||||
expect(staticAssetsTs).toContain(`export const staticAssets`);
|
||||
expect(staticAssetsTs).toContain(`"/assets/app.js": Bun.file(f1)`);
|
||||
expect(staticAssetsTs).toContain(`indexHtml: Bun.file(f0)`);
|
||||
});
|
||||
|
||||
test("生成的 migrations-data 嵌入真实记录形状", () => {
|
||||
const migrationsTs = createMigrationsDataSource([
|
||||
{ checksum: "abc123", id: "0000_initial", sql: "CREATE TABLE projects (id TEXT);" },
|
||||
]);
|
||||
|
||||
expect(migrationsTs).toContain(`import type { MigrationRecord }`);
|
||||
expect(migrationsTs).toContain(`export const MIGRATIONS: MigrationRecord[]`);
|
||||
expect(migrationsTs).toContain(`id: "0000_initial"`);
|
||||
expect(migrationsTs).toContain(`checksum: "abc123"`);
|
||||
expect(migrationsTs).toContain(`CREATE TABLE projects`);
|
||||
});
|
||||
|
||||
test("构建步骤编号保持 1/4 到 4/4 一致", async () => {
|
||||
const source = await Bun.file(new URL("../../scripts/build.ts", import.meta.url)).text();
|
||||
|
||||
expect(source).toContain(`Step 1/4: Vite build`);
|
||||
expect(source).toContain(`Step 2/4: Code generation`);
|
||||
expect(source).toContain(`Step 3/4: Generating static assets`);
|
||||
expect(source).toContain(`Step 4/4: Bun compile`);
|
||||
expect(source).not.toContain(`Step 1/3`);
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,13 @@ globalThis.document = dom.window.document;
|
||||
globalThis.window = dom.window as unknown as typeof globalThis & Window;
|
||||
globalThis.navigator = dom.window.navigator;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLBodyElement = dom.window.HTMLBodyElement;
|
||||
globalThis.HTMLHtmlElement = dom.window.HTMLHtmlElement;
|
||||
globalThis.Element = dom.window.Element;
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle;
|
||||
globalThis.getComputedStyle = (element: Element, pseudoElt?: null | string) => {
|
||||
// jsdom 不支持伪元素计算样式;antd/rc-trigger 会传入伪元素参数,测试中退回普通样式即可。
|
||||
return dom.window.getComputedStyle(element, pseudoElt ? undefined : pseudoElt);
|
||||
};
|
||||
|
||||
// Ensure document.body exists
|
||||
if (!globalThis.document.body) {
|
||||
@@ -44,6 +49,32 @@ Object.defineProperty(elementProto, "detachEvent", { configurable: true, value:
|
||||
Object.defineProperty(htmlElementProto, "attachEvent", { configurable: true, value: attachEventFn, writable: true });
|
||||
Object.defineProperty(htmlElementProto, "detachEvent", { configurable: true, value: detachEventFn, writable: true });
|
||||
|
||||
// 抑制 antd/rc-trigger 在 jsdom 中产生的 NaN height warning
|
||||
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = (chunk: string | Uint8Array, encodingOrCb?: unknown, cb?: unknown) => {
|
||||
const str = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
|
||||
if (str.includes("NaN") && str.includes("height") && str.includes("css style property")) return true;
|
||||
return originalStderrWrite(
|
||||
chunk,
|
||||
encodingOrCb as Parameters<typeof process.stderr.write>[1],
|
||||
cb as Parameters<typeof process.stderr.write>[2],
|
||||
);
|
||||
};
|
||||
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
const message = args.map(String).join(" ");
|
||||
if (message.includes("NaN") && message.includes("height") && message.includes("css style property")) return;
|
||||
originalConsoleError(...args);
|
||||
};
|
||||
|
||||
const originalConsoleWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
const message = args.map(String).join(" ");
|
||||
if (message.includes("NaN") && message.includes("height") && message.includes("css style property")) return;
|
||||
originalConsoleWarn(...args);
|
||||
};
|
||||
|
||||
// Other polyfills
|
||||
globalThis.ResizeObserver = class {
|
||||
disconnect() {}
|
||||
|
||||
@@ -4,77 +4,27 @@ import { createElement } from "react";
|
||||
|
||||
import { APP } from "../../src/shared/app";
|
||||
import { App } from "../../src/web/app";
|
||||
import { renderWithProviders } from "./test-utils";
|
||||
import { installFetchMock, mockMetaResponse, renderWithProviders } from "./test-utils";
|
||||
|
||||
describe("App", () => {
|
||||
test("渲染 Layout 骨架和品牌名", () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
test("渲染管理台入口、品牌和主题切换项", () => {
|
||||
installFetchMock(() => mockMetaResponse());
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getByText(APP.title)).not.toBeNull();
|
||||
expect(screen.getByText("管理台")).not.toBeNull();
|
||||
expect(screen.getByText("系统")).not.toBeNull();
|
||||
expect(screen.getByText("明亮")).not.toBeNull();
|
||||
expect(screen.getByText("黑暗")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("渲染 Admin 侧边栏菜单项", () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
test("渲染 Admin 导航菜单项", () => {
|
||||
installFetchMock(() => mockMetaResponse());
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getAllByText("总览").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("项目管理").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("Sider 渲染侧边栏菜单", () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
const sider = document.querySelector(".ant-layout-sider");
|
||||
expect(sider).not.toBeNull();
|
||||
const menu = document.querySelector(".ant-menu");
|
||||
expect(menu).not.toBeNull();
|
||||
});
|
||||
|
||||
test("Admin header 显示管理台标题", () => {
|
||||
window.fetch = (() => {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, service: "test-app", timestamp: new Date().toISOString(), version: "0.1.0" }),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
renderWithProviders(createElement(App));
|
||||
|
||||
expect(screen.getByText("管理台")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
62
tests/web/components/ErrorBoundary.test.tsx
Normal file
62
tests/web/components/ErrorBoundary.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { App as AntApp, ConfigProvider } from "antd";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
|
||||
import { ErrorBoundary } from "../../../src/web/components/ErrorBoundary";
|
||||
|
||||
function BrokenChild(): never {
|
||||
throw new Error("render failed");
|
||||
}
|
||||
|
||||
function captureConsoleError(callback: () => void): string[] {
|
||||
const originalError = console.error;
|
||||
const errors: string[] = [];
|
||||
console.error = (...args: unknown[]) => {
|
||||
errors.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
callback();
|
||||
} finally {
|
||||
console.error = originalError;
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
test("子组件渲染失败后展示错误结果并隔离 console.error", () => {
|
||||
const errors = captureConsoleError(() => {
|
||||
render(
|
||||
createElement(
|
||||
ConfigProvider,
|
||||
null,
|
||||
createElement(AntApp, null, createElement(ErrorBoundary, null, createElement(BrokenChild))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText("渲染错误")).not.toBeNull();
|
||||
expect(screen.getByText("页面渲染出现异常,请刷新重试")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "刷新页面" })).not.toBeNull();
|
||||
expect(errors.some((line) => line.includes("渲染错误:"))).toBe(true);
|
||||
});
|
||||
|
||||
test("点击刷新页面按钮不会破坏错误兜底界面", () => {
|
||||
captureConsoleError(() => {
|
||||
render(
|
||||
createElement(
|
||||
ConfigProvider,
|
||||
null,
|
||||
createElement(AntApp, null, createElement(ErrorBoundary, null, createElement(BrokenChild))),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(() => {
|
||||
captureConsoleError(() => fireEvent.click(screen.getByRole("button", { name: "刷新页面" })));
|
||||
}).not.toThrow();
|
||||
expect(screen.getByText("渲染错误")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,17 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
import { Sidebar } from "../../../../src/web/components/Sidebar";
|
||||
import { ADMIN_MENU_ITEMS } from "../../../../src/web/consoles/admin/menu";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <span>当前路径:{location.pathname}</span>;
|
||||
}
|
||||
|
||||
describe("Sidebar", () => {
|
||||
test("渲染 Admin 菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }));
|
||||
@@ -14,23 +20,21 @@ describe("Sidebar", () => {
|
||||
expect(screen.getByText("项目管理")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("项目管理菜单项可导航到 /projects", () => {
|
||||
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
|
||||
initialRoute: "/projects",
|
||||
});
|
||||
test("点击项目管理菜单项导航到 /projects", () => {
|
||||
renderWithProviders(
|
||||
createElement("div", null, createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), createElement(LocationProbe)),
|
||||
);
|
||||
|
||||
const activeItem = document.querySelector(".ant-menu-item-selected");
|
||||
expect(activeItem).not.toBeNull();
|
||||
expect(activeItem?.textContent).toContain("项目管理");
|
||||
fireEvent.click(screen.getByText("项目管理"));
|
||||
|
||||
expect(screen.getByText("当前路径:/projects")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("高亮当前路由对应的总览菜单项", () => {
|
||||
test("当前路由仍展示对应菜单项", () => {
|
||||
renderWithProviders(createElement(Sidebar, { menuItems: ADMIN_MENU_ITEMS }), {
|
||||
initialRoute: "/",
|
||||
});
|
||||
|
||||
const activeItem = document.querySelector(".ant-menu-item-selected");
|
||||
expect(activeItem).not.toBeNull();
|
||||
expect(activeItem?.textContent).toContain("总览");
|
||||
expect(screen.getByText("总览")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
88
tests/web/hooks/use-projects.test.ts
Normal file
88
tests/web/hooks/use-projects.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
archiveProject,
|
||||
createProject,
|
||||
deleteProject,
|
||||
fetchProject,
|
||||
fetchProjectList,
|
||||
restoreProject,
|
||||
updateProject,
|
||||
} from "../../../src/web/hooks/use-projects";
|
||||
import { installFetchMock, jsonResponse } from "../test-utils";
|
||||
|
||||
const PROJECT = {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "描述",
|
||||
id: "p1",
|
||||
name: "项目",
|
||||
status: "active" as const,
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
async function expectRejectsWithMessage(action: () => Promise<unknown>, message: string) {
|
||||
try {
|
||||
await action();
|
||||
throw new Error("expected rejection");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe(message);
|
||||
}
|
||||
}
|
||||
|
||||
function jsonBody(body: BodyInit | null | undefined): unknown {
|
||||
return JSON.parse(typeof body === "string" ? body : "{}");
|
||||
}
|
||||
|
||||
describe("use-projects request helpers", () => {
|
||||
test("fetchProjectList 按协议拼接 query 参数", async () => {
|
||||
const calls = installFetchMock(() => jsonResponse({ items: [PROJECT], page: 2, pageSize: 10, total: 1 }));
|
||||
|
||||
const result = await fetchProjectList({ keyword: "项目", page: 2, pageSize: 10, status: "active" });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(calls[0]?.method).toBe("GET");
|
||||
expect(calls[0]?.url).toBe("/api/projects?page=2&pageSize=10&keyword=%E9%A1%B9%E7%9B%AE&status=active");
|
||||
});
|
||||
|
||||
test("创建、更新、归档、恢复和删除使用正确 method 与 body", async () => {
|
||||
const calls = installFetchMock((call) => {
|
||||
if (call.method === "DELETE") return new Response(null, { status: 204 });
|
||||
return jsonResponse(
|
||||
{ project: PROJECT },
|
||||
{ status: call.method === "POST" && call.url === "/api/projects" ? 201 : 200 },
|
||||
);
|
||||
});
|
||||
|
||||
await createProject({ description: "描述", name: "项目" });
|
||||
await updateProject("p1", { name: "新项目" });
|
||||
await archiveProject("p1");
|
||||
await restoreProject("p1");
|
||||
await deleteProject("p1");
|
||||
await fetchProject("p1");
|
||||
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"POST /api/projects",
|
||||
"PATCH /api/projects/p1",
|
||||
"POST /api/projects/p1/archive",
|
||||
"POST /api/projects/p1/restore",
|
||||
"DELETE /api/projects/p1",
|
||||
"GET /api/projects/p1",
|
||||
]);
|
||||
expect(jsonBody(calls[0]?.body)).toEqual({ description: "描述", name: "项目" });
|
||||
expect(jsonBody(calls[1]?.body)).toEqual({ name: "新项目" });
|
||||
});
|
||||
|
||||
test("错误响应优先使用后端 error 字段", async () => {
|
||||
installFetchMock(() => jsonResponse({ error: "项目名称已存在" }, { status: 409 }));
|
||||
|
||||
await expectRejectsWithMessage(() => createProject({ name: "重复项目" }), "项目名称已存在");
|
||||
});
|
||||
|
||||
test("非 JSON 错误响应回退到 HTTP 状态", async () => {
|
||||
installFetchMock(() => new Response("broken", { status: 500 }));
|
||||
|
||||
await expectRejectsWithMessage(() => fetchProject("p-missing"), "HTTP 500");
|
||||
});
|
||||
});
|
||||
54
tests/web/hooks/use-sidebar-collapsed.test.ts
Normal file
54
tests/web/hooks/use-sidebar-collapsed.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
parseSidebarCollapsed,
|
||||
readSidebarCollapsed,
|
||||
SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||
writeSidebarCollapsed,
|
||||
} from "../../../src/web/hooks/use-sidebar-collapsed";
|
||||
|
||||
function createStorage(initial?: string): Storage {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== undefined) values.set(SIDEBAR_COLLAPSED_STORAGE_KEY, initial);
|
||||
return {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("sidebar collapsed 纯逻辑", () => {
|
||||
test("仅字符串 true 解析为折叠", () => {
|
||||
expect(parseSidebarCollapsed("true")).toBe(true);
|
||||
expect(parseSidebarCollapsed("false")).toBe(false);
|
||||
expect(parseSidebarCollapsed(true)).toBe(false);
|
||||
expect(parseSidebarCollapsed(null)).toBe(false);
|
||||
});
|
||||
|
||||
test("读取和写入 localStorage", () => {
|
||||
const storage = createStorage("true");
|
||||
expect(readSidebarCollapsed(storage)).toBe(true);
|
||||
|
||||
writeSidebarCollapsed(false, storage);
|
||||
expect(storage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY)).toBe("false");
|
||||
});
|
||||
|
||||
test("storage 异常时回退且不抛错", () => {
|
||||
const brokenStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
} as unknown as Storage;
|
||||
|
||||
expect(readSidebarCollapsed(brokenStorage)).toBe(false);
|
||||
expect(() => writeSidebarCollapsed(true, brokenStorage)).not.toThrow();
|
||||
});
|
||||
});
|
||||
77
tests/web/hooks/use-theme-preference.test.ts
Normal file
77
tests/web/hooks/use-theme-preference.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
getSystemPrefersDark,
|
||||
parseThemePreference,
|
||||
readThemePreference,
|
||||
resolveEffectiveTheme,
|
||||
THEME_MEDIA_QUERY,
|
||||
THEME_PREFERENCE_STORAGE_KEY,
|
||||
writeThemePreference,
|
||||
} from "../../../src/web/hooks/use-theme-preference";
|
||||
|
||||
function createStorage(initial?: string): Storage {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== undefined) values.set(THEME_PREFERENCE_STORAGE_KEY, initial);
|
||||
return {
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("theme preference 纯逻辑", () => {
|
||||
test("解析合法和非法主题偏好", () => {
|
||||
expect(parseThemePreference("dark")).toBe("dark");
|
||||
expect(parseThemePreference("light")).toBe("light");
|
||||
expect(parseThemePreference("system")).toBe("system");
|
||||
expect(parseThemePreference("unknown")).toBe("system");
|
||||
});
|
||||
|
||||
test("读取和写入 localStorage", () => {
|
||||
const storage = createStorage("dark");
|
||||
expect(readThemePreference(storage)).toBe("dark");
|
||||
|
||||
writeThemePreference("light", storage);
|
||||
expect(storage.getItem(THEME_PREFERENCE_STORAGE_KEY)).toBe("light");
|
||||
});
|
||||
|
||||
test("storage 异常时回退且不抛错", () => {
|
||||
const brokenStorage = {
|
||||
getItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
setItem: () => {
|
||||
throw new Error("blocked");
|
||||
},
|
||||
} as unknown as Storage;
|
||||
|
||||
expect(readThemePreference(brokenStorage)).toBe("system");
|
||||
expect(() => writeThemePreference("dark", brokenStorage)).not.toThrow();
|
||||
});
|
||||
|
||||
test("解析系统主题和异常回退", () => {
|
||||
const matchMedia = ((query: string) => {
|
||||
expect(query).toBe(THEME_MEDIA_QUERY);
|
||||
return { matches: true };
|
||||
}) as Window["matchMedia"];
|
||||
const brokenMatchMedia = (() => {
|
||||
throw new Error("blocked");
|
||||
}) as Window["matchMedia"];
|
||||
|
||||
expect(getSystemPrefersDark(matchMedia)).toBe(true);
|
||||
expect(getSystemPrefersDark(brokenMatchMedia)).toBe(false);
|
||||
});
|
||||
|
||||
test("根据系统主题解析实际主题", () => {
|
||||
expect(resolveEffectiveTheme("dark", false)).toBe("dark");
|
||||
expect(resolveEffectiveTheme("light", true)).toBe("light");
|
||||
expect(resolveEffectiveTheme("system", true)).toBe("dark");
|
||||
expect(resolveEffectiveTheme("system", false)).toBe("light");
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
import { NotFoundPage } from "../../../src/web/pages/404";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return <span>当前路径:{location.pathname}</span>;
|
||||
}
|
||||
|
||||
describe("NotFoundPage", () => {
|
||||
test("渲染 404 页面", () => {
|
||||
renderWithProviders(createElement(NotFoundPage));
|
||||
@@ -14,10 +20,14 @@ describe("NotFoundPage", () => {
|
||||
expect(screen.getByRole("button", { name: "返回首页" })).not.toBeNull();
|
||||
});
|
||||
|
||||
test("返回首页按钮存在且可点击", () => {
|
||||
renderWithProviders(createElement(NotFoundPage));
|
||||
test("点击返回首页按钮导航到首页", () => {
|
||||
renderWithProviders(createElement("div", null, createElement(NotFoundPage), createElement(LocationProbe)), {
|
||||
initialRoute: "/missing",
|
||||
});
|
||||
|
||||
const button = screen.getByRole("button", { name: "返回首页" });
|
||||
expect(button).not.toBeNull();
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(screen.getByText("当前路径:/")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,114 +1,278 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, mock, test } from "bun:test";
|
||||
import { createElement } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
|
||||
import type { Project } from "../../../src/shared/api";
|
||||
|
||||
import { App } from "../../../src/web/app";
|
||||
import { renderWithProviders } from "../test-utils";
|
||||
import { ProjectFormModal } from "../../../src/web/pages/projects/components/ProjectFormModal";
|
||||
import { ProjectTable } from "../../../src/web/pages/projects/components/ProjectTable";
|
||||
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
||||
|
||||
const ACTIVE_PROJECT = {
|
||||
const ACTIVE_PROJECT: Project = {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "",
|
||||
description: "活跃描述",
|
||||
id: "p1",
|
||||
name: "活跃项目",
|
||||
status: "active",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const ARCHIVED_PROJECT = {
|
||||
const ARCHIVED_PROJECT: Project = {
|
||||
archivedAt: "2024-01-02T00:00:00.000Z",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
description: "",
|
||||
description: "归档描述",
|
||||
id: "p2",
|
||||
name: "归档项目",
|
||||
status: "archived",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-02T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function createMockHandler(projectList?: unknown[]) {
|
||||
const handler = (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/meta")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
service: "test-app",
|
||||
timestamp: new Date().toISOString(),
|
||||
version: "0.1.0",
|
||||
}),
|
||||
{ headers: { "Content-Type": "application/json" }, status: 200 },
|
||||
);
|
||||
}
|
||||
if (url.includes("/api/projects")) {
|
||||
const items = projectList ?? [];
|
||||
return new Response(JSON.stringify({ items, page: 1, pageSize: 10, total: items.length }), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
status: 200,
|
||||
function clickLatestConfirmButton() {
|
||||
const buttons = screen.getAllByRole("button", { name: /OK|确\s*定/ });
|
||||
fireEvent.click(buttons[buttons.length - 1]!);
|
||||
}
|
||||
|
||||
function createProjectFetchMock(initialProjects: Project[] = [ACTIVE_PROJECT, ARCHIVED_PROJECT]) {
|
||||
let projects = [...initialProjects];
|
||||
return installFetchMock((call) => {
|
||||
if (call.url.includes("/api/meta")) return mockMetaResponse();
|
||||
|
||||
const url = new URL(call.url, "http://localhost");
|
||||
if (url.pathname === "/api/projects" && call.method === "GET") {
|
||||
const status = url.searchParams.get("status");
|
||||
const keyword = url.searchParams.get("keyword") ?? "";
|
||||
const items = projects.filter((project) => {
|
||||
const statusMatched = !status || project.status === status;
|
||||
const keywordMatched = !keyword || `${project.name}${project.description}`.includes(keyword);
|
||||
return statusMatched && keywordMatched;
|
||||
});
|
||||
return jsonResponse({ items, page: 1, pageSize: 20, total: items.length });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "Not Found" }), {
|
||||
status: 404,
|
||||
});
|
||||
};
|
||||
const mocked = handler as unknown as typeof fetch;
|
||||
globalThis.fetch = mocked;
|
||||
window.fetch = mocked;
|
||||
|
||||
if (url.pathname === "/api/projects" && call.method === "POST") {
|
||||
const data = jsonBody(call.body) as { description?: string; name: string };
|
||||
const created: Project = {
|
||||
archivedAt: null,
|
||||
createdAt: "2024-01-03T00:00:00.000Z",
|
||||
description: data.description ?? "",
|
||||
id: "p-created",
|
||||
name: data.name,
|
||||
status: "active",
|
||||
updatedAt: "2024-01-03T00:00:00.000Z",
|
||||
};
|
||||
projects = [created, ...projects];
|
||||
return jsonResponse({ project: created }, { status: 201 });
|
||||
}
|
||||
|
||||
const projectId = /^\/api\/projects\/([^/]+)(?:\/(archive|restore))?$/.exec(url.pathname);
|
||||
if (projectId) {
|
||||
const [, id, action] = projectId;
|
||||
const project = projects.find((item) => item.id === id);
|
||||
if (!project) return jsonResponse({ error: "项目不存在", status: 404 }, { status: 404 });
|
||||
|
||||
if (call.method === "PATCH") {
|
||||
const data = jsonBody(call.body) as { description?: string; name?: string };
|
||||
const updated = { ...project, ...data, updatedAt: "2024-01-04T00:00:00.000Z" };
|
||||
projects = projects.map((item) => (item.id === id ? updated : item));
|
||||
return jsonResponse({ project: updated });
|
||||
}
|
||||
|
||||
if (call.method === "POST" && action === "archive") {
|
||||
const archived = { ...project, archivedAt: "2024-01-04T00:00:00.000Z", status: "archived" as const };
|
||||
projects = projects.map((item) => (item.id === id ? archived : item));
|
||||
return jsonResponse({ project: archived });
|
||||
}
|
||||
|
||||
if (call.method === "POST" && action === "restore") {
|
||||
const restored = { ...project, archivedAt: null, status: "active" as const };
|
||||
projects = projects.map((item) => (item.id === id ? restored : item));
|
||||
return jsonResponse({ project: restored });
|
||||
}
|
||||
|
||||
if (call.method === "DELETE") {
|
||||
projects = projects.filter((item) => item.id !== id);
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse({ error: "Not Found" }, { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
function jsonBody(body: BodyInit | null | undefined): unknown {
|
||||
return JSON.parse(typeof body === "string" ? body : "{}");
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
return createElement("span", null, `当前路径:${location.pathname}`);
|
||||
}
|
||||
|
||||
describe("ProjectsPage", () => {
|
||||
test("渲染 Tab、搜索框、新建按钮和表格", async () => {
|
||||
createMockHandler();
|
||||
test("渲染项目管理入口并按状态请求项目列表", async () => {
|
||||
const calls = createProjectFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("进行中")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("活跃项目")).not.toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.getByText("已归档")).not.toBeNull();
|
||||
expect(screen.getByText("新建项目")).not.toBeNull();
|
||||
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
|
||||
expect(screen.getByPlaceholderText("搜索项目名称或描述")).not.toBeNull();
|
||||
expect(calls.some((call) => call.url.includes("status=active"))).toBe(true);
|
||||
});
|
||||
|
||||
test("新建按钮点击打开弹窗", async () => {
|
||||
createMockHandler();
|
||||
test("搜索和切换 Tab 会更新请求参数与用户可见结果", async () => {
|
||||
const calls = createProjectFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText("进行中")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
|
||||
fireEvent.click(screen.getByText("已归档"));
|
||||
|
||||
const createBtn = screen.getByRole("button", { name: /新建项目/ });
|
||||
createBtn.click();
|
||||
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(document.body.querySelector(".ant-modal")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true);
|
||||
expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true);
|
||||
});
|
||||
|
||||
test("active 项目行显示'进入工作台',archived 行不显示", async () => {
|
||||
createMockHandler([ACTIVE_PROJECT, ARCHIVED_PROJECT]);
|
||||
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
|
||||
const calls = createProjectFetchMock();
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.queryByText("活跃项目")).not.toBeNull();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "归档" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("搜索项目名称或描述"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /搜\s*索/ }));
|
||||
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
||||
});
|
||||
|
||||
test("新建项目提交请求 body 并显示创建结果", async () => {
|
||||
const calls = createProjectFetchMock([]);
|
||||
|
||||
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /新建项目/ }));
|
||||
await waitFor(() => expect(screen.getAllByText("新建项目").length).toBeGreaterThan(1));
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "新增项目" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入项目描述"), { target: { value: "新增描述" } });
|
||||
clickLatestConfirmButton();
|
||||
|
||||
await waitFor(() => expect(screen.getByText("新增项目")).not.toBeNull());
|
||||
|
||||
const createCall = calls.find((call) => call.url.endsWith("/api/projects") && call.method === "POST");
|
||||
expect(createCall).toBeDefined();
|
||||
expect(jsonBody(createCall?.body)).toEqual({ description: "新增描述", name: "新增项目" });
|
||||
});
|
||||
|
||||
test("编辑项目表单只提交变更字段", async () => {
|
||||
const updateCalls: unknown[] = [];
|
||||
const onUpdate = mock((args: unknown) => {
|
||||
updateCalls.push(args);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProjectFormModal, {
|
||||
editingProject: ACTIVE_PROJECT,
|
||||
onCancel: () => undefined,
|
||||
onCreate: () => Promise.resolve(),
|
||||
onOpenChange: () => undefined,
|
||||
onUpdate,
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
const enterBtns = screen.getAllByText("进入工作台");
|
||||
expect(enterBtns.length).toBe(1);
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } });
|
||||
clickLatestConfirmButton();
|
||||
|
||||
const archivedRow = screen.getByText("归档项目").closest("tr");
|
||||
expect(archivedRow?.textContent).not.toContain("进入工作台");
|
||||
await waitFor(() => expect(onUpdate).toHaveBeenCalled());
|
||||
expect(updateCalls[0]).toEqual({ data: { name: "编辑项目" }, id: "p1" });
|
||||
});
|
||||
|
||||
test("项目表单校验失败不会提交,接口失败时保留弹窗", async () => {
|
||||
const onCreate = mock(() => Promise.reject(new Error("创建失败")));
|
||||
const onOpenChange = mock(() => undefined);
|
||||
|
||||
renderWithProviders(
|
||||
createElement(ProjectFormModal, {
|
||||
editingProject: null,
|
||||
onCancel: () => undefined,
|
||||
onCreate,
|
||||
onOpenChange,
|
||||
onUpdate: () => Promise.resolve(),
|
||||
open: true,
|
||||
submitting: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
||||
clickLatestConfirmButton();
|
||||
expect(onCreate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } });
|
||||
clickLatestConfirmButton();
|
||||
await waitFor(() => expect(onCreate).toHaveBeenCalled());
|
||||
expect(onOpenChange).not.toHaveBeenCalledWith(false);
|
||||
expect(screen.getByText("新建项目")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("项目表格操作触发导航和行级动作", async () => {
|
||||
const onArchive = mock(() => Promise.resolve());
|
||||
const onDelete = mock(() => Promise.resolve());
|
||||
const onRestore = mock(() => Promise.resolve());
|
||||
|
||||
renderWithProviders(
|
||||
createElement(
|
||||
"div",
|
||||
null,
|
||||
createElement(LocationProbe),
|
||||
createElement(ProjectTable, {
|
||||
data: { items: [ACTIVE_PROJECT, ARCHIVED_PROJECT], page: 1, pageSize: 20, total: 2 },
|
||||
loading: false,
|
||||
onArchive,
|
||||
onDelete,
|
||||
onEdit: () => undefined,
|
||||
onPageChange: () => undefined,
|
||||
onRestore,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /进入工作台/ }));
|
||||
expect(screen.getByText("当前路径:/workbench/p1")).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /归档/ }));
|
||||
await waitFor(() => expect(screen.getByText("确认归档此项目?")).not.toBeNull());
|
||||
clickLatestConfirmButton();
|
||||
await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /恢复/ }));
|
||||
await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull());
|
||||
clickLatestConfirmButton();
|
||||
await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2"));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /删除/ }));
|
||||
await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull());
|
||||
clickLatestConfirmButton();
|
||||
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,48 @@ void mock.module("recharts", () => ({
|
||||
YAxis: () => null,
|
||||
}));
|
||||
|
||||
export interface FetchMockCall {
|
||||
body?: BodyInit | null;
|
||||
method: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface RenderWithProvidersOptions {
|
||||
initialRoute?: string;
|
||||
}
|
||||
|
||||
export function installFetchMock(handler: (call: FetchMockCall) => Promise<Response> | Response): FetchMockCall[] {
|
||||
const calls: FetchMockCall[] = [];
|
||||
const mocked = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : undefined;
|
||||
const url = request?.url ?? (typeof input === "string" ? input : input instanceof URL ? input.href : input.url);
|
||||
const call: FetchMockCall = {
|
||||
body: init?.body ?? null,
|
||||
method: init?.method ?? request?.method ?? "GET",
|
||||
url,
|
||||
};
|
||||
calls.push(call);
|
||||
return handler(call);
|
||||
}) as typeof fetch;
|
||||
|
||||
globalThis.fetch = mocked;
|
||||
window.fetch = mocked;
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (!headers.has("Content-Type")) headers.set("Content-Type", "application/json");
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers,
|
||||
status: init?.status ?? 200,
|
||||
});
|
||||
}
|
||||
|
||||
export function mockMetaResponse(): Response {
|
||||
return jsonResponse({ ok: true, service: "test-app", timestamp: "2024-01-01T00:00:00.000Z", version: "0.1.0" });
|
||||
}
|
||||
|
||||
export function renderWithProviders(ui: React.ReactElement, options?: RenderWithProvidersOptions) {
|
||||
const queryClient = createTestQueryClient();
|
||||
const initialRoute = options?.initialRoute ?? "/";
|
||||
@@ -58,38 +96,3 @@ function createTestQueryClient() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Custom test helpers (替代 jest-dom matchers)
|
||||
export const testHelpers = {
|
||||
toBeInTheDocument: (element: Element | null) => {
|
||||
const pass = element !== null && document.contains(element);
|
||||
return {
|
||||
message: () => (pass ? "Expected element not to be in document" : "Expected element to be in document"),
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toHaveAttribute: (element: Element | null, attr: string, value?: string) => {
|
||||
const pass = value === undefined ? (element?.hasAttribute(attr) ?? false) : element?.getAttribute(attr) === value;
|
||||
return {
|
||||
message: () =>
|
||||
pass ? `Expected element not to have attribute "${attr}"` : `Expected element to have attribute "${attr}"`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toHaveClass: (element: Element | null, className: string) => {
|
||||
const pass = element?.classList.contains(className) ?? false;
|
||||
return {
|
||||
message: () =>
|
||||
pass ? `Expected element not to have class "${className}"` : `Expected element to have class "${className}"`,
|
||||
pass,
|
||||
};
|
||||
},
|
||||
toHaveTextContent: (element: Element | null, text: RegExp | string) => {
|
||||
const content = element?.textContent ?? "";
|
||||
const pass = element !== null && (typeof text === "string" ? content.includes(text) : text.test(content));
|
||||
return {
|
||||
message: () => (pass ? `Expected element not to have text "${text}"` : `Expected element to have text "${text}"`),
|
||||
pass,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user