595 lines
22 KiB
TypeScript
595 lines
22 KiB
TypeScript
/** @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])
|
||
})
|
||
})
|