import { describe, it, expect, beforeEach, jest } from '@jest/globals'; import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import '@testing-library/jest-dom'; /** * 内容编辑器「保存 / 发布」链路的回归测试(B-1)。 * * 回归背景:编辑器曾在每次保存时回传 status,而后端 PUT /api/admin/items 对状态变更 * 一律 400(src/app/api/admin/items/route.ts:224),导致编辑器每次保存都失败、 * 发布按钮永远不可用。此文件钉死三件事: * 1. 保存载荷(POST 新建 / PUT 更新)永远不含 status 键; * 2. 状态变更只经 adminApi.runWorkflow 通道,且必须先保存成功; * 3. 状态区为只读展示,表单里没有改状态的控件。 */ // jest.Mock 的返回值必须是 Promise 形态,否则 mockResolvedValue 的参数会被解析成 never type Fn = jest.Mock<(...args: unknown[]) => Promise>; interface ApiMock { getModels: Fn; getItems: Fn; createItem: Fn; updateItem: Fn; runWorkflow: Fn; } interface ToastMock { success: Fn; error: Fn; info: Fn; dismiss: Fn; } interface NavMock { state: { params: Record }; push: Fn; } interface AuthMock { state: { user: Record | null; loading: boolean }; } // next/navigation:App Router 动态段与路由跳转在此打桩(factory 内建 mock,避免 hoist TDZ) // router 对象必须稳定:page.tsx 的 load effect 以 router 为依赖,每次返回新对象会让 load() 反复重跑, // 从而把 workflow 刚写入的 status 覆盖回条目原状态(真实 Next router 是 context 单例,身份稳定)。 jest.mock('next/navigation', () => { const push = jest.fn(); const router = { push, prefetch: jest.fn() }; const state = { params: { modelCode: 'news', itemId: 'new' } as Record }; return { state, push, useRouter: () => router, useParams: () => state.params }; }); jest.mock('@/components/admin/auth-context', () => { const state = { user: { id: 'u1', username: 'admin', nickname: '管理员', role: 'superadmin', avatar: '' }, loading: false, }; return { state, useAuth: () => ({ user: state.user, loading: state.loading, token: 't', login: jest.fn(), logout: jest.fn() }) }; }); // 只 mock 客户端方法层:断言「调了哪个方法、传了什么」,不碰 fetch / 加密细节 jest.mock('@/lib/admin-api', () => ({ adminApi: { getModels: jest.fn(), getItems: jest.fn(), createItem: jest.fn(), updateItem: jest.fn(), runWorkflow: jest.fn(), }, })); jest.mock('@/components/ui/sonner', () => ({ Toaster: () => null, toast: { success: jest.fn(), error: jest.fn(), info: jest.fn(), dismiss: jest.fn() }, })); import ContentEditorPage from './page'; const nav = jest.requireMock('next/navigation') as NavMock; const auth = jest.requireMock('@/components/admin/auth-context') as AuthMock; const api = (jest.requireMock('@/lib/admin-api') as { adminApi: ApiMock }).adminApi; const toastMock = (jest.requireMock('@/components/ui/sonner') as { toast: ToastMock }).toast; const MODEL = { id: 'model-news', code: 'news', name: '新闻', // 不设 required,避免每个用例都要填满字段才能触发保存 fields: [{ name: 'summary', type: 'text', label: '摘要' }], }; function itemFixture(overrides: Record = {}) { return { id: 'item-1', title: '原标题', slug: 'old-news', status: 'draft', data: { summary: '原摘要' }, ...overrides, }; } function itemsResult(items: unknown[]) { return { items, total: items.length, page: 1, pageSize: 100, totalPages: 1 }; } /** 渲染并等待 load() 完成(loading 门禁期间表单不存在) */ async function renderEditor(params: { modelCode: string; itemId: string }) { nav.state.params = params; const utils = render(); await screen.findByPlaceholderText('请输入标题'); return utils; } /** page.tsx 的 AUTO_SAVE_DELAY 为 3000ms(真实时长,本仓库单测不使用假定时器)。 * 跨过这个窗口才能断言「自动保存到底有没有发出请求」。 */ const AUTOSAVE_WINDOW = 3500; async function waitMs(ms: number) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, ms)); }); } function requireCall(mock: Fn, label: string): unknown[] { const call = mock.mock.calls[0]; if (!call) throw new Error(`${label} 未被调用`); return call; } function payloadOf(mock: Fn, argIndex = 0, label = 'api'): Record { const call = requireCall(mock, label); return call[argIndex] as Record; } function callsOf(mock: Fn): unknown[][] { return mock.mock.calls as unknown[][]; } /** 同名按钮常有页眉/页脚两份(如「保存」「创建」),点第一份即可,两者回调相同。 * 保存/发布回调内部要 await 多个请求,故在 act 作用域里让微任务链走完,否则 React 报未包裹 act */ async function clickButton(name: string, index = 0) { const buttons = screen.getAllByRole('button', { name }); await act(async () => { fireEvent.click(buttons[index] as HTMLElement); }); } async function typeText(placeholder: string, value: string) { await changeInput(screen.getByPlaceholderText(placeholder), value); } async function changeInput(el: HTMLElement, value: string) { await act(async () => { fireEvent.change(el, { target: { value } }); }); } async function openPublishDialog() { await clickButton('发布'); const confirmBtn = await screen.findByRole('button', { name: '确认发布' }); await act(async () => { fireEvent.click(confirmBtn); }); } // 每组用例都重建整套默认实现(clearAllMocks 不清实现),避免依赖上一组的残留 function resetMocks() { jest.clearAllMocks(); auth.state.user = { id: 'u1', username: 'admin', nickname: '管理员', role: 'superadmin', avatar: '' }; auth.state.loading = false; api.getModels.mockResolvedValue([MODEL]); api.getItems.mockResolvedValue(itemsResult([itemFixture()])); api.createItem.mockResolvedValue({ id: 'created-1' }); api.updateItem.mockResolvedValue({ id: 'item-1' }); api.runWorkflow.mockResolvedValue({ status: 'review' }); } describe('内容编辑器:保存载荷不得携带 status(B-1 回归)', () => { beforeEach(resetMocks); it('新建保存走 createItem,且 POST 载荷不含 status 键', async () => { await renderEditor({ modelCode: 'news', itemId: 'new' }); await typeText('请输入标题', '新条目'); await changeInput(screen.getByLabelText('摘要'), '新摘要'); await clickButton('创建'); await waitFor(() => expect(api.createItem).toHaveBeenCalledTimes(1)); const payload = payloadOf(api.createItem, 0, 'createItem'); expect(payload).toEqual( expect.objectContaining({ modelId: 'model-news', modelCode: 'news', title: '新条目', data: { summary: '新摘要' }, }), ); // 后端 POST 只接受 status 缺省或 'draft'(items/route.ts:138),回传其它值直接 400 expect(payload).not.toHaveProperty('status'); expect(api.updateItem).not.toHaveBeenCalled(); await waitFor(() => expect(nav.push).toHaveBeenCalledWith('/admin/content/news')); }); it('编辑保存走 updateItem,且 PUT 载荷不含 status 键', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); await typeText('请输入标题', '改过的标题'); await clickButton('保存'); await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1)); const call = requireCall(api.updateItem, 'updateItem'); expect(call[0]).toBe('item-1'); const payload = call[1] as Record; expect(payload).toEqual( expect.objectContaining({ title: '改过的标题', data: { summary: '原摘要' }, }), ); // 此处正是缺陷现场:PUT 拒绝任何状态变更(items/route.ts:224),带上 status 即 400 expect(payload).not.toHaveProperty('status'); expect(api.createItem).not.toHaveBeenCalled(); // 「保存」不导航 expect(nav.push).not.toHaveBeenCalled(); }); it('保存失败时抛出后端错误文案,且不触达 workflow', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); api.updateItem.mockRejectedValue(new Error('状态变更请使用 /api/admin/items/[id]/workflow 接口')); await openPublishDialog(); await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1)); expect(payloadOf(api.updateItem, 1, 'updateItem')).not.toHaveProperty('status'); expect(api.runWorkflow).not.toHaveBeenCalled(); expect(toastMock.error).toHaveBeenCalledWith('状态变更请使用 /api/admin/items/[id]/workflow 接口'); // 保存未成功 → 状态停留在草稿 expect(screen.getByText('草稿')).toBeInTheDocument(); }); }); describe('内容编辑器:状态流转只经 runWorkflow 通道', () => { beforeEach(resetMocks); it('draft 项点发布:内容先落库,再 submit 后 approve,UI 落到已发布', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); api.runWorkflow .mockResolvedValueOnce({ status: 'review' }) .mockResolvedValueOnce({ status: 'published' }); await openPublishDialog(); await waitFor(() => expect(screen.getByText('已发布')).toBeInTheDocument()); // 保存先于状态流转 expect(api.updateItem).toHaveBeenCalledTimes(1); expect(callsOf(api.runWorkflow)).toEqual([ ['item-1', 'submit'], ['item-1', 'approve'], ]); expect(toastMock.success).toHaveBeenCalledWith('新闻资讯已发布'); // 已发布后发布按钮消失 expect(screen.queryByRole('button', { name: '发布' })).toBeNull(); }); it('review 项点发布:跳过 submit,只 approve', async () => { api.getItems.mockResolvedValue(itemsResult([itemFixture({ status: 'review' })])); await renderEditor({ modelCode: 'news', itemId: 'item-1' }); expect(screen.getByText('待审核')).toBeInTheDocument(); api.runWorkflow.mockResolvedValue({ status: 'published' }); await openPublishDialog(); await waitFor(() => expect(screen.getByText('已发布')).toBeInTheDocument()); expect(callsOf(api.runWorkflow)).toEqual([['item-1', 'approve']]); }); it('approve 被拒(403 无 publish 权限):submit 已生效,approve 失败只提示不改状态', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); api.runWorkflow .mockResolvedValueOnce({ status: 'review' }) .mockRejectedValueOnce(new Error('无权限执行该操作')); await openPublishDialog(); await waitFor(() => expect(callsOf(api.runWorkflow)).toHaveLength(2)); expect(callsOf(api.runWorkflow)).toEqual([ ['item-1', 'submit'], ['item-1', 'approve'], ]); expect(toastMock.error).toHaveBeenCalledWith('无权限执行该操作'); expect(toastMock.success).not.toHaveBeenCalled(); // submit 已在服务端把条目推进到 review,approve 失败不回滚:UI 必须跟随服务端状态显示「待审核」, // 否则编辑者会以为还是草稿而重复提交。 expect(screen.getByText('待审核')).toBeInTheDocument(); expect(screen.queryByText('草稿')).not.toBeInTheDocument(); }); it('新建项发布:workflow 用 createItem 返回的 id,发布成功回到列表', async () => { await renderEditor({ modelCode: 'news', itemId: 'new' }); api.createItem.mockResolvedValue({ id: 'created-9' }); api.runWorkflow .mockResolvedValueOnce({ status: 'review' }) .mockResolvedValueOnce({ status: 'published' }); await typeText('请输入标题', '新条目'); await openPublishDialog(); await waitFor(() => expect(callsOf(api.runWorkflow)).toHaveLength(2)); expect(payloadOf(api.createItem, 0, 'createItem')).not.toHaveProperty('status'); expect(callsOf(api.runWorkflow)).toEqual([ ['created-9', 'submit'], ['created-9', 'approve'], ]); // 新建路径不得走 PUT expect(api.updateItem).not.toHaveBeenCalled(); await waitFor(() => expect(nav.push).toHaveBeenCalledWith('/admin/content/news')); }); it('新建但未取回 id:不发 workflow,提示去列表页重开', async () => { await renderEditor({ modelCode: 'news', itemId: 'new' }); api.createItem.mockResolvedValue({}); await typeText('请输入标题', '新条目'); await openPublishDialog(); await waitFor(() => expect(api.createItem).toHaveBeenCalledTimes(1)); expect(api.runWorkflow).not.toHaveBeenCalled(); expect(toastMock.error).toHaveBeenCalledWith('创建成功但未取回条目 ID,请在列表页重新打开后发布'); expect(nav.push).not.toHaveBeenCalled(); }); it('标题为空时不打开发布对话框,也不保存', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); await typeText('请输入标题', ''); await clickButton('发布'); await waitFor(() => expect(toastMock.error).toHaveBeenCalledWith('请修正表单中的错误后再发布')); expect(screen.queryByRole('button', { name: '确认发布' })).toBeNull(); expect(api.updateItem).not.toHaveBeenCalled(); expect(api.runWorkflow).not.toHaveBeenCalled(); }); }); describe('内容编辑器:状态区只读', () => { beforeEach(resetMocks); it('渲染当前状态文案,且表单内不存在任何 select / combobox', async () => { const { container } = await renderEditor({ modelCode: 'news', itemId: 'item-1' }); expect(screen.getByText('草稿')).toBeInTheDocument(); expect(container.querySelector('select')).toBeNull(); expect(screen.queryByRole('combobox')).toBeNull(); // 状态标签是段落而非 label,说明它不指向任何可交互控件 expect(screen.getByText('状态').tagName).toBe('P'); expect(screen.getByText('状态通过「发布」提交审核变更,不可在此直接修改')).toBeInTheDocument(); }); it('四态文案与 ContentStatus 取值一致,且任一状态下都没有改状态的控件', async () => { nav.state.params = { modelCode: 'news', itemId: 'item-1' }; const cases: Array<[string, string]> = [ ['draft', '草稿'], ['review', '待审核'], ['published', '已发布'], ['archived', '已归档'], ]; for (const [status, label] of cases) { api.getItems.mockResolvedValue(itemsResult([itemFixture({ status })])); const { container, unmount } = render(); await screen.findByPlaceholderText('请输入标题'); expect(screen.getByText(label)).toBeInTheDocument(); expect(container.querySelector('select')).toBeNull(); // 逐轮卸载,避免同名文案在多份渲染结果里重复命中 unmount(); } }); }); describe('内容编辑器:条目定位不得依赖列表分页窗口', () => { beforeEach(resetMocks); it('分页窗口之外的条目(第 101 条起)按 itemId 精确取回并回填表单', async () => { // 真实服务端语义:不带 id 的列表请求只回一页数据,目标条目可能不在其中; // 带 id 的请求才保证回目标条目本身。 api.getItems.mockImplementation((...args: unknown[]) => { const params = args[0] as Record; if (params.id === 'item-101') return Promise.resolve(itemsResult([itemFixture({ id: 'item-101' })])); return Promise.resolve(itemsResult([])); }); await renderEditor({ modelCode: 'news', itemId: 'item-101' }); expect(screen.getByPlaceholderText('请输入标题')).toHaveValue('原标题'); expect(screen.getByLabelText('摘要')).toHaveValue('原摘要'); }); it('按 itemId 取不到条目时给出未找到提示,且不渲染任何保存入口', async () => { api.getItems.mockResolvedValue(itemsResult([])); nav.state.params = { modelCode: 'news', itemId: 'deleted-item' }; render(); expect(await screen.findByText(/条目未找到/)).toBeInTheDocument(); // 空表单一旦可保存,PUT 就会用 { title, data: {} } 覆盖真实记录 expect(screen.queryByPlaceholderText('请输入标题')).toBeNull(); expect(screen.queryByRole('button', { name: '保存并返回' })).toBeNull(); expect(api.updateItem).not.toHaveBeenCalled(); }); }); describe('内容编辑器:自动保存的触发时机与并发顺序', () => { beforeEach(resetMocks); it('只打开已有条目、未做任何编辑:不标记未保存,跨过自动保存窗口也不发 PUT', async () => { nav.state.params = { modelCode: 'news', itemId: 'item-1' }; render(); await screen.findByPlaceholderText('请输入标题'); expect(screen.getByPlaceholderText('请输入标题')).toHaveValue('原标题'); expect(screen.queryByText('未保存')).toBeNull(); await waitMs(AUTOSAVE_WINDOW); // 幻影保存会把 version 加一(items/route.ts:240)并覆盖并发编辑者的写入 expect(api.updateItem).not.toHaveBeenCalled(); }); it('上一次保存仍在飞行中:下一次自动保存排队等待,且排队的是最新快照', async () => { let releaseFirst: ((value: unknown) => void) | null = null; api.updateItem.mockImplementation( () => new Promise((resolve) => { releaseFirst = resolve; }), ); await renderEditor({ modelCode: 'news', itemId: 'item-1' }); await changeInput(screen.getByPlaceholderText('请输入标题'), '第一版'); await waitMs(AUTOSAVE_WINDOW); expect(callsOf(api.updateItem)).toHaveLength(1); expect(payloadOf(api.updateItem, 1, 'updateItem')).toEqual( expect.objectContaining({ title: '第一版' }), ); await changeInput(screen.getByPlaceholderText('请输入标题'), '第二版'); await waitMs(AUTOSAVE_WINDOW); // 修复前:自动保存不看在飞请求就再发一次,两个 PUT 在服务端的落库顺序不确定, // 旧快照可能覆盖新快照 expect(callsOf(api.updateItem)).toHaveLength(1); await act(async () => { (releaseFirst as (value: unknown) => void)({ id: 'item-1' }); }); await waitFor(() => expect(callsOf(api.updateItem)).toHaveLength(2)); // 排队不能丢更新:第二次的载荷必须是最新快照 expect(callsOf(api.updateItem)[1]?.[1]).toEqual( expect.objectContaining({ title: '第二版' }), ); }); }); describe('内容编辑器:richtext / array / object 字段的读写保真(S-21/S-22 回归)', () => { const RICH_MODEL = { id: 'model-rich', code: 'news', name: '新闻', fields: [ { name: 'content', type: 'richtext', label: '正文内容' }, { name: 'metrics', type: 'array', label: '指标', fields: [ { name: 'label', type: 'text', label: '标签' }, { name: 'value', type: 'text', label: '数值' }, ], }, { name: 'testimonial', type: 'object', label: '客户证言', fields: [ { name: 'quote', type: 'textarea', label: '证言内容' }, { name: 'author', type: 'text', label: '作者' }, ], }, ], }; const richItem = { id: 'item-1', title: '原标题', slug: 'rich-news', status: 'draft', data: { content: '第一段\n第二段', metrics: [{ label: '上线周期', value: '6周' }], testimonial: { quote: '非常专业', author: '张三' }, }, }; beforeEach(() => { resetMocks(); api.getModels.mockResolvedValue([RICH_MODEL]); api.getItems.mockResolvedValue(itemsResult([richItem])); }); it('富文本渲染为 textarea:编辑不丢换行,保存载荷保留完整段落', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); const box = screen.getByLabelText('正文内容'); expect(box.tagName).toBe('TEXTAREA'); expect(box).toHaveValue('第一段\n第二段'); await changeInput(box, '第一段\n第二段\n第三段'); await clickButton('保存'); await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1)); expect(payloadOf(api.updateItem, 1, 'updateItem')).toEqual( expect.objectContaining({ data: expect.objectContaining({ content: '第一段\n第二段\n第三段' }) }), ); }); it('array 字段编辑回写数组本身:不产生顶层 label/value 垃圾键', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); const box = screen.getByLabelText('指标'); expect(box.tagName).toBe('TEXTAREA'); expect(box).toHaveValue(JSON.stringify(richItem.data.metrics, null, 2)); await changeInput(box, JSON.stringify([{ label: '上线周期', value: '5天' }])); await clickButton('保存'); await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1)); const data = (payloadOf(api.updateItem, 1, 'updateItem').data as Record); expect(data.metrics).toEqual([{ label: '上线周期', value: '5天' }]); // 修复前:子字段 onChange 写的是顶层键,保存载荷被污染出 label/value expect(data).not.toHaveProperty('label'); expect(data).not.toHaveProperty('value'); }); it('object 子字段读写父对象:编辑作者只改 testimonial.author,不泄漏顶层键', async () => { await renderEditor({ modelCode: 'news', itemId: 'item-1' }); // 修复前:子字段读的是顶层 formData(undefined),输入框是空的 expect(screen.getByLabelText('作者')).toHaveValue('张三'); expect(screen.getByLabelText('证言内容')).toHaveValue('非常专业'); await changeInput(screen.getByLabelText('作者'), '李四'); await clickButton('保存'); await waitFor(() => expect(api.updateItem).toHaveBeenCalledTimes(1)); const data = (payloadOf(api.updateItem, 1, 'updateItem').data as Record); expect(data.testimonial).toEqual({ quote: '非常专业', author: '李四' }); expect(data).not.toHaveProperty('author'); expect(data).not.toHaveProperty('quote'); }); }); describe('内容编辑器:加载失败时不得出现任何写入口(与「条目未找到」同型的数据丢失面)', () => { beforeEach(resetMocks); /** 三处写入口(页眉/页脚)必须全部缺席:只要有一份渲染出来,用户就能对 * 一条从未成功加载的记录发起 PUT/POST。 */ function expectNoWriteAffordances() { expect(screen.queryByRole('button', { name: '保存' })).toBeNull(); expect(screen.queryByRole('button', { name: '保存并返回' })).toBeNull(); expect(screen.queryByRole('button', { name: '发布' })).toBeNull(); expect(screen.queryByRole('button', { name: '创建' })).toBeNull(); } it('模型查找失败(回包里没有当前 modelCode):保留「模型未找到」提示,但无任何保存/发布入口,不发 PUT/POST', async () => { api.getModels.mockResolvedValue([{ ...MODEL, code: 'other-model' }]); nav.state.params = { modelCode: 'news', itemId: 'item-1' }; render(); expect(await screen.findByText('模型未找到')).toBeInTheDocument(); expectNoWriteAffordances(); expect(api.updateItem).not.toHaveBeenCalled(); expect(api.createItem).not.toHaveBeenCalled(); }); it('getModels 网络/5xx 错误(reject):错误文案照常展示,同样无任何写入口', async () => { api.getModels.mockRejectedValue(new Error('请求失败 (500)')); nav.state.params = { modelCode: 'news', itemId: 'item-1' }; render(); expect(await screen.findByText('请求失败 (500)')).toBeInTheDocument(); expectNoWriteAffordances(); expect(api.updateItem).not.toHaveBeenCalled(); expect(api.createItem).not.toHaveBeenCalled(); }); it('getItems 网络错误:条目从未加载成功,跨过自动保存窗口后 PUT 仍为零,且不渲染写入口', async () => { api.getItems.mockRejectedValue(new Error('网络异常')); nav.state.params = { modelCode: 'news', itemId: 'item-1' }; render(); expect(await screen.findByText('网络异常')).toBeInTheDocument(); expectNoWriteAffordances(); await waitMs(AUTOSAVE_WINDOW); expect(api.updateItem).not.toHaveBeenCalled(); }); it('新建路径模型加载失败:无「创建」入口,不发 POST', async () => { api.getModels.mockResolvedValue([]); nav.state.params = { modelCode: 'news', itemId: 'new' }; render(); expect(await screen.findByText('模型未找到')).toBeInTheDocument(); expectNoWriteAffordances(); expect(api.createItem).not.toHaveBeenCalled(); expect(api.updateItem).not.toHaveBeenCalled(); }); });