gongxiangp2/lib/__tests__/task-material-processor.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

157 lines
5.2 KiB
TypeScript

/** @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<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')
})
})