476 lines
18 KiB
TypeScript
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')
|
|
})
|
|
})
|