gongxiangp2/lib/__tests__/task-image-material-completion.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

242 lines
8.5 KiB
TypeScript

/** @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 }],
});
}