/** @jest-environment node */ import { generatePreviewImages, resolveOwnedTaskMaterialSourceFiles, resolveUniqueZipEntryName, TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES, } from '@/lib/task-material-processor' import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { uploadToS3 } from '@/lib/s3' import { compressImage } from '@/lib/image-processor' import { readBoundedRegularFile } from '@/lib/bounded-file-reader' import { lstat } from 'fs/promises' import { getOwnedUploadedFile } from '@/lib/upload-sessions' jest.mock('fs/promises', () => ({ readFile: jest.fn(), mkdir: jest.fn(), unlink: jest.fn(), rm: jest.fn(), lstat: jest.fn(), })) jest.mock('@/lib/upload-sessions', () => ({ getUploadedFile: jest.fn(), getOwnedUploadedFile: jest.fn(), })) jest.mock('@/lib/upload-sessions-redis', () => ({ deleteUploadSession: jest.fn(), })) jest.mock('@/lib/image-processor', () => ({ compressImage: jest.fn(), })) jest.mock('@/lib/bounded-file-reader', () => ({ readBoundedRegularFile: jest.fn(), })) jest.mock('@/lib/public-display', () => ({ ...jest.requireActual('@/lib/public-display'), uploadPublicDisplayToS3: jest.fn(), })) jest.mock('@/lib/s3', () => ({ uploadToS3: jest.fn(), deleteFromS3: jest.fn(), })) describe('task-material-processor', () => { const { getUploadedFile } = jest.requireMock('@/lib/upload-sessions') as { getUploadedFile: jest.Mock } let dateNowSpy: jest.SpyInstance let mathRandomSpy: jest.SpyInstance beforeEach(() => { jest.clearAllMocks() ;(readBoundedRegularFile as jest.Mock).mockResolvedValue(Buffer.from('source-image')) getUploadedFile.mockResolvedValue('/tmp/uploaded/image.png') ;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed-image')) ;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue( 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-123.webp', ) ;(uploadToS3 as jest.Mock).mockResolvedValue( 'https://s3.jiansun.vip/gongxiangpan/product-package/preview-123.webp', ) dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1711111111111) mathRandomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.123456789) }) afterEach(() => { dateNowSpy.mockRestore() mathRandomSpy.mockRestore() }) test('generatePreviewImages 应将任务类预览图写入 public-display materials 前缀', async () => { const result = await generatePreviewImages( [{ uploadId: 'upload-1', fileName: 'origin.png' }], '任务-3D原图', ) expect(result).toEqual([ 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-123.webp', ]) expect(uploadPublicDisplayToS3).toHaveBeenCalledWith( Buffer.from('compressed-image'), expect.stringMatching(/^materials\/preview-1711111111111-0-[a-z0-9]+\.webp$/), 'image/webp', ) expect(readBoundedRegularFile).toHaveBeenCalledWith( '/tmp/uploaded/image.png', expect.objectContaining({ maxBytes: 100 * 1024 * 1024, label: '预览源图片 origin.png', }), ) expect(uploadToS3).not.toHaveBeenCalled() }) test('generatePreviewImages 使用调用方已验证路径时不再二次解析无 owner 会话', async () => { await generatePreviewImages( [{ uploadId: 'upload-1', fileName: 'origin.png', filePath: '/verified/origin.png' }], '任务-页面', ) expect(getUploadedFile).not.toHaveBeenCalled() expect(readBoundedRegularFile).toHaveBeenCalledWith( '/verified/origin.png', expect.any(Object), ) }) test('任务图片源文件以磁盘实际大小为准并拒绝客户端伪造小尺寸', async () => { ;(getOwnedUploadedFile as jest.Mock).mockResolvedValue('/verified/oversized.png') ;(lstat as jest.Mock).mockResolvedValue({ isSymbolicLink: () => false, isFile: () => true, size: TASK_MATERIAL_PREVIEW_SOURCE_MAX_BYTES + 1, }) await expect(resolveOwnedTaskMaterialSourceFiles({ userId: 'user-1', assets: [{ uploadId: 'upload-1', fileName: 'oversized.png', fileSize: 1 }], })).rejects.toMatchObject({ status: 413 }) }) test('任务图片源文件拒绝重复 uploadId 和超过 60 张', async () => { await expect(resolveOwnedTaskMaterialSourceFiles({ userId: 'user-1', assets: [ { uploadId: 'duplicate', fileName: 'one.png' }, { uploadId: 'duplicate', fileName: 'two.png' }, ], })).rejects.toThrow('上传会话编号重复') await expect(resolveOwnedTaskMaterialSourceFiles({ userId: 'user-1', assets: Array.from({ length: 61 }, (_, index) => ({ uploadId: `upload-${index}`, fileName: `${index}.png`, })), })).rejects.toThrow('最多上传 60 张') }) test('resolveUniqueZipEntryName 应避免同名文件在解压时互相覆盖', () => { const usedNames = new Set() expect(resolveUniqueZipEntryName('01.jpg', 0, usedNames)).toBe('01.jpg') expect(resolveUniqueZipEntryName('01.jpg', 1, usedNames)).toBe('01-2.jpg') expect(resolveUniqueZipEntryName('01.JPG', 2, usedNames)).toBe('01-3.JPG') expect(resolveUniqueZipEntryName('主图/01.jpg', 3, usedNames)).toBe('主图_01.jpg') expect(resolveUniqueZipEntryName('', 4, usedNames)).toBe('file-005') }) })