gongxiangp2/lib/__tests__/materials-upload-task-other-route.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

521 lines
18 KiB
TypeScript

/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { POST } from '@/app/api/materials/upload/route'
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
import { createMaterialLog } from '@/lib/material-log'
import { prisma } from '@/lib/prisma'
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
import { assignTaskNoOnComplete, createTaskMaterialOnComplete } from '@/lib/product-package'
import { enqueueVideoTranscodeTask } from '@/lib/video-transcode-task'
import { deleteFiles } from '@/lib/file-cleanup'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue(undefined),
stat: jest.fn().mockResolvedValue({ size: 10 }),
},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
material: {
create: jest.fn(),
update: jest.fn(),
findUnique: jest.fn(),
delete: jest.fn(),
},
materialAsset: {
createMany: jest.fn(),
},
},
}))
jest.mock('@/lib/s3', () => ({
uploadToS3: jest.fn(),
}))
jest.mock('@/lib/audio-processor', () => ({
parseAudioMetadata: jest.fn(),
isAudioFile: jest.fn(() => false),
}))
jest.mock('@/lib/video-transcode-task', () => ({
enqueueVideoTranscodeTask: jest.fn(),
}))
jest.mock('@/lib/material-canonical-source', () => ({
...jest.requireActual('@/lib/material-canonical-source'),
validateCanonicalMaterialSource: jest.fn().mockResolvedValue({
sourcePath: 'C:\\uploads\\video-source.mp4',
size: BigInt(10),
}),
}))
jest.mock('@/lib/image-processor', () => ({
compressImage: jest.fn(),
sanitizeFileName: jest.fn((value: string) => value),
}))
jest.mock('@/lib/pdf-upload-handler', () => ({
processPdfUpload: jest.fn(),
}))
jest.mock('@/lib/file-cleanup', () => ({
deleteFiles: jest.fn().mockResolvedValue({ deletedCount: 0, failedCount: 0 }),
}))
jest.mock('@/lib/material-cleanup', () => ({
deleteMaterialRelatedRecords: jest.fn(),
}))
jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn().mockResolvedValue(undefined),
}))
jest.mock('@/lib/public-display', () => ({
buildMaterialPublicDisplayPath: jest.fn((fileName: string) => `materials/${fileName}`),
uploadPublicDisplayToS3: jest.fn(),
}))
jest.mock('@/lib/product-package', () => ({
validateTaskNo: jest.fn(() => true),
isTaskNoExists: jest.fn().mockResolvedValue(false),
assignTaskNoOnComplete: jest.fn().mockResolvedValue(null),
createTaskMaterialOnComplete: jest.fn(),
purgeDeletedMaterialsByTaskNo: jest.fn().mockResolvedValue({
deletedMaterials: 0,
deletedFiles: 0,
failedFiles: 0,
}),
}))
describe('materials upload route - 任务-其他', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'user-1',
},
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({
username: '安乔',
})
;(prisma.material.create as jest.Mock).mockResolvedValue({
id: 'material-task-other-1',
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
productModel: 'TASK202603310001',
categoryName: '通用',
sceneName: '通用',
platformName: '',
productCategoryName: '',
materialType: '任务-其他',
renderer: '',
authorName: '安乔',
zipFileName: 'source-package.zip',
zipFilePath: 'uploads/material-1.zip',
zipFileSize: BigInt(7),
displayImages: [],
videoSourceUrl: null,
videoProcessingStatus: null,
userId: 'user-1',
user: {
username: '安乔',
},
})
;(createTaskMaterialOnComplete as jest.Mock).mockResolvedValue({
id: 'material-task-other-1',
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
productModel: 'TASK202603310001',
categoryName: '通用',
sceneName: '通用',
materialType: '任务-其他',
zipFileName: 'source-package.zip',
zipFilePath: 'uploads/material-1.zip',
zipFileSize: BigInt(7),
displayImages: [],
userId: 'user-1',
user: { username: '安乔' },
})
;(prisma.material.update as jest.Mock).mockResolvedValue({})
;(prisma.materialAsset.createMany as jest.Mock).mockResolvedValue({ count: 2 })
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed-preview'))
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue(
'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
)
;(sanitizeFileName as jest.Mock).mockImplementation((value: string) => value)
;(enqueueVideoTranscodeTask as jest.Mock).mockResolvedValue(undefined)
})
function createRequest(formData: FormData) {
return {
headers: new Headers({
'content-length': '1024',
'content-type': 'multipart/form-data; boundary=jest-boundary',
}),
formData: jest.fn().mockResolvedValue(formData),
} as any
}
function buildTaskOtherFormData() {
const formData = new FormData()
formData.append('materialType', '任务-其他')
formData.append('taskNo', 'TASK202603310001')
formData.append('dingtalkApprovalNo', '202508211445000157693')
formData.append('productModel', '')
formData.append('categoryName', '')
formData.append('sceneName', '')
formData.append(
'previewImage',
new File([Buffer.from('preview-image')], 'preview.png', { type: 'image/png' }),
)
formData.append(
'zipFile',
new File([Buffer.from('zipdata')], 'source-package.zip', { type: 'application/zip' }),
)
return formData
}
test('缺少任务号时应立即返回失败', async () => {
const formData = buildTaskOtherFormData()
formData.set('taskNo', '')
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toBe('任务-其他必须填写任务号')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('缺少 Content-Length 时必须在解析 multipart 前拒绝', async () => {
const request = {
headers: new Headers({
'content-type': 'multipart/form-data; boundary=jest-boundary',
}),
formData: jest.fn(),
} as any
const response = await POST(request)
const body = await response.json()
expect(response.status).toBe(411)
expect(body.code).toBe('CONTENT_LENGTH_REQUIRED')
expect(request.formData).not.toHaveBeenCalled()
})
test('超过 128MB 的请求必须在解析 multipart 前拒绝', async () => {
const request = {
headers: new Headers({
'content-length': String(128 * 1024 * 1024 + 1),
'content-type': 'multipart/form-data; boundary=jest-boundary',
}),
formData: jest.fn(),
} as any
const response = await POST(request)
const body = await response.json()
expect(response.status).toBe(413)
expect(body.code).toBe('UPLOAD_REQUEST_TOO_LARGE')
expect(request.formData).not.toHaveBeenCalled()
})
test('达到 5MB 的主文件必须在读取 buffer 前要求分片上传', async () => {
const formData = new FormData()
const file = new File(
[new Uint8Array(5 * 1024 * 1024)],
'must-use-chunks.zip',
{ type: 'application/zip' },
)
const arrayBufferSpy = jest.spyOn(file, 'arrayBuffer')
formData.append('zipFile', file)
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(413)
expect(body.code).toBe('CHUNK_UPLOAD_REQUIRED')
expect(arrayBufferSpy).not.toHaveBeenCalled()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('普通上传入口必须拒绝任务-详情,避免绕过需求运营必填规则', async () => {
const formData = new FormData()
formData.append('materialType', '任务-详情')
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toContain('必须使用专用上传入口')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('普通上传入口必须拒绝任务-页面,避免生成缺少专用页面资产的完成记录', async () => {
const formData = new FormData()
formData.append('materialType', '任务-页面')
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toBe('任务-页面必须使用专用上传入口')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('缺少预览图时应立即返回失败', async () => {
const formData = buildTaskOtherFormData()
formData.delete('previewImage')
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toBe('任务-其他必须上传至少一张预览图')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('钉钉审批编号日期无效时应在创建素材前返回失败', async () => {
const formData = buildTaskOtherFormData()
formData.set('dingtalkApprovalNo', '20250230123')
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error).toBe('钉钉审批编号前8位不是有效日期')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('正常上传时应自动补通用店铺和场景,并保留原始压缩包文件名', async () => {
const formData = buildTaskOtherFormData()
const response = await POST(createRequest(formData))
const body = await response.json()
expect(response.status).toBe(201)
expect(body.message).toBe('上传成功')
expect(createTaskMaterialOnComplete).toHaveBeenCalledWith(
expect.objectContaining({
materialData: expect.objectContaining({
productModel: 'TASK202603310001',
categoryName: '通用',
sceneName: '通用',
materialType: '任务-其他',
zipFileName: 'source-package.zip',
zipFilePath: expect.stringContaining('uploads'),
}),
assets: expect.arrayContaining([
expect.objectContaining({ assetType: 'preview' }),
expect.objectContaining({ assetType: 'zip', fileName: 'source-package.zip' }),
]),
taskNo: 'TASK202603310001',
dingtalkApprovalNo: '202508211445000157693',
}),
)
expect(prisma.material.create).not.toHaveBeenCalled()
expect(prisma.materialAsset.createMany).not.toHaveBeenCalled()
expect(assignTaskNoOnComplete).not.toHaveBeenCalled()
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
changes: expect.objectContaining({
categoryName: expect.objectContaining({
newValue: '通用',
}),
sceneName: expect.objectContaining({
newValue: '通用',
}),
}),
}),
)
})
test('视频手动预览图应同时保存为视频封面并跳过自动封面生成', async () => {
const formData = new FormData()
formData.append('materialType', '淘系-主图视频')
formData.append('productModel', 'VIDEO-SPU-001')
formData.append('categoryName', '通用')
formData.append('sceneName', '通用')
formData.append(
'previewImage',
new File([Buffer.from('manual-cover')], 'manual-cover.png', { type: 'image/png' }),
)
formData.append(
'zipFile',
new File([Buffer.from('video-data')], 'product-video.mp4', { type: 'video/mp4' }),
)
const response = await POST(createRequest(formData))
expect(response.status).toBe(201)
expect(prisma.material.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
materialType: '淘系-主图视频',
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
videoCoverUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/preview-task-other.webp',
videoSourceUrl: expect.stringMatching(/^\/uploads\/video-.*\.mp4$/),
videoProcessingStatus: 'pending',
}),
}),
)
expect(enqueueVideoTranscodeTask).toHaveBeenCalledWith(
'material-task-other-1',
expect.stringMatching(/^\/uploads\/video-.*\.mp4$/),
false,
)
})
test('视频没有手动预览图时应保留自动封面生成', async () => {
const formData = new FormData()
formData.append('materialType', '淘系-主图视频')
formData.append('productModel', 'VIDEO-SPU-002')
formData.append('categoryName', '通用')
formData.append('sceneName', '通用')
formData.append(
'zipFile',
new File([Buffer.from('video-data')], 'product-video.mp4', { type: 'video/mp4' }),
)
const response = await POST(createRequest(formData))
expect(response.status).toBe(201)
expect(prisma.material.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
materialType: '淘系-主图视频',
previewUrl: '',
videoCoverUrl: null,
videoSourceUrl: expect.stringMatching(/^\/uploads\/video-.*\.mp4$/),
videoProcessingStatus: 'pending',
}),
}),
)
expect(enqueueVideoTranscodeTask).toHaveBeenCalledWith(
'material-task-other-1',
expect.stringMatching(/^\/uploads\/video-.*\.mp4$/),
true,
)
})
test.each([
'任务-3D原图',
'任务-AI原图',
'任务-实拍原图',
'任务-视频截图',
])('%s 也必须通过素材与资产原子事务完成', async (materialType) => {
const formData = new FormData()
formData.append('materialType', materialType)
formData.append('taskNo', 'TASK202603310001')
formData.append('productModel', 'SPU-ORIGINAL-001')
formData.append('categoryName', '客厅')
formData.append('sceneName', '家居')
formData.append(
'zipFile',
new File([Buffer.from('zipdata')], 'source-package.zip', { type: 'application/zip' }),
)
const response = await POST(createRequest(formData))
expect(response.status).toBe(201)
expect(createTaskMaterialOnComplete).toHaveBeenCalledWith(expect.objectContaining({
materialData: expect.objectContaining({
materialType,
productModel: 'SPU-ORIGINAL-001',
}),
assets: expect.arrayContaining([
expect.objectContaining({ assetType: 'zip' }),
]),
taskNo: 'TASK202603310001',
}))
expect(prisma.material.create).not.toHaveBeenCalled()
expect(prisma.materialAsset.createMany).not.toHaveBeenCalled()
expect(assignTaskNoOnComplete).not.toHaveBeenCalled()
})
test('任务已完成后审计日志失败仍返回成功且绝不删除源文件', async () => {
;(createMaterialLog as jest.Mock).mockRejectedValueOnce(new Error('audit db unavailable'))
const response = await POST(createRequest(buildTaskOtherFormData()))
const body = await response.json()
expect(response.status).toBe(201)
expect(body.message).toBe('上传成功')
expect(createTaskMaterialOnComplete).toHaveBeenCalled()
expect(assignTaskNoOnComplete).not.toHaveBeenCalled()
expect(deleteFiles).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
test('事务结果无法向数据库复核时必须保留源文件和预览文件', async () => {
;(createTaskMaterialOnComplete as jest.Mock).mockRejectedValueOnce(
new Error('commit acknowledgement lost'),
)
;(prisma.material.findUnique as jest.Mock).mockRejectedValueOnce(
new Error('database unavailable'),
)
const response = await POST(createRequest(buildTaskOtherFormData()))
expect(response.status).toBe(500)
expect(deleteFiles).not.toHaveBeenCalled()
expect(prisma.material.delete).not.toHaveBeenCalled()
})
test('小红书批量上传的附加图片必须写入素材展示图', async () => {
const formData = new FormData()
formData.append('materialType', '小红书种草')
formData.append('productModel', 'RED-BOOK-001')
formData.append('categoryName', '通用')
formData.append('sceneName', '通用')
formData.append(
'previewImage',
new File([Buffer.from('cover')], 'cover.png', { type: 'image/png' }),
)
formData.append(
'modelImages',
new File([Buffer.from('gallery-1')], 'gallery-1.png', { type: 'image/png' }),
)
formData.append(
'modelImages',
new File([Buffer.from('gallery-2')], 'gallery-2.png', { type: 'image/png' }),
)
;(uploadPublicDisplayToS3 as jest.Mock)
.mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/cover.webp')
.mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-1.png')
.mockResolvedValueOnce('https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-2.png')
const response = await POST(createRequest(formData))
expect(response.status).toBe(201)
expect(prisma.material.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
materialType: '小红书种草',
previewUrl: 'https://s3.jiansun.vip/gongxiangpan/public-display/materials/cover.webp',
displayImages: [
'https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-1.png',
'https://s3.jiansun.vip/gongxiangpan/public-display/materials/gallery-2.png',
],
}),
}),
)
})
})