172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import {
|
|
generatePreviewImages,
|
|
buildTaskDetailPreviewNameForZipEntry,
|
|
resolveProductPackageZipEntryNames,
|
|
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'
|
|
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
|
|
|
|
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 和超过分组上限', 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: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup + 1 }, (_, index) => ({
|
|
uploadId: `upload-${index}`,
|
|
fileName: `${index}.png`,
|
|
})),
|
|
})).rejects.toThrow(`最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张`)
|
|
})
|
|
|
|
test('resolveUniqueZipEntryName 应避免同名文件在解压时互相覆盖', () => {
|
|
const usedNames = new Set<string>()
|
|
|
|
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')
|
|
})
|
|
|
|
test('同名原图的 ZIP 名和预览来源锚一致', () => {
|
|
const names = resolveProductPackageZipEntryNames(['功能1.jpg', '功能1.jpg', '功能1.jpg'])
|
|
expect(names).toEqual(['功能1.jpg', '功能1-2.jpg', '功能1-3.jpg'])
|
|
expect(names.map((name) => buildTaskDetailPreviewNameForZipEntry(name, { kind: 'preview' })))
|
|
.toEqual(['功能1-preview.webp', '功能1-2-preview.webp', '功能1-3-preview.webp'])
|
|
expect(buildTaskDetailPreviewNameForZipEntry(names[1], {
|
|
kind: 'slice', sliceCount: 2, sliceIndex: 1,
|
|
})).toBe('功能1-2-002.webp')
|
|
expect(resolveProductPackageZipEntryNames(['图.jpg', '图.png', '图-2.jpg']))
|
|
.toEqual(['图.jpg', '图-2.png', '图-2-2.jpg'])
|
|
})
|
|
})
|