gongxiangp2/lib/__tests__/performance-survey-work-item-upload.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

469 lines
17 KiB
TypeScript
Raw Permalink 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 { 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<typeof makeReleaseLock>
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<void>((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<void>((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<void>((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)],
}),
})
}