/** @jest-environment node */ import { prisma } from '@/lib/prisma' import { deleteFileIfUnreferenced } from '@/lib/file-cleanup' import { buildWhiteBackgroundWebpVariants } from '@/lib/white-background-preview' import { uploadPublicDisplayToS3 } from '@/lib/public-display' import { deleteFromS3 } from '@/lib/s3' import { assertWhiteBackgroundUploadFiles } from '@/lib/standard-model-white-background-upload-guard' import { cleanupStandardModelWhiteBackgroundForSku, uploadStandardModelWhiteBackground, } from '@/lib/standard-model-white-background-service' jest.mock('fs', () => ({ promises: { mkdir: jest.fn().mockResolvedValue(undefined), open: jest.fn(), rename: jest.fn().mockResolvedValue(undefined), unlink: jest.fn().mockResolvedValue(undefined), }, })) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findUnique: jest.fn(), }, materialSku: { findFirst: jest.fn(), }, materialAsset: { findMany: jest.fn(), }, $transaction: jest.fn(), }, })) jest.mock('@/lib/image-processor', () => ({ sanitizeFileName: jest.fn((value: string) => value), })) jest.mock('@/lib/file-cleanup', () => ({ deleteFileIfUnreferenced: jest.fn().mockResolvedValue({ success: true }), })) jest.mock('@/lib/material-log', () => ({ createMaterialLog: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/white-background-preview', () => ({ buildWhiteBackgroundWebpVariants: jest.fn(), })) jest.mock('@/lib/public-display', () => ({ ...jest.requireActual('@/lib/public-display'), uploadPublicDisplayToS3: jest.fn(), })) jest.mock('@/lib/s3', () => ({ deleteFromS3: jest.fn().mockResolvedValue(undefined), })) jest.mock('@/lib/standard-model-white-background-upload-guard', () => ({ ...jest.requireActual('@/lib/standard-model-white-background-upload-guard'), assertWhiteBackgroundUploadFiles: jest.fn().mockResolvedValue(undefined), })) type StoredAsset = { id: string materialId: string assetType: string fileUrl: string fileName: string fileSize: bigint sortOrder: number } function deferred() { let resolve!: () => void const promise = new Promise((accept) => { resolve = accept }) return { promise, resolve } } function createFile(name: string, contents: string): File { const bytes = Uint8Array.from(Buffer.from(contents)) return { name, size: bytes.byteLength, arrayBuffer: async () => bytes.buffer, } as unknown as File } function createInput(contents: string) { return { materialId: 'material-1', skuId: 'sku-1', skuName: 'SKU001', sourceType: 'AI' as const, operatorUserId: 'user-1', fixedImages: { rightFront45: createFile('right-front.png', contents), }, } } describe('standard-model-white-background service concurrency safety', () => { const fsPromises = jest.requireMock('fs').promises as { mkdir: jest.Mock open: jest.Mock rename: jest.Mock unlink: jest.Mock } let storedAssets: StoredAsset[] let nextAssetId: number let consoleErrorSpy: jest.SpyInstance beforeEach(() => { jest.clearAllMocks() consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) storedAssets = [] nextAssetId = 1 fsPromises.open.mockImplementation(async () => ({ writeFile: jest.fn().mockResolvedValue(undefined), sync: jest.fn().mockResolvedValue(undefined), close: jest.fn().mockResolvedValue(undefined), })) fsPromises.mkdir.mockResolvedValue(undefined) fsPromises.rename.mockResolvedValue(undefined) fsPromises.unlink.mockResolvedValue(undefined) ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ id: 'material-1', materialType: '标准模型', productCategoryName: '鞋柜', categoryName: '', }) ;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue({ id: 'sku-1', skuName: 'SKU001', }) ;(prisma.materialAsset.findMany as jest.Mock).mockImplementation(async (args: any) => { const requestedFileUrls = args?.where?.fileUrl?.in as string[] | undefined const selected = requestedFileUrls ? storedAssets.filter((asset) => requestedFileUrls.includes(asset.fileUrl)) : storedAssets return selected.map((asset) => ({ ...asset })) }) ;(buildWhiteBackgroundWebpVariants as jest.Mock).mockImplementation(async (source: Buffer) => ({ original: Buffer.from(`original-${source.toString()}`), preview: Buffer.from(`preview-${source.toString()}`), })) ;(uploadPublicDisplayToS3 as jest.Mock).mockImplementation( async (_file: Buffer, relativePath: string) => `https://s3.example/${relativePath}`, ) ;(assertWhiteBackgroundUploadFiles as jest.Mock).mockResolvedValue(undefined) ;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true }) ;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined) }) afterEach(() => { consoleErrorSpy.mockRestore() }) function createTransactionClient() { return { materialAsset: { deleteMany: jest.fn(async ({ where }: any) => { const assetTypes = (where?.assetType?.in || []) as string[] storedAssets = assetTypes.length > 0 ? storedAssets.filter((asset) => !assetTypes.includes(asset.assetType)) : [] return { count: 0 } }), createMany: jest.fn(async ({ data }: any) => { for (const asset of data as Omit[]) { storedAssets.push({ ...asset, id: `asset-${nextAssetId++}`, }) } return { count: data.length } }), }, materialSku: { update: jest.fn().mockResolvedValue({ id: 'sku-1' }), }, material: { updateMany: jest.fn().mockResolvedValue({ count: 1 }), }, } } test('same material and SKU serialize; a later failed attempt only deletes its unique outputs', async () => { const firstTransactionStarted = deferred() const allowFirstTransaction = deferred() let transactionCount = 0 ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => { transactionCount += 1 if (transactionCount === 1) { firstTransactionStarted.resolve() await allowFirstTransaction.promise return callback(createTransactionClient()) } throw new Error('second transaction failed') }) const firstUpload = uploadStandardModelWhiteBackground(createInput('first')) await firstTransactionStarted.promise const secondUpload = uploadStandardModelWhiteBackground(createInput('second')) await Promise.resolve() await Promise.resolve() // 第二个请求已经发起,但在同一 materialId+skuId 的独占锁外等待, // 尚不能读取旧资产或开始图片处理。 expect(prisma.material.findUnique).toHaveBeenCalledTimes(1) expect(buildWhiteBackgroundWebpVariants).toHaveBeenCalledTimes(1) allowFirstTransaction.resolve() const [firstResult, secondResult] = await Promise.allSettled([firstUpload, secondUpload]) expect(firstResult.status).toBe('fulfilled') expect(secondResult).toMatchObject({ status: 'rejected', reason: expect.objectContaining({ message: 'second transaction failed' }), }) const uploadedObjectPaths = (uploadPublicDisplayToS3 as jest.Mock).mock.calls.map( (call) => call[1] as string, ) expect(uploadedObjectPaths).toHaveLength(2) expect(uploadedObjectPaths[0]).not.toBe(uploadedObjectPaths[1]) expect(uploadedObjectPaths[0]).toMatch(/--[0-9a-f-]{36}\.webp$/) expect(uploadedObjectPaths[1]).toMatch(/--[0-9a-f-]{36}\.webp$/) const successfulUrl = `https://s3.example/${uploadedObjectPaths[0]}` const failedAttemptUrl = `https://s3.example/${uploadedObjectPaths[1]}` expect(deleteFromS3).toHaveBeenCalledTimes(1) expect(deleteFromS3).toHaveBeenCalledWith(failedAttemptUrl) expect(deleteFromS3).not.toHaveBeenCalledWith(successfulUrl) expect(storedAssets).toHaveLength(1) const successfulFileName = uploadedObjectPaths[0].split('/').at(-1)! const failedLocalPath = uploadedObjectPaths[1].split('/').at(-1)! expect(storedAssets[0].fileUrl).toBe( `uploads/skus/material-1/white-background/sku-1/${successfulFileName}`, ) expect(fsPromises.unlink).toHaveBeenCalledWith( expect.stringContaining(failedLocalPath), ) expect(fsPromises.unlink).not.toHaveBeenCalledWith( expect.stringContaining(successfulFileName), ) }) test('a transaction response lost after commit is re-read and does not delete committed outputs', async () => { ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => { await callback(createTransactionClient()) throw new Error('connection lost after commit') }) const result = await uploadStandardModelWhiteBackground(createInput('commit-ambiguous')) expect(result.whiteBackgroundUploaded).toBe(true) expect(storedAssets).toHaveLength(1) expect(deleteFromS3).not.toHaveBeenCalled() expect(fsPromises.unlink).not.toHaveBeenCalled() }) test('cleanup for the same material and SKU waits for an active upload lock', async () => { const uploadTransactionStarted = deferred() const allowUploadTransaction = deferred() let transactionCount = 0 ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => { transactionCount += 1 if (transactionCount === 1) { uploadTransactionStarted.resolve() await allowUploadTransaction.promise } return callback(createTransactionClient()) }) const upload = uploadStandardModelWhiteBackground(createInput('before-cleanup')) await uploadTransactionStarted.promise const cleanup = cleanupStandardModelWhiteBackgroundForSku({ materialId: 'material-1', skuId: 'sku-1', operatorUserId: 'user-2', reason: 'test', }) await Promise.resolve() await Promise.resolve() expect(prisma.materialAsset.findMany).toHaveBeenCalledTimes(1) allowUploadTransaction.resolve() const [, cleanupResult] = await Promise.all([upload, cleanup]) expect(cleanupResult.deletedAssetCount).toBe(1) expect(storedAssets).toEqual([]) expect(prisma.$transaction).toHaveBeenCalledTimes(2) }) test('unknown transaction outcome preserves unique outputs for later reconciliation', async () => { ;(prisma.materialAsset.findMany as jest.Mock).mockImplementation(async (args: any) => { if (args?.where?.fileUrl?.in) { throw new Error('database unavailable during commit check') } return [] }) ;(prisma.$transaction as jest.Mock).mockRejectedValue(new Error('transaction response lost')) await expect( uploadStandardModelWhiteBackground(createInput('unknown-commit')), ).rejects.toThrow('transaction response lost') expect(deleteFromS3).not.toHaveBeenCalled() expect(fsPromises.unlink).not.toHaveBeenCalled() }) })