356 lines
13 KiB
TypeScript
356 lines
13 KiB
TypeScript
/** @jest-environment node */
|
||
|
||
import { NextRequest } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
|
||
import { POST } from '@/app/api/materials/upload/certificate-report/complete/route'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { getOwnedUploadedFile } from '@/lib/upload-sessions'
|
||
import {
|
||
uploadPublicDisplayFileToS3,
|
||
uploadPublicDisplayToS3,
|
||
} from '@/lib/public-display'
|
||
import { cleanupTempFiles } from '@/lib/task-material-processor'
|
||
import { createMaterialLog } from '@/lib/material-log'
|
||
import { deleteFromS3 } from '@/lib/s3'
|
||
import { inspectBoundedRegularFile } from '@/lib/bounded-file-reader'
|
||
import {
|
||
acquireTaskMaterialCompletionLock,
|
||
acquireTaskMaterialUploadSessionLocks,
|
||
} from '@/lib/material-upload-cross-process-lock'
|
||
import { MaterialUploadRequestError } from '@/lib/material-upload-request-guard'
|
||
|
||
jest.mock('next-auth', () => ({
|
||
getServerSession: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
material: {
|
||
findUnique: jest.fn(),
|
||
},
|
||
$transaction: jest.fn(),
|
||
},
|
||
}))
|
||
|
||
jest.mock('@/lib/upload-sessions', () => ({
|
||
getOwnedUploadedFile: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/public-display', () => ({
|
||
buildCertificateReportPublicDisplayPath: jest.fn(
|
||
(materialId: string, kind: string, fileName: string) => `${materialId}/${kind}/${fileName}`,
|
||
),
|
||
uploadPublicDisplayFileToS3: jest.fn(),
|
||
uploadPublicDisplayToS3: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/task-material-processor', () => ({
|
||
cleanupTempFiles: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/material-log', () => ({
|
||
createMaterialLog: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/s3', () => ({
|
||
deleteFromS3: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/pdf-processor', () => ({
|
||
generatePdfPreview: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/bounded-file-reader', () => ({
|
||
inspectBoundedRegularFile: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/material-upload-cross-process-lock', () => ({
|
||
acquireTaskMaterialCompletionLock: jest.fn(),
|
||
acquireTaskMaterialUploadSessionLocks: jest.fn(),
|
||
}))
|
||
|
||
const materialId = 'material-report-1'
|
||
const userId = 'user-1'
|
||
const uploadId = 'upload-report-1'
|
||
const uploadedUrl = 'https://s3.jiansun.vip/gongxiangpan/report.jpg'
|
||
|
||
function completeRequest(files: unknown[] = [{
|
||
uploadId,
|
||
fileName: 'report.jpg',
|
||
fileSize: 4,
|
||
sortOrder: 0,
|
||
}]) {
|
||
return new NextRequest('http://localhost/api/materials/upload/certificate-report/complete', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify({ materialId, files }),
|
||
})
|
||
}
|
||
|
||
function reportAsset(overrides: Record<string, unknown> = {}) {
|
||
return {
|
||
assetType: 'report-image',
|
||
fileUrl: uploadedUrl,
|
||
fileName: 'report.jpg',
|
||
fileSize: BigInt(4),
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function materialWithStatus(
|
||
taskStatus: string | null,
|
||
overrides: Record<string, unknown> = {},
|
||
) {
|
||
const completed = taskStatus === 'completed'
|
||
return {
|
||
id: materialId,
|
||
userId,
|
||
materialType: '证书-检测报告',
|
||
taskStatus,
|
||
previewUrl: completed ? uploadedUrl : '',
|
||
zipFileName: completed ? 'report.jpg' : '',
|
||
zipFilePath: '',
|
||
zipFileSize: completed ? BigInt(4) : BigInt(0),
|
||
deletedAt: null,
|
||
assets: completed ? [reportAsset()] : [],
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function createReleaseLock() {
|
||
return Object.assign(jest.fn().mockResolvedValue(undefined), {
|
||
assertHeld: jest.fn(),
|
||
})
|
||
}
|
||
|
||
describe('certificate report complete data safety', () => {
|
||
let transactionMaterialUpdateMany: jest.Mock
|
||
let transactionAssetCreateMany: jest.Mock
|
||
let releaseCompletionLock: ReturnType<typeof createReleaseLock>
|
||
let releaseUploadSessionLocks: ReturnType<typeof createReleaseLock>
|
||
let consoleError: jest.SpyInstance
|
||
let consoleWarn: jest.SpyInstance
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
||
consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||
releaseCompletionLock = createReleaseLock()
|
||
releaseUploadSessionLocks = createReleaseLock()
|
||
transactionMaterialUpdateMany = jest.fn().mockResolvedValue({ count: 1 })
|
||
transactionAssetCreateMany = jest.fn().mockResolvedValue({ count: 1 })
|
||
|
||
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: userId } })
|
||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(materialWithStatus('uploading'))
|
||
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: Function) => (
|
||
callback({
|
||
material: { updateMany: transactionMaterialUpdateMany },
|
||
materialAsset: { createMany: transactionAssetCreateMany },
|
||
})
|
||
))
|
||
;(getOwnedUploadedFile as jest.Mock).mockResolvedValue('/uploads/assembled/report.jpg')
|
||
;(inspectBoundedRegularFile as jest.Mock).mockResolvedValue({ size: 4 })
|
||
;(uploadPublicDisplayFileToS3 as jest.Mock).mockResolvedValue(uploadedUrl)
|
||
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue(uploadedUrl)
|
||
;(cleanupTempFiles as jest.Mock).mockResolvedValue(undefined)
|
||
;(createMaterialLog as jest.Mock).mockResolvedValue(undefined)
|
||
;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined)
|
||
;(acquireTaskMaterialCompletionLock as jest.Mock).mockResolvedValue(releaseCompletionLock)
|
||
;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLocks)
|
||
})
|
||
|
||
afterEach(() => {
|
||
consoleError.mockRestore()
|
||
consoleWarn.mockRestore()
|
||
})
|
||
|
||
test('处理期间数据库保持 uploading,最终资产和 completed 在同一事务提交', async () => {
|
||
;(uploadPublicDisplayFileToS3 as jest.Mock).mockImplementation(async () => {
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
return uploadedUrl
|
||
})
|
||
|
||
const response = await POST(completeRequest())
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledWith(materialId)
|
||
expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith([uploadId])
|
||
expect(transactionAssetCreateMany).toHaveBeenCalledWith({
|
||
data: [expect.objectContaining({
|
||
materialId,
|
||
assetType: 'report-image',
|
||
fileUrl: uploadedUrl,
|
||
fileSize: BigInt(4),
|
||
})],
|
||
})
|
||
expect(transactionMaterialUpdateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||
where: expect.objectContaining({
|
||
id: materialId,
|
||
OR: [
|
||
{ taskStatus: 'uploading' },
|
||
{ taskStatus: null, previewUrl: '', zipFilePath: '' },
|
||
],
|
||
}),
|
||
data: expect.objectContaining({
|
||
previewUrl: uploadedUrl,
|
||
zipFileName: 'report.jpg',
|
||
zipFileSize: BigInt(4),
|
||
taskStatus: 'completed',
|
||
isIncompleteShell: false,
|
||
}),
|
||
}))
|
||
expect(cleanupTempFiles).toHaveBeenCalledWith(
|
||
['/uploads/assembled/report.jpg'],
|
||
[uploadId],
|
||
)
|
||
expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(1)
|
||
expect(releaseCompletionLock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('同一素材锁繁忙时拒绝重复完成,不上传也不清理文件', async () => {
|
||
;(acquireTaskMaterialCompletionLock as jest.Mock).mockRejectedValueOnce(
|
||
new MaterialUploadRequestError(
|
||
'该素材正在处理,请勿重复提交',
|
||
409,
|
||
'TASK_MATERIAL_COMPLETION_BUSY',
|
||
3,
|
||
),
|
||
)
|
||
|
||
const response = await POST(completeRequest())
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(response.headers.get('retry-after')).toBe('3')
|
||
expect(body.code).toBe('TASK_MATERIAL_COMPLETION_BUSY')
|
||
expect(uploadPublicDisplayFileToS3).not.toHaveBeenCalled()
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupTempFiles).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已经 completed 且关键资产完整时幂等成功,并补做源会话清理', async () => {
|
||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(materialWithStatus('completed'))
|
||
|
||
const response = await POST(completeRequest())
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(body).toMatchObject({ success: true, materialId, alreadyCompleted: true })
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
expect(uploadPublicDisplayFileToS3).not.toHaveBeenCalled()
|
||
expect(cleanupTempFiles).toHaveBeenCalledWith([], [uploadId])
|
||
})
|
||
|
||
test('completed 但资产缺失或汇总大小不一致时拒绝假成功并保留文件', async () => {
|
||
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(materialWithStatus('completed', {
|
||
zipFileSize: BigInt(8),
|
||
}))
|
||
|
||
const response = await POST(completeRequest())
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(uploadPublicDisplayFileToS3).not.toHaveBeenCalled()
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupTempFiles).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('事务响应异常但数据库实际完整 completed 时保留派生文件并返回成功警告', async () => {
|
||
;(prisma.material.findUnique as jest.Mock)
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockResolvedValueOnce(materialWithStatus('completed'))
|
||
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('connection reset'))
|
||
|
||
const response = await POST(completeRequest())
|
||
const body = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(body).toMatchObject({ success: true, materialId })
|
||
expect(body.warning).toContain('响应确认')
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupTempFiles).toHaveBeenCalledWith(
|
||
['/uploads/assembled/report.jpg'],
|
||
[uploadId],
|
||
)
|
||
})
|
||
|
||
test('事务响应异常且 completed 证据不完整时拒绝假成功并保留全部文件', async () => {
|
||
;(prisma.material.findUnique as jest.Mock)
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockResolvedValueOnce(materialWithStatus('completed', { assets: [] }))
|
||
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('connection reset'))
|
||
|
||
const response = await POST(completeRequest())
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupTempFiles).not.toHaveBeenCalled()
|
||
expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(1)
|
||
expect(releaseCompletionLock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('无法确认数据库最终状态时 fail-closed 保留源文件和本次派生文件', async () => {
|
||
;(prisma.material.findUnique as jest.Mock)
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockRejectedValueOnce(new Error('database unavailable'))
|
||
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('connection reset'))
|
||
|
||
const response = await POST(completeRequest())
|
||
|
||
expect(response.status).toBe(500)
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupTempFiles).not.toHaveBeenCalled()
|
||
expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(1)
|
||
expect(releaseCompletionLock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('事务明确失败且数据库仍 uploading 时只删除本次派生 S3,不删除源会话和素材', async () => {
|
||
;(prisma.material.findUnique as jest.Mock)
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
.mockResolvedValueOnce(materialWithStatus('uploading'))
|
||
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('write failed'))
|
||
|
||
const response = await POST(completeRequest())
|
||
|
||
expect(response.status).toBe(500)
|
||
expect(deleteFromS3).toHaveBeenCalledWith(uploadedUrl)
|
||
expect(cleanupTempFiles).not.toHaveBeenCalled()
|
||
expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(1)
|
||
expect(releaseCompletionLock).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('一次处理异常释放锁后,同一所有者可直接重试而无需重传源文件', async () => {
|
||
;(uploadPublicDisplayFileToS3 as jest.Mock).mockRejectedValueOnce(new Error('temporary s3 failure'))
|
||
|
||
const failedResponse = await POST(completeRequest())
|
||
const retryResponse = await POST(completeRequest())
|
||
|
||
expect(failedResponse.status).toBe(500)
|
||
expect(retryResponse.status).toBe(200)
|
||
expect(acquireTaskMaterialCompletionLock).toHaveBeenCalledTimes(2)
|
||
expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledTimes(2)
|
||
expect(getOwnedUploadedFile).toHaveBeenCalledTimes(2)
|
||
expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(2)
|
||
expect(releaseCompletionLock).toHaveBeenCalledTimes(2)
|
||
})
|
||
|
||
test('重复 uploadId 在加锁和读取源文件前直接拒绝', async () => {
|
||
const response = await POST(completeRequest([
|
||
{ uploadId, fileName: 'report-a.jpg', fileSize: 4, sortOrder: 0 },
|
||
{ uploadId, fileName: 'report-b.jpg', fileSize: 4, sortOrder: 1 },
|
||
]))
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(acquireTaskMaterialCompletionLock).not.toHaveBeenCalled()
|
||
expect(getOwnedUploadedFile).not.toHaveBeenCalled()
|
||
expect(deleteFromS3).not.toHaveBeenCalled()
|
||
expect(cleanupTempFiles).not.toHaveBeenCalled()
|
||
})
|
||
})
|