/** @jest-environment node */ import { NextRequest } from 'next/server' import { getServerSession } from 'next-auth' import { GET } from '@/app/api/materials/route' import { prisma } from '@/lib/prisma' import { canUserViewMaterial, getUserViewableMaterialTypes, isSuperAdmin, isSystemAdmin, } from '@/lib/department-permission' 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(), }, materialLike: { findMany: jest.fn(), findFirst: jest.fn(), }, materialFavorite: { findMany: jest.fn(), findFirst: jest.fn(), }, materialAsset: { findMany: jest.fn(), }, materialSku: { findMany: jest.fn(), }, user: { findMany: jest.fn(), }, }, })) jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: jest.fn(), getUserViewableMaterialTypes: jest.fn(), isSuperAdmin: jest.fn(), canUserViewMaterial: jest.fn(), })) jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn(), isS3Url: jest.fn(() => false), })) jest.mock('@/lib/file-cleanup', () => ({ deleteFiles: jest.fn(), })) jest.mock('@/lib/material-cleanup', () => ({ collectMaterialFiles: jest.fn(), deleteMaterialRelatedRecords: jest.fn(), })) jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn(), })) jest.mock('@/lib/material-download-log', () => ({ ensureMaterialDownloadLogsTable: jest.fn(), })) jest.mock('@/lib/url-rewrite', () => ({ rewriteMaterialStorageUrlsForClient: jest.fn((value) => value), rewriteMediaUrlForClient: jest.fn((value) => value), })) jest.mock('@/lib/network-detection', () => ({ detectIsIntranet: jest.fn(() => false), })) jest.mock('@/lib/product-package', () => ({ getMaterialCoverUrl: jest.fn(), })) jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn((materialType: string) => materialType === '任务-详情'), })) describe('materials route ai generated', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1', role: 'USER', department: '设计部', managedDepartment: null, isSuperAdmin: false, }, }) ;(isSuperAdmin as jest.Mock).mockResolvedValue(false) ;(canUserViewMaterial as jest.Mock).mockResolvedValue(true) ;(isSystemAdmin as jest.Mock).mockReturnValue(false) ;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-详情']) ;(prisma.material.count as jest.Mock).mockResolvedValue(1) ;(prisma.material.findMany as jest.Mock).mockResolvedValue([ { id: 'material-1', userId: 'user-1', previewUrl: 'https://example.com/preview.jpg', productModel: 'SPU-001', categoryName: '旗舰店', sceneName: '客厅', platformName: '天猫', productCategoryName: '沙发', materialType: '任务-详情', renderer: '', authorName: '张三', zipFileName: 'sample.zip', zipFileSize: BigInt(1024), downloadCount: 0, likeCount: 0, createdAt: new Date('2026-03-25T08:00:00.000Z'), videoSourceUrl: null, displayImages: [], videoPreview540Url: null, videoPreviewClipUrl: null, videoCoverUrl: null, videoDuration: null, videoProcessingStatus: null, transcodeLastError: null, transcodeAttempts: 0, isAiGenerated: true, taskDetailSourceType: 'AI', user: { username: 'zhangsan', realName: '张三', avatar: null, }, }, ]) ;(prisma.materialLike.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.materialFavorite.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([]) ;(prisma.materialLike.findFirst as jest.Mock).mockResolvedValue(null) ;(prisma.materialFavorite.findFirst as jest.Mock).mockResolvedValue(null) }) test('列表接口应返回 isAiGenerated 标记,供首页展示 AI生成 标签', async () => { const request = new NextRequest('http://localhost/api/materials?page=1&pageSize=20') const response = await GET(request) const body = await response.json() expect(response.status).toBe(200) expect(prisma.material.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ isIncompleteShell: false, }), select: expect.objectContaining({ isAiGenerated: true, taskDetailSourceType: true, taskDetailRequesterUserId: true, taskDetailRequesterName: true, }), }), ) expect(body.materials).toHaveLength(1) expect(body.materials[0]).toEqual( expect.objectContaining({ id: 'material-1', isAiGenerated: true, taskDetailSourceType: 'AI', }), ) }) test('未登录访问列表接口应返回 401', async () => { ;(getServerSession as jest.Mock).mockResolvedValue(null) const request = new NextRequest('http://localhost/api/materials?page=1&pageSize=20') const response = await GET(request) const body = await response.json() expect(response.status).toBe(401) expect(body.code).toBe('UNAUTHORIZED') expect(prisma.material.findMany).not.toHaveBeenCalled() }) test('普通用户不能通过 deleted=true 查看回收站素材', async () => { const request = new NextRequest('http://localhost/api/materials?deleted=true&page=1&pageSize=20') const response = await GET(request) const body = await response.json() expect(response.status).toBe(403) expect(body.code).toBe('FORBIDDEN_DELETED_MATERIALS') expect(prisma.material.findMany).not.toHaveBeenCalled() }) test('share 参数不能绕过普通素材列表权限过滤', async () => { ;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['买家秀图片']) const request = new NextRequest('http://localhost/api/materials?share=1&materialType=任务-详情&page=1&pageSize=20') const response = await GET(request) const body = await response.json() expect(response.status).toBe(200) expect(body.materials).toEqual([]) expect(body.totalCount).toBe(0) expect(prisma.material.findMany).not.toHaveBeenCalled() }) test('任务-页面缺少顶层 previewUrl 时应使用 preview 资产作为首页封面', async () => { ;(getUserViewableMaterialTypes as jest.Mock).mockResolvedValue(['任务-页面']) ;(prisma.material.findMany as jest.Mock).mockResolvedValue([ { id: 'task-page-1', userId: 'user-1', previewUrl: '', productModel: 'SJ0371', categoryName: '索菲亚', sceneName: '书房', platformName: '', productCategoryName: '', materialType: '任务-页面', renderer: '', authorName: '张三', zipFileName: 'task-page.zip', zipFileSize: BigInt(1024), downloadCount: 0, likeCount: 0, createdAt: new Date('2026-06-26T08:00:00.000Z'), videoSourceUrl: null, displayImages: [], videoPreview540Url: null, videoPreviewClipUrl: null, videoCoverUrl: null, videoDuration: null, videoProcessingStatus: null, transcodeLastError: null, transcodeAttempts: 0, isAiGenerated: false, taskDetailSourceType: null, taskDetailRequesterUserId: null, taskDetailRequesterName: null, user: { username: 'zhangsan', realName: '张三', avatar: null, }, }, ]) ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([ { materialId: 'task-page-1', assetType: 'preview', fileUrl: 'https://example.com/task-page-preview.webp', sortOrder: 0, }, ]) const response = await GET( new NextRequest('http://localhost/api/materials?materialType=任务-页面&page=1&pageSize=20'), ) const body = await response.json() expect(response.status).toBe(200) expect(prisma.material.findMany).toHaveBeenCalledWith( expect.objectContaining({ select: expect.not.objectContaining({ assets: expect.anything(), }), take: 20, }), ) expect(prisma.materialAsset.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ materialId: { in: ['task-page-1'] }, assetType: { in: ['main', 'sku', 'main-preview', 'sku-preview', 'preview'] }, }), }), ) expect(body.materials[0].previewUrl).toBe('https://example.com/task-page-preview.webp') }) test('创意资产库 deferTotal 时不应先做全库 count,并用多取 1 条判断是否还有下一页', async () => { const pageItems = Array.from({ length: 21 }, (_, index) => ({ id: `material-${index + 1}`, userId: 'user-1', previewUrl: `https://example.com/preview-${index + 1}.jpg`, productModel: `SPU-${index + 1}`, categoryName: '旗舰店', sceneName: '客厅', platformName: '天猫', productCategoryName: '沙发', materialType: '任务-详情', renderer: '', authorName: '张三', zipFileName: 'sample.zip', zipFileSize: BigInt(1024), downloadCount: 0, likeCount: 0, createdAt: new Date('2026-03-25T08:00:00.000Z'), videoSourceUrl: null, displayImages: [], videoPreview540Url: null, videoPreviewClipUrl: null, videoCoverUrl: null, videoDuration: null, videoProcessingStatus: null, transcodeLastError: null, transcodeAttempts: 0, isAiGenerated: false, taskDetailSourceType: null, user: { username: 'zhangsan', realName: '张三', avatar: null, }, })) ;(prisma.material.findMany as jest.Mock).mockResolvedValue(pageItems) const request = new NextRequest('http://localhost/api/materials?page=1&pageSize=20&sortBy=createdAt&deferTotal=1') const response = await GET(request) const body = await response.json() expect(response.status).toBe(200) expect(prisma.material.count).not.toHaveBeenCalled() expect(prisma.material.findMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 0, take: 21, }), ) expect(body.materials).toHaveLength(20) expect(body.hasMore).toBe(true) expect(body.totalCount).toBeUndefined() expect(body.totalPages).toBeUndefined() }) test('uploadId 状态查询仍应直接返回 uploading 空壳,不套用普通列表隐藏条件', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValue({ id: 'uploading-shell-1', userId: 'user-1', previewUrl: '', productModel: 'SPU-UPLOADING', reportTitle: null, categoryName: '通用', sceneName: '通用', platformName: '', productCategoryName: '', materialType: '任务-页面', isAiGenerated: false, taskDetailSourceType: null, taskDetailRequesterUserId: null, taskDetailRequesterName: null, renderer: '', authorName: '张三', zipFileName: '', zipFileSize: BigInt(0), downloadCount: 0, likeCount: 0, createdAt: new Date('2026-08-12T00:00:00.000Z'), videoSourceUrl: null, displayImages: [], videoPreview540Url: null, videoPreviewClipUrl: null, videoCoverUrl: null, videoDuration: null, videoProcessingStatus: null, user: { username: 'zhangsan', realName: '张三', avatar: null, }, assets: [], }) const response = await GET( new NextRequest('http://localhost/api/materials?uploadId=uploading-shell-1'), ) const body = await response.json() expect(response.status).toBe(200) expect(body.material.id).toBe('uploading-shell-1') expect(prisma.material.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: { uploadId: 'uploading-shell-1', deletedAt: null }, }), ) expect(prisma.material.count).not.toHaveBeenCalled() expect(prisma.material.findMany).not.toHaveBeenCalled() }) })