Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
155 lines
4.8 KiB
TypeScript
155 lines
4.8 KiB
TypeScript
/** @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()
|
|
})
|
|
})
|