/** @jest-environment node */ import { getServerSession } from 'next-auth' import { POST } from '@/app/api/materials/[id]/assets/batch/route' import { submitTaskDetailChange, TaskDetailChangeApprovalError, } from '@/lib/task-detail-change-approval' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) jest.mock('@/lib/task-detail-asset-edit', () => ({ TaskDetailAssetEditError: class TaskDetailAssetEditError extends Error { statusCode: number constructor(message: string, statusCode = 400) { super(message) this.statusCode = statusCode } }, })) jest.mock('@/lib/task-detail-change-approval', () => ({ TaskDetailChangeApprovalError: class TaskDetailChangeApprovalError extends Error { statusCode: number constructor(message: string, statusCode = 400) { super(message) this.statusCode = statusCode } }, submitTaskDetailChange: jest.fn(), })) const mockedSubmit = submitTaskDetailChange as jest.Mock function requestWith(body: unknown) { return { json: async () => body } as any } describe('任务-详情批量单图编辑路由', () => { beforeEach(() => { jest.clearAllMocks() }) test('未登录返回 401,不触发批量逻辑', async () => { ;(getServerSession as jest.Mock).mockResolvedValue(null) const response = await POST( requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }), { params: { id: 'm1' } }, ) expect(response.status).toBe(401) expect(mockedSubmit).not.toHaveBeenCalled() }) test('operations 为空数组返回 400', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } }) const response = await POST(requestWith({ operations: [] }), { params: { id: 'm1' } }) expect(response.status).toBe(400) expect(mockedSubmit).not.toHaveBeenCalled() }) test('不支持的操作类型返回 400', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) const response = await POST( requestWith({ operations: [{ kind: 'magic', assetId: 'a1' }] }), { params: { id: 'm1' } }, ) expect(response.status).toBe(400) expect(mockedSubmit).not.toHaveBeenCalled() }) test('replace 缺少 uploadId 返回 400', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) const response = await POST( requestWith({ operations: [{ kind: 'replace', assetId: 'a1' }] }), { params: { id: 'm1' } }, ) expect(response.status).toBe(400) }) test('合法混合操作透传给批量引擎并返回结果', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1', role: 'ADMIN' } }) mockedSubmit.mockResolvedValue({ mode: 'applied', data: { success: true, appliedCount: 3, entryCount: 7 } }) const response = await POST( requestWith({ operations: [ { kind: 'delete', assetId: 'a1' }, { kind: 'rename', assetId: 'a2', newFileName: '新名字.jpg' }, { kind: 'replace', assetId: 'a3', uploadId: 'up-1' }, ], }), { params: { id: 'm1' } }, ) expect(response.status).toBe(200) const payload = await response.json() expect(payload.data.appliedCount).toBe(3) expect(mockedSubmit).toHaveBeenCalledWith( 'm1', { id: 'u1', role: 'ADMIN', isSuperAdmin: false }, [ { kind: 'delete', assetId: 'a1' }, { kind: 'rename', assetId: 'a2', newFileName: '新名字.jpg' }, { kind: 'replace', assetId: 'a3', uploadId: 'up-1' }, ], ) }) test('业务错误透传状态码与文案', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) mockedSubmit.mockRejectedValue( new TaskDetailChangeApprovalError('批量操作只支持同一分组(主图/SKU/详情)内的图片', 400), ) const response = await POST( requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }), { params: { id: 'm1' } }, ) expect(response.status).toBe(400) const payload = await response.json() expect(payload.error).toContain('同一分组') }) test('未知异常返回 500 且不泄漏内部信息', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'u1' } }) mockedSubmit.mockRejectedValue(new Error('internal detail')) const response = await POST( requestWith({ operations: [{ kind: 'delete', assetId: 'a1' }] }), { params: { id: 'm1' } }, ) expect(response.status).toBe(500) const payload = await response.json() expect(payload.error).not.toContain('internal detail') }) })