/** @jest-environment node */ import { completeTaskImageMaterial } from '@/lib/task-image-material-completion'; import { prisma } from '@/lib/prisma'; import { createMaterialLog } from '@/lib/material-log'; import { acquireTaskMaterialCompletionLock, acquireTaskMaterialUploadSessionLocks, } from '@/lib/material-upload-cross-process-lock'; import { inspectTaskMaterialCompletionAfterError } from '@/lib/task-material-completion-safety'; import { cleanupOnError, cleanupTempFiles, createZipFromFiles, generatePreviewImages, resolveOwnedTaskMaterialSourceFiles, saveTaskMaterialAssetsAndComplete, } from '@/lib/task-material-processor'; jest.mock('@/lib/prisma', () => ({ prisma: { material: { findUnique: jest.fn() } }, })); jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn() })); jest.mock('@/lib/material-upload-cross-process-lock', () => ({ acquireTaskMaterialCompletionLock: jest.fn(), acquireTaskMaterialUploadSessionLocks: jest.fn(), })); jest.mock('@/lib/task-material-completion-safety', () => ({ inspectTaskMaterialCompletionAfterError: jest.fn(), })); jest.mock('@/lib/task-material-processor', () => { class TaskMaterialSourceValidationError extends Error { constructor(message: string, readonly status: number = 400) { super(message); } } return { cleanupOnError: jest.fn(), cleanupTempFiles: jest.fn(), createZipFromFiles: jest.fn(), generatePreviewImages: jest.fn(), resolveOwnedTaskMaterialSourceFiles: jest.fn(), saveTaskMaterialAssetsAndComplete: jest.fn(), TaskMaterialSourceValidationError, TASK_MATERIAL_ZIP_TIMEOUT_MS: 1_800_000, TASK_MATERIAL_ZIP_COMPRESSION_LEVEL: 0, }; }); const releaseLock = Object.assign(jest.fn(async () => undefined), { assertHeld: jest.fn(), }); const releaseUploadLocks = Object.assign(jest.fn(async () => undefined), { assertHeld: jest.fn(), }); const baseMaterial = { id: 'material-1', userId: 'user-1', materialType: '任务-页面', productModel: 'SJ0371', taskStatus: 'uploading', previewUrl: '', zipFilePath: '', deletedAt: null, assets: [], }; describe('completeTaskImageMaterial', () => { beforeEach(() => { jest.clearAllMocks(); releaseLock.mockClear(); releaseLock.assertHeld.mockClear(); releaseUploadLocks.mockClear(); releaseUploadLocks.assertHeld.mockClear(); (acquireTaskMaterialCompletionLock as jest.Mock).mockResolvedValue(releaseLock); (acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadLocks); (prisma.material.findUnique as jest.Mock) .mockResolvedValueOnce(baseMaterial) .mockResolvedValueOnce(baseMaterial); (resolveOwnedTaskMaterialSourceFiles as jest.Mock).mockResolvedValue({ files: [{ path: 'assembled/source.jpg', name: 'source.jpg', size: 10 }], uploadIds: ['upload-1'], tempFiles: ['assembled/source.jpg'], }); (createZipFromFiles as jest.Mock).mockResolvedValue({ filePath: 'materials/source.zip', fileName: 'source.zip', fileSize: 20, }); (generatePreviewImages as jest.Mock).mockImplementation( async (_items, _type, _options, onUploaded) => { onUploaded?.('https://s3.example/preview.webp'); return ['https://s3.example/preview.webp']; }, ); (saveTaskMaterialAssetsAndComplete as jest.Mock).mockResolvedValue(undefined); (cleanupTempFiles as jest.Mock).mockResolvedValue(undefined); (createMaterialLog as jest.Mock).mockResolvedValue(undefined); (cleanupOnError as jest.Mock).mockResolvedValue(undefined); (inspectTaskMaterialCompletionAfterError as jest.Mock).mockResolvedValue({ state: 'other', taskStatus: 'uploading', }); }); test('holds the lock through derived files and atomic database completion', async () => { const result = await runCompletion(); expect(result).toEqual({ materialId: 'material-1' }); expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledWith('material-1'); expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-1']); expect(resolveOwnedTaskMaterialSourceFiles).toHaveBeenCalledWith({ assets: [{ uploadId: 'upload-1', fileName: 'source.jpg', fileSize: 1 }], userId: 'user-1', }); expect(createZipFromFiles).toHaveBeenCalledWith( expect.any(Array), 'material-1', 'SJ0371', '任务-页面', 'all', undefined, undefined, 1_800_000, 0, ); expect(releaseLock.assertHeld).toHaveBeenCalled(); expect(saveTaskMaterialAssetsAndComplete).toHaveBeenCalledWith( expect.objectContaining({ materialId: 'material-1', previewUrls: ['https://s3.example/preview.webp'], }), ); expect(cleanupTempFiles).toHaveBeenCalledWith( ['assembled/source.jpg'], ['upload-1'], ); expect(releaseLock).toHaveBeenCalled(); expect(releaseUploadLocks).toHaveBeenCalled(); }); test('a complete record is idempotent and retries controlled source cleanup under locks', async () => { (prisma.material.findUnique as jest.Mock) .mockReset() .mockResolvedValueOnce({ ...baseMaterial, taskStatus: 'completed' }) .mockResolvedValueOnce({ ...baseMaterial, taskStatus: 'completed', previewUrl: 'https://s3.example/preview.webp', zipFilePath: 'materials/source.zip', assets: [{ assetType: 'preview' }, { assetType: 'zip' }], }); const result = await runCompletion(); expect(result).toEqual({ materialId: 'material-1', alreadyCompleted: true }); expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledWith('material-1'); expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-1']); expect(cleanupTempFiles).toHaveBeenCalledWith([], ['upload-1']); expect(createZipFromFiles).not.toHaveBeenCalled(); }); test('completed without preview, ZIP, or asset evidence is rejected without cleanup', async () => { (prisma.material.findUnique as jest.Mock) .mockReset() .mockResolvedValueOnce({ ...baseMaterial, taskStatus: 'completed' }) .mockResolvedValueOnce({ ...baseMaterial, taskStatus: 'completed', previewUrl: '', zipFilePath: '', assets: [], }); await expect(runCompletion()).rejects.toMatchObject({ status: 409 }); expect(cleanupTempFiles).not.toHaveBeenCalled(); expect(createZipFromFiles).not.toHaveBeenCalled(); }); test('partial preview uploads are tracked and removed without deleting source sessions', async () => { (generatePreviewImages as jest.Mock).mockImplementation( async (_items, _type, _options, onUploaded) => { onUploaded?.('https://s3.example/partial.webp'); throw new Error('第二张压缩失败'); }, ); await expect(runCompletion()).rejects.toThrow('第二张压缩失败'); expect(cleanupOnError).toHaveBeenCalledWith( ['https://s3.example/partial.webp'], 'materials/source.zip', [], null, [], false, ); expect(cleanupTempFiles).not.toHaveBeenCalled(); expect(releaseLock).toHaveBeenCalled(); }); test('a committed-but-unacknowledged transaction returns success and preserves files', async () => { (saveTaskMaterialAssetsAndComplete as jest.Mock).mockRejectedValue(new Error('connection lost')); (inspectTaskMaterialCompletionAfterError as jest.Mock).mockResolvedValue({ state: 'completed', taskStatus: 'completed', }); const result = await runCompletion(); expect(result).toEqual({ materialId: 'material-1', warning: '素材已保存,但响应确认阶段出现异常', }); expect(cleanupOnError).not.toHaveBeenCalled(); expect(cleanupTempFiles).toHaveBeenCalledWith( ['assembled/source.jpg'], ['upload-1'], ); }); test('an unknown database outcome fails closed and preserves every file', async () => { (createZipFromFiles as jest.Mock).mockRejectedValue(new Error('disk error')); (inspectTaskMaterialCompletionAfterError as jest.Mock).mockResolvedValue({ state: 'unknown', error: new Error('database unavailable'), }); await expect(runCompletion()).rejects.toThrow('服务器已保留上传文件'); expect(cleanupOnError).not.toHaveBeenCalled(); expect(cleanupTempFiles).not.toHaveBeenCalled(); }); }); function runCompletion() { return completeTaskImageMaterial({ materialId: 'material-1', userId: 'user-1', materialType: '任务-页面', fallbackProductModel: 'task-page', assets: [{ uploadId: 'upload-1', fileName: 'source.jpg', fileSize: 1 }], }); }