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 | undefined transcodeQueue.add.mockImplementationOnce(async ( materialId: string, videoSourceUrl: string, needsCoverImage: boolean, taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise ) => { 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 | undefined transcodeQueue.add.mockImplementationOnce(async ( materialId: string, videoSourceUrl: string, needsCoverImage: boolean, taskFn: (materialId: string, videoSourceUrl: string, needsCoverImage: boolean) => Promise ) => { 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' }), }) }) })