Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
521 lines
18 KiB
TypeScript
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',
|
|
],
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
})
|