/** @jest-environment node */ import { getServerSession } from 'next-auth' import { prisma } from '@/lib/prisma' import { POST } from '@/app/api/materials/upload/product-package/complete/route' import { completeProductPackageUpload, } from '@/lib/product-package-completion' import { createTaskDetailUploadJob, getLatestTaskDetailUploadJobStatus, TaskDetailUploadQueueCapacityError, } from '@/lib/task-detail-upload-jobs' jest.mock('next-auth', () => ({ getServerSession: jest.fn() })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findUnique: jest.fn(), }, }, })) jest.mock('@/lib/product-package-completion', () => { const actual = jest.requireActual('@/lib/product-package-completion') return { ...actual, completeProductPackageUpload: jest.fn(), } }) jest.mock('@/lib/task-detail-upload-jobs', () => ({ createTaskDetailUploadJob: jest.fn(), getLatestTaskDetailUploadJobStatus: jest.fn().mockResolvedValue(null), TaskDetailUploadQueueCapacityError: class TaskDetailUploadQueueCapacityError extends Error { scope: 'user' | 'global' statusCode: 429 | 503 constructor(scope: 'user' | 'global') { super(scope === 'user' ? '您的等待任务已满' : '全局等待队列已满') this.scope = scope this.statusCode = scope === 'user' ? 429 : 503 } }, })) describe('product-package complete public route security', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) ;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue(null) ;(completeProductPackageUpload as jest.Mock).mockResolvedValue({ materialId: 'material-1', taskNo: null, isFullSet: false, }) ;(createTaskDetailUploadJob as jest.Mock).mockResolvedValue({ jobId: 'job-1', materialId: 'material-1', status: 'queued', stage: 'queued', progress: 0, }) }) test.each([ ['fileUrl', { fileUrl: 'http://127.0.0.1:3000/internal' }], ['localFilePath', { localFilePath: 'C:\\Windows\\win.ini' }], ])('公开 complete 路由拒绝 %s 来源', async (_source, sourceInput) => { const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [{ ...sourceInput, fileName: 'main.png', fileSize: 10 }], }, }), } as any) expect(response.status).toBe(400) expect(prisma.material.findUnique).not.toHaveBeenCalled() }) test('非任务详情素材不能使用专用完成接口', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '3D场景', taskDetailRequesterUserId: 'requester-1', }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [] }, }), } as any) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toContain('不是任务-详情') expect(completeProductPackageUpload).not.toHaveBeenCalled() }) test('缺少需求运营的新流程素材不能完成上传', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: null, }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [] }, }), } as any) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toContain('缺少需求运营人员') expect(completeProductPackageUpload).not.toHaveBeenCalled() }) test('缺少出图方式的新流程素材不能完成上传', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: null, }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [] }, }), } as any) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toContain('出图方式') expect(completeProductPackageUpload).not.toHaveBeenCalled() }) test('合法任务详情上传会快速入队并返回 jobId', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: '实拍', zipFilePath: '', }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [{ uploadId: 'upload-main', fileName: 'main.png', fileSize: 10, previewAssets: [{ uploadId: 'upload-preview', fileName: 'main-preview.webp', fileSize: 5, width: 800, height: 800, kind: 'preview', }], }], }, }), } as any) const body = await response.json() expect(response.status).toBe(202) expect(body.data.jobId).toBe('job-1') expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({ materialId: 'material-1', operatorUserId: 'user-1', })) expect(completeProductPackageUpload).not.toHaveBeenCalled() }) test('客户端预览失败时仍可带原图入队,由后台生成预览', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: '实拍', zipFilePath: '', _count: { assets: 0 }, }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', allowServerPreviewFallback: true, assets: { main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10, }], }, }), } as any) const body = await response.json() expect(response.status).toBe(202) expect(body.data.jobId).toBe('job-1') expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({ materialId: 'material-1', assets: expect.objectContaining({ main: [expect.objectContaining({ uploadId: 'upload-main' })], }), })) }) test('缺少客户端预览且未声明兜底时仍拒绝完成请求', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: '实拍', zipFilePath: '', _count: { assets: 0 }, }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }], }, }), } as any) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toContain('客户端预览图缺失') expect(createTaskDetailUploadJob).not.toHaveBeenCalled() }) test('已有部分持久化结果的素材不能重复入队', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: '实拍', zipFilePath: 'materials/product-package/material-1/all.zip', }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [{ uploadId: 'upload-main', fileName: 'main.png', fileSize: 10, previewAssets: [{ uploadId: 'upload-preview', fileName: 'main.webp', fileSize: 5, width: 800, height: 800, kind: 'preview', }], }], }, }), } as any) expect(response.status).toBe(409) expect(createTaskDetailUploadJob).not.toHaveBeenCalled() }) test('重复提交同一素材时返回已有后台任务,避免丢失 202 响应后重复上传', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: '实拍', zipFilePath: '', _count: { assets: 0 }, }) ;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({ jobId: 'job-existing', materialId: 'material-1', status: 'processing', stage: 'zips', progress: 80, }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { main: [] }, }), } as any) const body = await response.json() expect(response.status).toBe(202) expect(body.data.jobId).toBe('job-existing') expect(createTaskDetailUploadJob).not.toHaveBeenCalled() }) test('首次 202 丢失且后台已完成时,即使素材已有 ZIP 也返回原任务', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: '实拍', zipFilePath: 'materials/product-package/material-1/all.zip', _count: { assets: 5 }, }) ;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({ jobId: 'job-completed', materialId: 'material-1', status: 'completed', stage: 'completed', progress: 100, }) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: {} }), } as any) const body = await response.json() expect(response.status).toBe(202) expect(body.data).toEqual(expect.objectContaining({ jobId: 'job-completed', status: 'completed', progress: 100, })) expect(createTaskDetailUploadJob).not.toHaveBeenCalled() }) test('直接 HTTP 提交超过单组图片上限时在查询素材前拒绝', async () => { const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', allowServerPreviewFallback: true, assets: { main: Array.from({ length: 61 }, (_, index) => ({ uploadId: `upload-main-${index}`, fileName: `${index}.jpg`, fileSize: 1, })), }, }), } as any) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toContain('主图最多上传 60 张') expect(prisma.material.findUnique).not.toHaveBeenCalled() }) test('直接 HTTP 用大量小 PSD 绕过图片计数时拒绝', async () => { const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: { psd: Array.from({ length: 21 }, (_, index) => ({ uploadId: `upload-psd-${index}`, fileName: `${index}.psd`, fileSize: 1, })), }, }), } as any) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toContain('PSD 源文件最多上传 20 个') expect(prisma.material.findUnique).not.toHaveBeenCalled() }) test('用户等待队列已满时返回 429 和重试提示', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailRequesterUserId: 'requester-1', taskDetailSourceType: '实拍', zipFilePath: '', _count: { assets: 0 }, }) ;(createTaskDetailUploadJob as jest.Mock).mockRejectedValue( new TaskDetailUploadQueueCapacityError('user'), ) const response = await POST({ json: jest.fn().mockResolvedValue({ materialId: 'material-1', allowServerPreviewFallback: true, assets: { main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }], }, }), } as any) const body = await response.json() expect(response.status).toBe(429) expect(response.headers.get('retry-after')).toBe('60') expect(body.code).toBe('UPLOAD_QUEUE_FULL') }) })