flnxi-Wu/lib/__tests__/video-transcode-service.test.ts

275 lines
8.7 KiB
TypeScript

export {}
jest.mock('@/lib/prisma', () => ({
prisma: {
$queryRaw: jest.fn(),
material: {
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
videoTranscodeJob: {
upsert: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
},
}))
jest.mock('@/lib/video-transcode-queue', () => ({
transcodeQueue: {
add: jest.fn(),
},
}))
jest.mock('@/lib/video-processor', () => ({
processVideoTranscode: jest.fn(),
}))
const { prisma } = require('@/lib/prisma')
const { transcodeQueue } = require('@/lib/video-transcode-queue')
const { processVideoTranscode } = require('@/lib/video-processor')
describe('video-transcode-service', () => {
beforeEach(() => {
jest.resetAllMocks()
jest.useRealTimers()
transcodeQueue.add.mockResolvedValue(undefined)
prisma.videoTranscodeJob.upsert.mockResolvedValue({})
prisma.material.update.mockResolvedValue({})
})
afterEach(() => {
jest.useRealTimers()
delete process.env.VIDEO_TRANSCODE_RETRY_BASE_DELAY_MS
})
it('enqueueVideoTranscodeJob 会先落持久化任务再加入内存队列', async () => {
const { enqueueVideoTranscodeJob } = require('../video-transcode-service')
await enqueueVideoTranscodeJob({
materialId: 'material-1',
videoSourceUrl: 's3/video.mp4',
needsCoverImage: true,
})
expect(prisma.videoTranscodeJob.upsert).toHaveBeenCalledWith(
expect.objectContaining({
where: { materialId: 'material-1' },
create: expect.objectContaining({
materialId: 'material-1',
status: 'queued',
videoSourceUrl: 's3/video.mp4',
}),
})
)
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { videoProcessingStatus: 'pending' },
})
expect(transcodeQueue.add).toHaveBeenCalled()
})
it('recoverStaleVideoTranscodeJobs 会恢复卡住的 processing 任务和孤儿 pending 视频', async () => {
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')
prisma.videoTranscodeJob.findMany.mockResolvedValue([
{
materialId: 'material-1',
videoSourceUrl: 's3/old.mp4',
material: {
id: 'material-1',
videoSourceUrl: 's3/old.mp4',
videoCoverUrl: null,
},
},
])
prisma.$queryRaw.mockResolvedValue([
{
id: 'material-2',
videoSourceUrl: 's3/orphan.mp4',
videoCoverUrl: 's3/cover.jpg',
},
])
const result = await recoverStaleVideoTranscodeJobs()
expect(result.recovered).toBe(2)
expect(prisma.videoTranscodeJob.update).toHaveBeenCalledWith({
where: { materialId: 'material-1' },
data: expect.objectContaining({
status: 'queued',
lockedAt: null,
}),
})
expect(prisma.videoTranscodeJob.upsert).toHaveBeenCalledTimes(2)
expect(transcodeQueue.add).toHaveBeenCalledTimes(2)
expect(prisma.$queryRaw.mock.calls[0][0].join('')).toContain(`OR m."materialType" = '视频'`)
})
it('Prisma Client 缺少 videoTranscodeJob 时,启动恢复会安全跳过', async () => {
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')
const originalDelegate = prisma.videoTranscodeJob
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
prisma.videoTranscodeJob = undefined
try {
const result = await recoverStaleVideoTranscodeJobs()
expect(result).toEqual({ recovered: 0, hasMore: false, skipped: true })
expect(prisma.$queryRaw).not.toHaveBeenCalled()
expect(transcodeQueue.add).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('跳过启动恢复'))
} finally {
prisma.videoTranscodeJob = originalDelegate
warnSpy.mockRestore()
}
})
it('数据库缺少 video_transcode_jobs 表时,启动恢复会安全跳过', async () => {
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
prisma.videoTranscodeJob.findMany.mockRejectedValue({
code: 'P2021',
meta: { table: 'public.video_transcode_jobs' },
message: 'The table public.video_transcode_jobs does not exist',
})
try {
const result = await recoverStaleVideoTranscodeJobs()
expect(result).toEqual({ recovered: 0, hasMore: false, skipped: true })
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('跳过启动恢复'))
expect(prisma.$queryRaw).not.toHaveBeenCalled()
expect(transcodeQueue.add).not.toHaveBeenCalled()
} finally {
warnSpy.mockRestore()
}
})
it('恢复批次达到上限时会标记 hasMore 并安排继续恢复', async () => {
jest.useFakeTimers()
const { recoverStaleVideoTranscodeJobs } = require('../video-transcode-service')
prisma.videoTranscodeJob.findMany.mockResolvedValue(
Array.from({ length: 20 }, (_, index) => ({
materialId: `material-${index}`,
videoSourceUrl: `s3/video-${index}.mp4`,
material: {
id: `material-${index}`,
videoSourceUrl: `s3/video-${index}.mp4`,
videoCoverUrl: null,
},
}))
)
prisma.$queryRaw.mockResolvedValue([])
const result = await recoverStaleVideoTranscodeJobs()
expect(result).toEqual({ recovered: 20, hasMore: true })
expect(transcodeQueue.add).toHaveBeenCalledTimes(20)
prisma.videoTranscodeJob.findMany.mockResolvedValue([])
prisma.$queryRaw.mockResolvedValue([])
await jest.advanceTimersByTimeAsync(1000)
expect(prisma.videoTranscodeJob.findMany).toHaveBeenCalledTimes(2)
})
it('任务失败但还有重试次数时,会重新写回 queued 并延迟重新入队', async () => {
jest.useFakeTimers()
const { enqueueVideoTranscodeJob } = require('../video-transcode-service')
process.env.VIDEO_TRANSCODE_RETRY_BASE_DELAY_MS = '10'
prisma.videoTranscodeJob.upsert.mockResolvedValue({})
prisma.material.update.mockResolvedValue({})
prisma.videoTranscodeJob.update.mockResolvedValue({
materialId: 'material-1',
attempts: 1,
maxAttempts: 3,
})
processVideoTranscode.mockRejectedValue(new Error('ffmpeg failed'))
let firstQueueRun: Promise<void> | undefined
transcodeQueue.add.mockImplementationOnce(async (
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean,
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise<void>
) => {
firstQueueRun = expect(taskFn(materialId, videoSourceUrl, needsCoverImage)).rejects.toThrow('ffmpeg failed')
await firstQueueRun
})
transcodeQueue.add.mockResolvedValueOnce(undefined)
await enqueueVideoTranscodeJob({
materialId: 'material-1',
videoSourceUrl: 's3/video.mp4',
needsCoverImage: true,
})
await firstQueueRun
expect(prisma.videoTranscodeJob.update).toHaveBeenLastCalledWith({
where: { materialId: 'material-1' },
data: expect.objectContaining({
status: 'queued',
lockedAt: null,
finishedAt: null,
errorMessage: 'ffmpeg failed',
}),
})
await jest.advanceTimersByTimeAsync(10)
expect(transcodeQueue.add).toHaveBeenCalledTimes(2)
expect(transcodeQueue.add.mock.calls[1][0]).toBe('material-1')
})
it('任务开始处理时同步更新素材状态为 processing', async () => {
const { enqueueVideoTranscodeJob } = require('../video-transcode-service')
prisma.videoTranscodeJob.update.mockResolvedValue({
materialId: 'material-1',
attempts: 1,
maxAttempts: 3,
})
processVideoTranscode.mockResolvedValue({
preview540Url: 's3/preview.mp4',
clipUrl: 's3/clip.mp4',
coverUrl: null,
duration: 12,
})
let queueRun: Promise<void> | undefined
transcodeQueue.add.mockImplementationOnce(async (
materialId: string,
videoSourceUrl: string,
needsCoverImage: boolean,
taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise<void>
) => {
queueRun = taskFn(materialId, videoSourceUrl, needsCoverImage)
await queueRun
})
await enqueueVideoTranscodeJob({
materialId: 'material-1',
videoSourceUrl: 's3/video.mp4',
needsCoverImage: false,
})
await queueRun
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: { videoProcessingStatus: 'processing' },
})
expect(prisma.material.update).toHaveBeenLastCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({ videoProcessingStatus: 'done' }),
})
})
})