gongxiangp2/lib/__tests__/video-processor-output-cleanup.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

100 lines
3.6 KiB
TypeScript

/** @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()
})
})