gongxiangp2/lib/__tests__/standard-model-white-background-service.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

326 lines
11 KiB
TypeScript

/** @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<void>((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<StoredAsset, 'id'>[]) {
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()
})
})