Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
242 lines
8.5 KiB
TypeScript
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 }],
|
|
});
|
|
}
|