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