Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
326 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|