/** @jest-environment node */ import { EventEmitter } from 'events' import { mkdtemp, rm, writeFile } from 'fs/promises' import { tmpdir } from 'os' import path from 'path' import { spawn } from 'child_process' import { processVideoTranscode } from '@/lib/video-processor' import { deleteFromS3, uploadFileToS3 } from '@/lib/s3' jest.mock('child_process', () => { const { promisify } = jest.requireActual('util') as typeof import('util') const execFile = jest.fn() Object.defineProperty(execFile, promisify.custom, { value: async (_file: string, args: string[]) => ( args.includes('format=duration') ? { stdout: '12\n', stderr: '' } : { stdout: '', stderr: '' } ), }) return { execFile, spawn: jest.fn(), } }) jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn(), uploadFileToS3: jest.fn(), getS3KeyFromUrl: jest.fn(), getS3ObjectStream: jest.fn(), isHttpUrl: jest.fn(() => false), isS3PublicUrl: jest.fn(() => false), })) jest.mock('@/lib/disk-space', () => ({ checkDiskSpace: jest.fn().mockResolvedValue({ total: 100 * 1024 * 1024 * 1024, available: 100 * 1024 * 1024 * 1024, }), })) function createSuccessfulFFmpegProcess() { const child = new EventEmitter() as any child.pid = 424242 child.stdout = new EventEmitter() child.stderr = new EventEmitter() child.kill = jest.fn(() => true) queueMicrotask(() => child.emit('close', 0, null)) return child } describe('video processor uploaded-output cleanup', () => { let uploadRoot = '' const previousUploadDir = process.env.UPLOAD_DIR beforeEach(async () => { jest.clearAllMocks() uploadRoot = await mkdtemp(path.join(tmpdir(), 'gongxiangp2-video-output-')) process.env.UPLOAD_DIR = uploadRoot ;(spawn as unknown as jest.Mock).mockImplementation(() => createSuccessfulFFmpegProcess()) }) afterEach(async () => { await rm(uploadRoot, { recursive: true, force: true }) if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir }) test('后续片段上传失败时删除已经上传的唯一预览对象,且清理失败不覆盖原错误', async () => { const sourcePath = path.join(uploadRoot, 'source.mp4') await writeFile(sourcePath, Buffer.from('test-video-source')) ;(uploadFileToS3 as jest.Mock) .mockResolvedValueOnce('https://s3.jiansun.vip/video-preview-unique.mp4') .mockRejectedValueOnce(new Error('clip upload failed')) ;(deleteFromS3 as jest.Mock).mockRejectedValueOnce(new Error('cleanup temporarily unavailable')) const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}) await expect(processVideoTranscode({ inputPath: sourcePath, outputDir: './uploads/transcoded', materialId: 'material-output-cleanup', needsCoverImage: false, })).rejects.toThrow('clip upload failed') expect(uploadFileToS3).toHaveBeenCalledTimes(2) expect(deleteFromS3).toHaveBeenCalledTimes(1) expect(deleteFromS3).toHaveBeenCalledWith('https://s3.jiansun.vip/video-preview-unique.mp4') const uploadedNames = (uploadFileToS3 as jest.Mock).mock.calls.map((call) => call[1] as string) expect(uploadedNames[0]).toMatch(/^video-preview-\d+p-material-output-cleanup-[0-9a-f-]{36}\.mp4$/) expect(uploadedNames[1]).toMatch(/^video-clip-\d+s-material-output-cleanup-[0-9a-f-]{36}\.mp4$/) const previewIdentity = uploadedNames[0].match(/([0-9a-f-]{36})\.mp4$/)?.[1] const clipIdentity = uploadedNames[1].match(/([0-9a-f-]{36})\.mp4$/)?.[1] expect(previewIdentity).toBeTruthy() expect(clipIdentity).toBe(previewIdentity) consoleError.mockRestore() }) })