/** * R1修复:转码队列持久化测试 * * 测试内容: * - VideoTranscodeProgress 数据库模型 * - VideoTranscodeQueue 的数据库持久化功能 * - 启动恢复功能 * - 进度更新和清理功能 */ // Mock Prisma jest.mock('@/lib/prisma', () => ({ prisma: { videoTranscodeProgress: { upsert: jest.fn(), findMany: jest.fn(), deleteMany: jest.fn(), findFirst: jest.fn(), }, material: { findFirst: jest.fn(), }, }, })) // Mock console methods to avoid clutter global.console.log = jest.fn() global.console.warn = jest.fn() global.console.error = jest.fn() describe('R1: 转码队列持久化', () => { beforeEach(() => { jest.clearAllMocks() }) describe('VideoTranscodeProgress 数据模型', () => { it('应该定义正确的字段结构', () => { const progress = { id: 'test-id', materialId: 'material-123', stage: 'preview', progress: 50, status: 'processing', priority: 0, startedAt: new Date(), completedAt: null, errorMessage: null, retryCount: 0, lastHeartbeat: new Date(), createdAt: new Date(), updatedAt: new Date(), } expect(progress).toHaveProperty('id') expect(progress).toHaveProperty('materialId') expect(progress).toHaveProperty('stage') expect(progress).toHaveProperty('progress') expect(progress).toHaveProperty('status') expect(progress).toHaveProperty('lastHeartbeat') }) it('应该支持唯一约束 (materialId, stage)', () => { const uniqueKey = { materialId: 'material-123', stage: 'preview' } expect(uniqueKey).toHaveProperty('materialId') expect(uniqueKey).toHaveProperty('stage') }) it('应该支持有效的状态值', () => { const validStatuses = ['waiting', 'processing', 'completed', 'failed'] validStatuses.forEach(status => { expect(['waiting', 'processing', 'completed', 'failed']).toContain(status) }) }) it('应该支持有效的阶段值', () => { const validStages = ['preview', 'clip', 'cover'] validStages.forEach(stage => { expect(['preview', 'clip', 'cover']).toContain(stage) }) }) }) describe('saveStageProgress 功能', () => { it('应该使用 upsert 创建或更新进度记录', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.upsert = jest.fn().mockResolvedValue({ id: 'test-id', materialId: 'material-123', stage: 'preview', progress: 25, status: 'processing', lastHeartbeat: new Date(), }) // 模拟调用 saveStageProgress await prisma.videoTranscodeProgress.upsert({ where: { materialId_stage: { materialId: 'material-123', stage: 'preview' } }, create: { materialId: 'material-123', stage: 'preview', status: 'processing', progress: 25, errorMessage: null, lastHeartbeat: new Date(), }, update: { status: 'processing', progress: 25, errorMessage: null, lastHeartbeat: expect.any(Date), }, }) expect(prisma.videoTranscodeProgress.upsert).toHaveBeenCalledWith( expect.objectContaining({ where: { materialId_stage: { materialId: 'material-123', stage: 'preview' } }, }) ) }) it('处理状态应该更新 lastHeartbeat', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.upsert = jest.fn().mockResolvedValue({}) await prisma.videoTranscodeProgress.upsert({ where: { materialId_stage: { materialId: 'material-123', stage: 'preview' } }, create: { materialId: 'material-123', stage: 'preview', status: 'processing', progress: 50, errorMessage: null, lastHeartbeat: new Date(), }, update: { status: 'processing', progress: 50, errorMessage: null, lastHeartbeat: expect.any(Date), }, }) expect(prisma.videoTranscodeProgress.upsert).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ lastHeartbeat: expect.any(Date), }), }) ) }) it('完成状态应该设置 completedAt', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.upsert = jest.fn().mockResolvedValue({}) await prisma.videoTranscodeProgress.upsert({ where: { materialId_stage: { materialId: 'material-123', stage: 'preview' } }, create: { materialId: 'material-123', stage: 'preview', status: 'completed', progress: 100, errorMessage: null, lastHeartbeat: new Date(), }, update: { status: 'completed', progress: 100, errorMessage: null, completedAt: expect.any(Date), }, }) expect(prisma.videoTranscodeProgress.upsert).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ completedAt: expect.any(Date), }), }) ) }) }) describe('loadWaitingTasks 功能', () => { it('应该查询 waiting 状态的任务', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.findMany = jest.fn().mockResolvedValue([]) const timeoutThreshold = new Date(Date.now() - 10 * 60 * 1000) await prisma.videoTranscodeProgress.findMany({ where: { OR: [{ status: 'waiting' }], }, include: { material: { select: { videoSourceUrl: true, videoCoverUrl: true, }, }, }, orderBy: [{ priority: 'desc' }, { createdAt: 'asc' }], }) expect(prisma.videoTranscodeProgress.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ OR: expect.arrayContaining([ expect.objectContaining({ status: 'waiting' }), ]), }), }) ) }) it('应该查询心跳超时的 processing 任务', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.findMany = jest.fn().mockResolvedValue([]) const timeoutThreshold = new Date(Date.now() - 10 * 60 * 1000) await prisma.videoTranscodeProgress.findMany({ where: { OR: [ { AND: [ { status: 'processing' }, { lastHeartbeat: { lt: timeoutThreshold } }, ], }, ], }, }) expect(prisma.videoTranscodeProgress.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ OR: expect.arrayContaining([ expect.objectContaining({ AND: expect.arrayContaining([ { status: 'processing' }, { lastHeartbeat: { lt: timeoutThreshold } }, ]), }), ]), }), }) ) }) }) describe('cleanupCompletedStages 功能', () => { it('应该删除 completed 状态的记录', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.deleteMany = jest.fn().mockResolvedValue({ count: 3 }) await prisma.videoTranscodeProgress.deleteMany({ where: { materialId: 'material-123', status: { in: ['completed', 'failed'] }, }, }) expect(prisma.videoTranscodeProgress.deleteMany).toHaveBeenCalledWith({ where: { materialId: 'material-123', status: { in: ['completed', 'failed'] }, }, }) }) it('应该删除 failed 状态的记录', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.deleteMany = jest.fn().mockResolvedValue({ count: 1 }) await prisma.videoTranscodeProgress.deleteMany({ where: { materialId: 'material-456', status: { in: ['completed', 'failed'] }, }, }) expect(prisma.videoTranscodeProgress.deleteMany).toHaveBeenCalled() }) }) describe('心跳超时检测', () => { it('应该正确计算超时阈值(10分钟)', () => { const now = Date.now() const timeoutThreshold = new Date(now - 10 * 60 * 1000) const timeDiff = now - timeoutThreshold.getTime() expect(timeDiff).toBe(10 * 60 * 1000) }) it('应该只标记超过 10 分钟无心跳的任务为超时', () => { const now = Date.now() const tenMinutesAgo = new Date(now - 10 * 60 * 1000) const fiveMinutesAgo = new Date(now - 5 * 60 * 1000) const fifteenMinutesAgo = new Date(now - 15 * 60 * 1000) expect(fifteenMinutesAgo < tenMinutesAgo).toBe(true) // 超时 expect(fiveMinutesAgo > tenMinutesAgo).toBe(true) // 未超时 }) }) describe('任务优先级排序', () => { it('应该按优先级降序、创建时间升序排序', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.findMany = jest.fn().mockResolvedValue([]) await prisma.videoTranscodeProgress.findMany({ orderBy: [{ priority: 'desc' }, { createdAt: 'asc' }], }) expect(prisma.videoTranscodeProgress.findMany).toHaveBeenCalledWith( expect.objectContaining({ orderBy: [ { priority: 'desc' }, { createdAt: 'asc' }, ], }) ) }) }) describe('错误处理', () => { it('数据库错误应该记录但不中断程序', async () => { const { prisma } = await import('@/lib/prisma') prisma.videoTranscodeProgress.upsert = jest.fn().mockRejectedValue(new Error('Database error')) try { await prisma.videoTranscodeProgress.upsert({ where: { materialId_stage: { materialId: 'material-123', stage: 'preview' } }, create: { materialId: 'material-123', stage: 'preview', status: 'processing', progress: 50, errorMessage: null, lastHeartbeat: new Date(), }, update: {}, }) fail('Should have thrown') } catch (error) { expect(error).toBeInstanceOf(Error) } }) }) })