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