275 lines
8.7 KiB
TypeScript
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' }),
|
|
})
|
|
})
|
|
})
|