200 lines
9.2 KiB
TypeScript
200 lines
9.2 KiB
TypeScript
/** @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')
|
||
})
|
||
})
|