Files
novalon-website/src/app/admin/content/[modelCode]/[itemId]/page.test.tsx
T
zhangxiang a366bd1400 fix(qa): 第五轮系统性质量保障缺陷修复 + 单元/集成测试
安全/鉴权:登出清服务端 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 真库集成层。
2026-09-28 10:48:07 +08:00

617 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});