/** @jest-environment node */ import { mkdtemp, rm, writeFile } from 'fs/promises' import os from 'os' import path from 'path' import type { Session } from 'next-auth' import { PerformanceSurveyApiError, createPerformanceSurveyWorkItemFromRequest, } from '@/lib/performance-survey-service' import { prisma } from '@/lib/prisma' import { ensurePerformanceSurveyEmployeeProfileForUser } from '@/lib/performance-survey-report-sync' import { deleteFromS3, uploadFileToS3 } from '@/lib/s3' import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock' import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup' import { deleteOwnedUploadSession, getOwnedUploadedFile, getOwnedUploadSession, } from '@/lib/upload-sessions' jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn(), findMany: jest.fn() }, performanceSurvey: { findUnique: jest.fn(), count: jest.fn() }, performanceSurveyWorkItem: { findUnique: jest.fn() }, $transaction: jest.fn(), }, })) jest.mock('@/lib/performance-survey-report-sync', () => ({ ensurePerformanceSurveyDailyEmployeeSyncStarted: jest.fn(), ensurePerformanceSurveyEmployeeProfileForUser: jest.fn(), getPerformanceSurveyAccountProfileWhere: jest.fn(async () => ({ id: { not: '__hidden__' } })), })) jest.mock('@/lib/performance-survey-notifications', () => ({ ensurePerformanceSurveyNotificationWorkerStarted: jest.fn(), })) jest.mock('@/lib/dingtalk-client', () => ({ getDingtalkPerformanceSurveyNotificationChannel: jest.fn(() => 'work_notice'), getDingtalkRobotOtoConfigStatus: jest.fn(() => ({ configured: true })), getDingtalkRobotOtoTestUserIds: jest.fn(() => []), getDingtalkWorkNoticeConfigStatus: jest.fn(() => ({ configured: true })), })) jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn(), uploadFileToS3: jest.fn(), uploadToS3: jest.fn(), })) jest.mock('@/lib/material-upload-cross-process-lock', () => ({ acquireTaskMaterialUploadSessionLocks: jest.fn(), })) jest.mock('@/lib/temp-cleanup', () => ({ cleanupConsumedUploadSession: jest.fn(), })) jest.mock('@/lib/upload-sessions', () => ({ deleteOwnedUploadSession: jest.fn(), getOwnedUploadSession: jest.fn(), getOwnedUploadedFile: jest.fn(), })) function vp8xHeader(width: number, height: number) { const buffer = Buffer.alloc(30) buffer.write('RIFF', 0, 'ascii') buffer.write('WEBP', 8, 'ascii') buffer.write('VP8X', 12, 'ascii') buffer.writeUIntLE(width - 1, 24, 3) buffer.writeUIntLE(height - 1, 27, 3) return buffer } const session = { user: { id: 'user-1' } } as Session const employee = { id: 'employee-1', displayName: '测试人员', departmentName: '综合支持部', departmentPath: '集团/品牌中心/综合支持部', levelType: 'P', positionLevel: 'P4', isLeadership: false, } function makeReleaseLock() { return Object.assign(jest.fn(async () => undefined), { assertHeld: jest.fn(), }) } describe('performance survey processed work item upload', () => { let tempRoot = '' let webpPath = '' let transactionClient: any let releaseUploadSessionLocks: ReturnType beforeEach(async () => { jest.clearAllMocks() tempRoot = await mkdtemp(path.join(os.tmpdir(), 'survey-processed-')) process.env.UPLOAD_DIR = tempRoot webpPath = path.join(tempRoot, 'slice.webp') await writeFile(webpPath, vp8xHeader(1600, 900)) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: 'user-1', username: 'tester', email: 'tester@example.com', role: 'USER', department: '综合支持部', managedDepartment: null, isSuperAdmin: false, dingtalkUserId: 'ding-user-1', realName: '测试人员', avatar: null, }) ;(ensurePerformanceSurveyEmployeeProfileForUser as jest.Mock).mockResolvedValue(employee) ;(prisma.performanceSurvey.findUnique as jest.Mock).mockResolvedValue({ id: 'survey-1', title: '2026年7月 · 视觉评分', templateCode: 'visual_brand_tone', templateSnapshot: { code: 'visual_brand_tone', surveyType: 'visual_case', requiredWorkItemCount: 3, }, status: 'draft', creatorUserId: 'user-1', creatorEmployeeId: 'employee-1', departmentName: '综合支持部', targets: [{ id: 'target-1', employeeProfileId: 'employee-1' }], workItems: [], }) ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ fileName: 'slice.webp', fileSize: 30, userId: 'user-1', }) ;(getOwnedUploadedFile as jest.Mock).mockResolvedValue(webpPath) ;(uploadFileToS3 as jest.Mock).mockImplementation(async (_filePath, fileName) => `https://s3.example/${fileName}`) ;(deleteOwnedUploadSession as jest.Mock).mockResolvedValue(true) ;(cleanupConsumedUploadSession as jest.Mock).mockResolvedValue({ success: true }) ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue(null) releaseUploadSessionLocks = makeReleaseLock() ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLocks) transactionClient = { $queryRawUnsafe: jest.fn().mockResolvedValue([{ status: 'draft', dueAt: null }]), performanceSurveyWorkItem: { count: jest.fn().mockResolvedValue(0), create: jest.fn(async ({ data }) => ({ ...data, id: 'work-item-1', })), }, } ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => callback(transactionClient)) }) afterEach(async () => { await rm(tempRoot, { recursive: true, force: true }) }) test('streams validated WebP slices to S3 and uses the first slice as optional preview fallback', async () => { const request = new Request('https://example.test/api/performance-survey/surveys/survey-1/work-items', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ targetId: 'target-1', title: '案例 1', processedImages: [{ uploadId: 'upload-1', mimeType: 'image/webp', width: 1600, height: 900, kind: 'slice', sourceName: '1.webp', sourceIndex: 0, sliceIndex: 0, sliceCount: 1, }], }), }) const result = await createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', request) expect(uploadFileToS3).toHaveBeenCalledTimes(1) expect(result.imageGroups).toEqual([ expect.objectContaining({ sourceName: '1.webp', previewUrl: expect.stringContaining('.webp'), previewWidth: 1600, previewHeight: 900, slices: [expect.objectContaining({ width: 1600, height: 900 })], }), ]) expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-1']) expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1') expect(deleteOwnedUploadSession).not.toHaveBeenCalled() expect(releaseUploadSessionLocks).toHaveBeenCalledTimes(1) expect((acquireTaskMaterialUploadSessionLocks as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( (getOwnedUploadSession as jest.Mock).mock.invocationCallOrder[0], ) expect((cleanupConsumedUploadSession as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( releaseUploadSessionLocks.mock.invocationCallOrder[0], ) }) test('treats a committed transaction with a lost acknowledgement as success and preserves S3', async () => { let committedData: any transactionClient.performanceSurveyWorkItem.create.mockImplementation(async ({ data }: any) => { committedData = data ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue({ id: data.id, surveyId: data.surveyId, targetId: data.targetId, submitterUserId: data.submitterUserId, title: data.title, description: data.description, imageUrls: data.imageUrls, status: data.status, submittedAt: data.submittedAt, }) throw new Error('transaction response lost') }) const result = await createPerformanceSurveyWorkItemFromRequest( session, 'survey-1', makeProcessedImageRequest('upload-1'), ) expect(result).toEqual(expect.objectContaining({ id: committedData.id, imageGroups: committedData.imageUrls, })) expect(prisma.performanceSurveyWorkItem.findUnique).toHaveBeenCalledWith(expect.objectContaining({ where: { id: committedData.id }, })) expect(deleteFromS3).not.toHaveBeenCalled() expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-1') }) test('fails closed and preserves S3 and source session when database outcome is unknown', async () => { transactionClient.performanceSurveyWorkItem.create.mockRejectedValue(new Error('connection lost')) ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockRejectedValue(new Error('database unavailable')) const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) await expect(createPerformanceSurveyWorkItemFromRequest( session, 'survey-1', makeProcessedImageRequest('upload-1'), )).rejects.toMatchObject({ status: 503, message: expect.stringContaining('服务器已保留图片和上传会话'), }) expect(deleteFromS3).not.toHaveBeenCalled() expect(cleanupConsumedUploadSession).not.toHaveBeenCalled() expect(deleteOwnedUploadSession).not.toHaveBeenCalled() consoleError.mockRestore() }) test('requires the persisted work item to contain the complete expected image groups', async () => { let committedId = '' transactionClient.performanceSurveyWorkItem.create.mockImplementation(async ({ data }: any) => { committedId = data.id ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue({ id: data.id, surveyId: data.surveyId, targetId: data.targetId, submitterUserId: data.submitterUserId, title: data.title, description: data.description, imageUrls: [], status: data.status, submittedAt: data.submittedAt, }) throw new Error('transaction response lost') }) const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined) await expect(createPerformanceSurveyWorkItemFromRequest( session, 'survey-1', makeProcessedImageRequest('upload-1'), )).rejects.toMatchObject({ status: 503 }) expect(committedId).not.toBe('') expect(deleteFromS3).not.toHaveBeenCalled() expect(cleanupConsumedUploadSession).not.toHaveBeenCalled() consoleError.mockRestore() }) test('deletes only this attempt S3 after the database explicitly confirms no commit and preserves source', async () => { transactionClient.performanceSurveyWorkItem.create.mockRejectedValue(new Error('transaction rolled back')) ;(prisma.performanceSurveyWorkItem.findUnique as jest.Mock).mockResolvedValue(null) await expect(createPerformanceSurveyWorkItemFromRequest( session, 'survey-1', makeProcessedImageRequest('upload-1'), )).rejects.toThrow('transaction rolled back') expect(deleteFromS3).toHaveBeenCalledTimes(1) expect(cleanupConsumedUploadSession).not.toHaveBeenCalled() expect(deleteOwnedUploadSession).not.toHaveBeenCalled() }) test('sorts and locks all uploadIds before resolve, database commit, and successful cleanup', async () => { const request = new Request('https://example.test/upload', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ targetId: 'target-1', processedImages: [ makeProcessedImageDeclaration('upload-b', { sourceName: '2.webp', sourceIndex: 0 }), makeProcessedImageDeclaration('upload-a', { sourceName: '1.webp', sourceIndex: 1 }), ], }), }) await createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', request) expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-a', 'upload-b']) expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(2) expect((cleanupConsumedUploadSession as jest.Mock).mock.calls.map(([uploadId]) => uploadId)).toEqual([ 'upload-a', 'upload-b', ]) }) test('serializes concurrent reuse of one uploadId through cleanup before the next resolve', async () => { let tail = Promise.resolve() ;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockImplementation(async () => { const predecessor = tail let releaseNext!: () => void tail = new Promise((resolve) => { releaseNext = resolve }) await predecessor let active = true return Object.assign(jest.fn(async () => { active = false releaseNext() }), { assertHeld: jest.fn(() => { if (!active) throw new Error('lock released') }), }) }) let notifyCleanupStarted!: () => void const cleanupStarted = new Promise((resolve) => { notifyCleanupStarted = resolve }) let finishCleanup!: () => void ;(cleanupConsumedUploadSession as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => { notifyCleanupStarted() finishCleanup = () => resolve({ success: true }) })) ;(getOwnedUploadSession as jest.Mock) .mockResolvedValueOnce({ fileName: 'slice.webp', fileSize: 30, userId: 'user-1' }) .mockResolvedValueOnce(null) const first = createPerformanceSurveyWorkItemFromRequest( session, 'survey-1', makeProcessedImageRequest('upload-1'), ) await cleanupStarted const second = createPerformanceSurveyWorkItemFromRequest( session, 'survey-1', makeProcessedImageRequest('upload-1'), ) await new Promise((resolve) => setImmediate(resolve)) expect(getOwnedUploadSession).toHaveBeenCalledTimes(1) finishCleanup() await expect(first).resolves.toEqual(expect.objectContaining({ id: 'work-item-1' })) await expect(second).rejects.toThrow('图片上传已过期') expect(uploadFileToS3).toHaveBeenCalledTimes(1) expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(1) }) test('rejects client dimensions that do not match the actual WebP header', async () => { const request = new Request('https://example.test/upload', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ targetId: 'target-1', processedImages: [{ uploadId: 'upload-1', mimeType: 'image/webp', width: 1600, height: 800, kind: 'slice', sourceName: '1.webp', sourceIndex: 0, sliceIndex: 0, sliceCount: 1, }], }), }) await expect(createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', request)).rejects.toEqual( new PerformanceSurveyApiError('图片「1.webp」尺寸校验失败,请重新上传'), ) expect(uploadFileToS3).not.toHaveBeenCalled() }) test('explicitly rejects raw JSON and legacy multipart uploads', async () => { const rawJsonRequest = new Request('https://example.test/upload', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ images: [{ uploadId: 'old-upload', mimeType: 'image/png' }] }), }) await expect(createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', rawJsonRequest)).rejects.toEqual( new PerformanceSurveyApiError('原始图片上传协议已停用,请刷新页面后重新上传', 409), ) const multipartRequest = new Request('https://example.test/upload', { method: 'POST', headers: { 'content-type': 'multipart/form-data; boundary=legacy' }, body: '--legacy--', }) await expect(createPerformanceSurveyWorkItemFromRequest(session, 'survey-1', multipartRequest)).rejects.toEqual( new PerformanceSurveyApiError('旧版图片上传协议已停用,请刷新页面并使用最新版 Chrome 或 Edge 重新上传', 409), ) }) }) function makeProcessedImageDeclaration( uploadId: string, overrides: Partial<{ sourceName: string sourceIndex: number }> = {}, ) { return { uploadId, mimeType: 'image/webp', width: 1600, height: 900, kind: 'slice', sourceName: overrides.sourceName || '1.webp', sourceIndex: overrides.sourceIndex ?? 0, sliceIndex: 0, sliceCount: 1, } } function makeProcessedImageRequest(uploadId: string) { return new Request('https://example.test/upload', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ targetId: 'target-1', title: '案例 1', processedImages: [makeProcessedImageDeclaration(uploadId)], }), }) }