import { describe, it, expect, jest, beforeEach } from '@jest/globals'; import { NextRequest } from 'next/server'; // ─── Mock @/lib/db ──────────────────────────────────────────────────────── const mockContentItemFindUnique = jest.fn<(args: unknown) => Promise>(); const mockContentItemFindUniqueOrThrow = jest.fn<(args: unknown) => Promise>(); jest.mock('@/lib/db', () => ({ prisma: { contentItem: { findUnique: mockContentItemFindUnique, findUniqueOrThrow: mockContentItemFindUniqueOrThrow, }, }, })); // ─── Mock workflow service ──────────────────────────────────────────────── const mockSubmitForReview = jest.fn<(itemId: string, user: unknown) => Promise>(); const mockApprove = jest.fn<(itemId: string, user: unknown) => Promise>(); const mockReject = jest.fn<(itemId: string, user: unknown, reason?: string) => Promise>(); const mockArchive = jest.fn<(itemId: string, user: unknown) => Promise>(); jest.mock('@/lib/cms/workflow', () => ({ submitForReview: mockSubmitForReview, approve: mockApprove, reject: mockReject, archive: mockArchive, })); // ─── Mock permissions ───────────────────────────────────────────────────── const mockRequirePermission = jest.fn< (request: NextRequest, modelCode: string, action: string) => Promise >(); jest.mock('@/lib/permissions', () => ({ requirePermission: mockRequirePermission, })); // Q-8:workflow POST 现会先 authenticateRequest 再探存在性;默认给有效会话。 const mockAuthenticateRequest = jest.fn<(request: NextRequest) => unknown>(); jest.mock('@/lib/auth', () => ({ authenticateRequest: mockAuthenticateRequest, })); jest.unmock('./route'); import { POST } from './route'; function createMockRequest(body: Record): NextRequest { return { headers: new Headers(), url: 'http://localhost:3000/api/admin/items/item-1/workflow', json: async () => body, } as unknown as NextRequest; } function mockAuthorized(action: 'update' | 'publish' = 'update') { mockRequirePermission.mockResolvedValue({ user: { userId: 'user-1', username: 'editor', role: action === 'publish' ? 'reviewer' : 'editor' }, }); } const mockItem = { id: 'item-1', modelId: 'model-1', modelCode: 'news', title: '测试新闻', slug: 'test-news', locale: 'zh-CN', status: 'draft', data: '{}', version: 1, sortOrder: 0, createdBy: 'editor', updatedBy: 'editor', createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), }; const mockUpdatedItem = { ...mockItem, status: 'review', version: 2 }; beforeEach(() => { jest.clearAllMocks(); mockContentItemFindUnique.mockResolvedValue(mockItem); mockContentItemFindUniqueOrThrow.mockResolvedValue(mockUpdatedItem); mockAuthenticateRequest.mockReturnValue({ userId: 'user-1', username: 'editor', role: 'editor' }); }); describe('POST /api/admin/items/[id]/workflow', () => { it('returns 400 when action is missing', async () => { mockAuthorized(); const request = createMockRequest({}); const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) }); const body = await response.json(); expect(response.status).toBe(400); expect(body.error).toBe('缺少 action 参数'); }); it('returns 400 for invalid action', async () => { mockAuthorized(); const request = createMockRequest({ action: 'publish' }); const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) }); const body = await response.json(); expect(response.status).toBe(400); expect(body.error).toContain('非法的 action'); }); it('returns 404 when item not found', async () => { mockAuthorized(); mockContentItemFindUnique.mockResolvedValue(null); const request = createMockRequest({ action: 'submit' }); const response = await POST(request, { params: Promise.resolve({ id: 'missing' }) }); const body = await response.json(); expect(response.status).toBe(404); expect(body.error).toBe('内容不存在'); }); // Q-8:匿名调用者不得用「404 vs 401」枚举 ID —— 认证须先于存在性探针。 it('rejects an unauthenticated workflow with 401 WITHOUT probing item existence', async () => { mockAuthenticateRequest.mockReturnValue(null); const request = createMockRequest({ action: 'submit' }); const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) }); expect(response.status).toBe(401); expect(mockContentItemFindUnique).not.toHaveBeenCalled(); }); it('submit requires update permission', async () => { mockAuthorized('update'); const request = createMockRequest({ action: 'submit' }); const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) }); expect(response.status).toBe(200); expect(mockSubmitForReview).toHaveBeenCalledWith( 'item-1', expect.objectContaining({ username: 'editor' }) ); expect(mockRequirePermission).toHaveBeenCalledWith( request, 'news', 'update' ); }); it('approve requires publish permission', async () => { mockAuthorized('publish'); const request = createMockRequest({ action: 'approve' }); const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) }); expect(response.status).toBe(200); expect(mockApprove).toHaveBeenCalledWith( 'item-1', expect.objectContaining({ username: 'editor' }) ); expect(mockRequirePermission).toHaveBeenCalledWith( request, 'news', 'publish' ); }); it('reject passes reason to workflow service', async () => { mockAuthorized('publish'); const request = createMockRequest({ action: 'reject', reason: '需要修改' }); const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) }); expect(response.status).toBe(200); expect(mockReject).toHaveBeenCalledWith( 'item-1', expect.objectContaining({ username: 'editor' }), '需要修改' ); }); it('returns validation error on illegal transition', async () => { mockAuthorized('publish'); mockApprove.mockRejectedValue(new Error('当前状态 draft 不允许审核通过')); const request = createMockRequest({ action: 'approve' }); const response = await POST(request, { params: Promise.resolve({ id: 'item-1' }) }); const body = await response.json(); expect(response.status).toBe(400); expect(body.error).toBe('当前状态 draft 不允许审核通过'); }); });