gongxiangp2/lib/__tests__/video-source-preservation.test.ts
chenpei 5ef70bce4e fix: 转码按正式目录找源文件,失败只给用户可重试提示
把 /uploads 网址路径落到 UPLOAD_DIR,避免误判文件不存在。
页面和接口不再回传路径或堆栈,只提示稍后重试。
2026-08-17 15:24:13 +08:00

200 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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