fix(cms): resolve admin dogfood findings on auth, content editing, and UX

修复 CMS / Admin 后台 dogfood 专项测试发现的阻塞性与体验性问题:
- 登录后无限重定向(Secure Cookie + Edge Runtime JWT 兼容)
- 内容新增/编辑保存 400/500(默认不加密请求体、自动生成唯一 slug)
- 编辑时提交 status 导致验证错误
- 原生 confirm 阻塞自动化测试,改为 AlertDialog
- 表单字段可访问性(id/htmlFor/fieldset)
- JSON 数组标签字段新增 TagInput 组件
- 操作反馈统一使用 Sonner Toast
- 后台隐藏营销 Cookie 横幅
- 媒体库空状态优化

添加 dogfood-cms-regression 与 dogfood-cms-output 报告、截图及工作流路由测试。
This commit is contained in:
张翔
2026-07-25 08:03:06 +08:00
parent f6f5766bb9
commit d9c0e5f2c1
48 changed files with 1445 additions and 106 deletions
+361
View File
@@ -0,0 +1,361 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { NextRequest } from 'next/server';
// ─── Mock @/lib/db ────────────────────────────────────────────────────────
const mockContentItemFindMany = jest.fn<(args: unknown) => Promise<unknown[]>>();
const mockContentItemCount = jest.fn<(args: unknown) => Promise<number>>();
const mockContentItemFindFirst = jest.fn<(args: unknown) => Promise<unknown | null>>();
const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise<unknown | null>>();
const mockContentItemCreate = jest.fn<(args: unknown) => Promise<unknown>>();
const mockContentItemUpdate = jest.fn<(args: unknown) => Promise<unknown>>();
const mockContentItemDelete = jest.fn<(args: unknown) => Promise<unknown>>();
const mockAuditLogCreate = jest.fn<(args: unknown) => Promise<unknown>>();
jest.mock('@/lib/db', () => ({
prisma: {
contentItem: {
findMany: mockContentItemFindMany,
count: mockContentItemCount,
findFirst: mockContentItemFindFirst,
findUnique: mockContentItemFindUnique,
create: mockContentItemCreate,
update: mockContentItemUpdate,
delete: mockContentItemDelete,
},
auditLog: {
create: mockAuditLogCreate,
},
},
}));
// ─── Mock permissions ─────────────────────────────────────────────────────
const mockRequirePermission = jest.fn<
(request: NextRequest, modelCode: string, action: string) => Promise<unknown>
>();
jest.mock('@/lib/permissions', () => ({
requirePermission: mockRequirePermission,
}));
jest.unmock('./route');
import { GET, POST, PUT, DELETE } from './route';
function createMockRequest(options: {
url?: string;
json?: () => Promise<Record<string, unknown>>;
}): NextRequest {
return {
url: options.url || 'http://localhost/api/admin/items',
json: options.json || (async () => ({})),
} as unknown as NextRequest;
}
function mockAuthorized() {
mockRequirePermission.mockResolvedValue({
user: { userId: 'user-1', username: 'editor', role: 'content_editor' },
});
}
function mockUnauthorized() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '未授权' }), { status: 401 }),
});
}
function mockForbidden() {
mockRequirePermission.mockResolvedValue({
response: new Response(JSON.stringify({ error: '无权限' }), { status: 403 }),
});
}
const mockItem = {
id: 'item-1',
modelId: 'model-1',
modelCode: 'news',
title: '测试新闻',
slug: 'test-news',
locale: 'zh-CN',
status: 'draft',
data: JSON.stringify({ body: 'hello' }),
version: 1,
sortOrder: 0,
createdBy: 'editor',
updatedBy: 'editor',
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
publishedAt: null,
};
beforeEach(() => {
jest.clearAllMocks();
mockContentItemFindMany.mockResolvedValue([mockItem]);
mockContentItemCount.mockResolvedValue(1);
mockContentItemFindFirst.mockResolvedValue(null);
mockContentItemFindUnique.mockResolvedValue(mockItem);
mockContentItemCreate.mockResolvedValue(mockItem);
mockContentItemUpdate.mockResolvedValue(mockItem);
mockContentItemDelete.mockResolvedValue(undefined);
mockAuditLogCreate.mockResolvedValue({ id: 'log-1' });
});
describe('/api/admin/items', () => {
describe('GET', () => {
it('returns 401 when not authenticated', async () => {
mockUnauthorized();
const response = await GET(createMockRequest({}));
expect(response.status).toBe(401);
});
it('returns 403 when authenticated but unauthorized', async () => {
mockForbidden();
const response = await GET(createMockRequest({}));
expect(response.status).toBe(403);
});
it('returns paginated item list', async () => {
mockAuthorized();
const response = await GET(createMockRequest({ url: 'http://localhost/api/admin/items?page=1&pageSize=10' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.items).toHaveLength(1);
expect(body.total).toBe(1);
expect(body.page).toBe(1);
expect(body.totalPages).toBe(1);
expect(body.items[0]).toMatchObject({
id: 'item-1',
title: '测试新闻',
data: { body: 'hello' },
});
});
it('filters by modelCode and status', async () => {
mockAuthorized();
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&status=published' }));
expect(mockContentItemFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { modelCode: 'news', status: 'published' },
})
);
expect(mockRequirePermission).toHaveBeenCalledWith(
expect.anything(),
'news',
'read'
);
});
it('applies search filter', async () => {
mockAuthorized();
await GET(createMockRequest({ url: 'http://localhost/api/admin/items?search=测试' }));
expect(mockContentItemFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [
{ title: { contains: '测试' } },
{ slug: { contains: '测试' } },
],
}),
})
);
});
});
describe('POST', () => {
it('returns 401 when not authenticated', async () => {
mockUnauthorized();
const response = await POST(createMockRequest({
json: async () => ({ modelId: 'model-1', modelCode: 'news', title: '新闻' }),
}));
expect(response.status).toBe(401);
});
it('returns 400 when required fields missing', async () => {
mockAuthorized();
const response = await POST(createMockRequest({ json: async () => ({ title: '仅标题' }) }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('缺少必填字段');
});
it('returns 400 when slug already exists', async () => {
mockAuthorized();
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'test-news' });
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
slug: 'test-news',
}),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('已存在');
});
it('creates item and audit log', async () => {
mockAuthorized();
const response = await POST(createMockRequest({
json: async () => ({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
slug: 'new-news',
data: { body: 'content' },
status: 'published',
sortOrder: 1,
}),
}));
const body = await response.json();
expect(response.status).toBe(201);
expect(body.title).toBe('测试新闻');
expect(mockContentItemCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
modelId: 'model-1',
modelCode: 'news',
title: '新新闻',
slug: 'new-news',
status: 'published',
sortOrder: 1,
publishedAt: expect.any(Date),
}),
})
);
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'item',
action: 'create',
operator: 'editor',
}),
})
);
});
});
describe('PUT', () => {
it('returns 400 when id missing', async () => {
mockAuthorized();
const response = await PUT(createMockRequest({ url: 'http://localhost/api/admin/items', json: async () => ({}) }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('缺少 ID');
});
it('returns 404 when item not found', async () => {
mockAuthorized();
mockContentItemFindUnique.mockResolvedValue(null);
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=missing',
json: async () => ({ title: '更新' }),
}));
expect(response.status).toBe(404);
});
it('rejects status update through PUT', async () => {
mockAuthorized();
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ status: 'published' }),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('状态变更');
});
it('returns 400 when slug duplicated', async () => {
mockAuthorized();
mockContentItemFindFirst.mockResolvedValue({ id: 'other', slug: 'duplicated' });
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ slug: 'duplicated' }),
}));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toContain('已存在');
});
it('updates item and increments version', async () => {
mockAuthorized();
const response = await PUT(createMockRequest({
url: 'http://localhost/api/admin/items?id=item-1',
json: async () => ({ title: '更新标题', data: { body: 'new' }, sortOrder: 2 }),
}));
await response.json();
expect(response.status).toBe(200);
expect(mockContentItemUpdate).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'item-1' },
data: expect.objectContaining({
title: '更新标题',
data: JSON.stringify({ body: 'new' }),
sortOrder: 2,
version: { increment: 1 },
updatedBy: 'editor',
}),
})
);
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'item',
action: 'update',
operator: 'editor',
}),
})
);
});
});
describe('DELETE', () => {
it('returns 400 when id missing', async () => {
mockAuthorized();
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items' }));
const body = await response.json();
expect(response.status).toBe(400);
expect(body.error).toBe('缺少 ID');
});
it('returns 404 when item not found', async () => {
mockAuthorized();
mockContentItemFindUnique.mockResolvedValue(null);
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=missing' }));
expect(response.status).toBe(404);
});
it('deletes item and audit log', async () => {
mockAuthorized();
const response = await DELETE(createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' }));
const body = await response.json();
expect(response.status).toBe(200);
expect(body.message).toBe('删除成功');
expect(mockContentItemDelete).toHaveBeenCalledWith({ where: { id: 'item-1' } });
expect(mockAuditLogCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
module: 'item',
action: 'delete',
operator: 'editor',
}),
})
);
});
});
});