gongxiangp2/lib/__tests__/product-package-completion.test.ts

1054 lines
36 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 {
assertClientPreviewWebpMetadata,
assertOwnedProductPackageUploadSessions,
assertTaskDetailUploadAssetLimits,
completeProductPackageUpload,
parsePublicProductPackageAssets,
} from '@/lib/product-package-completion'
import { prisma } from '@/lib/prisma'
import {
queueTaskDetailBarcodeImagesUpdatedNotification,
queueTaskDetailFullSetNotification,
} from '@/lib/external-auto-listing'
import { createMaterialLog } from '@/lib/material-log'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
import {
cleanupOnError,
cleanupTempFiles,
createZipFromFiles,
saveProductPackageAssetsAndComplete,
} from '@/lib/task-material-processor'
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
import { getOwnedUploadSession, getUploadedFile } from '@/lib/upload-sessions'
import { withImageProcessingSlot } from '@/lib/image-processing-limiter'
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import path from 'path'
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findUnique: jest.fn(),
update: jest.fn(),
},
materialAsset: {
count: jest.fn(),
deleteMany: jest.fn(),
},
$transaction: jest.fn(),
},
}))
jest.mock('@/lib/product-package', () => ({
validateTaskNo: jest.fn(() => true),
}))
jest.mock('@/lib/task-material-processor', () => ({
cleanupOnError: jest.fn().mockResolvedValue(undefined),
cleanupTempFiles: jest.fn().mockResolvedValue(undefined),
createZipFromFiles: jest.fn()
.mockResolvedValueOnce({ filePath: 'zip-main', fileName: 'main.zip', fileSize: 11 })
.mockResolvedValueOnce({ filePath: 'zip-sku', fileName: 'sku.zip', fileSize: 22 })
.mockResolvedValueOnce({ filePath: 'zip-detail', fileName: 'detail.zip', fileSize: 33 }),
createZipFromZips: jest.fn().mockResolvedValue({ filePath: 'zip-all', fileName: 'all.zip', fileSize: 66 }),
saveProductPackageAssetsAndComplete: jest.fn().mockResolvedValue({
createdSkuPreviewAssets: [
{
id: 'sku-preview-1',
fileUrl: 'https://s3.example/sku-preview-1.webp',
fileName: 'sku-preview-1.webp',
sortOrder: 0,
skuCode: 'SKU-001',
createdAt: new Date('2026-04-22T09:00:00.000Z'),
updatedAt: new Date('2026-04-22T09:00:00.000Z'),
barcodes: ['690000000001'],
},
],
}),
}))
jest.mock('@/lib/upload-sessions', () => ({
getUploadedFile: jest.fn(),
getOwnedUploadSession: jest.fn().mockResolvedValue({ userId: 'user-1' }),
refreshOwnedUploadSessionTtls: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('@/lib/task-detail-full-set', () => ({
isTaskDetailFullSetFromUploadGroups: jest.fn(() => true),
}))
jest.mock('@/lib/public-display', () => ({
buildTaskDetailPublicDisplayPath: jest.fn((_materialId: string, group: string, fileName: string) => `task-detail/${group}/${fileName}`),
uploadPublicDisplayToS3: jest.fn((_: Buffer, path: string) => Promise.resolve(`https://s3.example/${path}`)),
}))
jest.mock('@/lib/external-auto-listing', () => ({
queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue([]),
queueTaskDetailBarcodeImagesUpdatedNotification: jest.fn().mockResolvedValue([]),
}))
jest.mock('@/lib/image-processor', () => ({
compressImage: jest.fn(async (buffer: Buffer) => buffer),
sliceImage: jest.fn(async (buffer: Buffer) => [buffer]),
}))
jest.mock('@/lib/image-processing-limiter', () => ({
withImageProcessingSlot: jest.fn(async (task: () => Promise<unknown>) => task()),
}))
jest.mock('@/lib/remote-upload-sync', () => ({
isRemoteUploadSyncEnabled: jest.fn(() => false),
syncStoredUploadFileToRemote: jest.fn().mockResolvedValue(null),
}))
jest.mock('sharp', () => ({
__esModule: true,
default: jest.fn(() => ({
metadata: jest.fn().mockResolvedValue({ width: 800, height: 800 }),
})),
}))
describe('product-package-completion', () => {
beforeEach(() => {
jest.clearAllMocks()
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
productModel: 'SPU-001',
categoryName: '旗舰店',
previewUrl: '',
user: {
realName: '张三',
username: 'zhangsan',
},
})
;(prisma.material.update as jest.Mock).mockResolvedValue(undefined)
;(prisma.materialAsset.count as jest.Mock).mockResolvedValue(0)
;(prisma.materialAsset.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: (tx: any) => unknown) => callback({
materialAsset: { deleteMany: prisma.materialAsset.deleteMany },
material: { update: prisma.material.update },
}))
global.fetch = jest.fn(async () =>
new Response(Buffer.from('image-binary'), {
status: 200,
headers: {
'Content-Type': 'image/png',
},
}),
) as any
})
test('后台处理入口再次执行图片、PSD 和预览数量门禁', () => {
expect(() => assertTaskDetailUploadAssetLimits({
main: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup + 1 }, (_, index) => ({
uploadId: `main-${index}`,
fileName: `${index}.jpg`,
fileSize: 1,
})),
})).toThrow(`主图最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张`)
expect(() => assertTaskDetailUploadAssetLimits({
psd: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount + 1 }, (_, index) => ({
uploadId: `psd-${index}`,
fileName: `${index}.psd`,
fileSize: 1,
})),
})).toThrow(`PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个`)
const imageGroup = (prefix: string, count: number) => Array.from({ length: count }, (_, index) => ({
uploadId: `${prefix}-${index}`,
fileName: `${prefix}-${index}.jpg`,
fileSize: 1,
}))
expect(() => assertTaskDetailUploadAssetLimits({
main: imageGroup('main', 250),
sku: imageGroup('sku', 200),
detail: imageGroup('detail', 151),
})).toThrow(`本次图片最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxTotalImageCount} 张`)
const previews = Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxPreviewCountPerImage }, (_, index) => ({
uploadId: `preview-${index}`,
fileName: `${index}.webp`,
fileSize: 1,
width: 1,
height: 1,
kind: 'preview',
}))
expect(() => assertTaskDetailUploadAssetLimits({
main: Array.from({ length: 34 }, (_, index) => ({
uploadId: `source-${index}`,
fileName: `${index}.jpg`,
fileSize: 1,
previewAssets: previews.map((preview, previewIndex) => ({
...preview,
uploadId: `${index}-${previewIndex}`,
})),
})),
})).toThrow('客户端预览图数量过多')
})
test('真实会话复核将源文件和预览图字节分别计算', async () => {
const sourceBytes = 1024 * 1024 * 1024
;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({
userId: 'user-1',
fileSize: uploadId === 'preview-1' ? 1 : sourceBytes - 1,
}))
await expect(assertOwnedProductPackageUploadSessions({
main: [{
fileUrl: 'https://img.example/main.png',
fileName: 'main.png',
fileSize: 1,
previewAssets: [{
uploadId: 'preview-1',
fileName: 'preview.webp',
fileSize: 1,
width: 1,
height: 1,
kind: 'preview',
}],
}],
psd: [{
uploadId: 'source-1',
fileName: 'source.psd',
fileSize: sourceBytes - 1,
}],
}, 'user-1')).resolves.toBeUndefined()
})
test('PSD 源文件不占用图片 1GB 总量,单文件按 10GB 计', async () => {
const twoGb = 2 * 1024 * 1024 * 1024
;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({
userId: 'user-1',
fileSize: uploadId === 'preview-1' ? 1 : uploadId === 'source-1' ? twoGb : 10 * 1024 * 1024,
}))
await expect(assertOwnedProductPackageUploadSessions({
main: [{
uploadId: 'image-1',
fileName: 'main.png',
fileSize: 10 * 1024 * 1024,
previewAssets: [{
uploadId: 'preview-1',
fileName: 'preview.webp',
fileSize: 1,
width: 1,
height: 1,
kind: 'preview',
}],
}],
psd: [{
uploadId: 'source-1',
fileName: '顾家750SKU.psd',
fileSize: twoGb,
}],
}, 'user-1')).resolves.toBeUndefined()
await expect(() => assertTaskDetailUploadAssetLimits({
psd: [{
uploadId: 'source-huge',
fileName: 'too-big.psd',
fileSize: 10 * 1024 * 1024 * 1024 + 1,
}],
})).toThrow('超过 10GB')
})
test('客户端 WebP 预览使用文件路径读取真实元数据并匹配申报尺寸', async () => {
const metadata = jest.fn().mockResolvedValue({
format: 'webp',
width: 800,
height: 600,
pages: 1,
})
const sharpModule = { default: jest.fn(() => ({ metadata })) } as any
await expect(assertClientPreviewWebpMetadata({
uploadId: 'preview-1',
fileName: 'preview.webp',
fileSize: 10,
width: 800,
height: 600,
kind: 'preview',
}, 'D:\\uploads\\preview.webp', sharpModule)).resolves.toBeUndefined()
expect(sharpModule.default).toHaveBeenCalledWith(
'D:\\uploads\\preview.webp',
expect.objectContaining({
limitInputPixels: 2_400_000,
sequentialRead: true,
failOn: 'error',
}),
)
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
})
test('服务器兜底生成预览时元数据读取也经过全局图片处理门禁', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-server-preview-fallback-'))
try {
const sourcePath = path.join(uploadRoot, 'source.jpg')
const sourceBuffer = Buffer.from('server-preview-source')
await writeFile(sourcePath, sourceBuffer)
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
userId: 'user-1',
fileSize: sourceBuffer.length,
})
;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath)
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
materialType: '任务-详情',
taskDetailSourceType: '实拍',
taskStatus: 'uploading',
previewUrl: '',
zipFilePath: '',
zipFileName: '',
zipFileSize: BigInt(0),
taskNo: null,
dingtalkApprovalNo: null,
isFullSet: false,
productModel: 'SPU-001',
categoryName: '旗舰店',
user: { realName: '张三', username: 'zhangsan' },
})
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'zip-main',
fileName: 'main.zip',
fileSize: sourceBuffer.length,
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{
uploadId: 'upload-main',
fileName: 'source.jpg',
fileSize: sourceBuffer.length,
}],
},
})).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' }))
expect(withImageProcessingSlot).toHaveBeenCalledTimes(1)
} finally {
await rm(uploadRoot, { recursive: true, force: true })
}
})
test('私密任务详情完成时预览写入本地 private-previews,且不上传 public-display', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-private-task-detail-preview-'))
const previousUploadDir = process.env.UPLOAD_DIR
process.env.UPLOAD_DIR = uploadRoot
try {
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailPrivate: true,
taskDetailSourceType: '实拍',
taskStatus: 'uploading',
previewUrl: '',
zipFilePath: '',
zipFileName: '',
zipFileSize: BigInt(0),
taskNo: null,
dingtalkApprovalNo: null,
isFullSet: false,
productModel: 'SPU-001',
categoryName: '旗舰店',
user: { realName: '张三', username: 'zhangsan' },
})
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'zip-main',
fileName: 'main.zip',
fileSize: 11,
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{
fileUrl: 'https://img.example/main.png',
fileName: 'main.png',
fileSize: 10,
}],
},
})).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' }))
const previewAsset = (saveProductPackageAssetsAndComplete as jest.Mock).mock.calls.at(-1)?.[0]?.previewAssets?.main?.[0]
expect(previewAsset?.fileUrl).toMatch(/^uploads\/materials\/material-1\/private-previews\/main\/.+\.webp$/)
expect(uploadPublicDisplayToS3).not.toHaveBeenCalled()
const absolutePath = path.join(
uploadRoot,
String(previewAsset.fileUrl).replace(/^uploads\//, '').replace(/\//g, path.sep),
)
await expect(readFile(absolutePath)).resolves.toEqual(Buffer.from('image-binary'))
} finally {
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
await rm(uploadRoot, { recursive: true, force: true })
}
})
test.each([
[{ format: 'jpeg', width: 800, height: 600, pages: 1 }, { width: 800, height: 600 }, '内容不是有效 WebP'],
[{ format: 'webp', width: 800, height: 600, pages: 2 }, { width: 800, height: 600 }, '不允许使用动画 WebP'],
[{ format: 'webp', width: 801, height: 600, pages: 1 }, { width: 800, height: 600 }, '真实尺寸超出限制'],
[{ format: 'webp', width: 799, height: 600, pages: 1 }, { width: 800, height: 600 }, '申报尺寸与真实尺寸不一致'],
])('拒绝伪造或超限客户端预览 %#', async (metadataValue, declared, message) => {
const sharpModule = {
default: jest.fn(() => ({
metadata: jest.fn().mockResolvedValue(metadataValue),
})),
} as any
await expect(assertClientPreviewWebpMetadata({
uploadId: 'preview-malicious',
fileName: 'malicious.webp',
fileSize: 10,
width: declared.width,
height: declared.height,
kind: 'preview',
}, 'D:\\uploads\\malicious.webp', sharpModule)).rejects.toThrow(message)
})
test('全套任务详情完成后应触发自动上架通知排队', async () => {
const result = await completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
dingtalkApprovalNo: '202508211445000157693',
operatorUserId: 'user-1',
assets: {
main: [
{
fileUrl: 'https://img.example/main.png',
fileName: 'main.png',
fileSize: 10,
sortOrder: 0,
},
],
sku: [
{
fileUrl: 'https://img.example/sku.png',
fileName: 'sku.png',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: ['690000000001'],
},
],
detail: [
{
fileUrl: 'https://img.example/detail.png',
fileName: 'detail.png',
fileSize: 10,
sortOrder: 0,
},
],
},
})
expect(result).toEqual({
materialId: 'material-1',
taskNo: null,
dingtalkApprovalNo: '202508211445000157693',
isFullSet: true,
})
expect(saveProductPackageAssetsAndComplete).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
taskNo: null,
dingtalkApprovalNo: '202508211445000157693',
isFullSet: true,
}),
)
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
expect(queueTaskDetailBarcodeImagesUpdatedNotification).toHaveBeenCalledWith('material-1', [
'sku-preview-1',
])
expect(createMaterialLog).toHaveBeenCalled()
})
test('任务详情直接完成时缺少出图方式应拒绝处理', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
materialType: '任务-详情',
taskDetailSourceType: null,
productModel: 'SPU-001',
categoryName: '旗舰店',
previewUrl: '',
user: {
realName: '张三',
username: 'zhangsan',
},
})
await expect(
completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [
{
fileUrl: 'https://img.example/main.png',
fileName: 'main.png',
fileSize: 10,
sortOrder: 0,
},
],
},
}),
).rejects.toThrow('任务-详情必须选择出图方式')
expect(createZipFromFiles).not.toHaveBeenCalled()
})
test('SKU 图缺少 skuCode 时应直接报错', async () => {
await expect(
completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
sku: [
{
fileUrl: 'https://img.example/sku.png',
fileName: 'sku.png',
fileSize: 10,
sortOrder: 0,
barcodes: ['690000000001'],
},
],
},
}),
).rejects.toThrow('缺少 SKU 编号')
})
test('SKU 图缺少产品条码时应直接报错', async () => {
await expect(
completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
sku: [
{
fileUrl: 'https://img.example/sku.png',
fileName: 'sku.png',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: [],
},
],
},
}),
).rejects.toThrow('至少需要 1 个产品条码')
})
test('历史导入允许 SKU 图没有产品条码', async () => {
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'zip-sku-only',
fileName: 'sku-only.zip',
fileSize: 22,
})
await expect(
completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
sku: [
{
fileUrl: 'https://img.example/sku.png',
fileName: 'sku.png',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: [],
},
],
},
options: {
allowSkuWithoutBarcodes: true,
},
}),
).resolves.toEqual({
materialId: 'material-1',
taskNo: null,
dingtalkApprovalNo: null,
isFullSet: true,
})
})
test('同一批次重复条码绑定两张 SKU 图时应拒绝', async () => {
await expect(
completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
sku: [
{
fileUrl: 'https://img.example/sku-a.png',
fileName: 'sku-a.png',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: ['690000000001'],
},
{
fileUrl: 'https://img.example/sku-b.png',
fileName: 'sku-b.png',
fileSize: 10,
sortOrder: 1,
skuCode: 'SKU-002',
barcodes: ['690000000001'],
},
],
},
}),
).rejects.toThrow('不能同时绑定')
})
test('公开上传资产不得携带 localFilePath', () => {
expect(() =>
parsePublicProductPackageAssets({
main: [
{
uploadId: 'upload-1',
localFilePath: 'C:\\Windows\\win.ini',
fileName: 'main.png',
fileSize: 10,
},
],
}),
).toThrow('只允许使用 uploadId')
})
test('公开上传资产不得使用 fileUrl 触发服务端请求', () => {
expect(() =>
parsePublicProductPackageAssets({
main: [
{
fileUrl: 'http://127.0.0.1:3000/api/admin/system-logs',
fileName: 'main.png',
fileSize: 10,
},
],
}),
).toThrow('只允许使用 uploadId')
})
test('公开上传资产只保留完成流程需要的字段', () => {
expect(
parsePublicProductPackageAssets({
sku: [
{
uploadId: ' upload-1 ',
fileName: ' sku.png ',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: ['690000000001', 123],
unexpected: 'ignored',
},
],
}),
).toEqual({
main: undefined,
sku: [
{
uploadId: 'upload-1',
fileName: 'sku.png',
fileSize: 10,
sortOrder: 0,
skuCode: 'SKU-001',
barcodes: ['690000000001'],
},
],
detail: undefined,
psd: undefined,
})
})
test('不能消费其他用户的上传会话', async () => {
;(getOwnedUploadSession as jest.Mock).mockResolvedValueOnce(null)
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ uploadId: 'upload-other', fileName: 'main.png', fileSize: 10 }],
},
})).rejects.toThrow('上传已过期')
})
test('同批同名 PSD 不会覆盖,成功后会清理全部上传会话', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-success-'))
const previousUploadDir = process.env.UPLOAD_DIR
process.env.UPLOAD_DIR = uploadRoot
try {
const firstSource = path.join(uploadRoot, 'first-upload.tmp')
const secondSource = path.join(uploadRoot, 'second-upload.tmp')
await writeFile(firstSource, 'first')
await writeFile(secondSource, 'second')
;(getOwnedUploadSession as jest.Mock).mockImplementation(async (uploadId: string) => ({
userId: 'user-1',
fileSize: uploadId === 'psd-1' ? 5 : 6,
}))
;(getUploadedFile as jest.Mock).mockImplementation(async (uploadId: string) => (
uploadId === 'psd-1' ? firstSource : secondSource
))
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'zip-main',
fileName: 'main.zip',
fileSize: 11,
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
psd: [
{ uploadId: 'psd-1', fileName: 'same.psd', fileSize: 5 },
{ uploadId: 'psd-2', fileName: 'same.psd', fileSize: 6 },
],
},
})).resolves.toEqual(expect.objectContaining({ materialId: 'material-1' }))
const psdInfos = (saveProductPackageAssetsAndComplete as jest.Mock).mock.calls.at(-1)?.[0]?.psdInfos
expect(psdInfos).toHaveLength(2)
expect(psdInfos[0].filePath).not.toBe(psdInfos[1].filePath)
await expect(readFile(path.join(uploadRoot, 'materials', path.basename(psdInfos[0].filePath)), 'utf8'))
.resolves.toBe('first')
await expect(readFile(path.join(uploadRoot, 'materials', path.basename(psdInfos[1].filePath)), 'utf8'))
.resolves.toBe('second')
expect((cleanupTempFiles as jest.Mock).mock.calls.at(-1)?.[1]).toEqual(
expect.arrayContaining(['psd-1', 'psd-2']),
)
} finally {
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
await rm(uploadRoot, { recursive: true, force: true })
}
})
test('PSD 持久化失败时保留已验证的上传会话供重试', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-failure-'))
const previousUploadDir = process.env.UPLOAD_DIR
process.env.UPLOAD_DIR = uploadRoot
try {
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
userId: 'user-1',
fileSize: 5,
})
;(getUploadedFile as jest.Mock).mockResolvedValue(null)
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'zip-main',
fileName: 'main.zip',
fileSize: 11,
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
psd: [{ uploadId: 'psd-failed', fileName: 'failed.psd', fileSize: 5 }],
},
})).rejects.toThrow('PSD 文件缺失或未上传完成')
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
expect(cleanupTempFiles).not.toHaveBeenCalled()
} finally {
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
await rm(uploadRoot, { recursive: true, force: true })
}
})
test('原子完成事务失败时不补偿半成品数据库,也不清理用户源会话', async () => {
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
materialType: '任务-详情',
taskDetailSourceType: '实拍',
taskStatus: 'uploading',
previewUrl: '',
zipFilePath: '',
zipFileName: '',
zipFileSize: BigInt(0),
taskNo: null,
dingtalkApprovalNo: null,
isFullSet: false,
productModel: 'SPU-001',
categoryName: '旗舰店',
user: { realName: '张三', username: 'zhangsan' },
})
;(saveProductPackageAssetsAndComplete as jest.Mock).mockRejectedValueOnce(new Error('任务号已存在'))
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: 'TASK202601030184',
dingtalkApprovalNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
},
})).rejects.toThrow('任务号已存在')
expect(prisma.materialAsset.deleteMany).not.toHaveBeenCalled()
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
})
test('事务提交回执丢失时重读完整资产,确认已完成后按成功返回并清理源会话', async () => {
;(createZipFromFiles as jest.Mock).mockResolvedValueOnce({
filePath: 'materials/main-ack.zip',
fileName: 'main-ack.zip',
fileSize: 11,
})
const createZipFromZips = jest.requireMock('@/lib/task-material-processor').createZipFromZips as jest.Mock
createZipFromZips.mockResolvedValueOnce({
filePath: 'materials/all-ack.zip',
fileName: 'all-ack.zip',
fileSize: 22,
})
;(saveProductPackageAssetsAndComplete as jest.Mock).mockImplementationOnce(async (input) => {
const mainPreview = input.previewAssets.main[0]
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
taskStatus: 'completed',
taskNo: null,
dingtalkApprovalNo: null,
previewUrl: input.cardPreviewUrl,
zipFilePath: input.zipInfos.all.filePath,
zipFileName: input.zipInfos.all.fileName,
zipFileSize: BigInt(input.zipInfos.all.fileSize),
isFullSet: input.isFullSet,
assets: [
{
id: 'main-preview-1',
assetType: 'main-preview',
fileUrl: mainPreview.fileUrl,
fileName: mainPreview.fileName,
fileSize: BigInt(0),
sortOrder: 0,
skuCode: null,
createdAt: new Date('2026-08-12T00:00:00.000Z'),
updatedAt: new Date('2026-08-12T00:00:00.000Z'),
barcodes: [],
},
{
id: 'main-zip-1',
assetType: 'main',
fileUrl: input.zipInfos.main.filePath,
fileName: input.zipInfos.main.fileName,
fileSize: BigInt(input.zipInfos.main.fileSize),
sortOrder: 0,
skuCode: null,
createdAt: new Date('2026-08-12T00:00:00.000Z'),
updatedAt: new Date('2026-08-12T00:00:00.000Z'),
barcodes: [],
},
{
id: 'all-zip-1',
assetType: 'all',
fileUrl: input.zipInfos.all.filePath,
fileName: input.zipInfos.all.fileName,
fileSize: BigInt(input.zipInfos.all.fileSize),
sortOrder: 0,
skuCode: null,
createdAt: new Date('2026-08-12T00:00:00.000Z'),
updatedAt: new Date('2026-08-12T00:00:00.000Z'),
barcodes: [],
},
],
})
throw new Error('COMMIT acknowledgement lost')
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
},
})).resolves.toEqual(expect.objectContaining({
materialId: 'material-1',
warning: '素材已保存,但响应确认阶段出现异常',
}))
expect(cleanupTempFiles).toHaveBeenCalled()
expect(cleanupOnError).not.toHaveBeenCalled()
})
test('事务结果无法重读确认时保留源文件和全部派生物', async () => {
;(createZipFromFiles as jest.Mock).mockResolvedValueOnce({
filePath: 'materials/main-unknown.zip',
fileName: 'main-unknown.zip',
fileSize: 11,
})
;(saveProductPackageAssetsAndComplete as jest.Mock).mockImplementationOnce(async () => {
;(prisma.material.findUnique as jest.Mock).mockRejectedValueOnce(new Error('database unavailable'))
throw new Error('COMMIT acknowledgement lost')
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
},
})).rejects.toThrow('完成状态暂时无法确认')
expect(cleanupTempFiles).not.toHaveBeenCalled()
expect(cleanupOnError).not.toHaveBeenCalled()
})
test('执行租约守卫在最终事务前失效时不提交且不清理源会话', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-product-lease-'))
const previousUploadDir = process.env.UPLOAD_DIR
process.env.UPLOAD_DIR = uploadRoot
try {
const sourcePath = path.join(uploadRoot, 'source.jpg')
const sourceBuffer = Buffer.from('lease-guard-source')
await writeFile(sourcePath, sourceBuffer)
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({
userId: 'user-1',
fileSize: sourceBuffer.length,
})
;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath)
;(prisma.material.findUnique as jest.Mock).mockResolvedValueOnce({
id: 'material-1',
userId: 'user-1',
materialType: '任务-详情',
taskDetailSourceType: '实拍',
taskStatus: 'uploading',
previewUrl: '',
zipFilePath: '',
productModel: 'SPU-001',
categoryName: '旗舰店',
user: { realName: '张三', username: 'zhangsan' },
})
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'materials/main-lease.zip',
fileName: 'main-lease.zip',
fileSize: 11,
})
let executionCurrent = true
const assertExecutionCurrent = jest.fn(async () => {
if (!executionCurrent) throw new Error('job lease lost')
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ uploadId: 'source-lease', fileName: 'source.jpg', fileSize: sourceBuffer.length }],
},
options: {
assertExecutionCurrent,
onProgress: ({ stage, progress }) => {
if (stage === 'database' && progress === 85) executionCurrent = false
},
},
})).rejects.toThrow('job lease lost')
expect(saveProductPackageAssetsAndComplete).not.toHaveBeenCalled()
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
await expect(readFile(sourcePath, 'utf8')).resolves.toBe('lease-guard-source')
} finally {
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
await rm(uploadRoot, { recursive: true, force: true })
}
})
test('中止信号在最终事务前触发时不提交且不消费源会话', async () => {
const controller = new AbortController()
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'materials/main-aborted.zip',
fileName: 'main-aborted.zip',
fileSize: 11,
})
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
},
options: {
signal: controller.signal,
onProgress: ({ stage, progress }) => {
if (stage === 'database' && progress === 85) {
controller.abort(new Error('worker drained'))
}
},
},
})).rejects.toThrow('worker drained')
expect(saveProductPackageAssetsAndComplete).not.toHaveBeenCalled()
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
expect(cleanupTempFiles).not.toHaveBeenCalled()
})
test('PSD 在原子事务失败前只复制不移动,源文件仍可原地重试', async () => {
const uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiang-psd-copy-'))
const previousUploadDir = process.env.UPLOAD_DIR
process.env.UPLOAD_DIR = uploadRoot
try {
const sourcePath = path.join(uploadRoot, 'source.psd')
await writeFile(sourcePath, 'retryable-psd')
;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ userId: 'user-1', fileSize: 13 })
;(getUploadedFile as jest.Mock).mockResolvedValue(sourcePath)
;(createZipFromFiles as jest.Mock).mockResolvedValue({
filePath: 'materials/main-psd.zip',
fileName: 'main-psd.zip',
fileSize: 11,
})
;(saveProductPackageAssetsAndComplete as jest.Mock).mockRejectedValueOnce(new Error('transaction rolled back'))
await expect(completeProductPackageUpload({
materialId: 'material-1',
taskNo: null,
operatorUserId: 'user-1',
assets: {
main: [{ fileUrl: 'https://img.example/main.png', fileName: 'main.png', fileSize: 10 }],
psd: [{ uploadId: 'psd-retry', fileName: 'source.psd', fileSize: 13 }],
},
})).rejects.toThrow('transaction rolled back')
await expect(readFile(sourcePath, 'utf8')).resolves.toBe('retryable-psd')
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[2]).toEqual([])
expect((cleanupOnError as jest.Mock).mock.calls.at(-1)?.[4]).toEqual([])
} finally {
if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR
else process.env.UPLOAD_DIR = previousUploadDir
await rm(uploadRoot, { recursive: true, force: true })
}
})
})