/** @jest-environment node */ import { execFile } from 'child_process' import { writeFileSync } from 'fs' import { mkdtemp, rm, writeFile } from 'fs/promises' import os from 'os' import path from 'path' import sharp from 'sharp' jest.mock('child_process', () => ({ execFile: jest.fn() })) jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() })) jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/lib/file-cleanup', () => ({ deleteFileIfUnreferenced: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/lib/material-upload-cross-process-lock', () => ({ acquireTaskMaterialCompletionLock: jest.fn() })) jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) })) jest.mock('@/lib/task-detail-full-set', () => ({ deriveTaskDetailIsFullSet: jest.fn(() => false) })) jest.mock('@/lib/external-auto-listing', () => ({ queueTaskDetailBarcodeImagesUpdatedNotification: jest.fn(), queueTaskDetailFullSetNotification: jest.fn(), })) jest.mock('@/lib/public-display', () => ({ buildTaskDetailPublicDisplayPath: jest.fn((materialId: string, group: string, fileName: string) => `${materialId}/${group}/${fileName}`), uploadPublicDisplayToS3: jest.fn(), })) jest.mock('@/lib/user-permission-inherit', () => ({ canUserAccessPrivateTaskDetail: jest.fn().mockResolvedValue(false), canUserEditTaskDetailAssets: jest.fn().mockResolvedValue(false), })) jest.mock('@/lib/temp-cleanup', () => ({ cleanupConsumedUploadSession: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/lib/remote-upload-sync', () => ({ deleteStoredUploadFileFromRemote: jest.fn().mockResolvedValue(undefined) })) jest.mock('@/lib/upload-sessions', () => ({ getOwnedUploadSession: jest.fn(), getUploadedFile: jest.fn(), isSafeUploadId: jest.fn(() => true), })) jest.mock('@/lib/task-material-processor', () => ({ createZipFromFiles: jest.fn(), createZipFromZips: jest.fn(), })) jest.mock('@/lib/zip-entry-reader', () => ({ createTaskDetailEditWorkspace: jest.fn(), extractZipEntriesToDirectory: jest.fn(), listZipEntries: jest.fn(), })) jest.mock('@/lib/upload-paths', () => ({ resolveStoredUploadPath: jest.fn((value: string) => value) })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findFirst: jest.fn(), update: jest.fn(), }, materialAsset: { findMany: jest.fn(), count: jest.fn(), update: jest.fn(), delete: jest.fn(), deleteMany: jest.fn(), create: jest.fn(), createMany: jest.fn(), }, materialAssetBarcode: { findMany: jest.fn(), createMany: jest.fn(), }, taskDetailChangeRequest: { updateMany: jest.fn(), }, $transaction: jest.fn(), }, })) import { canManageMaterial } from '@/lib/material-authorization' import { queueTaskDetailBarcodeImagesUpdatedNotification } from '@/lib/external-auto-listing' import { acquireTaskMaterialCompletionLock } from '@/lib/material-upload-cross-process-lock' import { prisma } from '@/lib/prisma' import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { canUserAccessPrivateTaskDetail, canUserEditTaskDetailAssets, } from '@/lib/user-permission-inherit' import { createZipFromFiles, createZipFromZips } from '@/lib/task-material-processor' import { ServerTrustedTaskDetailStagedFile, TaskDetailAssetEditError, batchEditTaskDetailImages, } from '@/lib/task-detail-asset-edit' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions' import { createTaskDetailEditWorkspace, extractZipEntriesToDirectory, listZipEntries } from '@/lib/zip-entry-reader' const mockExecFile = execFile as unknown as jest.Mock const mockCanManageMaterial = canManageMaterial as jest.Mock const mockAcquireLock = acquireTaskMaterialCompletionLock as jest.Mock const mockUploadPreview = uploadPublicDisplayToS3 as jest.Mock const mockCreateZipFromFiles = createZipFromFiles as jest.Mock const mockCreateZipFromZips = createZipFromZips as jest.Mock const mockCleanupConsumedUploadSession = cleanupConsumedUploadSession as jest.Mock const mockGetOwnedUploadSession = getOwnedUploadSession as jest.Mock const mockGetUploadedFile = getUploadedFile as jest.Mock const mockCreateWorkspace = createTaskDetailEditWorkspace as jest.Mock const mockExtractZipEntries = extractZipEntriesToDirectory as jest.Mock const mockListZipEntries = listZipEntries as jest.Mock const mockQueueSkuNotification = queueTaskDetailBarcodeImagesUpdatedNotification as jest.Mock const mockCanAccessPrivateTaskDetail = canUserAccessPrivateTaskDetail as jest.Mock const mockCanUserEditTaskDetailAssets = canUserEditTaskDetailAssets as jest.Mock type PreviewRow = { id: string assetType: string fileUrl: string fileName: string sortOrder: number skuCode?: string | null createdAt?: Date | null } describe('task-detail asset edit batch data-plane', () => { let tempRoot: string beforeEach(async () => { jest.clearAllMocks() tempRoot = await mkdtemp(path.join(os.tmpdir(), 'task-detail-batch-')) mockCanManageMaterial.mockResolvedValue(true) mockCanAccessPrivateTaskDetail.mockResolvedValue(false) mockCanUserEditTaskDetailAssets.mockResolvedValue(false) mockAcquireLock.mockResolvedValue(Object.assign(jest.fn().mockResolvedValue(undefined), { assertHeld: jest.fn(), })) ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'operator-1', materialType: '任务-详情', taskStatus: 'completed', productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', previewUrl: 'https://preview/existing-cover.webp', isFullSet: false, updatedAt: new Date('2026-08-28T08:00:00.000Z'), }) ;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0) ;(prisma.material.update as jest.Mock).mockResolvedValue(undefined) ;(prisma.materialAsset.update as jest.Mock).mockResolvedValue(undefined) ;(prisma.materialAsset.delete as jest.Mock).mockResolvedValue(undefined) ;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 0 }) ;(prisma.materialAsset.create as jest.Mock).mockResolvedValue(undefined) ;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 0 }) ;(prisma.materialAssetBarcode.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.materialAssetBarcode.createMany as jest.Mock).mockResolvedValue({ count: 0 }) ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => callback({ material: prisma.material, materialAsset: prisma.materialAsset, materialAssetBarcode: prisma.materialAssetBarcode, taskDetailChangeRequest: prisma.taskDetailChangeRequest, })) mockUploadPreview.mockImplementation(async (_buffer: Buffer, key: string) => `https://preview/${key}`) mockCreateZipFromFiles.mockResolvedValue({ filePath: 'uploads/materials/group.zip', fileName: 'group.zip', fileSize: 123, }) mockCreateZipFromZips.mockResolvedValue({ filePath: 'uploads/materials/all.zip', fileName: 'all.zip', fileSize: 456, }) mockCreateWorkspace.mockImplementation(async () => { const dir = await mkdtemp(path.join(tempRoot, 'workspace-')) return { dir, cleanup: () => rm(dir, { recursive: true, force: true }), } }) }) afterEach(async () => { await rm(tempRoot, { recursive: true, force: true }) }) async function createSolidImage(fileName: string, width: number, height: number, format: 'png' | 'webp' = 'png') { const filePath = path.join(tempRoot, fileName) const buffer = await sharp({ create: { width, height, channels: 3, background: { r: 180, g: 180, b: 180 }, }, })[format]().toBuffer() await writeFile(filePath, buffer) return { filePath, size: buffer.length } } async function createAnimatedGif(fileName: string, width: number, height: number) { const redFrame = Buffer.alloc(width * height * 4) const blueFrame = Buffer.alloc(width * height * 4) for (let index = 0; index < width * height; index += 1) { redFrame[index * 4] = 255 redFrame[index * 4 + 3] = 255 blueFrame[index * 4 + 2] = 255 blueFrame[index * 4 + 3] = 255 } const buffer = await sharp(Buffer.concat([redFrame, blueFrame]), { raw: { width, height: height * 2, pageHeight: height, channels: 4 }, }).gif({ loop: 0, delay: [120, 120] }).toBuffer() const filePath = path.join(tempRoot, fileName) await writeFile(filePath, buffer) return { filePath, size: buffer.length, buffer } } function configureUploadSources(files: Record) { mockGetOwnedUploadSession.mockImplementation(async (uploadId: string) => { const item = files[uploadId] return item ? { fileName: item.fileName, fileSize: item.size } : null }) mockGetUploadedFile.mockImplementation(async (uploadId: string) => files[uploadId]?.path || null) } function configureAssetQueries(options: { previewRows: PreviewRow[] zipRows: Array<{ id: string; assetType: string; fileUrl: string; fileName: string | null }> extraFindMany?: unknown[] }) { const mock = prisma.materialAsset.findMany as jest.Mock mock.mockReset() mock.mockResolvedValueOnce(options.previewRows) mock.mockResolvedValueOnce(options.zipRows) for (const item of options.extraFindMany || []) { mock.mockResolvedValueOnce(item) } } test('sku 追加支持多张按顺序提交,并保留 skuCode/barcodes', async () => { const existingSource = await createSolidImage('sku-old.png', 80, 80) const existingPreview = await createSolidImage('sku-old-preview.webp', 80, 80, 'webp') const append1 = await createSolidImage('append-1.png', 90, 90) const append2 = await createSolidImage('append-2.png', 100, 100) configureUploadSources({ 'up-1': { path: append1.filePath, size: append1.size, fileName: 'append-1.png' }, 'up-2': { path: append2.filePath, size: append2.size, fileName: 'append-2.png' }, }) configureAssetQueries({ previewRows: [ { id: 'sku-0', assetType: 'sku-preview', fileUrl: existingPreview.filePath, fileName: 'sku-old-preview.webp', sortOrder: 0, skuCode: 'OLD' }, ], zipRows: [ { id: 'zip-sku', assetType: 'sku', fileUrl: path.join(tempRoot, 'sku.zip'), fileName: 'sku.zip' }, { id: 'zip-all', assetType: 'all', fileUrl: path.join(tempRoot, 'all.zip'), fileName: 'all.zip' }, ], extraFindMany: [ [ { id: 'new-sku-1', fileUrl: 'https://preview/material-1/sku/append-1__srv-1.webp' }, { id: 'new-sku-2', fileUrl: 'https://preview/material-1/sku/append-2__srv-2.webp' }, ], [ { id: 'new-sku-1' }, { id: 'new-sku-2' }, ], ], }) mockListZipEntries.mockResolvedValue([{ name: 'sku-old.png' }]) mockExtractZipEntries.mockResolvedValue([ { entryName: 'sku-old.png', filePath: existingSource.filePath, size: existingSource.size }, ]) const result = await batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', approvalRequestId: 'request-atomic', operations: [ { kind: 'append', group: 'sku', uploadId: 'up-1', skuCode: 'SKU-1', barcodes: ['b-1'] }, { kind: 'append', group: 'sku', uploadId: 'up-2', skuCode: 'SKU-2', barcodes: ['b-2', 'b-3'] }, ], }) expect(result).toMatchObject({ success: true, appliedCount: 2, entryCount: 3 }) expect(mockCreateZipFromFiles).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ name: 'sku-old.png' }), expect.objectContaining({ name: 'append-1.png' }), expect.objectContaining({ name: 'append-2.png' }), ]), 'material-1', 'MODEL-1', '任务-详情', 'SKU图', '作者A', '店铺A', ) expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toEqual( expect.arrayContaining([ expect.objectContaining({ skuCode: 'SKU-1' }), expect.objectContaining({ skuCode: 'SKU-2' }), ]), ) expect(mockCleanupConsumedUploadSession).toHaveBeenCalledWith('up-1') expect(mockCleanupConsumedUploadSession).toHaveBeenCalledWith('up-2') expect(mockQueueSkuNotification).toHaveBeenCalledWith('material-1', ['new-sku-1', 'new-sku-2']) expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith({ where: { id: 'request-atomic', materialId: 'material-1', status: 'applying', }, data: expect.objectContaining({ status: 'applied', applyError: null }), }) }) test('详情插图要求 seam 两侧 preview 必须相邻', async () => { const sourceA = await createSolidImage('detail-a.png', 80, 120) const sourceB = await createSolidImage('detail-b.png', 80, 120) const sourceC = await createSolidImage('detail-c.png', 80, 120) const insertFile = await createSolidImage('insert-middle.png', 70, 70) configureUploadSources({ insert: { path: insertFile.filePath, size: insertFile.size, fileName: 'insert-middle.png' }, }) configureAssetQueries({ previewRows: [ { id: 'a', assetType: 'detail-preview', fileUrl: sourceA.filePath, fileName: '1_01-preview.webp', sortOrder: 0 }, { id: 'b', assetType: 'detail-preview', fileUrl: sourceB.filePath, fileName: '1_02-preview.webp', sortOrder: 1 }, { id: 'c', assetType: 'detail-preview', fileUrl: sourceC.filePath, fileName: '1_03-preview.webp', sortOrder: 2 }, ], zipRows: [ { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }, ], }) mockListZipEntries.mockResolvedValue([{ name: '1_01.jpg' }, { name: '1_02.jpg' }, { name: '1_03.jpg' }]) mockExtractZipEntries.mockResolvedValue([ { entryName: '1_01.jpg', filePath: sourceA.filePath, size: sourceA.size }, { entryName: '1_02.jpg', filePath: sourceB.filePath, size: sourceB.size }, { entryName: '1_03.jpg', filePath: sourceC.filePath, size: sourceC.size }, ]) await expect(batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'insert', afterAssetId: 'a', beforeAssetId: 'c', uploadId: 'insert' }, ], })).rejects.toThrow('相邻预览之间') expect(mockCreateZipFromFiles).not.toHaveBeenCalled() }) test('详情跨 run 插图支持 server-trusted staged file,不依赖 upload session', async () => { const sourceA = await createSolidImage('cross-a.png', 80, 120) const sourceB = await createSolidImage('cross-b.png', 80, 120) const staged = await createSolidImage('cross-insert.png', 70, 90) configureAssetQueries({ previewRows: [ { id: 'a', assetType: 'detail-preview', fileUrl: sourceA.filePath, fileName: '1_01-preview.webp', sortOrder: 0 }, { id: 'b', assetType: 'detail-preview', fileUrl: sourceB.filePath, fileName: '1_02-preview.webp', sortOrder: 1 }, ], zipRows: [ { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }, ], }) mockListZipEntries.mockResolvedValue([{ name: '1_01.jpg' }, { name: '1_02.jpg' }]) mockExtractZipEntries.mockResolvedValue([ { entryName: '1_01.jpg', filePath: sourceA.filePath, size: sourceA.size }, { entryName: '1_02.jpg', filePath: sourceB.filePath, size: sourceB.size }, ]) const stagedFile: ServerTrustedTaskDetailStagedFile = { trusted: true, filePath: staged.filePath, fileName: 'cross-insert.png', fileSize: staged.size, } const result = await batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'insert', afterAssetId: 'a', beforeAssetId: 'b', stagedFile }, ], }) expect(result.entryCount).toBe(3) expect(mockGetOwnedUploadSession).not.toHaveBeenCalled() expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ '1_01.jpg', 'cross-insert.png', '1_02.jpg', ]) }) test('详情同 run 静态长图插图会拆成上半/插入/下半并重建 preview', async () => { const longSource = await createSolidImage('long-source.png', 1000, 4000) const preview1 = await createSolidImage('long-001.webp', 1000, 1500, 'webp') const preview2 = await createSolidImage('long-002.webp', 1000, 1500, 'webp') const preview3 = await createSolidImage('long-003.webp', 1000, 1000, 'webp') const inserted = await createSolidImage('insert-static.png', 900, 600) configureUploadSources({ insert: { path: inserted.filePath, size: inserted.size, fileName: 'insert-static.png' }, }) configureAssetQueries({ previewRows: [ { id: 'slice-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'long-001.webp', sortOrder: 0 }, { id: 'slice-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'long-002.webp', sortOrder: 1 }, { id: 'slice-3', assetType: 'detail-preview', fileUrl: preview3.filePath, fileName: 'long-003.webp', sortOrder: 2 }, ], zipRows: [ { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }, ], }) mockListZipEntries.mockResolvedValue([{ name: 'long.png' }]) mockExtractZipEntries.mockResolvedValue([ { entryName: 'long.png', filePath: longSource.filePath, size: longSource.size }, ]) const result = await batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'insert', afterAssetId: 'slice-1', beforeAssetId: 'slice-2', uploadId: 'insert' }, ], }) expect(result.entryCount).toBe(3) expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ 'long__top.png', 'insert-static.png', 'long__bottom.png', ]) expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toHaveLength(4) }) test('同一长图接缝一次选择多张图片时按选择顺序连续插入', async () => { const longSource = await createSolidImage('multi-long-source.png', 1000, 3000) const preview1 = await createSolidImage('multi-long-001.webp', 1000, 1500, 'webp') const preview2 = await createSolidImage('multi-long-002.webp', 1000, 1500, 'webp') const inserted1 = await createSolidImage('insert-first.png', 900, 500) const inserted2 = await createSolidImage('insert-second.png', 900, 600) configureUploadSources({ 'insert-1': { path: inserted1.filePath, size: inserted1.size, fileName: 'insert-first.png' }, 'insert-2': { path: inserted2.filePath, size: inserted2.size, fileName: 'insert-second.png' }, }) configureAssetQueries({ previewRows: [ { id: 'multi-slice-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'multi-long-001.webp', sortOrder: 0 }, { id: 'multi-slice-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'multi-long-002.webp', sortOrder: 1 }, ], zipRows: [ { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'multi-detail.zip'), fileName: 'multi-detail.zip' }, ], }) mockListZipEntries.mockResolvedValue([{ name: 'multi-long.png' }]) mockExtractZipEntries.mockResolvedValue([ { entryName: 'multi-long.png', filePath: longSource.filePath, size: longSource.size }, ]) const result = await batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'insert', afterAssetId: 'multi-slice-1', beforeAssetId: 'multi-slice-2', uploadId: 'insert-1' }, { kind: 'insert', afterAssetId: 'multi-slice-1', beforeAssetId: 'multi-slice-2', uploadId: 'insert-2' }, ], }) expect(result.entryCount).toBe(4) expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ 'multi-long__top.png', 'insert-first.png', 'insert-second.png', 'multi-long__bottom.png', ]) }) test('详情同 run GIF 长图插图走 ffmpeg 保留动画链路', async () => { const longGif = await createAnimatedGif('long-run.gif', 100, 2000) const topGif = await createAnimatedGif('long-run-top.gif', 100, 1500) const bottomGif = await createAnimatedGif('long-run-bottom.gif', 100, 500) const preview1 = await createSolidImage('gif-001.webp', 100, 1500, 'webp') const preview2 = await createSolidImage('gif-002.webp', 100, 500, 'webp') const inserted = await createSolidImage('insert-gif-static.png', 100, 300) configureUploadSources({ insert: { path: inserted.filePath, size: inserted.size, fileName: 'insert-gif-static.png' }, }) configureAssetQueries({ previewRows: [ { id: 'gif-1', assetType: 'detail-preview', fileUrl: preview1.filePath, fileName: 'gif-run-001.webp', sortOrder: 0 }, { id: 'gif-2', assetType: 'detail-preview', fileUrl: preview2.filePath, fileName: 'gif-run-002.webp', sortOrder: 1 }, ], zipRows: [ { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail-gif.zip'), fileName: 'detail-gif.zip' }, ], }) mockListZipEntries.mockResolvedValue([{ name: 'gif-run.gif' }]) mockExtractZipEntries.mockResolvedValue([ { entryName: 'gif-run.gif', filePath: longGif.filePath, size: longGif.size }, ]) mockExecFile.mockImplementation((file: string, args: string[], options: any, callback: (error: Error | null, stdout?: string, stderr?: string) => void) => { const outputPath = args[args.length - 1] if (String(outputPath).endsWith('.gif')) { const filters = args.join(' ') writeFileSync(outputPath, filters.includes('crop=100:1500') ? topGif.buffer : bottomGif.buffer) } else { writeFileSync(outputPath, Buffer.from('RIFFxxxxWEBP')) } callback(null, '', '') }) const result = await batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'insert', afterAssetId: 'gif-1', beforeAssetId: 'gif-2', uploadId: 'insert' }, ], }) expect(result.entryCount).toBe(3) expect(mockExecFile.mock.calls.length).toBeGreaterThanOrEqual(4) expect(mockExecFile.mock.calls.some((call) => String(call[1]).includes('libwebp_anim'))).toBe(true) expect(mockExecFile.mock.calls.some((call) => String(call[1]).includes('crop='))).toBe(true) expect(mockCreateZipFromFiles.mock.calls[0][0].map((item: { name: string }) => item.name)).toEqual([ 'gif-run__top.gif', 'insert-gif-static.png', 'gif-run__bottom.gif', ]) }) test('私密详情动图插图把新 preview 写到本地 private-previews,不走 public-display', async () => { const previousUploadDir = process.env.UPLOAD_DIR process.env.UPLOAD_DIR = tempRoot try { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'operator-1', materialType: '任务-详情', taskDetailPrivate: true, taskDetailRequesterUserId: 'operator-1', taskStatus: 'completed', productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', previewUrl: 'uploads/materials/material-1/private-previews/detail/old-cover.webp', isFullSet: false, updatedAt: new Date('2026-08-28T08:00:00.000Z'), }) const longGif = await createAnimatedGif('private-long-run.gif', 100, 2000) const topGif = await createAnimatedGif('private-long-run-top.gif', 100, 1500) const bottomGif = await createAnimatedGif('private-long-run-bottom.gif', 100, 500) const topPreview = await createSolidImage('private-gif-001.webp', 100, 1500, 'webp') const bottomPreview = await createSolidImage('private-gif-002.webp', 100, 500, 'webp') const inserted = await createSolidImage('private-insert.png', 100, 300) configureUploadSources({ insert: { path: inserted.filePath, size: inserted.size, fileName: 'private-insert.png' }, }) configureAssetQueries({ previewRows: [ { id: 'gif-1', assetType: 'detail-preview', fileUrl: topPreview.filePath, fileName: 'gif-run-001.webp', sortOrder: 0 }, { id: 'gif-2', assetType: 'detail-preview', fileUrl: bottomPreview.filePath, fileName: 'gif-run-002.webp', sortOrder: 1 }, ], zipRows: [ { id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'private-detail-gif.zip'), fileName: 'detail-gif.zip' }, ], }) mockListZipEntries.mockResolvedValue([{ name: 'gif-run.gif' }]) mockExtractZipEntries.mockResolvedValue([ { entryName: 'gif-run.gif', filePath: longGif.filePath, size: longGif.size }, ]) mockExecFile.mockImplementation((file: string, args: string[], options: any, callback: (error: Error | null, stdout?: string, stderr?: string) => void) => { const outputPath = args[args.length - 1] if (String(outputPath).endsWith('.gif')) { const filters = args.join(' ') writeFileSync(outputPath, filters.includes('crop=100:1500') ? topGif.buffer : bottomGif.buffer) } else { writeFileSync(outputPath, Buffer.from('RIFFxxxxWEBP')) } callback(null, '', '') }) const result = await batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'insert', afterAssetId: 'gif-1', beforeAssetId: 'gif-2', uploadId: 'insert' }, ], }) expect(result.entryCount).toBe(3) expect(mockUploadPreview).not.toHaveBeenCalled() const createdRows = (prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data const localPreviewUrls = createdRows.map((row: { fileUrl: string }) => row.fileUrl) expect(localPreviewUrls).toEqual( expect.arrayContaining([ expect.stringMatching(/^uploads\/materials\/material-1\/private-previews\/detail\/.+\.webp$/), ]), ) } finally { if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir } }) test('追加后超过图片组上限会拒绝提交', async () => { const existingRows: PreviewRow[] = [] const entryNames: Array<{ name: string; filePath: string }> = [] for (let index = 0; index < 300; index += 1) { existingRows.push({ id: `main-${index}`, assetType: 'main-preview', fileUrl: `https://preview/existing-${index}.webp`, fileName: `existing-${index}-preview.webp`, sortOrder: index, }) entryNames.push({ name: `existing-${index}.jpg`, filePath: path.join(tempRoot, `existing-${index}.jpg`) }) } const appended = await createSolidImage('append-over-limit.png', 80, 80) configureUploadSources({ append: { path: appended.filePath, size: appended.size, fileName: 'append-over-limit.png' }, }) configureAssetQueries({ previewRows: existingRows, zipRows: [ { id: 'zip-main', assetType: 'main', fileUrl: path.join(tempRoot, 'main.zip'), fileName: 'main.zip' }, ], }) mockListZipEntries.mockResolvedValue(entryNames.map((item) => ({ name: item.name }))) mockExtractZipEntries.mockResolvedValue(entryNames.map((item, index) => ({ entryName: item.name, filePath: item.filePath, size: index + 1, }))) await expect(batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'append', group: 'main', uploadId: 'append' }, ], })).rejects.toThrow('最多保留 300 张') expect(mockCreateZipFromFiles).not.toHaveBeenCalled() }) test('私密素材访问门禁先于 canEditAssets,编辑权限不能绕过', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'owner-2', materialType: '任务-详情', taskDetailPrivate: true, taskDetailRequesterUserId: 'owner-2', taskStatus: 'completed', productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', previewUrl: 'uploads/materials/material-1/private-previews/main/cover.webp', isFullSet: false, updatedAt: new Date('2026-08-28T08:00:00.000Z'), }) mockCanManageMaterial.mockResolvedValue(false) mockCanUserEditTaskDetailAssets.mockResolvedValue(true) mockCanAccessPrivateTaskDetail.mockResolvedValue(false) await expect(batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', operations: [ { kind: 'append', group: 'main', uploadId: 'append-any' }, ], })).rejects.toThrow('您没有权限编辑此素材') expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() }) test('审批期间正式素材已更新时在持锁上下文中停止应用', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'material-1', userId: 'operator-1', materialType: '任务-详情', taskStatus: 'completed', productModel: 'MODEL-1', authorName: '作者A', categoryName: '店铺A', previewUrl: 'https://preview/existing-cover.webp', isFullSet: false, updatedAt: new Date('2026-08-28T08:01:00.000Z'), }) await expect(batchEditTaskDetailImages({ materialId: 'material-1', operatorUserId: 'operator-1', expectedMaterialUpdatedAt: new Date('2026-08-28T08:00:00.000Z'), operations: [{ kind: 'append', group: 'main', uploadId: 'append' }], })).rejects.toThrow('正式素材在审批期间已更新') expect(prisma.materialAsset.findMany).not.toHaveBeenCalled() expect(mockCreateZipFromFiles).not.toHaveBeenCalled() }) })