gongxiangp2/lib/__tests__/task-detail-asset-edit-batch.test.ts

706 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @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<string, { path: string; size: number; fileName: string }>) {
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()
})
})