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

1025 lines
48 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,
replaceTaskDetailImage,
} 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 }
}
async function createStripedLongImage(fileName: string) {
const width = 1200
const heights = [1500, 1500, 1451]
const colors = [
{ r: 230, g: 20, b: 20 },
{ r: 20, g: 210, b: 20 },
{ r: 20, g: 20, b: 230 },
]
const bands = await Promise.all(heights.map((height, index) => sharp({
create: { width, height, channels: 3, background: colors[index] },
}).png().toBuffer()))
const buffer = await sharp({
create: {
width,
height: heights.reduce((sum, height) => sum + height, 0),
channels: 3,
background: { r: 0, g: 0, b: 0 },
},
}).composite([
{ input: bands[0], top: 0, left: 0 },
{ input: bands[1], top: heights[0], left: 0 },
{ input: bands[2], top: heights[0] + heights[1], left: 0 },
]).png().toBuffer()
const filePath = path.join(tempRoot, fileName)
await writeFile(filePath, buffer)
return { filePath, size: buffer.length, heights, colors }
}
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('主管批准删除和原接缝插图的六项组合时整体应用一次,允许复用已删除源文件名', async () => {
const originals = await Promise.all(Array.from({ length: 6 }, (_, i) => createSolidImage(`original-${i}.png`, 80, 120)))
const uploads = await Promise.all(Array.from({ length: 3 }, (_, i) => createSolidImage(`incoming-${i}.png`, 70, 90)))
const names = originals.map((_, i) => `1_0${i + 1}.png`)
configureAssetQueries({
previewRows: originals.map((source, i) => ({ id: `row-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `1_0${i + 1}-preview.webp`, sortOrder: i })),
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue(names.map(name => ({ name })))
mockExtractZipEntries.mockResolvedValue(originals.map((source, i) => ({ entryName: names[i], filePath: source.filePath, size: source.size })))
const result = await batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1', approvalRequestId: 'request-combined',
operations: uploads.flatMap((source, i) => [
{ kind: 'delete' as const, assetId: `row-${i * 2 + 1}` },
{ kind: 'insert' as const, afterAssetId: `row-${i * 2}`, beforeAssetId: `row-${i * 2 + 1}`,
stagedFile: { trusted: true as const, filePath: source.filePath, fileSize: source.size, fileName: names[i * 2 + 1] } },
]),
})
expect(result).toMatchObject({ success: true, appliedCount: 6, entryCount: 6 })
expect(mockCreateZipFromFiles).toHaveBeenCalledTimes(1)
expect(mockCreateZipFromZips).toHaveBeenCalledTimes(1)
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([
originals[0].filePath, uploads[0].filePath, originals[2].filePath, uploads[1].filePath, originals[4].filePath, uploads[2].filePath,
])
expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['row-1', 'row-3', 'row-5'] } } })
expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({
where: { id: 'request-combined', materialId: 'material-1', status: 'applying' },
data: expect.objectContaining({ status: 'applied' }),
}))
})
test.each([{ deleteFirst: true }, { deleteFirst: false }])('长图删除与两个接缝插图保留相同顺序,删除在前=$deleteFirst', async ({ deleteFirst }) => {
const long = await createSolidImage('deleted-long.png', 1000, 4000)
const previews = await Promise.all([1500, 1500, 1000].map((height, i) => createSolidImage(`long-00${i + 1}.webp`, 1000, height, 'webp')))
const tail = await createSolidImage('tail.png', 80, 120)
const inserted = await Promise.all([0, 1].map(i => createSolidImage(`new-${i}.png`, 80, 90)))
configureAssetQueries({
previewRows: [
...previews.map((source, i) => ({ id: `slice-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `long-00${i + 1}.webp`, sortOrder: i })),
{ id: 'tail', assetType: 'detail-preview', fileUrl: tail.filePath, fileName: 'tail-preview.webp', sortOrder: 3 },
],
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue([{ name: 'long.png' }, { name: 'tail.png' }])
mockExtractZipEntries.mockResolvedValue([
{ entryName: 'long.png', filePath: long.filePath, size: long.size },
{ entryName: 'tail.png', filePath: tail.filePath, size: tail.size },
])
const remove = { kind: 'delete' as const, assetId: 'slice-1' }
const inserts = inserted.map((source, i) => ({
kind: 'insert' as const, afterAssetId: `slice-${i}`, beforeAssetId: `slice-${i + 1}`,
stagedFile: { trusted: true as const, filePath: source.filePath, fileName: `new-${i}.png`, fileSize: source.size },
}))
const result = await batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1',
operations: deleteFirst ? [remove, ...inserts] : [...inserts, remove],
})
expect(result.entryCount).toBe(3)
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([inserted[0].filePath, inserted[1].filePath, tail.filePath])
expect(prisma.materialAsset.deleteMany).toHaveBeenCalledWith({ where: { id: { in: ['slice-0', 'slice-1', 'slice-2'] } } })
expect(mockUploadPreview).toHaveBeenCalledTimes(2)
})
test('接缝两侧原图都删除时,插入仍保留在原位置且按选择顺序排列', async () => {
const sources = await Promise.all([0, 1, 2].map(i => createSolidImage(`source-${i}.png`, 80, 90)))
const inserts = await Promise.all([0, 1].map(i => createSolidImage(`insert-${i}.png`, 70, 80)))
configureAssetQueries({
previewRows: sources.map((source, i) => ({ id: `row-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `1_0${i + 1}-preview.webp`, sortOrder: i })),
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue(sources.map((_, i) => ({ name: `1_0${i + 1}.png` })))
mockExtractZipEntries.mockResolvedValue(sources.map((source, i) => ({ entryName: `1_0${i + 1}.png`, filePath: source.filePath, size: source.size })))
await batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1',
operations: [
{ kind: 'delete', assetId: 'row-0' }, { kind: 'delete', assetId: 'row-1' },
...inserts.map((source, i) => ({ kind: 'insert' as const, afterAssetId: 'row-0', beforeAssetId: 'row-1',
stagedFile: { trusted: true as const, filePath: source.filePath, fileName: `insert-${i}.png`, fileSize: source.size } })),
],
})
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { path: string }) => entry.path)).toEqual([inserts[0].filePath, inserts[1].filePath, sources[2].filePath])
})
test.each(['replace', 'rename'] as const)('同一源长图的不同切片不能同时删除和%s,必须在生成图片前拒绝', async (kind) => {
const source = await createSolidImage('conflicting-long.png', 80, 3000)
const replacement = await createSolidImage('replacement.png', 80, 90)
configureAssetQueries({
previewRows: [0, 1].map(i => ({ id: `slice-${i}`, assetType: 'detail-preview', fileUrl: source.filePath, fileName: `long-00${i + 1}.webp`, sortOrder: i })),
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: source.filePath, size: source.size }])
await expect(batchEditTaskDetailImages({
materialId: 'material-1', operatorUserId: 'operator-1',
operations: [
{ kind: 'delete', assetId: 'slice-1' },
kind === 'rename' ? { kind, assetId: 'slice-0', newFileName: 'renamed.png' }
: { kind, assetId: 'slice-0', stagedFile: { trusted: true, filePath: replacement.filePath, fileName: 'replacement.png', fileSize: replacement.size } },
],
})).rejects.toThrow('同一张源图不能同时删除并替换或改名')
expect(mockUploadPreview).not.toHaveBeenCalled()
expect(mockCreateZipFromFiles).not.toHaveBeenCalled()
expect(prisma.material.update).not.toHaveBeenCalled()
})
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.each([
{ targetIndex: 0, expectedNames: ['long.png', 'long__bottom.png'] },
{ targetIndex: 1, expectedNames: ['long__top.png', 'long.png', 'long__bottom.png'] },
{ targetIndex: 2, expectedNames: ['long__top.png', 'long.png'] },
])('详情长图精确替换第 $targetIndex 张切片并保留其它原图区段', async ({ targetIndex, expectedNames }) => {
const long = await createStripedLongImage('long.png')
const previews = await Promise.all(long.heights.map((height, index) =>
createSolidImage(`legacy-${index}.webp`, 1200, height, 'webp')))
const replacement = await createSolidImage(`replacement-${targetIndex}.png`, 900, 600)
configureUploadSources({
replacement: { path: replacement.filePath, size: replacement.size, fileName: `replacement-${targetIndex}.png` },
})
configureAssetQueries({
previewRows: previews.map((preview, index) => ({
id: `slice-${index}`,
assetType: 'detail-preview',
fileUrl: preview.filePath,
fileName: `detail-preview-1788790000000-0-slice${index}-legacy.webp`,
sortOrder: index,
})),
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: long.filePath, size: long.size }])
let capturedNames: string[] = []
let capturedFragments: Array<{ name: string; width: number; height: number; pixel: number[] }> = []
mockCreateZipFromFiles.mockImplementationOnce(async (entries: Array<{ path: string; name: string }>) => {
capturedNames = entries.map((entry) => entry.name)
capturedFragments = await Promise.all(entries
.filter((entry) => entry.name.includes('__top') || entry.name.includes('__bottom'))
.map(async (entry) => {
const metadata = await sharp(entry.path).metadata()
const pixel = await sharp(entry.path).extract({ left: 0, top: 0, width: 1, height: 1 }).raw().toBuffer()
return {
name: entry.name,
width: Number(metadata.width),
height: Number(metadata.height),
pixel: Array.from(pixel.slice(0, 3)),
}
}))
return { filePath: 'uploads/materials/group.zip', fileName: 'group.zip', fileSize: 123 }
})
const result = await batchEditTaskDetailImages({
materialId: 'material-1',
operatorUserId: 'operator-1',
operations: [{ kind: 'replace', assetId: `slice-${targetIndex}`, uploadId: 'replacement' }],
})
expect(result.entryCount).toBe(expectedNames.length)
expect(capturedNames).toEqual(expectedNames)
expect((prisma.materialAsset.createMany as jest.Mock).mock.calls[0][0].data).toHaveLength(3)
if (targetIndex === 1) {
expect(capturedFragments).toEqual([
expect.objectContaining({ name: 'long__top.png', width: 1200, height: 1500 }),
expect.objectContaining({ name: 'long__bottom.png', width: 1200, height: 1451 }),
])
expect(capturedFragments[0].pixel[0]).toBeGreaterThan(200)
expect(capturedFragments[0].pixel[1]).toBeLessThan(40)
expect(capturedFragments[1].pixel[2]).toBeGreaterThan(200)
expect(capturedFragments[1].pixel[0]).toBeLessThan(40)
}
})
test('同一详情长图可在一次批量中精确替换多张不同切片', async () => {
const long = await createStripedLongImage('multi-replace-long.png')
const previews = await Promise.all(long.heights.map((height, index) =>
createSolidImage(`multi-replace-${index}.webp`, 1200, height, 'webp')))
const first = await createSolidImage('replace-first.png', 700, 500)
const last = await createSolidImage('replace-last.png', 800, 700)
configureUploadSources({
first: { path: first.filePath, size: first.size, fileName: 'replace-first.png' },
last: { path: last.filePath, size: last.size, fileName: 'replace-last.png' },
})
configureAssetQueries({
previewRows: previews.map((preview, index) => ({
id: `multi-slice-${index}`,
assetType: 'detail-preview',
fileUrl: preview.filePath,
fileName: `multi-replace-long-${String(index + 1).padStart(3, '0')}.webp`,
sortOrder: index,
})),
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue([{ name: 'multi-replace-long.png' }])
mockExtractZipEntries.mockResolvedValue([{ entryName: 'multi-replace-long.png', filePath: long.filePath, size: long.size }])
const result = await batchEditTaskDetailImages({
materialId: 'material-1',
operatorUserId: 'operator-1',
operations: [
{ kind: 'replace', assetId: 'multi-slice-0', uploadId: 'first' },
{ kind: 'replace', assetId: 'multi-slice-2', uploadId: 'last' },
],
})
expect(result).toMatchObject({ success: true, appliedCount: 2, entryCount: 3 })
const entries = mockCreateZipFromFiles.mock.calls[0][0] as Array<{ path: string; name: string }>
expect(entries.map((entry) => entry.name)).toEqual([
'multi-replace-long.png',
'multi-replace-long__bottom__top.png',
'multi-replace-long__bottom.png',
])
expect(entries[0].path).toBe(first.filePath)
expect(entries[2].path).toBe(last.filePath)
})
test('单张替换接口复用精确切片链,后续可继续编辑已拆分长图', async () => {
const top = await createSolidImage('continued__top.png', 1200, 3000)
const replacementEntry = await createSolidImage('continued.png', 900, 600)
const bottom = await createSolidImage('continued__bottom.png', 1200, 1451)
const previews = await Promise.all([1500, 1500, 600, 1451].map((height, index) =>
createSolidImage(`continued-preview-${index}.webp`, index === 2 ? 900 : 1200, height, 'webp')))
const replacement = await createSolidImage('continued-second-replacement.png', 640, 480)
configureUploadSources({
replacement: { path: replacement.filePath, size: replacement.size, fileName: 'continued-second-replacement.png' },
})
configureAssetQueries({
previewRows: [
{ id: 'continued-0', assetType: 'detail-preview', fileUrl: previews[0].filePath, fileName: 'continued__top__srv-1788790000000-0-slice0-a.webp', sortOrder: 0 },
{ id: 'continued-1', assetType: 'detail-preview', fileUrl: previews[1].filePath, fileName: 'continued__top__srv-1788790000000-0-slice1-b.webp', sortOrder: 1 },
{ id: 'continued-replaced', assetType: 'detail-preview', fileUrl: previews[2].filePath, fileName: 'continued__srv-1788790000000-1-c.webp', sortOrder: 2 },
{ id: 'continued-bottom', assetType: 'detail-preview', fileUrl: previews[3].filePath, fileName: 'continued__bottom__srv-1788790000000-2-d.webp', sortOrder: 3 },
],
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue([
{ name: 'continued__top.png' },
{ name: 'continued.png' },
{ name: 'continued__bottom.png' },
])
mockExtractZipEntries.mockResolvedValue([
{ entryName: 'continued__top.png', filePath: top.filePath, size: top.size },
{ entryName: 'continued.png', filePath: replacementEntry.filePath, size: replacementEntry.size },
{ entryName: 'continued__bottom.png', filePath: bottom.filePath, size: bottom.size },
])
const result = await replaceTaskDetailImage({
materialId: 'material-1',
assetId: 'continued-1',
operatorUserId: 'operator-1',
uploadId: 'replacement',
})
expect(result).toMatchObject({ success: true, entryName: 'continued__top.png' })
expect(mockCreateZipFromFiles.mock.calls[0][0].map((entry: { name: string }) => entry.name)).toEqual([
'continued__top__top.png',
'continued__top.png',
'continued.png',
'continued__bottom.png',
])
})
test('详情长图与旧预览切片几何不一致时拒绝精确替换', async () => {
const long = await createSolidImage('geometry-long.png', 1200, 3000)
const previews = await Promise.all([1500, 1400].map((height, index) =>
createSolidImage(`geometry-${index}.webp`, 1200, height, 'webp')))
const replacement = await createSolidImage('geometry-replacement.png', 500, 500)
configureUploadSources({
replacement: { path: replacement.filePath, size: replacement.size, fileName: 'geometry-replacement.png' },
})
configureAssetQueries({
previewRows: previews.map((preview, index) => ({
id: `geometry-${index}`,
assetType: 'detail-preview',
fileUrl: preview.filePath,
fileName: `geometry-long-${String(index + 1).padStart(3, '0')}.webp`,
sortOrder: index,
})),
zipRows: [{ id: 'zip-detail', assetType: 'detail', fileUrl: path.join(tempRoot, 'detail.zip'), fileName: 'detail.zip' }],
})
mockListZipEntries.mockResolvedValue([{ name: 'geometry-long.png' }])
mockExtractZipEntries.mockResolvedValue([{ entryName: 'geometry-long.png', filePath: long.filePath, size: long.size }])
await expect(batchEditTaskDetailImages({
materialId: 'material-1',
operatorUserId: 'operator-1',
operations: [{ kind: 'replace', assetId: 'geometry-0', uploadId: 'replacement' }],
})).rejects.toThrow('预览切片总高度与原图缩放规则不一致')
expect(mockUploadPreview).not.toHaveBeenCalled()
expect(mockCreateZipFromFiles).not.toHaveBeenCalled()
})
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()
})
})