- 全表新增 deleted_at 列,统一软删除替代硬删除+archived_at - models.model_id 重命名为 external_id,消除语义混淆 - conversations.model_id 改为可空(模型为建议而非绑定) - messages 新增 updated_at,移除 CASCADE 改为 DAO 层级联 - 移除 DB 层 UNIQUE 约束,改为应用层检查(配合软删除) - 新增 helpers.ts(baseColumns + 构造层防御)、ESLint 规则、契约测试 - 迁移 0004 补全 CHECK 约束(providers.type/materials.status/messages.role) - DAO 层全面重写:级联软删除、应用层唯一、provider 删除保护 - 路由/前端/测试全量适配 externalId 重命名及类型变更
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
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 { ProjectFormModal } from "../../../src/web/features/projects/components/ProjectFormModal";
|
|
import { ProjectTable } from "../../../src/web/features/projects/components/ProjectTable";
|
|
import { installFetchMock, jsonResponse, mockMetaResponse, renderWithProviders } from "../test-utils";
|
|
|
|
const ACTIVE_PROJECT: Project = {
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
description: "活跃描述",
|
|
id: "p1",
|
|
name: "活跃项目",
|
|
status: "active",
|
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
|
};
|
|
|
|
const ARCHIVED_PROJECT: Project = {
|
|
createdAt: "2024-01-01T00:00:00.000Z",
|
|
description: "归档描述",
|
|
id: "p2",
|
|
name: "归档项目",
|
|
status: "archived",
|
|
updatedAt: "2024-01-02T00:00:00.000Z",
|
|
};
|
|
|
|
async function clickLatestConfirmButton() {
|
|
const confirmTexts = await screen.findAllByText(/确\s*定|OK|确认/);
|
|
const lastText = confirmTexts[confirmTexts.length - 1]!;
|
|
const button = lastText.closest("button") ?? lastText.closest("[role='button']") ?? lastText;
|
|
fireEvent.click(button);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
if (url.pathname === "/api/projects" && call.method === "POST") {
|
|
const data = jsonBody(call.body) as { description?: string; name: string };
|
|
const created: Project = {
|
|
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, 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, 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("渲染项目管理入口并展示项目列表", async () => {
|
|
const calls = createProjectFetchMock();
|
|
|
|
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("活跃项目")).not.toBeNull();
|
|
});
|
|
|
|
expect(screen.getByText("归档项目")).not.toBeNull();
|
|
expect(screen.getByRole("button", { name: /新建项目/ })).not.toBeNull();
|
|
expect(screen.getByPlaceholderText("搜索名称或描述")).not.toBeNull();
|
|
expect(calls.filter((call) => !call.url.includes("/api/meta")).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("搜索和状态筛选会更新请求参数与用户可见结果", async () => {
|
|
const calls = createProjectFetchMock();
|
|
|
|
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
|
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
|
|
|
const searchInput1 = screen.getByPlaceholderText("搜索名称或描述");
|
|
fireEvent.change(searchInput1, { target: { value: "归档" } });
|
|
fireEvent.keyDown(searchInput1, { key: "Enter" });
|
|
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
|
|
|
|
const statusLabels = screen.getAllByText("状态");
|
|
const selectLabel = statusLabels.find((el) => el.closest(".ant-select"));
|
|
if (selectLabel) fireEvent.mouseDown(selectLabel);
|
|
await waitFor(() => {
|
|
const archivedOptions = screen.getAllByText("已归档");
|
|
const dropdownOption = archivedOptions.find((el) => el.closest(".ant-select-item"));
|
|
if (dropdownOption) fireEvent.click(dropdownOption);
|
|
});
|
|
await waitFor(() => expect(calls.some((call) => call.url.includes("status=archived"))).toBe(true));
|
|
|
|
await waitFor(() => expect(screen.getByText("归档项目")).not.toBeNull());
|
|
});
|
|
|
|
test("清空搜索条件复位请求参数并重新展示全部项目", async () => {
|
|
const calls = createProjectFetchMock();
|
|
|
|
renderWithProviders(createElement(App), { initialRoute: "/projects" });
|
|
await waitFor(() => expect(screen.getByText("活跃项目")).not.toBeNull());
|
|
|
|
const searchInput2 = screen.getByPlaceholderText("搜索名称或描述");
|
|
fireEvent.change(searchInput2, { target: { value: "归档" } });
|
|
fireEvent.keyDown(searchInput2, { key: "Enter" });
|
|
await waitFor(() => expect(calls.some((call) => call.url.includes("keyword=%E5%BD%92%E6%A1%A3"))).toBe(true));
|
|
|
|
const searchInput3 = screen.getByPlaceholderText("搜索名称或描述");
|
|
const clearButton = searchInput3.closest(".ant-input-search")?.querySelector(".ant-input-clear-icon");
|
|
if (clearButton) fireEvent.click(clearButton);
|
|
|
|
await waitFor(() => {
|
|
const lastProjectCall = [...calls].reverse().find((call) => call.url.includes("/api/projects"));
|
|
expect(lastProjectCall && !lastProjectCall.url.includes("keyword=")).toBe(true);
|
|
});
|
|
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: "新增描述" } });
|
|
await 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,
|
|
}),
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByPlaceholderText("请输入项目名称")).not.toBeNull());
|
|
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "编辑项目" } });
|
|
await clickLatestConfirmButton();
|
|
|
|
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());
|
|
await clickLatestConfirmButton();
|
|
expect(onCreate).not.toHaveBeenCalled();
|
|
|
|
fireEvent.change(screen.getByPlaceholderText("请输入项目名称"), { target: { value: "失败项目" } });
|
|
await 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,
|
|
onChange: () => undefined,
|
|
onDelete,
|
|
onEdit: () => 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());
|
|
await clickLatestConfirmButton();
|
|
await waitFor(() => expect(onArchive).toHaveBeenCalledWith("p1"));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /恢复/ }));
|
|
await waitFor(() => expect(screen.getByText("确认恢复此项目?")).not.toBeNull());
|
|
await clickLatestConfirmButton();
|
|
await waitFor(() => expect(onRestore).toHaveBeenCalledWith("p2"));
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: /删除/ }));
|
|
await waitFor(() => expect(screen.getByText("确认永久删除此项目?")).not.toBeNull());
|
|
await clickLatestConfirmButton();
|
|
await waitFor(() => expect(onDelete).toHaveBeenCalledWith("p2"));
|
|
}, 15000);
|
|
});
|