/** @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' import { canAccessOperationsGMaterials } from '@/lib/material-organization-access' jest.mock('@/lib/material-organization-access', () => ({ canAccessOperationsGMaterials: jest.fn() })) jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { count: jest.fn(), findFirst: jest.fn(), findMany: 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() ;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(true) ;(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() }) test('调出运营中心 G 的上传者不能重试专属视频,管理员也不能越过组织范围', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'moved-owner', role: 'ADMIN' } }) ;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true) ;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(false) ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'moved-owner', accessScope: 'operations_g', materialType: '淘系-主图视频', videoSourceUrl: 'uploads/video.mp4', videoProcessingStatus: 'failed', }) expect((await POST(createRequest())).status).toBe(403) expect(transcodeQueue.add).not.toHaveBeenCalled() }) test('中心外管理员的转码队列只返回通用素材', async () => { ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'outside-admin', role: 'ADMIN' } }) ;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true) ;(canAccessOperationsGMaterials as jest.Mock).mockResolvedValue(false) ;(prisma.material.findMany as jest.Mock).mockResolvedValue([{ id: 'material-1' }]) ;(transcodeQueue.getStatus as jest.Mock).mockReturnValueOnce({ waiting: ['g-video'], processingIds: ['material-1'], maxConcurrent: 1, progress: new Map([['g-video', 20]]), stages: new Map([['g-video', 'processing']]), }) const response = await POST(createRequest()) expect(response.status).toBe(200) const body = await response.json() expect(body.queueStatus.waiting).toEqual([]) expect(body.queueStatus.processingIds).toEqual(['material-1']) expect(body.queueStatus.queueLength).toBe(0) expect(prisma.material.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: ['g-video', 'material-1'] }, accessScope: 'general' }, })) }) })