/** @jest-environment node */ import { getServerSession } from 'next-auth' import { POST } from '@/app/api/materials/upload/route' import { compressImage, sanitizeFileName } from '@/lib/image-processor' import { createMaterialLog } from '@/lib/material-log' import { prisma } from '@/lib/prisma' import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { assignTaskNoOnComplete, createTaskMaterialOnComplete } from '@/lib/product-package' import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task' import { deleteFiles } from '@/lib/file-cleanup' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('fs', () => ({ promises: { mkdir: jest.fn().mockResolvedValue(undefined), writeFile: jest.fn().mockResolvedValue(undefined), stat: jest.fn().mockResolvedValue({ size: 10 }), }, })) jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn(), }, material: { create: jest.fn(), update: jest.fn(), findUnique: jest.fn(), delete: jest.fn(), }, materialAsset: { createMany: jest.fn(), }, }, })) jest.mock('@/lib/s3', () => ({ uploadToS3: jest.fn(), })) jest.mock('@/lib/audio-processor', () => ({ parseAudioMetadata: jest.fn(), isAudioFile: jest.fn(() => false), })) jest.mock('@/lib/video-transcode-task', () => ({ enqueueVideoTranscodeTask: jest.fn(), })) jest.mock('@/lib/material-canonical-source', () => ({ ...jest.requireActual('@/lib/material-canonical-source'), validateCanonicalMaterialSource: jest.fn().mockResolvedValue({ sourcePath: 'C:\\uploads\\video-source.mp4', size: BigInt(10), }), })) jest.mock('@/lib/image-processor', () => ({ compressImage: jest.fn(), sanitizeFileName: jest.fn((value: string) => value), })) jest.mock('@/lib/pdf-upload-handler', () => ({ processPdfUpload: jest.fn(), })) jest.mock('@/lib/file-cleanup', () => ({ deleteFiles: jest.fn().mockResolvedValue({ deletedCount: 0, failedCount: 0 }), })) jest.mock('@/lib/material-cleanup', () => ({ deleteMaterialRelatedRecords: jest.fn(), })) jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/public-display', () => ({ buildMaterialPublicDisplayPath: jest.fn((fileName: string) => `materials/${fileName}`), uploadPublicDisplayToS3: jest.fn(), })) jest.mock('@/lib/product-package', () => ({ validateTaskNo: jest.fn(() => true), isTaskNoExists: jest.fn().mockResolvedValue(false), assignTaskNoOnComplete: jest.fn().mockResolvedValue(null), createTaskMaterialOnComplete: jest.fn(), purgeDeletedMaterialsByTaskNo: jest.fn().mockResolvedValue({ deletedMaterials: 0, deletedFiles: 0, failedFiles: 0, }), })) describe('materials upload route - 任务-其他', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1', }, }) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ username: '安乔', }) ;(prisma.material.create as jest.Mock).mockResolvedValue({ id: 'material-task-other-1', previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp', productModel: 'TASK202603310001', categoryName: '通用', sceneName: '通用', platformName: '', productCategoryName: '', materialType: '任务-其他', renderer: '', authorName: '安乔', zipFileName: 'source-package.zip', zipFilePath: 'uploads/material-1.zip', zipFileSize: BigInt(7), displayImages: [], videoSourceUrl: null, videoProcessingStatus: null, userId: 'user-1', user: { username: '安乔', }, }) ;(createTaskMaterialOnComplete as jest.Mock).mockResolvedValue({ id: 'material-task-other-1', previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp', productModel: 'TASK202603310001', categoryName: '通用', sceneName: '通用', materialType: '任务-其他', zipFileName: 'source-package.zip', zipFilePath: 'uploads/material-1.zip', zipFileSize: BigInt(7), displayImages: [], userId: 'user-1', user: { username: '安乔' }, }) ;(prisma.material.update as jest.Mock).mockResolvedValue({}) ;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 2 }) ;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed-preview')) ;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue( 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp', ) ;(sanitizeFileName as jest.Mock).mockImplementation((value: string) => value) ;(enqueueVideoTranscodeTask as jest.Mock).mockResolvedValue(undefined) }) function createRequest(formData: FormData) { return { headers: new Headers({ 'content-length': '1024', 'content-type': 'multipart/form-data; boundary=jest-boundary', }), formData: jest.fn().mockResolvedValue(formData), } as any } function buildTaskOtherFormData() { const formData = new FormData() formData.append('materialType', '任务-其他') formData.append('taskNo', 'TASK202603310001') formData.append('dingtalkApprovalNo', '202508211445000157693') formData.append('productModel', '') formData.append('categoryName', '') formData.append('sceneName', '') formData.append( 'previewImage', new File([Buffer.from('preview-image')], 'preview.png', { type: 'image/png' }), ) formData.append( 'zipFile', new File([Buffer.from('zipdata')], 'source-package.zip', { type: 'application/zip' }), ) return formData } test('缺少任务号时应立即返回失败', async () => { const formData = buildTaskOtherFormData() formData.set('taskNo', '') const response = await POST(createRequest(formData)) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toBe('任务-其他必须填写任务号') expect(prisma.material.create).not.toHaveBeenCalled() }) test('缺少 Content-Length 时必须在解析 multipart 前拒绝', async () => { const request = { headers: new Headers({ 'content-type': 'multipart/form-data; boundary=jest-boundary', }), formData: jest.fn(), } as any const response = await POST(request) const body = await response.json() expect(response.status).toBe(411) expect(body.code).toBe('CONTENT_LENGTH_REQUIRED') expect(request.formData).not.toHaveBeenCalled() }) test('超过 128MB 的请求必须在解析 multipart 前拒绝', async () => { const request = { headers: new Headers({ 'content-length': String(128 * 1024 * 1024 + 1), 'content-type': 'multipart/form-data; boundary=jest-boundary', }), formData: jest.fn(), } as any const response = await POST(request) const body = await response.json() expect(response.status).toBe(413) expect(body.code).toBe('UPLOAD_REQUEST_TOO_LARGE') expect(request.formData).not.toHaveBeenCalled() }) test('达到 5MB 的主文件必须在读取 buffer 前要求分片上传', async () => { const formData = new FormData() const file = new File( [new Uint8Array(5 * 1024 * 1024)], 'must-use-chunks.zip', { type: 'application/zip' }, ) const arrayBufferSpy = jest.spyOn(file, 'arrayBuffer') formData.append('zipFile', file) const response = await POST(createRequest(formData)) const body = await response.json() expect(response.status).toBe(413) expect(body.code).toBe('CHUNK_UPLOAD_REQUIRED') expect(arrayBufferSpy).not.toHaveBeenCalled() expect(prisma.material.create).not.toHaveBeenCalled() }) test('普通上传入口必须拒绝任务-详情,避免绕过需求运营必填规则', async () => { const formData = new FormData() formData.append('materialType', '任务-详情') const response = await POST(createRequest(formData)) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toContain('必须使用专用上传入口') expect(prisma.material.create).not.toHaveBeenCalled() }) test('普通上传入口必须拒绝任务-页面,避免生成缺少专用页面资产的完成记录', async () => { const formData = new FormData() formData.append('materialType', '任务-页面') const response = await POST(createRequest(formData)) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toBe('任务-页面必须使用专用上传入口') expect(prisma.material.create).not.toHaveBeenCalled() }) test('缺少预览图时应立即返回失败', async () => { const formData = buildTaskOtherFormData() formData.delete('previewImage') const response = await POST(createRequest(formData)) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toBe('任务-其他必须上传至少一张预览图') expect(prisma.material.create).not.toHaveBeenCalled() }) test('钉钉审批编号日期无效时应在创建素材前返回失败', async () => { const formData = buildTaskOtherFormData() formData.set('dingtalkApprovalNo', '20250230123') const response = await POST(createRequest(formData)) const body = await response.json() expect(response.status).toBe(400) expect(body.error).toBe('钉钉审批编号前8位不是有效日期') expect(prisma.material.create).not.toHaveBeenCalled() }) test('正常上传时应自动补通用店铺和场景,并保留原始压缩包文件名', async () => { const formData = buildTaskOtherFormData() const response = await POST(createRequest(formData)) const body = await response.json() expect(response.status).toBe(201) expect(body.message).toBe('上传成功') expect(createTaskMaterialOnComplete).toHaveBeenCalledWith( expect.objectContaining({ materialData: expect.objectContaining({ productModel: 'TASK202603310001', categoryName: '通用', sceneName: '通用', materialType: '任务-其他', zipFileName: 'source-package.zip', zipFilePath: expect.stringContaining('uploads'), }), assets: expect.arrayContaining([ expect.objectContaining({ assetType: 'preview' }), expect.objectContaining({ assetType: 'zip', fileName: 'source-package.zip' }), ]), taskNo: 'TASK202603310001', dingtalkApprovalNo: '202508211445000157693', }), ) expect(prisma.material.create).not.toHaveBeenCalled() expect(prisma.materialAsset.createMany).not.toHaveBeenCalled() expect(assignTaskNoOnComplete).not.toHaveBeenCalled() expect(createMaterialLog).toHaveBeenCalledWith( expect.objectContaining({ changes: expect.objectContaining({ categoryName: expect.objectContaining({ newValue: '通用', }), sceneName: expect.objectContaining({ newValue: '通用', }), }), }), ) }) test('视频手动预览图应同时保存为视频封面并跳过自动封面生成', async () => { const formData = new FormData() formData.append('materialType', '淘系-主图视频') formData.append('productModel', 'VIDEO-SPU-001') formData.append('categoryName', '通用') formData.append('sceneName', '通用') formData.append( 'previewImage', new File([Buffer.from('manual-cover')], 'manual-cover.png', { type: 'image/png' }), ) formData.append( 'zipFile', new File([Buffer.from('video-data')], 'product-video.mp4', { type: 'video/mp4' }), ) const response = await POST(createRequest(formData)) expect(response.status).toBe(201) expect(prisma.material.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ materialType: '淘系-主图视频', previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp', videoCoverUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp', videoSourceUrl: expect.stringMatching(/^\/uploads\/video-.*\.mp4$/), videoProcessingStatus: 'pending', }), }), ) expect(enqueueVideoTranscodeTask).toHaveBeenCalledWith( 'material-task-other-1', expect.stringMatching(/^\/uploads\/video-.*\.mp4$/), false, ) }) test('视频没有手动预览图时应保留自动封面生成', async () => { const formData = new FormData() formData.append('materialType', '淘系-主图视频') formData.append('productModel', 'VIDEO-SPU-002') formData.append('categoryName', '通用') formData.append('sceneName', '通用') formData.append( 'zipFile', new File([Buffer.from('video-data')], 'product-video.mp4', { type: 'video/mp4' }), ) const response = await POST(createRequest(formData)) expect(response.status).toBe(201) expect(prisma.material.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ materialType: '淘系-主图视频', previewUrl: '', videoCoverUrl: null, videoSourceUrl: expect.stringMatching(/^\/uploads\/video-.*\.mp4$/), videoProcessingStatus: 'pending', }), }), ) expect(enqueueVideoTranscodeTask).toHaveBeenCalledWith( 'material-task-other-1', expect.stringMatching(/^\/uploads\/video-.*\.mp4$/), true, ) }) test.each([ '任务-3D原图', '任务-AI原图', '任务-实拍原图', '任务-视频截图', ])('%s 也必须通过素材与资产原子事务完成', async (materialType) => { const formData = new FormData() formData.append('materialType', materialType) formData.append('taskNo', 'TASK202603310001') formData.append('productModel', 'SPU-ORIGINAL-001') formData.append('categoryName', '客厅') formData.append('sceneName', '家居') formData.append( 'zipFile', new File([Buffer.from('zipdata')], 'source-package.zip', { type: 'application/zip' }), ) const response = await POST(createRequest(formData)) expect(response.status).toBe(201) expect(createTaskMaterialOnComplete).toHaveBeenCalledWith(expect.objectContaining({ materialData: expect.objectContaining({ materialType, productModel: 'SPU-ORIGINAL-001', }), assets: expect.arrayContaining([ expect.objectContaining({ assetType: 'zip' }), ]), taskNo: 'TASK202603310001', })) expect(prisma.material.create).not.toHaveBeenCalled() expect(prisma.materialAsset.createMany).not.toHaveBeenCalled() expect(assignTaskNoOnComplete).not.toHaveBeenCalled() }) test('任务已完成后审计日志失败仍返回成功且绝不删除源文件', async () => { ;(createMaterialLog as jest.Mock).mockRejectedValueOnce(new Error('audit db unavailable')) const response = await POST(createRequest(buildTaskOtherFormData())) const body = await response.json() expect(response.status).toBe(201) expect(body.message).toBe('上传成功') expect(createTaskMaterialOnComplete).toHaveBeenCalled() expect(assignTaskNoOnComplete).not.toHaveBeenCalled() expect(deleteFiles).not.toHaveBeenCalled() expect(prisma.material.delete).not.toHaveBeenCalled() }) test('事务结果无法向数据库复核时必须保留源文件和预览文件', async () => { ;(createTaskMaterialOnComplete as jest.Mock).mockRejectedValueOnce( new Error('commit acknowledgement lost'), ) ;(prisma.material.findUnique as jest.Mock).mockRejectedValueOnce( new Error('database unavailable'), ) const response = await POST(createRequest(buildTaskOtherFormData())) expect(response.status).toBe(500) expect(deleteFiles).not.toHaveBeenCalled() expect(prisma.material.delete).not.toHaveBeenCalled() }) test('小红书批量上传的附加图片必须写入素材展示图', async () => { const formData = new FormData() formData.append('materialType', '小红书种草') formData.append('productModel', 'RED-BOOK-001') formData.append('categoryName', '通用') formData.append('sceneName', '通用') formData.append( 'previewImage', new File([Buffer.from('cover')], 'cover.png', { type: 'image/png' }), ) formData.append( 'modelImages', new File([Buffer.from('gallery-1')], 'gallery-1.png', { type: 'image/png' }), ) formData.append( 'modelImages', new File([Buffer.from('gallery-2')], 'gallery-2.png', { type: 'image/png' }), ) ;(uploadPublicDisplayToS3 as jest.Mock) .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/cover.webp') .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-1.png') .mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-2.png') const response = await POST(createRequest(formData)) expect(response.status).toBe(201) expect(prisma.material.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ materialType: '小红书种草', previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/cover.webp', displayImages: [ 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-1.png', 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-2.png', ], }), }), ) }) })