/** @jest-environment node */ import { NextRequest } from 'next/server' import { getServerSession } from 'next-auth' import { POST } from '@/app/api/materials/transcode/route' import { prisma } from '@/lib/prisma' import { hasSystemAdminPermission } from '@/lib/department-permission' import { transcodeQueue } from '@/lib/video-transcode-queue' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { count: jest.fn(), findFirst: jest.fn(), }, }, })) jest.mock('@/lib/department-permission', () => ({ canUserViewMaterial: jest.fn(), hasSystemAdminPermission: jest.fn(), })) jest.mock('@/lib/video-transcode-task', () => ({ executeTranscodeTask: jest.fn(), })) jest.mock('@/lib/video-transcode-queue', () => ({ VIDEO_MATERIAL_TYPES: ['淘系-主图视频'], transcodeQueue: { add: jest.fn(), getStatus: jest.fn(() => ({ waiting: [], processingIds: ['material-1'], progress: new Map(), stages: new Map(), maxConcurrent: 1, })), recoverOnStartup: jest.fn().mockResolvedValue(1), recoverInterruptedTasks: jest.fn(), }, })) function createRequest() { return new NextRequest('http://localhost/api/materials/transcode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId: 'material-1', forceRetry: true }), }) } describe('transcode route authorization', () => { beforeEach(() => { jest.clearAllMocks() ;(transcodeQueue.add as jest.Mock).mockResolvedValue(undefined) ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'owner-1', role: 'USER', isSuperAdmin: false }, }) ;(hasSystemAdminPermission as jest.Mock).mockReturnValue(false) ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'owner-1', videoSourceUrl: 'uploads/materials/video.mp4', videoCoverUrl: null, videoProcessingStatus: 'failed', materialType: '淘系-主图视频', }) }) test('素材所有者可以重试自己的转码任务', async () => { const response = await POST(createRequest()) expect(response.status).toBe(200) expect(transcodeQueue.add).toHaveBeenCalledWith( 'material-1', 'uploads/materials/video.mp4', true, expect.any(Function), ) expect(transcodeQueue.recoverOnStartup).not.toHaveBeenCalled() }) test('普通用户不能启动其他用户素材的转码任务', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'other-owner', videoSourceUrl: 'uploads/materials/video.mp4', videoCoverUrl: null, videoProcessingStatus: 'failed', materialType: '淘系-主图视频', }) const response = await POST(createRequest()) const body = await response.json() expect(response.status).toBe(403) expect(body.error).toContain('只能启动自己素材') expect(transcodeQueue.add).not.toHaveBeenCalled() }) test('系统管理员可以启动任意素材的转码任务,恢复由 instrumentation 周期负责', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'admin-1', role: 'SYSTEM_ADMIN', isSuperAdmin: false }, }) ;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true) ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'other-owner', videoSourceUrl: 'uploads/materials/video.mp4', videoCoverUrl: null, videoProcessingStatus: 'failed', materialType: '淘系-主图视频', }) const response = await POST(createRequest()) expect(response.status).toBe(200) expect(transcodeQueue.recoverOnStartup).not.toHaveBeenCalled() expect(transcodeQueue.add).toHaveBeenCalledTimes(1) }) test('POST 只等待持久化入队,不等待后台转码任务完成', async () => { const responsePromise = POST(createRequest()) await expect(responsePromise).resolves.toMatchObject({ status: 200 }) expect(transcodeQueue.add).toHaveBeenCalledTimes(1) }) test('processing 状态即使 forceRetry 也不能绕过后台租约恢复并启动第二个任务', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'owner-1', videoSourceUrl: 'uploads/materials/video.mp4', videoCoverUrl: null, videoProcessingStatus: 'processing', transcodeLeaseToken: null, transcodeLeaseExpiresAt: null, materialType: '淘系-主图视频', }) const response = await POST(createRequest()) expect(response.status).toBe(409) expect(transcodeQueue.add).not.toHaveBeenCalled() }) })