gongxiangp2/app/api/materials/upload/route.idempotency.test.ts

595 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @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<typeof createMaterial>
function createMaterial(overrides: Record<string, unknown> = {}) {
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])
})
})