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

430 lines
12 KiB
TypeScript

/** @jest-environment node */
import {
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 { createZipFromFiles } from '@/lib/task-material-processor'
import { getOwnedUploadSession } from '@/lib/upload-sessions'
import { assignTaskNoOnComplete } from '@/lib/product-package'
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', () => ({
assignTaskNoOnComplete: jest.fn().mockResolvedValue(null),
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 }),
saveProductPackageAssets: 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' }),
}))
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('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('全套任务详情完成后应触发自动上架通知排队', 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(assignTaskNoOnComplete).toHaveBeenCalledWith(
'material-1',
null,
'202508211445000157693',
)
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('数据库资产已保存但完成标记失败时会补偿并清理文件', 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' },
})
;(assignTaskNoOnComplete 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).toHaveBeenCalledWith({ where: { materialId: 'material-1' } })
expect(prisma.$transaction).toHaveBeenCalled()
})
})