/** @jest-environment node */ import { assertClientPreviewWebpMetadata, assertOwnedProductPackageUploadSessions, assertTaskDetailUploadAssetLimits, completeProductPackageUpload, parsePublicProductPackageAssets, } from '@/lib/product-package-completion' import { prisma } from '@/lib/prisma' import { queueTaskDetailBarcodeImagesUpdatedNotification, queueTaskDetailFullSetNotification, } from '@/lib/external-auto-listing' import { createMaterialLog } from '@/lib/material-log' import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { cleanupOnError, cleanupTempFiles, createZipFromFiles, saveProductPackageAssetsAndComplete, } from '@/lib/task-material-processor' import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy' import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions' import { withImageProcessingSlot } from '@/lib/image-processing-limiter' import { mkdtemp, readFile, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import path from 'path' jest.mock('@/lib/prisma', () => ({ prisma: { material: { findUnique: jest.fn(), update: jest.fn(), }, materialAsset: { count: jest.fn(), deleteMany: jest.fn(), }, $transaction: jest.fn(), }, })) jest.mock('@/lib/product-package', () => ({ validateTaskNo: jest.fn(() => true), })) jest.mock('@/lib/task-material-processor', () => ({ cleanupOnError: jest.fn().mockResolvedValue(undefined), cleanupTempFiles: jest.fn().mockResolvedValue(undefined), createZipFromFiles: jest.fn() .mockResolvedValueOnce({ filePath: 'zip-main', fileName: 'main.zip', fileSize: 11 }) .mockResolvedValueOnce({ filePath: 'zip-sku', fileName: 'sku.zip', fileSize: 22 }) .mockResolvedValueOnce({ filePath: 'zip-detail', fileName: 'detail.zip', fileSize: 33 }), createZipFromZips: jest.fn().mockResolvedValue({ filePath: 'zip-all', fileName: 'all.zip', fileSize: 66 }), saveProductPackageAssetsAndComplete: jest.fn().mockResolvedValue({ createdSkuPreviewAssets: [ { id: 'sku-preview-1', fileUrl: 'https://s3.example/sku-preview-1.webp', fileName: 'sku-preview-1.webp', sortOrder: 0, skuCode: 'SKU-001', createdAt: new Date('2026-04-22T09:00:00.000Z'), updatedAt: new Date('2026-04-22T09:00:00.000Z'), barcodes: ['690000000001'], }, ], }), })) jest.mock('@/lib/upload-sessions', () => ({ getUploadedFile: jest.fn(), getOwnedUploadSession: jest.fn().mockResolvedValue({ userId: 'user-1' }), refreshOwnedUploadSessionTtls: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/task-detail-full-set', () => ({ isTaskDetailFullSetFromUploadGroups: jest.fn(() => true), })) jest.mock('@/lib/public-display', () => ({ buildTaskDetailPublicDisplayPath: jest.fn((_materialId: string, group: string, fileName: string) => `task-detail/${group}/${fileName}`), uploadPublicDisplayToS3: jest.fn((_: Buffer, path: string) => Promise.resolve(`https://s3.example/${path}`)), })) jest.mock('@/lib/external-auto-listing', () => ({ queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue([]), queueTaskDetailBarcodeImagesUpdatedNotification: jest.fn().mockResolvedValue([]), })) jest.mock('@/lib/image-processor', () => ({ compressImage: jest.fn(async (buffer: Buffer) => buffer), sliceImage: jest.fn(async (buffer: Buffer) => [buffer]), })) jest.mock('@/lib/image-processing-limiter', () => ({ withImageProcessingSlot: jest.fn(async (task: () => Promise) => task()), })) jest.mock('@/lib/remote-upload-sync', () => ({ isRemoteUploadSyncEnabled: jest.fn(() => false), syncStoredUploadFileToRemote: jest.fn().mockResolvedValue(null), })) jest.mock('sharp', () => ({ __esModule: true, default: jest.fn(() => ({ metadata: jest.fn().mockResolvedValue({ width: 800, height: 800 }), })), })) describe('product-package-completion', () => { beforeEach(() => { jest.clearAllMocks() ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', productModel: 'SPU-001', categoryName: '旗舰店', previewUrl: '', user: { realName: '张三', username: 'zhangsan', }, }) ;(prisma.material.update as jest.Mock).mockResolvedValue(undefined) ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) ;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: (tx: any) => unknown) => callback({ materialAsset: { deleteMany: prisma.materialAsset.deleteMany }, material: { update: prisma.material.update }, })) global.fetch = jest.fn(async () => new Response(Buffer.from('image-binary'), { status: 200, headers: { 'Content-Type': 'image/png', }, }), ) as any }) test('后台处理入口再次执行图片、PSD 和预览数量门禁', () => { expect(() => assertTaskDetailUploadAssetLimits({ main: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup + 1 }, (_, index) => ({ uploadId: `main-${index}`, fileName: `${index}.jpg`, fileSize: 1, })), })).toThrow(`主图最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张`) expect(() => assertTaskDetailUploadAssetLimits({ psd: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount + 1 }, (_, index) => ({ uploadId: `psd-${index}`, fileName: `${index}.psd`, fileSize: 1, })), })).toThrow(`PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个`) const imageGroup = (prefix: string, count: number) => Array.from({ length: count }, (_, index) => ({ uploadId: `${prefix}-${index}`, fileName: `${prefix}-${index}.jpg`, fileSize: 1, })) expect(() => assertTaskDetailUploadAssetLimits({ main: imageGroup('main', 250), sku: imageGroup('sku', 200), detail: imageGroup('detail', 151), })).toThrow(`本次图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张`) const previews = Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage }, (_, index) => ({ uploadId: `preview-${index}`, fileName: `${index}.webp`, fileSize: 1, width: 1, height: 1, kind: 'preview', })) expect(() => assertTaskDetailUploadAssetLimits({ main: Array.from({ length: 34 }, (_, index) => ({ uploadId: `source-${index}`, fileName: `${index}.jpg`, fileSize: 1, previewAssets: previews.map((preview, previewIndex) => ({ ...preview, uploadId: `${index}-${previewIndex}`, })), })), })).toThrow('客户端预览图数量过多') }) test('真实会话复核将源文件和预览图字节分别计算', async () => { const sourceBytes = 1024 * 1024 * 1024 ;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({ userId: 'user-1', fileSize: uploadId === 'preview-1' ? 1 : sourceBytes - 1, })) await expect(assertOwnedProductPackageUploadSessions({ main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 1, previewAssets: [{ uploadId: 'preview-1', fileName: 'preview.webp', fileSize: 1, width: 1, height: 1, kind: 'preview', }], }], psd: [{ uploadId: 'source-1', fileName: 'source.psd', fileSize: sourceBytes - 1, }], }, 'user-1')).resolves.toBeUndefined() }) test('PSD 源文件不占用图片 1GB 总量,单文件按 10GB 计', async () => { const twoGb = 2 * 1024 * 1024 * 1024 ;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({ userId: 'user-1', fileSize: uploadId === 'preview-1' ? 1 : uploadId === 'source-1' ? twoGb : 10 * 1024 * 1024, })) await expect(assertOwnedProductPackageUploadSessions({ main: [{ uploadId: 'image-1', fileName: 'main.png', fileSize: 10 * 1024 * 1024, previewAssets: [{ uploadId: 'preview-1', fileName: 'preview.webp', fileSize: 1, width: 1, height: 1, kind: 'preview', }], }], psd: [{ uploadId: 'source-1', fileName: '顾家750SKU.psd', fileSize: twoGb, }], }, 'user-1')).resolves.toBeUndefined() await expect(() => assertTaskDetailUploadAssetLimits({ psd: [{ uploadId: 'source-huge', fileName: 'too-big.psd', fileSize: 10 * 1024 * 1024 * 1024 + 1, }], })).toThrow('超过 10GB') }) test('客户端 WebP 预览使用文件路径读取真实元数据并匹配申报尺寸', async () => { const metadata = jest.fn().mockResolvedValue({ format: 'webp', width: 800, height: 600, pages: 1, }) const sharpModule = { default: jest.fn(() => ({ metadata })) } as any await expect(assertClientPreviewWebpMetadata({ uploadId: 'preview-1', fileName: 'preview.webp', fileSize: 10, width: 800, height: 600, kind: 'preview', }, 'D:\\uploads\\preview.webp', sharpModule)).resolves.toBeUndefined() expect(sharpModule.default).toHaveBeenCalledWith( 'D:\\uploads\\preview.webp', expect.objectContaining({ limitInputPixels: 2_400_000, sequentialRead: true, failOn: 'error', }), ) expect(withImageProcessingSlot).toHaveBeenCalledTimes(1) }) test('服务器兜底生成预览时元数据读取也经过全局图片处理门禁', async () => { const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-server-preview-fallback-')) try { const sourcePath = path.join(uploadRoot, 'source.jpg') const sourceBuffer = Buffer.from('server-preview-source') await writeFile(sourcePath, sourceBuffer) ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ userId: 'user-1', fileSize: sourceBuffer.length, }) ;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath) ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ id: 'material-1', materialType: '任务-详情', taskDetailSourceType: '实拍', taskStatus: 'uploading', previewUrl: '', zipFilePath: '', zipFileName: '', zipFileSize: BigInt(0), taskNo: null, dingtalkApprovalNo: null, isFullSet: false, productModel: 'SPU-001', categoryName: '旗舰店', user: { realName: '张三', username: 'zhangsan' }, }) ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'zip-main', fileName: 'main.zip', fileSize: sourceBuffer.length, }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ uploadId: 'upload-main', fileName: 'source.jpg', fileSize: sourceBuffer.length, }], }, })).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' })) expect(withImageProcessingSlot).toHaveBeenCalledTimes(1) } finally { await rm(uploadRoot, { recursive: true, force: true }) } }) test('私密任务详情完成时预览写入本地 private-previews,且不上传 public-display', async () => { const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-private-task-detail-preview-')) const previousUploadDir = process.env.UPLOAD_DIR process.env.UPLOAD_DIR = uploadRoot try { ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailPrivate: true, taskDetailSourceType: '实拍', taskStatus: 'uploading', previewUrl: '', zipFilePath: '', zipFileName: '', zipFileSize: BigInt(0), taskNo: null, dingtalkApprovalNo: null, isFullSet: false, productModel: 'SPU-001', categoryName: '旗舰店', user: { realName: '张三', username: 'zhangsan' }, }) ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'zip-main', fileName: 'main.zip', fileSize: 11, }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10, }], }, })).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' })) const previewAsset = (saveProductPackageAssetsAndComplete as jest.Mock).mock.calls.at(-1)?.[0]?.previewAssets?.main?.[0] expect(previewAsset?.fileUrl).toMatch(/^uploads\/materials\/material-1\/private-previews\/main\/.+\.webp$/) expect(uploadPublicDisplayToS3).not.toHaveBeenCalled() const absolutePath = path.join( uploadRoot, String(previewAsset.fileUrl).replace(/^uploads\//, '').replace(/\//g, path.sep), ) await expect(readFile(absolutePath)).resolves.toEqual(Buffer.from('image-binary')) } finally { if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir await rm(uploadRoot, { recursive: true, force: true }) } }) test.each([ [{ format: 'jpeg', width: 800, height: 600, pages: 1 }, { width: 800, height: 600 }, '内容不是有效 WebP'], [{ format: 'webp', width: 800, height: 600, pages: 2 }, { width: 800, height: 600 }, '不允许使用动画 WebP'], [{ format: 'webp', width: 801, height: 600, pages: 1 }, { width: 800, height: 600 }, '真实尺寸超出限制'], [{ format: 'webp', width: 799, height: 600, pages: 1 }, { width: 800, height: 600 }, '申报尺寸与真实尺寸不一致'], ])('拒绝伪造或超限客户端预览 %#', async (metadataValue, declared, message) => { const sharpModule = { default: jest.fn(() => ({ metadata: jest.fn().mockResolvedValue(metadataValue), })), } as any await expect(assertClientPreviewWebpMetadata({ uploadId: 'preview-malicious', fileName: 'malicious.webp', fileSize: 10, width: declared.width, height: declared.height, kind: 'preview', }, 'D:\\uploads\\malicious.webp', sharpModule)).rejects.toThrow(message) }) test('全套任务详情完成后应触发自动上架通知排队', async () => { const result = await completeProductPackageUpload({ materialId: 'material-1', taskNo: null, dingtalkApprovalNo: '202508211445000157693', operatorUserId: 'user-1', assets: { main: [ { fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10, sortOrder: 0, }, ], sku: [ { fileUrl: 'https://img.example/sku.png', fileName: 'sku.png', fileSize: 10, sortOrder: 0, skuCode: 'SKU-001', barcodes: ['690000000001'], }, ], detail: [ { fileUrl: 'https://img.example/detail.png', fileName: 'detail.png', fileSize: 10, sortOrder: 0, }, ], }, }) expect(result).toEqual({ materialId: 'material-1', taskNo: null, dingtalkApprovalNo: '202508211445000157693', isFullSet: true, }) expect(saveProductPackageAssetsAndComplete).toHaveBeenCalledWith( expect.objectContaining({ materialId: 'material-1', taskNo: null, dingtalkApprovalNo: '202508211445000157693', isFullSet: true, }), ) expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1') expect(queueTaskDetailBarcodeImagesUpdatedNotification).toHaveBeenCalledWith('material-1', [ 'sku-preview-1', ]) expect(createMaterialLog).toHaveBeenCalled() }) test('任务详情直接完成时缺少出图方式应拒绝处理', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ id: 'material-1', materialType: '任务-详情', taskDetailSourceType: null, productModel: 'SPU-001', categoryName: '旗舰店', previewUrl: '', user: { realName: '张三', username: 'zhangsan', }, }) await expect( completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [ { fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10, sortOrder: 0, }, ], }, }), ).rejects.toThrow('任务-详情必须选择出图方式') expect(createZipFromFiles).not.toHaveBeenCalled() }) test('SKU 图缺少 skuCode 时应直接报错', async () => { await expect( completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { sku: [ { fileUrl: 'https://img.example/sku.png', fileName: 'sku.png', fileSize: 10, sortOrder: 0, barcodes: ['690000000001'], }, ], }, }), ).rejects.toThrow('缺少 SKU 编号') }) test('SKU 图缺少产品条码时应直接报错', async () => { await expect( completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { sku: [ { fileUrl: 'https://img.example/sku.png', fileName: 'sku.png', fileSize: 10, sortOrder: 0, skuCode: 'SKU-001', barcodes: [], }, ], }, }), ).rejects.toThrow('至少需要 1 个产品条码') }) test('历史导入允许 SKU 图没有产品条码', async () => { ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'zip-sku-only', fileName: 'sku-only.zip', fileSize: 22, }) await expect( completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { sku: [ { fileUrl: 'https://img.example/sku.png', fileName: 'sku.png', fileSize: 10, sortOrder: 0, skuCode: 'SKU-001', barcodes: [], }, ], }, options: { allowSkuWithoutBarcodes: true, }, }), ).resolves.toEqual({ materialId: 'material-1', taskNo: null, dingtalkApprovalNo: null, isFullSet: true, }) }) test('同一批次重复条码绑定两张 SKU 图时应拒绝', async () => { await expect( completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { sku: [ { fileUrl: 'https://img.example/sku-a.png', fileName: 'sku-a.png', fileSize: 10, sortOrder: 0, skuCode: 'SKU-001', barcodes: ['690000000001'], }, { fileUrl: 'https://img.example/sku-b.png', fileName: 'sku-b.png', fileSize: 10, sortOrder: 1, skuCode: 'SKU-002', barcodes: ['690000000001'], }, ], }, }), ).rejects.toThrow('不能同时绑定') }) test('公开上传资产不得携带 localFilePath', () => { expect(() => parsePublicProductPackageAssets({ main: [ { uploadId: 'upload-1', localFilePath: 'C:\\Windows\\win.ini', fileName: 'main.png', fileSize: 10, }, ], }), ).toThrow('只允许使用 uploadId') }) test('公开上传资产不得使用 fileUrl 触发服务端请求', () => { expect(() => parsePublicProductPackageAssets({ main: [ { fileUrl: 'http://127.0.0.1:3000/api/admin/system-logs', fileName: 'main.png', fileSize: 10, }, ], }), ).toThrow('只允许使用 uploadId') }) test('公开上传资产只保留完成流程需要的字段', () => { expect( parsePublicProductPackageAssets({ sku: [ { uploadId: ' upload-1 ', fileName: ' sku.png ', fileSize: 10, sortOrder: 0, skuCode: 'SKU-001', barcodes: ['690000000001', 123], unexpected: 'ignored', }, ], }), ).toEqual({ main: undefined, sku: [ { uploadId: 'upload-1', fileName: 'sku.png', fileSize: 10, sortOrder: 0, skuCode: 'SKU-001', barcodes: ['690000000001'], }, ], detail: undefined, psd: undefined, }) }) test('不能消费其他用户的上传会话', async () => { ;(getOwnedUploadSession as jest.Mock).mockResolvedValueOnce(null) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ uploadId: 'upload-other', fileName: 'main.png', fileSize: 10 }], }, })).rejects.toThrow('上传已过期') }) test('同批同名 PSD 不会覆盖,成功后会清理全部上传会话', async () => { const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-success-')) const previousUploadDir = process.env.UPLOAD_DIR process.env.UPLOAD_DIR = uploadRoot try { const firstSource = path.join(uploadRoot, 'first-upload.tmp') const secondSource = path.join(uploadRoot, 'second-upload.tmp') await writeFile(firstSource, 'first') await writeFile(secondSource, 'second') ;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({ userId: 'user-1', fileSize: uploadId === 'psd-1' ? 5 : 6, })) ;(getUploadedFile as jest.Mock).mockImplementation(async (uploadId: string) => ( uploadId === 'psd-1' ? firstSource : secondSource )) ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'zip-main', fileName: 'main.zip', fileSize: 11, }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], psd: [ { uploadId: 'psd-1', fileName: 'same.psd', fileSize: 5 }, { uploadId: 'psd-2', fileName: 'same.psd', fileSize: 6 }, ], }, })).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' })) const psdInfos = (saveProductPackageAssetsAndComplete as jest.Mock).mock.calls.at(-1)?.[0]?.psdInfos expect(psdInfos).toHaveLength(2) expect(psdInfos[0].filePath).not.toBe(psdInfos[1].filePath) await expect(readFile(path.join(uploadRoot, 'materials', path.basename(psdInfos[0].filePath)), 'utf8')) .resolves.toBe('first') await expect(readFile(path.join(uploadRoot, 'materials', path.basename(psdInfos[1].filePath)), 'utf8')) .resolves.toBe('second') expect((cleanupTempFiles as jest.Mock).mock.calls.at(-1)?.[1]).toEqual( expect.arrayContaining(['psd-1', 'psd-2']), ) } finally { if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir await rm(uploadRoot, { recursive: true, force: true }) } }) test('PSD 持久化失败时保留已验证的上传会话供重试', async () => { const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-failure-')) const previousUploadDir = process.env.UPLOAD_DIR process.env.UPLOAD_DIR = uploadRoot try { ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ userId: 'user-1', fileSize: 5, }) ;(getUploadedFile as jest.Mock).mockResolvedValue(null) ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'zip-main', fileName: 'main.zip', fileSize: 11, }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], psd: [{ uploadId: 'psd-failed', fileName: 'failed.psd', fileSize: 5 }], }, })).rejects.toThrow('PSD 文件缺失或未上传完成') expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([]) expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([]) expect(cleanupTempFiles).not.toHaveBeenCalled() } finally { if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir await rm(uploadRoot, { recursive: true, force: true }) } }) test('原子完成事务失败时不补偿半成品数据库,也不清理用户源会话', async () => { ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ id: 'material-1', materialType: '任务-详情', taskDetailSourceType: '实拍', taskStatus: 'uploading', previewUrl: '', zipFilePath: '', zipFileName: '', zipFileSize: BigInt(0), taskNo: null, dingtalkApprovalNo: null, isFullSet: false, productModel: 'SPU-001', categoryName: '旗舰店', user: { realName: '张三', username: 'zhangsan' }, }) ;(saveProductPackageAssetsAndComplete as jest.Mock).mockRejectedValueOnce(new Error('任务号已存在')) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: 'TASK202601030184', dingtalkApprovalNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], }, })).rejects.toThrow('任务号已存在') expect(prisma.materialAsset.deleteMany).not.toHaveBeenCalled() expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([]) expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([]) }) test('事务提交回执丢失时重读完整资产,确认已完成后按成功返回并清理源会话', async () => { ;(createZipFromFiles as jest.Mock).mockResolvedValueOnce({ filePath: 'materials/main-ack.zip', fileName: 'main-ack.zip', fileSize: 11, }) const createZipFromZips = jest.requireMock('@/lib/task-material-processor').createZipFromZips as jest.Mock createZipFromZips.mockResolvedValueOnce({ filePath: 'materials/all-ack.zip', fileName: 'all-ack.zip', fileSize: 22, }) ;(saveProductPackageAssetsAndComplete as jest.Mock).mockImplementationOnce(async (input) => { const mainPreview = input.previewAssets.main[0] ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ taskStatus: 'completed', taskNo: null, dingtalkApprovalNo: null, previewUrl: input.cardPreviewUrl, zipFilePath: input.zipInfos.all.filePath, zipFileName: input.zipInfos.all.fileName, zipFileSize: BigInt(input.zipInfos.all.fileSize), isFullSet: input.isFullSet, assets: [ { id: 'main-preview-1', assetType: 'main-preview', fileUrl: mainPreview.fileUrl, fileName: mainPreview.fileName, fileSize: BigInt(0), sortOrder: 0, skuCode: null, createdAt: new Date('2026-08-12T00:00:00.000Z'), updatedAt: new Date('2026-08-12T00:00:00.000Z'), barcodes: [], }, { id: 'main-zip-1', assetType: 'main', fileUrl: input.zipInfos.main.filePath, fileName: input.zipInfos.main.fileName, fileSize: BigInt(input.zipInfos.main.fileSize), sortOrder: 0, skuCode: null, createdAt: new Date('2026-08-12T00:00:00.000Z'), updatedAt: new Date('2026-08-12T00:00:00.000Z'), barcodes: [], }, { id: 'all-zip-1', assetType: 'all', fileUrl: input.zipInfos.all.filePath, fileName: input.zipInfos.all.fileName, fileSize: BigInt(input.zipInfos.all.fileSize), sortOrder: 0, skuCode: null, createdAt: new Date('2026-08-12T00:00:00.000Z'), updatedAt: new Date('2026-08-12T00:00:00.000Z'), barcodes: [], }, ], }) throw new Error('COMMIT acknowledgement lost') }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], }, })).resolves.toEqual(expect.objectContaining({ materialId: 'material-1', warning: '素材已保存,但响应确认阶段出现异常', })) expect(cleanupTempFiles).toHaveBeenCalled() expect(cleanupOnError).not.toHaveBeenCalled() }) test('事务结果无法重读确认时保留源文件和全部派生物', async () => { ;(createZipFromFiles as jest.Mock).mockResolvedValueOnce({ filePath: 'materials/main-unknown.zip', fileName: 'main-unknown.zip', fileSize: 11, }) ;(saveProductPackageAssetsAndComplete as jest.Mock).mockImplementationOnce(async () => { ;(prisma.material.findUnique as jest.Mock).mockRejectedValueOnce(new Error('database unavailable')) throw new Error('COMMIT acknowledgement lost') }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], }, })).rejects.toThrow('完成状态暂时无法确认') expect(cleanupTempFiles).not.toHaveBeenCalled() expect(cleanupOnError).not.toHaveBeenCalled() }) test('执行租约守卫在最终事务前失效时不提交且不清理源会话', async () => { const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-product-lease-')) const previousUploadDir = process.env.UPLOAD_DIR process.env.UPLOAD_DIR = uploadRoot try { const sourcePath = path.join(uploadRoot, 'source.jpg') const sourceBuffer = Buffer.from('lease-guard-source') await writeFile(sourcePath, sourceBuffer) ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ userId: 'user-1', fileSize: sourceBuffer.length, }) ;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath) ;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'user-1', materialType: '任务-详情', taskDetailSourceType: '实拍', taskStatus: 'uploading', previewUrl: '', zipFilePath: '', productModel: 'SPU-001', categoryName: '旗舰店', user: { realName: '张三', username: 'zhangsan' }, }) ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'materials/main-lease.zip', fileName: 'main-lease.zip', fileSize: 11, }) let executionCurrent = true const assertExecutionCurrent = jest.fn(async () => { if (!executionCurrent) throw new Error('job lease lost') }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ uploadId: 'source-lease', fileName: 'source.jpg', fileSize: sourceBuffer.length }], }, options: { assertExecutionCurrent, onProgress: ({ stage, progress }) => { if (stage === 'database' && progress === 85) executionCurrent = false }, }, })).rejects.toThrow('job lease lost') expect(saveProductPackageAssetsAndComplete).not.toHaveBeenCalled() expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([]) expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([]) await expect(readFile(sourcePath, 'utf8')).resolves.toBe('lease-guard-source') } finally { if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir await rm(uploadRoot, { recursive: true, force: true }) } }) test('中止信号在最终事务前触发时不提交且不消费源会话', async () => { const controller = new AbortController() ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'materials/main-aborted.zip', fileName: 'main-aborted.zip', fileSize: 11, }) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], }, options: { signal: controller.signal, onProgress: ({ stage, progress }) => { if (stage === 'database' && progress === 85) { controller.abort(new Error('worker drained')) } }, }, })).rejects.toThrow('worker drained') expect(saveProductPackageAssetsAndComplete).not.toHaveBeenCalled() expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([]) expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([]) expect(cleanupTempFiles).not.toHaveBeenCalled() }) test('PSD 在原子事务失败前只复制不移动,源文件仍可原地重试', async () => { const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-copy-')) const previousUploadDir = process.env.UPLOAD_DIR process.env.UPLOAD_DIR = uploadRoot try { const sourcePath = path.join(uploadRoot, 'source.psd') await writeFile(sourcePath, 'retryable-psd') ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ userId: 'user-1', fileSize: 13 }) ;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath) ;(createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'materials/main-psd.zip', fileName: 'main-psd.zip', fileSize: 11, }) ;(saveProductPackageAssetsAndComplete as jest.Mock).mockRejectedValueOnce(new Error('transaction rolled back')) await expect(completeProductPackageUpload({ materialId: 'material-1', taskNo: null, operatorUserId: 'user-1', assets: { main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }], psd: [{ uploadId: 'psd-retry', fileName: 'source.psd', fileSize: 13 }], }, })).rejects.toThrow('transaction rolled back') await expect(readFile(sourcePath, 'utf8')).resolves.toBe('retryable-psd') expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([]) expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([]) } finally { if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir await rm(uploadRoot, { recursive: true, force: true }) } }) })