/** @jest-environment node */ import { readFile } from 'fs/promises' import path from 'path' import { EventEmitter } from 'events' import { spawn } from 'child_process' import { TRANSCODE_CONFIG, buildClipFFmpegArgs, buildPreviewFFmpegArgs, execFFmpegWithTimeout, resolveTranscodeOutputRoot, } from '@/lib/video-processor' jest.mock('child_process', () => ({ execFile: jest.fn(), spawn: jest.fn(), })) function createMockFFmpegProcess() { const child = new EventEmitter() as any child.pid = 424242 child.stdout = new EventEmitter() child.stderr = new EventEmitter() child.kill = jest.fn(() => true) return child } describe('canonical video source preservation', () => { test('视频处理器只清理任务临时文件,不再删除 canonical uploads 源文件', async () => { const source = await readFile(path.join(process.cwd(), 'lib/video-processor.ts'), 'utf8') expect(source).not.toContain('deleteLocalSourceIfNeeded') expect(source).not.toContain("import('./file-cleanup')") expect(source).toContain('canonical 源文件是用户上传的唯一原件') expect(source).toContain('Local canonical source opened read-only') expect(source).not.toContain('copyFile') expect(source).not.toContain('tmpdir()') expect(source).toContain("await rm(workDir, { recursive: true, force: true })") expect(source).toContain("/^video-task-[a-zA-Z0-9_-]+$/") }) test('通用上传在数据库提交前和成功响应前都严格复核 canonical 原件', async () => { const [routeSource, validatorSource] = await Promise.all([ readFile(path.join(process.cwd(), 'app/api/materials/upload/route.ts'), 'utf8'), readFile(path.join(process.cwd(), 'lib/material-canonical-source.ts'), 'utf8'), ]) expect(validatorSource).toContain('isPathFreeOfSymbolicLinks(uploadRoot, sourcePath)') expect(validatorSource).toContain('pathStats.isSymbolicLink() || !pathStats.isFile()') expect(validatorSource).toContain('constants.O_NOFOLLOW') expect(validatorSource).toContain('openedStats.size !== expectedSize') const preCommitValidation = routeSource.indexOf( 'await validateCanonicalSourceForMaterial({', ) const materialCreate = routeSource.indexOf('await prisma.material.create({') const postCommitValidation = routeSource.indexOf( "phase: 'post-commit'", materialCreate, ) const journalRemoval = routeSource.indexOf( 'await removeMaterialUploadConsumptionJournal({', materialCreate, ) expect(preCommitValidation).toBeGreaterThan(-1) expect(preCommitValidation).toBeLessThan(materialCreate) expect(postCommitValidation).toBeGreaterThan(materialCreate) expect(postCommitValidation).toBeLessThan(journalRemoval) expect(routeSource).toContain("phase: 'idempotent-replay'") expect(routeSource).toContain("code: 'CANONICAL_SOURCE_INTEGRITY_FAILED'") }) test('生成的视频与封面从临时文件流式上传,不把大文件整体读入 Node 内存', async () => { const source = await readFile(path.join(process.cwd(), 'lib/video-processor.ts'), 'utf8') expect(source).toContain('uploadFileToS3,') expect(source).toContain('getS3KeyFromUrl,') expect(source).toContain('deleteFromS3,') expect(source).toContain("uploadFileToS3(previewPath, preview540FileName, 'video/mp4')") expect(source).toContain("uploadFileToS3(clipPath, clipFileName, 'video/mp4')") expect(source).toContain("uploadFileToS3(coverPath, coverFileName, 'image/jpeg')") expect(source).not.toMatch(/readFile\(temp(?:540|Clip|Cover)Path\)/) }) test('FFmpeg 非零退出码即使带有进度日志也必须失败', async () => { const child = createMockFFmpegProcess() ;(spawn as unknown as jest.Mock).mockReturnValueOnce(child) const result = execFFmpegWithTimeout(['-i', 'broken-input'], 1000) child.stderr.emit('data', Buffer.from('Duration: 00:00:10.00 frame=12 conversion failed')) child.emit('close', 1, null) await expect(result).rejects.toThrow('FFmpeg exited with code 1') expect(child.kill).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledWith('ffmpeg', ['-i', 'broken-input'], expect.objectContaining({ shell: false })) }) test('租约取消会终止 FFmpeg 进程组,并在 5 秒后使用 SIGKILL 兜底', async () => { jest.useFakeTimers() const child = createMockFFmpegProcess() const processKill = jest.spyOn(process, 'kill').mockImplementation(() => true) ;(spawn as unknown as jest.Mock).mockReturnValueOnce(child) const controller = new AbortController() const result = execFFmpegWithTimeout(['-i', 'source.mp4'], 60_000, undefined, controller.signal) controller.abort(new Error('lease lost')) if (process.platform === 'win32') { expect(child.kill).toHaveBeenCalledWith('SIGTERM') } else { expect(processKill).toHaveBeenCalledWith(-child.pid, 'SIGTERM') } jest.advanceTimersByTime(5000) if (process.platform === 'win32') { expect(child.kill).toHaveBeenCalledWith('SIGKILL') } else { expect(processKill).toHaveBeenCalledWith(-child.pid, 'SIGKILL') } child.emit('close', null, 'SIGKILL') await expect(result).rejects.toThrow('lease lost') processKill.mockRestore() jest.useRealTimers() }) test('操作系统拒绝终止信号时不会从 Abort 回调抛出未捕获异常', async () => { const child = createMockFFmpegProcess() const killError = Object.assign(new Error('operation not permitted'), { code: 'EPERM' }) child.kill.mockImplementation(() => { throw killError }) const processKill = jest.spyOn(process, 'kill').mockImplementation(() => { throw killError }) const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}) ;(spawn as unknown as jest.Mock).mockReturnValueOnce(child) const controller = new AbortController() const result = execFFmpegWithTimeout(['-i', 'source.mp4'], 60_000, undefined, controller.signal) expect(() => controller.abort(new Error('lease lost'))).not.toThrow() child.emit('close', null, 'SIGTERM') await expect(result).rejects.toThrow('lease lost') expect(consoleError).toHaveBeenCalledWith( '[VideoProcessor] Failed to send SIGTERM to FFmpeg:', killError, ) consoleError.mockRestore() processKill.mockRestore() }) test('x265 输入解码、输出编码器和内部线程池都受到线程上限约束', async () => { for (const args of [ buildPreviewFFmpegArgs('input with spaces.mp4', 'preview.mp4'), buildClipFFmpegArgs('input with spaces.mp4', 'clip.mp4'), ]) { expect(args.slice(0, 4)).toEqual(['-threads', String(TRANSCODE_CONFIG.threads), '-i', 'input with spaces.mp4']) const codecIndex = args.indexOf('libx265') const outputThreadsIndex = args.indexOf('-threads', codecIndex) expect(outputThreadsIndex).toBeGreaterThan(codecIndex) expect(args[outputThreadsIndex + 1]).toBe(String(TRANSCODE_CONFIG.threads)) expect(args).toContain(`pools=${TRANSCODE_CONFIG.threads}:frame-threads=${TRANSCODE_CONFIG.threads}`) } }) test('工作目录只能位于配置的 UPLOAD_DIR,且远端下载同时检查声明长度和流式字节数', async () => { const previousUploadDir = process.env.UPLOAD_DIR const uploadRoot = path.join(process.cwd(), 'uploads-test-root') process.env.UPLOAD_DIR = uploadRoot try { expect(resolveTranscodeOutputRoot('./uploads/transcoded')).toBe(path.join(uploadRoot, 'transcoded')) expect(() => resolveTranscodeOutputRoot(path.resolve(process.cwd(), 'outside'))).toThrow('must stay inside UPLOAD_DIR') } finally { if (previousUploadDir === undefined) delete process.env.UPLOAD_DIR else process.env.UPLOAD_DIR = previousUploadDir } const source = await readFile(path.join(process.cwd(), 'lib/video-processor.ts'), 'utf8') expect(source).toContain("res.headers.get('content-length')") expect(source).toContain('createByteLimitTransform(TRANSCODE_CONFIG.maxTempFileSize)') expect(source).toContain("fetch(remoteUrl, { signal: abortSignal })") expect(source).toContain("{ timeout: 30000, encoding: 'utf8', signal: abortSignal }") expect(source).toContain('if (!space) {') expect(source).toContain('无法确认 uploads/NAS 转码工作目录的剩余空间') }) test('转码入队失败时保留源视频并维持周期扫描可恢复的 pending 状态', async () => { const source = await readFile(path.join(process.cwd(), 'app/api/materials/upload/route.ts'), 'utf8') const enqueueFailureBlock = source.slice( source.indexOf('.catch(async (e: any) => {'), source.indexOf('return NextResponse.json', source.indexOf('.catch(async (e: any) => {')), ) expect(enqueueFailureBlock).toContain("videoProcessingStatus: 'pending'") expect(enqueueFailureBlock).toContain('60 秒周期扫描会重新装载该任务') expect(enqueueFailureBlock).toContain('transcodeLastError: USER_TRANSCODE_FAILURE_MESSAGE') expect(enqueueFailureBlock).not.toContain('fullErrorMsg') expect(enqueueFailureBlock).not.toContain("videoProcessingStatus: 'failed'") expect(enqueueFailureBlock).toContain('canonical 源视频必须保留') expect(enqueueFailureBlock).not.toContain('deleteLocalFile') expect(enqueueFailureBlock).not.toContain('Removed source video file') }) })