/** @jest-environment node */ import { NextRequest } from 'next/server' import { getServerSession } from 'next-auth' import { promises as fs } from 'fs' import { GET, POST } from './route' import { deleteFiles } from '@/lib/file-cleanup' import { processPdfUpload } from '@/lib/pdf-upload-handler' import { prisma } from '@/lib/prisma' import { deleteOwnedUploadSession, getOwnedUploadSession, resolveOwnedMergedUpload, } from '@/lib/upload-sessions' import { recoverMaterialUploadConsumptionJournal, removeMaterialUploadConsumptionJournal, syncMaterialUploadRootDirectory, writeMaterialUploadConsumptionJournal, } from '@/lib/material-upload-consumption-journal' import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock' import { CanonicalMaterialSourceValidationError, validateCanonicalMaterialSource, } from '@/lib/material-canonical-source' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) jest.mock('fs', () => ({ promises: { mkdir: jest.fn().mockResolvedValue(undefined), rename: jest.fn().mockResolvedValue(undefined), stat: jest.fn().mockResolvedValue({ size: 5 }), }, })) const mockFsPromises = fs as any jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() }, material: { findFirst: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, materialAsset: { createMany: jest.fn() }, }, })) jest.mock('@/lib/upload-sessions', () => ({ deleteOwnedUploadSession: jest.fn(), getOwnedUploadSession: jest.fn(), isSafeUploadId: jest.fn((value: string) => /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value)), resolveOwnedMergedUpload: jest.fn(), })) jest.mock('@/lib/material-upload-consumption-journal', () => ({ recoverMaterialUploadConsumptionJournal: jest.fn(), removeMaterialUploadConsumptionJournal: jest.fn(), syncMaterialUploadRootDirectory: jest.fn(), writeMaterialUploadConsumptionJournal: jest.fn(), })) jest.mock('@/lib/material-upload-cross-process-lock', () => ({ acquireTaskMaterialUploadSessionLocks: jest.fn(), })) jest.mock('@/lib/material-canonical-source', () => { const actual = jest.requireActual('@/lib/material-canonical-source') return { ...actual, validateCanonicalMaterialSource: jest.fn(), } }) jest.mock('@/lib/temp-cleanup', () => ({ cleanupUploadSession: jest.fn().mockResolvedValue(true), })) 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().mockResolvedValue(undefined), })) 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(), })) type MaterialFixture = ReturnType function createMaterial(overrides: Record = {}) { return { id: 'material-created', uploadId: 'upload-owned', previewUrl: 'data:image/svg+xml;base64,preview', productModel: 'SPU-001', categoryName: '通用', sceneName: '通用', platformName: '', productCategoryName: '', materialType: '音乐', renderer: '', authorName: '安乔', zipFileName: 'audio-source.mp3', zipFilePath: 'uploads/audio-source.mp3', zipFileSize: BigInt(5), displayImages: [], userId: 'user-1', user: { username: '安乔' }, ...overrides, } } function buildFormData(input: { uploadId?: string materialType?: string existingFilePath?: string productModel?: string } = {}) { const uploadId = input.uploadId || 'upload-owned' const formData = new FormData() formData.append('uploadId', uploadId) formData.append( 'existingFilePath', input.existingFilePath || `uploads/temp/${uploadId}/merged-source.bin`, ) formData.append('originalFileName', input.materialType === '品牌VI灵感' ? 'brand.pdf' : 'song.mp3') formData.append('productModel', input.productModel || 'SPU-001') formData.append('categoryName', '通用') formData.append('sceneName', '通用') formData.append('materialType', input.materialType || '音乐') return formData } function createPostRequest(formData: FormData) { return { headers: new Headers({ 'content-length': '1024', 'content-type': 'multipart/form-data; boundary=jest-boundary', }), formData: jest.fn().mockResolvedValue(formData), } as any } describe('materials upload second-stage idempotency', () => { beforeEach(() => { jest.clearAllMocks() ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ username: '安乔' }) ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(null) ;(prisma.material.create as jest.Mock).mockResolvedValue(createMaterial()) ;(prisma.material.update as jest.Mock).mockResolvedValue(createMaterial()) ;(validateCanonicalMaterialSource as jest.Mock).mockResolvedValue({ sourcePath: require('path').resolve('uploads/audio-source.mp3'), size: BigInt(5), }) ;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValue({ uploadId: 'upload-owned', filePath: 'uploads/temp/upload-owned/merged-source.bin', session: { fileName: 'source.bin', fileSize: 5, totalChunks: 1, chunks: new Map(), createdAt: Date.now(), userId: 'user-1', }, }) ;(deleteOwnedUploadSession as jest.Mock).mockResolvedValue(true) ;(getOwnedUploadSession as jest.Mock).mockResolvedValue(null) ;(processPdfUpload as jest.Mock).mockResolvedValue({ success: false, error: 'not a pdf' }) ;(recoverMaterialUploadConsumptionJournal as jest.Mock).mockResolvedValue(null) ;(removeMaterialUploadConsumptionJournal as jest.Mock).mockResolvedValue(true) ;(syncMaterialUploadRootDirectory as jest.Mock).mockResolvedValue(undefined) ;(writeMaterialUploadConsumptionJournal as jest.Mock).mockImplementation(async (input) => ({ version: 1, ...input, mergedPath: require('path').resolve(input.mergedPath), finalPath: require('path').resolve(input.finalPath), createdAt: Date.now(), })) ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue( Object.assign(jest.fn().mockResolvedValue(undefined), { assertHeld: jest.fn() }), ) mockFsPromises.mkdir.mockResolvedValue(undefined) mockFsPromises.rename.mockResolvedValue(undefined) mockFsPromises.stat.mockResolvedValue({ size: 5 }) }) test('视频上传拒绝把纯数字文件尾号当成 SPU,且不消费源会话', async () => { const response = await POST(createPostRequest(buildFormData({ materialType: '抖音-千川素材', productModel: '-624-456', }))) expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining('SPU 不合规'), }) expect(acquireTaskMaterialUploadSessionLocks).not.toHaveBeenCalled() expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() expect(prisma.material.create).not.toHaveBeenCalled() }) test('视频上传只把 SPU 转成大写,不会删除非法字符后继续建档', async () => { const response = await POST(createPostRequest(buildFormData({ materialType: '抖音-千川素材', productModel: 'df-a3dz0008 中文', }))) expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error: expect.stringContaining('只能使用字母、数字'), }) expect(acquireTaskMaterialUploadSessionLocks).not.toHaveBeenCalled() expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() expect(prisma.material.create).not.toHaveBeenCalled() }) test('视频上传将合规的小写 SPU 转成大写后保存', async () => { const response = await POST(createPostRequest(buildFormData({ materialType: '抖音-千川素材', productModel: 'df-a3dz0008', }))) expect(response.status).toBe(201) expect(prisma.material.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ productModel: 'DF-A3DZ0008' }), })) }) test('Material.create 只写服务器从 merged 路径派生的 uploadId', async () => { ;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValueOnce({ uploadId: 'server-derived-upload', filePath: 'uploads/temp/server-derived-upload/merged-song.mp3', session: { fileName: 'song.mp3', fileSize: 5, totalChunks: 1, chunks: new Map(), createdAt: Date.now(), userId: 'user-1', }, }) const response = await POST(createPostRequest(buildFormData({ uploadId: 'client-claimed-upload' }))) expect(response.status).toBe(201) expect(prisma.material.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ uploadId: 'server-derived-upload' }), }), ) expect(validateCanonicalMaterialSource).toHaveBeenCalledTimes(2) expect(validateCanonicalMaterialSource).toHaveBeenNthCalledWith(1, { storedPath: expect.stringContaining('audio-source-'), expectedSize: BigInt(5), }) }) test('canonical 源文件 stat 失败时提交前返回 503 且不建档', async () => { ;(validateCanonicalMaterialSource as jest.Mock).mockRejectedValueOnce( new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_STAT_FAILED', 'source disappeared', ), ) const response = await POST(createPostRequest(buildFormData())) const body = await response.json() expect(response.status).toBe(503) expect(body.code).toBe('CANONICAL_SOURCE_STAT_FAILED') expect(prisma.material.create).not.toHaveBeenCalled() // 第一次 rename 移入正式目录,第二次原子恢复到会话目录;不删除唯一源。 expect(mockFsPromises.rename).toHaveBeenCalledTimes(2) expect(deleteFiles).not.toHaveBeenCalled() }) test('分片实际大小与会话 zipFileSize 不一致时提交前返回 503', async () => { ;(validateCanonicalMaterialSource as jest.Mock).mockRejectedValueOnce( new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_SIZE_MISMATCH', 'actual size differs from known zipFileSize', ), ) const response = await POST(createPostRequest(buildFormData())) const body = await response.json() expect(response.status).toBe(503) expect(body.code).toBe('CANONICAL_SOURCE_SIZE_MISMATCH') expect(validateCanonicalMaterialSource).toHaveBeenCalledTimes(1) expect(prisma.material.create).not.toHaveBeenCalled() expect(mockFsPromises.rename).toHaveBeenCalledTimes(2) expect(deleteFiles).not.toHaveBeenCalled() }) test('同用户重放在临时目录已删后直接返回原 material', async () => { const winner = createMaterial({ id: 'material-from-first-response' }) ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(winner) const response = await POST(createPostRequest(buildFormData())) const body = await response.json() expect(response.status).toBe(201) expect(body.replayed).toBe(true) expect(body.material.id).toBe('material-from-first-response') expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() expect(mockFsPromises.rename).not.toHaveBeenCalled() expect(deleteFiles).not.toHaveBeenCalled() expect(prisma.material.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { uploadId: 'upload-owned', userId: 'user-1', deletedAt: null }, })) }) test('软删的同 uploadId 不是幂等赢家,允许创建新的 active material', async () => { // helper 的 deletedAt:null 由数据库过滤;软删记录不会作为 replay 返回。 ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(null) const response = await POST(createPostRequest(buildFormData())) const body = await response.json() expect(response.status).toBe(201) expect(body.replayed).toBeUndefined() expect(resolveOwnedMergedUpload).toHaveBeenCalledTimes(1) expect(prisma.material.create).toHaveBeenCalledTimes(1) expect(prisma.material.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { uploadId: 'upload-owned', userId: 'user-1', deletedAt: null }, })) }) test('幂等既有记录的 canonical 源损坏时不能返回 replayed 成功', async () => { const winner = createMaterial({ id: 'material-broken-replay' }) ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(winner) ;(validateCanonicalMaterialSource as jest.Mock).mockRejectedValueOnce( new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_SIZE_MISMATCH', 'stored file truncated', ), ) const response = await POST(createPostRequest(buildFormData())) const body = await response.json() expect(response.status).toBe(503) expect(body.code).toBe('CANONICAL_SOURCE_INTEGRITY_FAILED') expect(body.replayed).toBeUndefined() expect(prisma.material.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'material-broken-replay' }, data: expect.objectContaining({ deletedAt: expect.any(Date) }), })) expect(removeMaterialUploadConsumptionJournal).not.toHaveBeenCalled() expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() expect(mockFsPromises.rename).not.toHaveBeenCalled() expect(deleteFiles).not.toHaveBeenCalled() }) test('提交后复核失败时软隔离记录、保留 canonical 源与 journal 并返回 503', async () => { ;(validateCanonicalMaterialSource as jest.Mock) .mockResolvedValueOnce({ sourcePath: 'uploads/audio-source.mp3', size: BigInt(5) }) .mockRejectedValueOnce(new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_STAT_FAILED', 'post-commit stat failed', )) const response = await POST(createPostRequest(buildFormData())) const body = await response.json() expect(response.status).toBe(503) expect(body.code).toBe('CANONICAL_SOURCE_INTEGRITY_FAILED') expect(prisma.material.create).toHaveBeenCalledTimes(1) expect(prisma.material.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'material-created' }, data: expect.objectContaining({ deletedAt: expect.any(Date) }), })) expect(removeMaterialUploadConsumptionJournal).not.toHaveBeenCalled() expect(deleteOwnedUploadSession).not.toHaveBeenCalled() expect(deleteFiles).not.toHaveBeenCalled() // 数据库提交后的复核失败不能把正式源移回或删除。 expect(mockFsPromises.rename).toHaveBeenCalledTimes(1) }) test('其他用户不能查询或复用 uploadId', async () => { ;(resolveOwnedMergedUpload as jest.Mock).mockResolvedValueOnce(null) const postResponse = await POST(createPostRequest(buildFormData())) const postBody = await postResponse.json() expect(postResponse.status).toBe(403) expect(postBody.error).toBe('上传会话不存在、已失效或不属于当前用户') expect(prisma.material.create).not.toHaveBeenCalled() const getResponse = await GET(new NextRequest( 'http://localhost/api/materials/upload?uploadId=upload-owned', )) const getBody = await getResponse.json() expect(getResponse.status).toBe(404) expect(getBody.status).toBe('not_found') }) test('P2002 竞争返回同用户赢家且不删除赢家文件', async () => { const winner = createMaterial({ id: 'material-winner' }) ;(prisma.material.findFirst as jest.Mock) .mockResolvedValueOnce(null) .mockResolvedValueOnce(winner) ;(prisma.material.create as jest.Mock).mockRejectedValueOnce( Object.assign(new Error('unique constraint'), { code: 'P2002' }), ) const response = await POST(createPostRequest(buildFormData())) const body = await response.json() expect(response.status).toBe(201) expect(body.replayed).toBe(true) expect(body.material.id).toBe('material-winner') expect(deleteFiles).not.toHaveBeenCalled() expect(mockFsPromises.rename).toHaveBeenCalledTimes(1) }) test('P2002 赢家暂不可见时保守留文件,不恢复或删除未知赢家', async () => { ;(prisma.material.findFirst as jest.Mock) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) ;(prisma.material.create as jest.Mock).mockRejectedValueOnce( Object.assign(new Error('unique constraint'), { code: 'P2002' }), ) const response = await POST(createPostRequest(buildFormData())) expect(response.status).toBe(500) expect(mockFsPromises.rename).toHaveBeenCalledTimes(1) expect(deleteFiles).not.toHaveBeenCalled() }) test('建档失败时原子移回 merged;PDF 预览 URL 纳入清理', async () => { ;(prisma.material.findFirst as jest.Mock) .mockResolvedValueOnce(null) .mockResolvedValueOnce(null) ;(prisma.material.create as jest.Mock).mockRejectedValueOnce(new Error('database unavailable')) ;(processPdfUpload as jest.Mock).mockResolvedValueOnce({ success: true, previewUrl: 'https://s3.example/pdf-preview.jpg', }) const response = await POST(createPostRequest(buildFormData({ materialType: '品牌VI灵感' }))) expect(response.status).toBe(500) expect(mockFsPromises.rename).toHaveBeenCalledTimes(2) expect(mockFsPromises.rename.mock.calls[1]).toEqual([ expect.stringContaining('material-'), 'uploads/temp/upload-owned/merged-source.bin', ]) expect(deleteFiles).toHaveBeenCalledWith(['https://s3.example/pdf-preview.jpg']) }) test('owner-scoped GET 返回已确认 material', async () => { const winner: MaterialFixture = createMaterial({ id: 'material-status-complete' }) ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(winner) const response = await GET(new NextRequest( 'http://localhost/api/materials/upload?uploadId=upload-owned', )) const body = await response.json() expect(response.status).toBe(200) expect(body.status).toBe('completed') expect(body.material.id).toBe('material-status-complete') expect(body.material.zipFileSize).toBe('5') expect(getOwnedUploadSession).not.toHaveBeenCalled() }) test('owner-scoped GET 也不能把损坏 canonical 源报告为 completed', async () => { const broken = createMaterial({ id: 'material-status-broken' }) ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(broken) ;(validateCanonicalMaterialSource as jest.Mock).mockRejectedValueOnce( new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_STAT_FAILED', 'source missing during status lookup', ), ) const response = await GET(new NextRequest( 'http://localhost/api/materials/upload?uploadId=upload-owned', )) const body = await response.json() expect(response.status).toBe(503) expect(body.status).toBeUndefined() expect(body.code).toBe('CANONICAL_SOURCE_INTEGRITY_FAILED') expect(prisma.material.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'material-status-broken' }, data: expect.objectContaining({ deletedAt: expect.any(Date) }), })) expect(getOwnedUploadSession).not.toHaveBeenCalled() expect(deleteFiles).not.toHaveBeenCalled() }) test('rename 后进程中断的重试通过 journal 复用同一正式源且不再次移动', async () => { const path = require('path') const mergedPath = path.resolve('uploads/temp/upload-owned/merged-source.bin') const finalPath = path.resolve('uploads/audio-source-crash-recovery.mp3') ;(recoverMaterialUploadConsumptionJournal as jest.Mock).mockResolvedValueOnce({ sourceLocation: 'final', sourcePath: finalPath, record: { version: 1, uploadId: 'upload-owned', ownerId: 'user-1', mergedPath, finalPath, storedFinalPath: finalPath, fileName: 'audio-source-crash-recovery.mp3', size: 5, createdAt: Date.now(), }, }) const response = await POST(createPostRequest(buildFormData({ existingFilePath: mergedPath }))) expect(response.status).toBe(201) expect(resolveOwnedMergedUpload).not.toHaveBeenCalled() expect(writeMaterialUploadConsumptionJournal).not.toHaveBeenCalled() expect(mockFsPromises.rename).not.toHaveBeenCalled() expect(prisma.material.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ uploadId: 'upload-owned', zipFileName: 'audio-source-crash-recovery.mp3', zipFilePath: finalPath, }), })) expect(removeMaterialUploadConsumptionJournal).toHaveBeenCalledWith({ ownerId: 'user-1', uploadId: 'upload-owned', expectedFinalPath: finalPath, }) }) test('同一 uploadId 的跨工作流锁覆盖 resolve、建档和会话清理', async () => { const release = Object.assign(jest.fn().mockResolvedValue(undefined), { assertHeld: jest.fn() }) ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValueOnce(release) const response = await POST(createPostRequest(buildFormData())) expect(response.status).toBe(201) expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-owned']) expect((acquireTaskMaterialUploadSessionLocks as jest.Mock).mock.invocationCallOrder[0]) .toBeLessThan((resolveOwnedMergedUpload as jest.Mock).mock.invocationCallOrder[0]) expect((resolveOwnedMergedUpload as jest.Mock).mock.invocationCallOrder[0]) .toBeLessThan((prisma.material.create as jest.Mock).mock.invocationCallOrder[0]) expect((prisma.material.create as jest.Mock).mock.invocationCallOrder[0]) .toBeLessThan((deleteOwnedUploadSession as jest.Mock).mock.invocationCallOrder[0]) expect((deleteOwnedUploadSession as jest.Mock).mock.invocationCallOrder[0]) .toBeLessThan(release.mock.invocationCallOrder[0]) }) })