安全/鉴权:登出清服务端 httpOnly 令牌、登录 IP 限流前置 bcrypt、停用账号 令牌在 6 个内联 role handler 失效、admin/items 存在性 oracle 认证前置、 同意偏好 sanitize fail-closed、api-crypto 按 HTTP 方法判体。 CMS:workflow 状态更新+审计收进交互式 $transaction(失败即回滚)、编辑器 richtext 走 textarea 防换行损毁、array/object 子字段按作用域读写防污染、 about/contact/erp-upgrade 补 ISR revalidate 与路由映射。 UI/a11y:AnimatedCounter 入视口前显起点、CLS 累计上报、MetricCard 动画入 effect 并卸载取消 rAF、后台状态四态映射/搜索防抖+序号守卫/媒体单 input/ 吞错改横幅/表单 label-aria 关联。 新增对应判别性单测与 pagination/rate-limit/client-ip/sanitize/validate-content-data 等模块及 tests-integration 真库集成层。
617 lines
24 KiB
TypeScript
617 lines
24 KiB
TypeScript
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<unknown>>;
|
||
|
||
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<string, string> };
|
||
push: Fn;
|
||
}
|
||
|
||
interface AuthMock {
|
||
state: { user: Record<string, string> | 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<string, string> };
|
||
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<string, unknown> = {}) {
|
||
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(<ContentEditorPage />);
|
||
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<string, unknown> {
|
||
const call = requireCall(mock, label);
|
||
return call[argIndex] as Record<string, unknown>;
|
||
}
|
||
|
||
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<string, unknown>;
|
||
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(<ContentEditorPage />);
|
||
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<string, unknown>;
|
||
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(<ContentEditorPage />);
|
||
|
||
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(<ContentEditorPage />);
|
||
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<string, unknown>);
|
||
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<string, unknown>);
|
||
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(<ContentEditorPage />);
|
||
|
||
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(<ContentEditorPage />);
|
||
|
||
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(<ContentEditorPage />);
|
||
|
||
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(<ContentEditorPage />);
|
||
|
||
expect(await screen.findByText('模型未找到')).toBeInTheDocument();
|
||
expectNoWriteAffordances();
|
||
expect(api.createItem).not.toHaveBeenCalled();
|
||
expect(api.updateItem).not.toHaveBeenCalled();
|
||
});
|
||
});
|