/** @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 = {}) { return { assetType: 'report-image', fileUrl: uploadedUrl, fileName: 'report.jpg', fileSize: BigInt(4), ...overrides, } } function materialWithStatus( taskStatus: string | null, overrides: Record = {}, ) { 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 let releaseUploadSessionLocks: ReturnType 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() }) })