/** @jest-environment node */ import { NextRequest } from 'next/server' import { GET as getMaterialById, PUT as putMaterialById } from '@/app/api/materials/[id]/route' import { PUT as putMaterialUpdate } from '@/app/api/materials/update/route' import { getServerSession } from 'next-auth' import { updateMaterialWithSharedRules } from '@/lib/material-update' import { prisma } from '@/lib/prisma' import { canUserDownloadMaterial, canUserDownloadSource, canUserViewMaterial, isSuperAdmin, isSystemAdmin, } from '@/lib/department-permission' import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {}, })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findFirst: jest.fn(), }, user: { findUnique: jest.fn(), }, }, })) jest.mock('@/lib/department-permission', () => ({ canUserDownloadMaterial: jest.fn(), canUserDownloadSource: jest.fn(), canUserViewMaterial: jest.fn(), isSuperAdmin: jest.fn(), isSystemAdmin: jest.fn(), })) jest.mock('@/lib/task-detail-full-set', () => ({ deriveTaskDetailIsFullSet: jest.fn(), })) jest.mock('@/lib/material-update', () => ({ updateMaterialWithSharedRules: jest.fn(), })) describe('material edit routes', () => { const originalFile = globalThis.File beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1', role: 'USER', department: '测试部门', managedDepartment: null, }, }) ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) ;(isSystemAdmin as jest.Mock).mockReturnValue(false) ;(canUserViewMaterial as jest.Mock).mockResolvedValue(true) ;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true) ;(canUserDownloadSource as jest.Mock).mockResolvedValue(true) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true }) ;(deriveTaskDetailIsFullSet as jest.Mock).mockReturnValue(false) ;(updateMaterialWithSharedRules as jest.Mock).mockResolvedValue({ material: { id: 'material-1', productModel: 'SPU-001', categoryName: '店铺A', sceneName: '场景A', platformName: 'Amazon', productCategoryName: '椅子', materialType: '标准模型', renderer: 'CR', authorName: '测试作者', taskNo: null, taskDetailSourceType: null, taskDetailRequesterUserId: null, taskDetailRequesterName: null, taskStatus: null, taskFetchedAt: null, isFullSet: false, zipFileSize: 0, }, }) Object.defineProperty(globalThis, 'File', { configurable: true, writable: true, value: undefined, }) }) afterAll(() => { Object.defineProperty(globalThis, 'File', { configurable: true, writable: true, value: originalFile, }) }) function createFormData() { const formData = new FormData() formData.append('productModel', 'SPU-001') formData.append('categoryName', '店铺A') formData.append('sceneName', '场景A') formData.append('platformName', 'Amazon') formData.append('productCategoryName', '椅子') formData.append('materialType', '标准模型') formData.append('renderer', 'CR') formData.append('authorName', '测试作者') formData.append('userId', 'user-1') formData.append('taskNo', '') formData.append('taskDetailSourceType', 'AI') formData.append('taskDetailRequesterUserId', 'requester-1') return formData } function createPutRequest(url: string, formData: FormData) { return new NextRequest(url, { method: 'PUT', headers: { 'content-length': '1024', }, body: formData, }) } test('PUT /api/materials/[id] 在没有全局 File 时仍可正常处理表单更新', async () => { const request = createPutRequest( 'http://localhost/api/materials/material-1', createFormData(), ) const response = await putMaterialById(request, { params: { id: 'material-1' }, }) const body = await response.json() expect(response.status).toBe(200) expect(body.success).toBe(true) expect(updateMaterialWithSharedRules).toHaveBeenCalledWith( expect.objectContaining({ materialId: 'material-1', previewImage: null, }), ) }) test('PUT /api/materials/update 在没有全局 File 时仍可正常处理表单更新', async () => { const formData = createFormData() formData.append('id', 'material-1') const request = createPutRequest('http://localhost/api/materials/update', formData) const response = await putMaterialUpdate(request) const body = await response.json() expect(response.status).toBe(200) expect(body.message).toBe('更新成功') expect(updateMaterialWithSharedRules).toHaveBeenCalledWith( expect.objectContaining({ materialId: 'material-1', previewImage: null, }), ) }) test.each([ { name: '/api/materials/[id]', put: async (formData: FormData) => putMaterialById( createPutRequest('http://localhost/api/materials/material-1', formData), { params: { id: 'material-1' } }, ), }, { name: '/api/materials/update', put: async (formData: FormData) => { formData.append('id', 'material-1') return putMaterialUpdate( createPutRequest('http://localhost/api/materials/update', formData), ) }, }, ])('$name 不应把未提交的可选字段合成为空值', async ({ put }) => { const formData = new FormData() formData.append('productModel', 'SPU-001') formData.append('categoryName', '店铺A') formData.append('sceneName', '场景A') formData.append('materialType', '3D场景') formData.append('renderer', 'CR') formData.append('authorName', '测试作者') const response = await put(formData) expect(response.status).toBe(200) const call = (updateMaterialWithSharedRules as jest.Mock).mock.calls[0][0] expect(call.input).not.toHaveProperty('reportTitle') expect(call.input).not.toHaveProperty('userId') expect(call.input).not.toHaveProperty('taskNo') expect(call.input).not.toHaveProperty('dingtalkApprovalNo') expect(call.input).not.toHaveProperty('taskDetailSourceType') expect(call.input).not.toHaveProperty('taskDetailRequesterUserId') expect(call.input).not.toHaveProperty('isFullSet') }) test.each(['share=1', 'shareId=anything'])( 'GET /api/materials/[id] 不能通过 %s 绕过登录校验', async (shareParam) => { ;(getServerSession as jest.Mock).mockResolvedValue(null) const response = await getMaterialById( new NextRequest(`http://localhost/api/materials/material-1?${shareParam}`), { params: { id: 'material-1' } }, ) const body = await response.json() expect(response.status).toBe(401) expect(body.error).toBe('请先登录') expect(prisma.material.findFirst).not.toHaveBeenCalled() }, ) test('GET /api/materials/[id] 应将 assets 中的 BigInt fileSize 序列化为字符串', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', productModel: 'SPU-001', categoryName: '店铺A', sceneName: '场景A', platformName: 'Amazon', productCategoryName: '椅子', materialType: '任务-AI原图', isAiGenerated: false, renderer: 'CR', authorName: '测试作者', previewUrl: 'https://s3.jiansun.vip/preview.webp', zipFileName: 'material.zip', zipFilePath: 'uploads/brand-vi/original.pdf', zipFileSize: BigInt(123), downloadCount: 1, likeCount: 2, displayImages: [], createdAt: new Date('2026-04-03T00:00:00.000Z'), videoSourceUrl: null, videoPreview540Url: null, videoPreviewClipUrl: null, videoCoverUrl: null, videoDuration: null, videoProcessingStatus: null, taskNo: 'TASK202603261129596600-01-01', taskStatus: 'fetched', taskFetchedAt: new Date('2026-04-03T00:00:01.000Z'), isFullSet: false, taskDetailSourceType: '渲染', taskDetailRequesterUserId: 'requester-1', taskDetailRequesterName: '运营甲', user: { id: 'user-1', username: '测试用户', realName: '测试用户', }, assets: [ { id: 'asset-1', assetType: 'zip', fileUrl: 'uploads/material.zip', fileName: 'material.zip', fileSize: BigInt(456), sortOrder: 0, }, ], }) const request = new NextRequest('http://localhost/api/materials/material-1', { method: 'GET', }) const response = await getMaterialById(request, { params: { id: 'material-1' }, }) const body = await response.json() expect(response.status).toBe(200) expect(body.zipFileSize).toBe('123') expect(body.assets[0].fileSize).toBe('456') expect(body.taskDetailSourceType).toBe('渲染') expect(body.taskDetailRequesterUserId).toBe('requester-1') expect(body.taskDetailRequesterName).toBe('运营甲') expect(body.sourceFileUrl).toBe('/api/materials/download?id=material-1') expect(body.canEdit).toBe(true) expect(canUserDownloadSource).toHaveBeenCalledWith( '测试部门', 'USER', null, '任务-AI原图', 'user-1', ) }) test('视频等待期内,素材所有者仍可读取元数据编辑信息,但不能取得视频预览地址', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', productModel: 'VIDEO-001', reportTitle: null, categoryName: '店铺A', sceneName: '场景A', platformName: '', productCategoryName: '', materialType: '淘系-主图视频', isAiGenerated: false, taskDetailSourceType: null, taskDetailRequesterUserId: null, taskDetailRequesterName: null, renderer: '', authorName: '测试作者', previewUrl: 'https://s3.jiansun.vip/video-cover.webp', zipFileName: 'video.mp4', zipFilePath: 'uploads/video.mp4', zipFileSize: BigInt(123), downloadCount: 0, likeCount: 0, displayImages: [], createdAt: new Date(), videoSourceUrl: 'https://s3.jiansun.vip/video.mp4', videoPreview540Url: 'https://s3.jiansun.vip/video-540.mp4', videoPreviewClipUrl: 'https://s3.jiansun.vip/video-clip.mp4', videoCoverUrl: 'https://s3.jiansun.vip/video-cover.webp', videoDuration: 20, videoProcessingStatus: 'completed', taskNo: null, dingtalkApprovalNo: null, taskStatus: null, taskFetchedAt: null, isFullSet: false, user: { id: 'user-1', username: '测试用户', realName: '测试用户', }, assets: [], }) const response = await getMaterialById( new NextRequest('http://localhost/api/materials/material-1?intent=edit'), { params: { id: 'material-1' } }, ) const body = await response.json() expect(response.status).toBe(200) expect(body.canEdit).toBe(true) expect(body.productModel).toBe('VIDEO-001') expect(body.previewUrl).toBeNull() expect(body).not.toHaveProperty('videoSourceUrl') expect(body).not.toHaveProperty('videoPreview540Url') expect(body).not.toHaveProperty('sourceFileUrl') expect(canUserViewMaterial).not.toHaveBeenCalled() const detailResponse = await getMaterialById( new NextRequest('http://localhost/api/materials/material-1'), { params: { id: 'material-1' } }, ) const detailBody = await detailResponse.json() expect(detailResponse.status).toBe(403) expect(detailBody.code).toBe('VIDEO_WAIT_PERIOD') }) test('非素材所有者不能通过编辑读取意图绕过权限', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'other-user', materialType: '3D场景', createdAt: new Date('2026-01-01T00:00:00.000Z'), user: { id: 'other-user', username: '其他用户', realName: '其他用户', }, assets: [], }) const response = await getMaterialById( new NextRequest('http://localhost/api/materials/material-1?intent=edit'), { params: { id: 'material-1' } }, ) const body = await response.json() expect(response.status).toBe(403) expect(body.error).toBe('您没有权限编辑此素材') expect(canUserViewMaterial).not.toHaveBeenCalled() }) test('PUT 路由应透传任务详情出图方式字段', async () => { const request = createPutRequest( 'http://localhost/api/materials/material-1', createFormData(), ) await putMaterialById(request, { params: { id: 'material-1' }, }) expect(updateMaterialWithSharedRules).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ taskDetailSourceType: 'AI', }), }), ) }) test.each([ ['PUT /api/materials/[id]', false], ['PUT /api/materials/update', true], ])('%s 应透传需求运营人员字段', async (_label, legacyRoute) => { const formData = createFormData() if (legacyRoute) { formData.append('id', 'material-1') } const request = createPutRequest( legacyRoute ? 'http://localhost/api/materials/update' : 'http://localhost/api/materials/material-1', formData, ) if (legacyRoute) { await putMaterialUpdate(request) } else { await putMaterialById(request, { params: { id: 'material-1' }, }) } expect(updateMaterialWithSharedRules).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ taskDetailRequesterUserId: 'requester-1', }), }), ) }) })