993 lines
33 KiB
TypeScript
993 lines
33 KiB
TypeScript
/** @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 {
|
|
cleanupOnError,
|
|
cleanupTempFiles,
|
|
createZipFromFiles,
|
|
saveProductPackageAssetsAndComplete,
|
|
} from '@/lib/task-material-processor'
|
|
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: 61 }, (_, index) => ({
|
|
uploadId: `main-${index}`,
|
|
fileName: `${index}.jpg`,
|
|
fileSize: 1,
|
|
})),
|
|
})).toThrow('主图最多上传 60 张')
|
|
|
|
expect(() => assertTaskDetailUploadAssetLimits({
|
|
psd: Array.from({ length: 21 }, (_, index) => ({
|
|
uploadId: `psd-${index}`,
|
|
fileName: `${index}.psd`,
|
|
fileSize: 1,
|
|
})),
|
|
})).toThrow('PSD 源文件最多上传 20 个')
|
|
|
|
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', 41),
|
|
sku: imageGroup('sku', 40),
|
|
detail: imageGroup('detail', 40),
|
|
})).toThrow('本次图片最多上传 120 张')
|
|
|
|
const previews = Array.from({ length: 60 }, (_, index) => ({
|
|
uploadId: `preview-${index}`,
|
|
fileName: `${index}.webp`,
|
|
fileSize: 1,
|
|
width: 1,
|
|
height: 1,
|
|
kind: 'preview',
|
|
}))
|
|
expect(() => assertTaskDetailUploadAssetLimits({
|
|
main: Array.from({ length: 17 }, (_, 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.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 })
|
|
}
|
|
})
|
|
})
|