gongxiangp2/lib/__tests__/work-review-upload-safety.test.ts

476 lines
18 KiB
TypeScript

/** @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 {
WorkReviewApiError,
createWorkReviewProject,
createWorkReviewVersion,
} from '@/lib/work-review-service'
import { prisma } from '@/lib/prisma'
import { getPerformanceSurveyContext } from '@/lib/performance-survey-service'
import { deleteFromS3, uploadFileToS3 } from '@/lib/s3'
import { acquireTaskMaterialUploadSessionLocks } from '@/lib/material-upload-cross-process-lock'
import { cleanupConsumedUploadSession } from '@/lib/temp-cleanup'
import { getOwnedUploadedFile, getOwnedUploadSession } from '@/lib/upload-sessions'
jest.mock('@/lib/prisma', () => ({
prisma: {
user: { findFirst: jest.fn() },
performanceSurveyTemplateVersion: { findMany: jest.fn() },
performanceSurveyEmployeeProfile: { findFirst: jest.fn(), findMany: jest.fn() },
workReviewProject: { create: jest.fn(), findUnique: jest.fn() },
$transaction: jest.fn(),
},
}))
jest.mock('@/lib/performance-survey-service', () => ({
getPerformanceSurveyContext: jest.fn(),
}))
jest.mock('@/lib/performance-survey-report-sync', () => ({
getPerformanceSurveyAccountProfileWhere: jest.fn(async () => ({})),
}))
jest.mock('@/lib/work-review-notifications', () => ({
ensureWorkReviewNotificationWorkerStarted: jest.fn(),
queueWorkReviewNotification: jest.fn(async () => undefined),
}))
jest.mock('@/lib/public-app-url', () => ({
getPublicAppOrigin: jest.fn(() => 'https://example.test'),
}))
jest.mock('@/lib/s3', () => ({
deleteFromS3: jest.fn(async () => undefined),
uploadFileToS3: 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', () => ({
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
}
function makeReleaseLock() {
return Object.assign(jest.fn(async () => undefined), {
assertHeld: jest.fn(),
})
}
function makeProcessedImage(
uploadId: string,
options: { sourceName?: string; sourceIndex?: number } = {},
) {
return {
uploadId,
mimeType: 'image/webp',
width: 1600,
height: 900,
kind: 'slice',
sourceName: options.sourceName || `${uploadId}.webp`,
sourceIndex: options.sourceIndex ?? 0,
sliceIndex: 0,
sliceCount: 1,
}
}
const session = { user: { id: 'user-1' } } as Session
const submitter = {
id: 'submitter-employee',
displayName: '提交人',
dingtalkUserId: 'ding-submitter',
departmentName: '设计部',
departmentPath: '集团/设计部',
positionTitle: '设计师',
positionLevel: 'P4',
levelType: 'P',
sourcePayload: { materialUserId: 'user-1' },
}
const manager = {
id: 'manager-employee',
displayName: '评审人',
dingtalkUserId: 'ding-manager',
sourcePayload: { materialUserId: 'manager-user' },
}
const finalReviewer = {
id: 'final-employee',
displayName: '元泽',
dingtalkUserId: 'ding-final',
sourcePayload: { materialUserId: 'final-user' },
}
function makeProjectBody(uploadId = 'upload-work') {
return {
title: '测试作品',
brand: '亿家达',
workDate: '2026-08-12',
reviewerEmployeeId: manager.id,
processedImages: [makeProcessedImage(uploadId)],
}
}
function buildPersistedProject(data: any) {
const version = data.versions.create
return {
...data,
status: 'pending_review',
latestVersion: version.version,
createdAt: new Date('2026-08-12T01:00:00Z'),
updatedAt: new Date('2026-08-12T01:00:00Z'),
submitterEmployee: submitter,
versions: [{
...version,
projectId: data.id,
status: 'pending_review',
submittedAt: new Date('2026-08-12T01:00:00Z'),
completedAt: null,
createdAt: new Date('2026-08-12T01:00:00Z'),
updatedAt: new Date('2026-08-12T01:00:00Z'),
reviews: [],
}],
}
}
describe('work review processed image persistence safety', () => {
let tempRoot = ''
let webpPath = ''
let releaseUploadSessionLocks: ReturnType<typeof makeReleaseLock>
beforeEach(async () => {
jest.clearAllMocks()
tempRoot = await mkdtemp(path.join(os.tmpdir(), 'work-review-upload-'))
process.env.UPLOAD_DIR = tempRoot
webpPath = path.join(tempRoot, 'slice.webp')
await writeFile(webpPath, vp8xHeader(1600, 900))
;(getPerformanceSurveyContext as jest.Mock).mockResolvedValue({
user: { id: 'user-1', username: 'tester' },
employee: submitter,
isSystemAdmin: false,
})
;(prisma.performanceSurveyTemplateVersion.findMany as jest.Mock).mockResolvedValue([])
;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([finalReviewer])
;(prisma.performanceSurveyEmployeeProfile.findFirst as jest.Mock).mockResolvedValue(manager)
;(prisma.user.findFirst as jest.Mock).mockImplementation(async ({ where }: any) => {
const serialized = JSON.stringify(where)
return serialized.includes('ding-final')
? { id: 'final-user', dingtalkUserId: 'ding-final' }
: { id: 'manager-user', dingtalkUserId: 'ding-manager' }
})
;(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}`,
)
;(cleanupConsumedUploadSession as jest.Mock).mockResolvedValue({ success: true })
releaseUploadSessionLocks = makeReleaseLock()
;(acquireTaskMaterialUploadSessionLocks as jest.Mock).mockResolvedValue(releaseUploadSessionLocks)
;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(null)
;(prisma.workReviewProject.create as jest.Mock).mockImplementation(
async ({ data }) => buildPersistedProject(data),
)
})
afterEach(async () => {
await rm(tempRoot, { recursive: true, force: true })
})
test.each(Array.from({ length: 16 }, (_, mask) => mask).filter((mask) => ![1, 14, 15].includes(mask)))(
'rejects an empty or incomplete upload combination (%i) before storage writes',
async (mask) => {
const types = (['tone', 'main', 'detail', 'sku'] as const).filter((_, index) => mask & (1 << index))
await expect(createWorkReviewProject(session, {
...makeProjectBody(),
processedImages: types.map((type, index) => ({ ...makeProcessedImage(`upload-${type}`, { sourceIndex: index }), assetType: type })),
})).rejects.toBeInstanceOf(WorkReviewApiError)
expect(uploadFileToS3).not.toHaveBeenCalled()
expect(prisma.workReviewProject.create).not.toHaveBeenCalled()
},
)
test.each([false, true])('persists a complete multi-image suite, optional tone=%s, and typed reference images', async (includeTone) => {
const types = [...(includeTone ? ['tone'] : []), 'main', 'main', 'detail', 'sku', 'sku']
const result = await createWorkReviewProject(session, {
...makeProjectBody(),
processedImages: types.map((assetType, index) => ({ ...makeProcessedImage(`work-${index}`, { sourceIndex: index }), assetType })),
processedReferenceImages: [{ ...makeProcessedImage('main-reference'), assetType: 'main' }],
})
expect(result.versions[0].imageGroups.map((group: any) => group.assetType)).toEqual(types)
expect(result.versions[0].referenceImageGroups[0].assetType).toBe('main')
expect(result.versions[0].imageGroups.map((group: any) => group.imageId).every(Boolean)).toBe(true)
expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(types.length + 1)
})
test('rejects unknown types, mismatched slice types, orphaned references and malformed references', async () => {
const badPayloads = [
{ processedImages: [{ ...makeProcessedImage('bad'), assetType: 'unknown' }] },
{ processedImages: [
{ ...makeProcessedImage('slice-1'), assetType: 'tone', sliceCount: 2 },
{ ...makeProcessedImage('slice-2'), assetType: 'main', sliceCount: 2, sliceIndex: 1 },
] },
{ processedReferenceImages: [{ ...makeProcessedImage('orphan'), assetType: 'sku' }] },
{ processedReferenceImages: { invalid: true } },
]
for (const payload of badPayloads) {
await expect(createWorkReviewProject(session, { ...makeProjectBody(), ...payload })).rejects.toBeInstanceOf(WorkReviewApiError)
}
expect(uploadFileToS3).not.toHaveBeenCalled()
expect(prisma.workReviewProject.create).not.toHaveBeenCalled()
})
test('treats a committed project with a lost acknowledgement as success and preserves both image sets', async () => {
let persisted: any
;(prisma.workReviewProject.create as jest.Mock).mockImplementation(async ({ data }) => {
persisted = buildPersistedProject(data)
;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(persisted)
throw new Error('create response lost')
})
const result = await createWorkReviewProject(session, {
...makeProjectBody('upload-work'),
processedReferenceImages: [makeProcessedImage('upload-reference')],
})
expect(result.id).toBe(persisted.id)
expect(result.versions[0].imageGroups).toHaveLength(1)
expect(result.versions[0].referenceImageGroups).toHaveLength(1)
expect(deleteFromS3).not.toHaveBeenCalled()
expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(2)
expect((cleanupConsumedUploadSession as jest.Mock).mock.calls.map(([uploadId]) => uploadId)).toEqual([
'upload-reference',
'upload-work',
])
})
test('rejects a new project without a brand before touching uploaded images', async () => {
const body = makeProjectBody()
delete (body as Record<string, unknown>).brand
await expect(createWorkReviewProject(session, body)).rejects.toEqual(
new WorkReviewApiError('请填写品牌'),
)
expect(getOwnedUploadSession).not.toHaveBeenCalled()
expect(uploadFileToS3).not.toHaveBeenCalled()
})
test('fails closed and preserves S3 and source sessions when the commit readback is unavailable', async () => {
;(prisma.workReviewProject.create as jest.Mock).mockRejectedValue(new Error('connection lost'))
;(prisma.workReviewProject.findUnique as jest.Mock).mockRejectedValue(new Error('database unavailable'))
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
await expect(createWorkReviewProject(session, makeProjectBody())).rejects.toEqual(
new WorkReviewApiError(
'作品保存状态暂时无法确认,服务器已保留图片和上传会话,请勿重复提交',
503,
),
)
expect(deleteFromS3).not.toHaveBeenCalled()
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
consoleError.mockRestore()
})
test('preserves S3 when a persisted version does not contain the complete reference image evidence', async () => {
;(prisma.workReviewProject.create as jest.Mock).mockImplementation(async ({ data }) => {
const persisted = buildPersistedProject(data)
persisted.versions[0].imageUrls.referenceImageGroups = []
;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(persisted)
throw new Error('create response lost')
})
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
await expect(createWorkReviewProject(session, {
...makeProjectBody(),
processedReferenceImages: [makeProcessedImage('upload-reference')],
})).rejects.toMatchObject({ status: 503 })
expect(deleteFromS3).not.toHaveBeenCalled()
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
consoleError.mockRestore()
})
test('deletes only this attempt S3 after an exact readback confirms no project commit', async () => {
;(prisma.workReviewProject.create as jest.Mock).mockRejectedValue(new Error('rolled back'))
;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(null)
await expect(createWorkReviewProject(session, makeProjectBody())).rejects.toThrow('rolled back')
expect(deleteFromS3).toHaveBeenCalledTimes(1)
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
})
test('locks all work and reference uploadIds in sorted order before resolve and cleanup', async () => {
await createWorkReviewProject(session, {
...makeProjectBody('upload-z'),
processedReferenceImages: [makeProcessedImage('upload-a')],
})
expect(acquireTaskMaterialUploadSessionLocks).toHaveBeenCalledWith(['upload-a', 'upload-z'])
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('serializes cross-request reuse through consumed 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 finishCleanup!: () => void
let reportCleanupStarted!: () => void
const cleanupStarted = new Promise<void>((resolve) => {
reportCleanupStarted = resolve
})
;(cleanupConsumedUploadSession as jest.Mock).mockImplementationOnce(() => new Promise((resolve) => {
reportCleanupStarted()
finishCleanup = () => resolve({ success: true })
}))
;(getOwnedUploadSession as jest.Mock)
.mockResolvedValueOnce({ fileName: 'slice.webp', fileSize: 30, userId: 'user-1' })
.mockResolvedValueOnce(null)
const first = createWorkReviewProject(session, makeProjectBody('upload-shared'))
await cleanupStarted
const second = createWorkReviewProject(session, makeProjectBody('upload-shared'))
await new Promise<void>((resolve) => setImmediate(resolve))
expect(getOwnedUploadSession).toHaveBeenCalledTimes(1)
finishCleanup()
await expect(first).resolves.toEqual(expect.objectContaining({ title: '测试作品' }))
await expect(second).rejects.toThrow('图片上传已过期')
expect(uploadFileToS3).toHaveBeenCalledTimes(1)
expect(cleanupConsumedUploadSession).toHaveBeenCalledTimes(1)
})
test('deletes attempt S3 when a new-version readback proves the stable version ID was not committed', async () => {
const existing = {
id: 'project-existing',
submitterUserId: 'user-1',
status: 'revision_required',
latestVersion: 1,
versions: [{ id: 'old-version', version: 1 }],
}
;(prisma.workReviewProject.findUnique as jest.Mock)
.mockResolvedValueOnce(existing)
.mockResolvedValue(existing)
;(prisma.$transaction as jest.Mock).mockRejectedValue(new Error('transaction rolled back'))
await expect(createWorkReviewVersion(session, existing.id, {
expectedLatestVersion: 1,
processedImages: [makeProcessedImage('upload-work')],
})).rejects.toThrow('transaction rolled back')
expect(deleteFromS3).toHaveBeenCalledTimes(1)
expect(cleanupConsumedUploadSession).not.toHaveBeenCalled()
})
test('confirms a lost new-version transaction by the stable version ID and complete image payload', async () => {
const existing = {
id: 'project-existing',
submitterUserId: 'user-1',
status: 'revision_required',
latestVersion: 1,
versions: [{ id: 'old-version', version: 1 }],
}
;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValueOnce(existing)
let newVersionData: any
const tx = {
$queryRaw: jest.fn().mockResolvedValue([{ status: 'revision_required', latest_version: 1 }]),
workReviewVersion: {
create: jest.fn(async ({ data }) => {
newVersionData = data
return data
}),
},
workReviewProject: { update: jest.fn() },
}
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback) => {
await callback(tx)
const persisted = {
...existing,
title: '测试作品',
description: null,
workDate: new Date('2026-08-12T00:00:00Z'),
submitterEmployeeId: submitter.id,
submitterName: submitter.displayName,
submitterSnapshot: {},
managerEmployeeId: manager.id,
managerUserId: 'manager-user',
managerDingtalkUserId: 'ding-manager',
managerName: manager.displayName,
finalReviewerEmployeeId: finalReviewer.id,
finalReviewerUserId: 'final-user',
finalReviewerDingtalkUserId: 'ding-final',
finalReviewerName: finalReviewer.displayName,
status: 'pending_review',
latestVersion: 2,
createdAt: new Date('2026-08-12T00:00:00Z'),
updatedAt: new Date('2026-08-12T01:00:00Z'),
submitterEmployee: submitter,
versions: [{
...newVersionData,
status: 'pending_review',
submittedAt: new Date('2026-08-12T01:00:00Z'),
completedAt: null,
reviews: [],
}],
}
;(prisma.workReviewProject.findUnique as jest.Mock).mockResolvedValue(persisted)
throw new Error('transaction response lost')
})
const result = await createWorkReviewVersion(session, existing.id, {
expectedLatestVersion: 1,
processedImages: [makeProcessedImage('upload-work')],
})
expect(result.latestVersion).toBe(2)
expect(result.versions[0].id).toBe(newVersionData.id)
expect(deleteFromS3).not.toHaveBeenCalled()
expect(cleanupConsumedUploadSession).toHaveBeenCalledWith('upload-work')
})
})