479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
import {
|
||
acquireTaskNoAssignmentLock,
|
||
assignTaskNoOnComplete,
|
||
createTaskMaterialOnComplete,
|
||
createTaskOtherMaterialOnComplete,
|
||
finalizeTaskMaterialOnComplete,
|
||
purgeDeletedMaterialsByTaskNo,
|
||
} from '@/lib/product-package'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { saveProductPackageAssetsAndComplete } from '@/lib/task-material-processor'
|
||
import { collectMaterialFiles } from '@/lib/material-cleanup'
|
||
import { deleteFiles } from '@/lib/file-cleanup'
|
||
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
$transaction: jest.fn(),
|
||
material: {
|
||
findFirst: jest.fn(),
|
||
findMany: jest.fn(),
|
||
deleteMany: jest.fn(),
|
||
},
|
||
materialAsset: { createMany: jest.fn() },
|
||
},
|
||
}))
|
||
|
||
jest.mock('@/lib/material-cleanup', () => ({
|
||
collectMaterialFiles: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/file-cleanup', () => ({
|
||
deleteFiles: jest.fn(),
|
||
}))
|
||
|
||
describe('任务号完成写入并发锁', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null)
|
||
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
;(prisma.material.deleteMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 0 })
|
||
;(collectMaterialFiles as jest.Mock).mockResolvedValue({ fileUrls: [], directories: [] })
|
||
;(deleteFiles as jest.Mock).mockResolvedValue({ deletedCount: 0, failedCount: 0 })
|
||
})
|
||
|
||
test('advisory lock 只返回 Prisma 可反序列化的整数列', async () => {
|
||
const tx = { $queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]) } as any
|
||
|
||
await acquireTaskNoAssignmentLock(tx, 'TASK202604230001')
|
||
|
||
const [template, lockName] = tx.$queryRaw.mock.calls[0]
|
||
const sql = Array.from(template as TemplateStringsArray).join('?')
|
||
expect(sql).toContain('SELECT 1 AS locked')
|
||
expect(sql).toContain('pg_advisory_xact_lock(hashtext(?)')
|
||
expect(lockName).toBe('task-no:TASK202604230001')
|
||
})
|
||
|
||
test('两个素材并发完成同一任务号时只能一个写入成功', async () => {
|
||
let assignedMaterialId: string | null = null
|
||
let transactionTail = Promise.resolve()
|
||
const tx = {
|
||
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
|
||
material: {
|
||
findFirst: jest.fn(async () => assignedMaterialId
|
||
? {
|
||
id: assignedMaterialId,
|
||
productModel: 'SPU-EXISTING',
|
||
materialType: '任务-详情',
|
||
}
|
||
: null),
|
||
update: jest.fn(async ({ where }: { where: { id: string } }) => {
|
||
assignedMaterialId = where.id
|
||
return { id: where.id }
|
||
}),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => {
|
||
const previous = transactionTail
|
||
let release!: () => void
|
||
transactionTail = new Promise<void>((resolve) => { release = resolve })
|
||
await previous
|
||
try {
|
||
return await callback(tx)
|
||
} finally {
|
||
release()
|
||
}
|
||
})
|
||
|
||
const results = await Promise.allSettled([
|
||
assignTaskNoOnComplete('material-a', 'TASK202604230001'),
|
||
assignTaskNoOnComplete('material-b', 'TASK202604230001'),
|
||
])
|
||
|
||
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
|
||
const rejected = results.find((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||
expect(rejected?.reason).toEqual(new Error(
|
||
'任务号 TASK202604230001 已存在,对应素材:SPU-EXISTING(任务-详情)',
|
||
))
|
||
expect(tx.material.update).toHaveBeenCalledTimes(1)
|
||
expect(tx.$queryRaw).toHaveBeenCalledTimes(2)
|
||
})
|
||
|
||
test('任务图片完成在行锁后验证 owner/type/status,再与资产写入同事务完成', async () => {
|
||
const order: string[] = []
|
||
const tx = {
|
||
$queryRaw: jest.fn(async () => [{
|
||
taskStatus: 'uploading',
|
||
previewUrl: '',
|
||
zipFilePath: '',
|
||
deletedAt: null,
|
||
userId: 'user-1',
|
||
materialType: '任务-页面',
|
||
}]),
|
||
material: {
|
||
update: jest.fn(async () => {
|
||
order.push('completed')
|
||
return { id: 'material-1' }
|
||
}),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
|
||
|
||
await finalizeTaskMaterialOnComplete({
|
||
materialId: 'material-1',
|
||
expectedTaskStatuses: ['uploading', null],
|
||
expectedUserId: 'user-1',
|
||
expectedMaterialType: '任务-页面',
|
||
applyCompletion: async () => {
|
||
order.push('assets')
|
||
},
|
||
})
|
||
|
||
const queryMock = tx.$queryRaw as jest.Mock
|
||
const sql = Array.from(queryMock.mock.calls[0][0] as TemplateStringsArray).join('?')
|
||
expect(sql).toContain('FOR UPDATE')
|
||
expect(sql).toContain('"task_status" AS "taskStatus"')
|
||
expect(order).toEqual(['assets', 'completed'])
|
||
expect(tx.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: { taskStatus: 'completed', isIncompleteShell: false },
|
||
})
|
||
})
|
||
|
||
test('owner 或素材类型变化时事务整体拒绝,资产回调不会执行', async () => {
|
||
const applyCompletion = jest.fn()
|
||
const tx = {
|
||
$queryRaw: jest.fn(async () => [{
|
||
taskStatus: 'uploading',
|
||
previewUrl: '',
|
||
zipFilePath: '',
|
||
deletedAt: null,
|
||
userId: 'other-user',
|
||
materialType: '任务-页面',
|
||
}]),
|
||
material: { update: jest.fn() },
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(tx))
|
||
|
||
await expect(finalizeTaskMaterialOnComplete({
|
||
materialId: 'material-1',
|
||
expectedTaskStatuses: ['uploading'],
|
||
expectedUserId: 'user-1',
|
||
expectedMaterialType: '任务-页面',
|
||
applyCompletion,
|
||
})).rejects.toThrow('素材完成状态已变化')
|
||
expect(applyCompletion).not.toHaveBeenCalled()
|
||
expect(tx.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('任务详情旧资产替换、预览与 ZIP 字段、任务号和 completed 在同一事务提交', async () => {
|
||
const order: string[] = []
|
||
const tx = {
|
||
$queryRaw: jest.fn(async () => [{
|
||
taskStatus: 'uploading',
|
||
previewUrl: '',
|
||
zipFilePath: '',
|
||
deletedAt: null,
|
||
userId: 'user-1',
|
||
materialType: '任务-详情',
|
||
}]),
|
||
materialAsset: {
|
||
deleteMany: jest.fn(async () => { order.push('delete-old-assets') }),
|
||
createMany: jest.fn(async () => { order.push('create-assets') }),
|
||
create: jest.fn(async () => {
|
||
order.push('create-sku-preview')
|
||
return {
|
||
id: 'sku-asset-1',
|
||
fileUrl: 'https://s3.example/sku.webp',
|
||
fileName: 'sku.webp',
|
||
sortOrder: 1,
|
||
createdAt: new Date('2026-08-12T00:00:00.000Z'),
|
||
updatedAt: new Date('2026-08-12T00:00:00.000Z'),
|
||
}
|
||
}),
|
||
},
|
||
materialAssetBarcode: {
|
||
createMany: jest.fn(async () => { order.push('create-barcodes') }),
|
||
},
|
||
material: {
|
||
update: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
|
||
if (data.taskStatus === 'completed') order.push('completed')
|
||
else if ('previewUrl' in data) order.push('card-fields')
|
||
else order.push('zip-fields')
|
||
return { id: 'material-1' }
|
||
}),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
|
||
|
||
await saveProductPackageAssetsAndComplete({
|
||
materialId: 'material-1',
|
||
previewAssets: {
|
||
main: [{ fileUrl: 'https://s3.example/main.webp', fileName: 'main.webp' }],
|
||
sku: [{
|
||
fileUrl: 'https://s3.example/sku.webp',
|
||
fileName: 'sku.webp',
|
||
skuCode: 'SKU-1',
|
||
barcodes: ['690000000001'],
|
||
}],
|
||
detail: [],
|
||
},
|
||
zipInfos: {
|
||
main: { filePath: 'materials/main.zip', fileName: 'main.zip', fileSize: 10 },
|
||
all: { filePath: 'materials/all.zip', fileName: 'all.zip', fileSize: 20 },
|
||
},
|
||
cardPreviewUrl: 'https://s3.example/main.webp',
|
||
isFullSet: true,
|
||
taskNo: null,
|
||
dingtalkApprovalNo: '202508211445000157693',
|
||
userId: 'user-1',
|
||
materialType: '任务-详情',
|
||
})
|
||
|
||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||
expect(order).toEqual([
|
||
'delete-old-assets',
|
||
'create-assets',
|
||
'zip-fields',
|
||
'create-sku-preview',
|
||
'create-barcodes',
|
||
'card-fields',
|
||
'completed',
|
||
])
|
||
expect(tx.materialAsset.create).toHaveBeenCalledWith(expect.objectContaining({
|
||
data: expect.objectContaining({ skuCode: 'SKU-1' }),
|
||
}))
|
||
expect(tx.material.update).toHaveBeenLastCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: {
|
||
dingtalkApprovalNo: '202508211445000157693',
|
||
isIncompleteShell: false,
|
||
taskStatus: 'completed',
|
||
},
|
||
})
|
||
})
|
||
|
||
test('任务详情事务中资产写入故障时不会继续写 completed', async () => {
|
||
const tx = {
|
||
$queryRaw: jest.fn(async () => [{
|
||
taskStatus: 'uploading',
|
||
previewUrl: '',
|
||
zipFilePath: '',
|
||
deletedAt: null,
|
||
userId: 'user-1',
|
||
materialType: '任务-详情',
|
||
}]),
|
||
materialAsset: {
|
||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||
createMany: jest.fn().mockRejectedValue(new Error('asset insert fault')),
|
||
create: jest.fn(),
|
||
},
|
||
materialAssetBarcode: { createMany: jest.fn() },
|
||
material: { update: jest.fn() },
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
|
||
|
||
await expect(saveProductPackageAssetsAndComplete({
|
||
materialId: 'material-1',
|
||
previewAssets: {
|
||
main: [{ fileUrl: 'https://s3.example/main.webp', fileName: 'main.webp' }],
|
||
sku: [],
|
||
detail: [],
|
||
},
|
||
zipInfos: {
|
||
all: { filePath: 'materials/all.zip', fileName: 'all.zip', fileSize: 20 },
|
||
},
|
||
cardPreviewUrl: 'https://s3.example/main.webp',
|
||
isFullSet: false,
|
||
taskNo: null,
|
||
userId: 'user-1',
|
||
materialType: '任务-详情',
|
||
})).rejects.toThrow('asset insert fault')
|
||
|
||
expect(tx.material.update).not.toHaveBeenCalled()
|
||
expect(tx.materialAsset.create).not.toHaveBeenCalled()
|
||
expect(tx.materialAssetBarcode.createMany).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('任务-其他只有在素材和资产事务完整成功后才盘点旧软删数据', async () => {
|
||
const order: string[] = []
|
||
const tx = {
|
||
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
|
||
material: {
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
create: jest.fn(async () => {
|
||
order.push('material')
|
||
return { id: 'new-material', user: { username: 'tester' } }
|
||
}),
|
||
},
|
||
materialAsset: {
|
||
createMany: jest.fn(async () => {
|
||
order.push('assets')
|
||
return { count: 2 }
|
||
}),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => {
|
||
const result = await callback(tx)
|
||
order.push('commit')
|
||
return result
|
||
})
|
||
;(prisma.material.findMany as jest.Mock).mockImplementationOnce(async () => {
|
||
order.push('audit')
|
||
return []
|
||
})
|
||
|
||
await createTaskOtherMaterialOnComplete({
|
||
materialData: { productModel: 'TASK202604230001', userId: 'user-1' },
|
||
assets: [{
|
||
assetType: 'preview',
|
||
fileUrl: 'https://s3.example/preview.webp',
|
||
fileName: 'preview.webp',
|
||
fileSize: BigInt(1),
|
||
sortOrder: 0,
|
||
}],
|
||
taskNo: 'TASK202604230001',
|
||
})
|
||
|
||
expect(order).toEqual(['material', 'assets', 'commit', 'audit'])
|
||
expect(tx.material.create).toHaveBeenCalledWith(expect.objectContaining({
|
||
data: expect.objectContaining({ taskStatus: 'completed', taskNo: 'TASK202604230001' }),
|
||
}))
|
||
})
|
||
|
||
test('任务-其他资产写入失败时事务拒绝且绝不清理旧软删素材', async () => {
|
||
const tx = {
|
||
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
|
||
material: {
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
create: jest.fn().mockResolvedValue({ id: 'new-material' }),
|
||
},
|
||
materialAsset: {
|
||
createMany: jest.fn().mockRejectedValue(new Error('asset insert failed')),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
|
||
|
||
await expect(createTaskOtherMaterialOnComplete({
|
||
materialData: { productModel: 'TASK202604230001', userId: 'user-1' },
|
||
assets: [{
|
||
assetType: 'zip',
|
||
fileUrl: 'uploads/source.zip',
|
||
fileName: 'source.zip',
|
||
fileSize: BigInt(1),
|
||
sortOrder: 0,
|
||
}],
|
||
taskNo: 'TASK202604230001',
|
||
})).rejects.toThrow('asset insert failed')
|
||
|
||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('通用入口的任务原图资产写入失败时事务拒绝且绝不写 completed', async () => {
|
||
const tx = {
|
||
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
|
||
material: {
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
create: jest.fn().mockResolvedValue({ id: 'new-original-material' }),
|
||
},
|
||
materialAsset: {
|
||
createMany: jest.fn().mockRejectedValue(new Error('original asset insert failed')),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
|
||
|
||
await expect(createTaskMaterialOnComplete({
|
||
materialData: {
|
||
productModel: 'SPU-ORIGINAL-001',
|
||
materialType: '任务-3D原图',
|
||
userId: 'user-1',
|
||
},
|
||
assets: [{
|
||
assetType: 'zip',
|
||
fileUrl: 'uploads/original-source.zip',
|
||
fileName: 'original-source.zip',
|
||
fileSize: BigInt(1),
|
||
sortOrder: 0,
|
||
}],
|
||
taskNo: 'TASK202604230001',
|
||
})).rejects.toThrow('original asset insert failed')
|
||
|
||
expect(tx.material.create).toHaveBeenCalledWith(expect.objectContaining({
|
||
data: expect.objectContaining({
|
||
materialType: '任务-3D原图',
|
||
taskNo: 'TASK202604230001',
|
||
taskStatus: 'completed',
|
||
}),
|
||
}))
|
||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('旧软删素材可能共享文件 URL 时物理 GC 保持禁用', async () => {
|
||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
;(prisma.material.findMany as jest.Mock).mockResolvedValueOnce([{
|
||
id: 'old-material',
|
||
materialType: '任务-详情',
|
||
deletedAt: new Date(),
|
||
}])
|
||
;(collectMaterialFiles as jest.Mock).mockResolvedValueOnce({
|
||
fileUrls: ['uploads/old-a.zip', 'uploads/old-b.zip'],
|
||
directories: [],
|
||
})
|
||
;(deleteFiles as jest.Mock).mockResolvedValueOnce({ deletedCount: 1, failedCount: 1 })
|
||
|
||
const result = await purgeDeletedMaterialsByTaskNo('TASK202604230001')
|
||
|
||
expect(result).toEqual({ deletedMaterials: 0, deletedFiles: 0, failedFiles: 0 })
|
||
expect(collectMaterialFiles).not.toHaveBeenCalled()
|
||
expect(deleteFiles).not.toHaveBeenCalled()
|
||
expect(prisma.material.deleteMany).not.toHaveBeenCalled()
|
||
expect(warnSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('自动物理 GC 已禁用'),
|
||
expect.objectContaining({
|
||
taskNo: 'TASK202604230001',
|
||
deferredMaterials: 1,
|
||
materialIds: ['old-material'],
|
||
}),
|
||
)
|
||
warnSpy.mockRestore()
|
||
})
|
||
|
||
test('新素材完成后的旧数据盘点遇到数据库故障时也不删数据或改判完成结果', async () => {
|
||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
const tx = {
|
||
$queryRaw: jest.fn().mockResolvedValue([{ locked: 1 }]),
|
||
material: {
|
||
findFirst: jest.fn().mockResolvedValue(null),
|
||
update: jest.fn().mockResolvedValue({ id: 'new-material' }),
|
||
},
|
||
}
|
||
;(prisma.$transaction as jest.Mock).mockImplementationOnce(async (callback) => callback(tx))
|
||
;(prisma.material.findMany as jest.Mock).mockRejectedValueOnce(new Error('database unavailable'))
|
||
;(prisma.material.deleteMany as jest.Mock).mockRejectedValueOnce(new Error('must not run'))
|
||
|
||
await expect(assignTaskNoOnComplete('new-material', 'TASK202604230001')).resolves.toEqual({
|
||
deletedMaterials: 0,
|
||
deletedFiles: 0,
|
||
failedFiles: 1,
|
||
})
|
||
|
||
expect(tx.material.update).toHaveBeenCalledWith(expect.objectContaining({
|
||
where: { id: 'new-material' },
|
||
data: expect.objectContaining({
|
||
taskNo: 'TASK202604230001',
|
||
taskStatus: 'completed',
|
||
}),
|
||
}))
|
||
expect(collectMaterialFiles).not.toHaveBeenCalled()
|
||
expect(deleteFiles).not.toHaveBeenCalled()
|
||
expect(prisma.material.deleteMany).not.toHaveBeenCalled()
|
||
expect(errorSpy).toHaveBeenCalledWith(
|
||
expect.stringContaining('自动物理 GC 仍保持禁用'),
|
||
expect.objectContaining({ taskNo: 'TASK202604230001' }),
|
||
)
|
||
errorSpy.mockRestore()
|
||
})
|
||
})
|