import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { NextRequest } from 'next/server'; // ─── Mock @/lib/db ──────────────────────────────────────────────────────── const mockContentItemFindMany = jest.fn<(args: unknown) => Promise>(); const mockContentItemCount = jest.fn<(args: unknown) => Promise>(); const mockContentItemFindFirst = jest.fn<(args: unknown) => Promise>(); const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise>(); const mockContentItemCreate = jest.fn<(args: unknown) => Promise>(); const mockContentItemUpdate = jest.fn<(args: unknown) => Promise>(); const mockContentItemDelete = jest.fn<(args: unknown) => Promise>(); const mockAuditLogCreate = jest.fn<(args: unknown) => Promise>(); const mockContentModelFindUnique = jest.fn<(args: unknown) => Promise>(); jest.mock('@/lib/db', () => ({ prisma: { contentItem: { findMany: mockContentItemFindMany, count: mockContentItemCount, findFirst: mockContentItemFindFirst, findUnique: mockContentItemFindUnique, create: mockContentItemCreate, update: mockContentItemUpdate, delete: mockContentItemDelete, }, contentModel: { findUnique: mockContentModelFindUnique, }, auditLog: { create: mockAuditLogCreate, }, }, })); // ─── Mock permissions ───────────────────────────────────────────────────── const mockRequirePermission = jest.fn< (request: NextRequest, modelCode: string, action: string) => Promise >(); jest.mock('@/lib/permissions', () => ({ requirePermission: mockRequirePermission, })); // Q-8:PUT/DELETE 现会先 authenticateRequest 再探存在性;默认给出有效会话, // 让既有的「已授权」用例继续走通,枚举用例单独置空。 const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>(); jest.mock('@/lib/auth', () => ({ authenticateRequest: mockAuthenticateRequest, })); jest.unmock('./route'); import { GET, POST, PUT, DELETE } from './route'; import { CONTENT_TYPE_CONFIGS } from '@/lib/cms/content-types'; const productFields = CONTENT_TYPE_CONFIGS['product'].model.fields; function mockProductModel() { mockContentModelFindUnique.mockResolvedValue({ id: 'model-1', code: 'product', fields: JSON.stringify(productFields), }); } function createMockRequest(options: { url?: string; json?: () => Promise>; }): NextRequest { return { headers: new Headers(), 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' }); mockContentModelFindUnique.mockResolvedValue(null); // Q-8:默认视为已登录(合法会话),使「已授权」路径不受存在性前置门影响。 mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'content_editor' }); }); 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' }, }); }); // Q-7:`page`/`pageSize` 未经校验会把 NaN 传进 Prisma skip/take(真库抛错→500), // 且 pageSize 无界可被用来整表拉取。修复后经 parsePagination 收敛为有界默认值。 it('sanitizes non-numeric page and clamps oversized pageSize (no NaN/ unbounded take)', async () => { mockAuthorized(); const response = await GET( createMockRequest({ url: 'http://localhost/api/admin/items?page=abc&pageSize=99999' }) ); const body = await response.json(); expect(response.status).toBe(200); expect(body.page).toBe(1); expect(body.pageSize).toBe(100); expect(mockContentItemFindMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 0, take: 100 }) ); }); // Q-8:匿名调用者不得通过「404(存在) vs 401(不存在)」枚举内容 ID —— 认证必须先于存在性探针。 it('rejects an unauthenticated PUT with 401 WITHOUT probing item existence (no ID oracle)', async () => { mockAuthenticateRequest.mockReturnValue(null); const response = await PUT( createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' }) ); expect(response.status).toBe(401); expect(mockContentItemFindUnique).not.toHaveBeenCalled(); }); it('rejects an unauthenticated DELETE with 401 WITHOUT probing item existence (no ID oracle)', async () => { mockAuthenticateRequest.mockReturnValue(null); const response = await DELETE( createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1' }) ); expect(response.status).toBe(401); expect(mockContentItemFindUnique).not.toHaveBeenCalled(); }); 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: '测试' } }, ], }), }) ); }); it('applies id filter so the editor can locate one item without paging', async () => { mockAuthorized(); await GET( createMockRequest({ url: 'http://localhost/api/admin/items?modelCode=news&id=item-101&page=1&pageSize=1' }) ); expect(mockContentItemFindMany).toHaveBeenCalledWith( expect.objectContaining({ where: { modelCode: 'news', id: 'item-101' }, }) ); }); }); 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 as draft and writes audit log', async () => { mockAuthorized(); const response = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'news', title: '新新闻', slug: 'new-news', data: { body: 'content' }, 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: 'draft', sortOrder: 1, publishedAt: null, }), }) ); expect(mockAuditLogCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ module: 'item', action: 'create', operator: 'editor', }), }) ); }); // B-1:create 权限不得成为发布通道,且未知 status 不能入库(否则 workflow 表外状态会永久卡死)。 it.each(['published', 'review', 'archived', '任意字符串'])( 'refuses status "%s" on create and writes nothing', async (status) => { mockAuthorized(); const response = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'news', title: '新新闻', data: { body: 'content' }, status, }), })); const body = await response.json(); expect(response.status).toBe(400); expect(body.error).toContain('workflow'); expect(mockContentItemCreate).not.toHaveBeenCalled(); expect(mockAuditLogCreate).not.toHaveBeenCalled(); }, ); it('treats an explicit draft status as the default (no bypass, no rejection)', async () => { mockAuthorized(); const response = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'news', title: '新新闻', data: { body: 'content' }, status: 'draft', }), })); expect(response.status).toBe(201); expect(mockContentItemCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ status: 'draft', publishedAt: null }), }) ); }); }); 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('accepts a PUT that echoes the unchanged status (admin autosave sends it every time)', async () => { mockAuthorized(); const response = await PUT(createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1', json: async () => ({ title: '更新', status: 'draft' }), })); expect(response.status).toBe(200); expect(mockContentItemUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ title: '更新' }), }) ); }); 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', }), }) ); }); }); describe('写入侧字段声明校验(B-3)', () => { it('rejects a metric basis outside the declared options before any write', async () => { mockAuthorized(); mockProductModel(); const response = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '虚构指标产品', data: { metrics: [{ value: '99.9%', label: '系统可用率', basis: 'customer-proven' }] }, }), })); const body = await response.json(); expect(response.status).toBe(400); expect(body.code).toBe('VALIDATION_ERROR'); expect(body.details.fields).toEqual([ expect.objectContaining({ path: 'metrics[0].basis', rule: 'option' }), ]); expect(mockContentItemCreate).not.toHaveBeenCalled(); expect(mockAuditLogCreate).not.toHaveBeenCalled(); }); it('accepts a payload that stays inside the declared constraints', async () => { mockAuthorized(); mockProductModel(); const response = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'product', title: 'ERP 套件', data: { metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }], categoryId: 'enterprise', status: '研发中', }, }), })); expect(response.status).toBe(201); expect(mockContentItemCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ data: JSON.stringify({ metrics: [{ value: '30%', label: '库存周转提升', basis: 'target' }], categoryId: 'enterprise', status: '研发中', }), }), }) ); }); it('leaves keys the model never declares alone', async () => { mockAuthorized(); mockProductModel(); const response = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '含未声明键', data: { notDeclaredAtAll: { basis: 'whatever' }, freeform: '任意结构' }, }), })); expect(response.status).toBe(201); }); it('stays permissive when the model row is missing or its fields column is dirty', async () => { mockAuthorized(); mockContentModelFindUnique.mockResolvedValue({ id: 'model-1', code: 'product', fields: 'null' }); const missingModel = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '无模型', data: {} }), })); expect(missingModel.status).toBe(201); mockContentItemCreate.mockClear(); const dirtyFields = await POST(createMockRequest({ json: async () => ({ modelId: 'model-1', modelCode: 'product', title: '脏声明', data: {} }), })); expect(dirtyFields.status).toBe(201); }); it('rejects an invalid PUT payload before touching the row', async () => { mockAuthorized(); mockProductModel(); mockContentItemFindUnique.mockResolvedValue({ ...mockItem, modelCode: 'product' }); const response = await PUT(createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1', json: async () => ({ data: { status: '已上线' } }), })); const body = await response.json(); expect(response.status).toBe(400); expect(body.details.fields).toEqual([ expect.objectContaining({ path: 'status', rule: 'option' }), ]); expect(mockContentItemUpdate).not.toHaveBeenCalled(); expect(mockAuditLogCreate).not.toHaveBeenCalled(); }); }); describe('脏 data 列不再打断读写(B-4)', () => { it('returns {} instead of 500 when a stored data column is not valid JSON', async () => { mockAuthorized(); mockContentItemFindMany.mockResolvedValue([{ ...mockItem, data: 'null' }]); const response = await GET(createMockRequest({})); const body = await response.json(); expect(response.status).toBe(200); expect(body.items[0].data).toEqual({}); }); it('serialises a null PUT payload as {} so the column stays readable', async () => { mockAuthorized(); mockContentItemFindUnique.mockResolvedValue({ ...mockItem, data: 'null' }); const response = await PUT(createMockRequest({ url: 'http://localhost/api/admin/items?id=item-1', json: async () => ({ data: null }), })); expect(response.status).toBe(200); expect(mockContentItemUpdate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ data: '{}' }), }) ); }); }); });