1025 lines
48 KiB
TypeScript
1025 lines
48 KiB
TypeScript
/** @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()
|
||
})
|
||
})
|