356 lines
13 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 { 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()
})
})