gongxiangp2/lib/__tests__/white-background-upload-route.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

346 lines
11 KiB
TypeScript

/** @jest-environment node */
import { POST } from '@/app/api/materials/[id]/white-background/route'
import { getServerSession } from 'next-auth'
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 { canManageMaterial } from '@/lib/material-authorization'
import {
assertWhiteBackgroundUploadFiles,
WhiteBackgroundUploadValidationError,
} from '@/lib/standard-model-white-background-upload-guard'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/material-authorization', () => ({
canManageMaterial: jest.fn(),
}))
jest.mock('@/lib/standard-model-white-background-upload-guard', () => {
const actual = jest.requireActual('@/lib/standard-model-white-background-upload-guard')
return {
...actual,
assertWhiteBackgroundUploadFiles: jest.fn(),
}
})
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(),
},
user: {
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),
}))
describe('white-background upload route', () => {
const fsPromises = jest.requireMock('fs').promises as {
mkdir: jest.Mock
open: jest.Mock
rename: jest.Mock
unlink: jest.Mock
}
let atomicFileHandle: {
writeFile: jest.Mock
sync: jest.Mock
close: jest.Mock
}
let txMaterialUpdateMany: jest.Mock
let consoleErrorSpy: jest.SpyInstance
beforeEach(() => {
jest.clearAllMocks()
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
fsPromises.mkdir.mockResolvedValue(undefined)
atomicFileHandle = {
writeFile: jest.fn().mockResolvedValue(undefined),
sync: jest.fn().mockResolvedValue(undefined),
close: jest.fn().mockResolvedValue(undefined),
}
fsPromises.open.mockResolvedValue(atomicFileHandle)
fsPromises.rename.mockResolvedValue(undefined)
fsPromises.unlink.mockResolvedValue(undefined)
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'user-1',
},
})
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
id: 'material-1',
materialType: '标准模型',
userId: 'user-1',
productCategoryName: '鞋柜',
categoryName: '旗舰店',
})
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'USER' })
;(canManageMaterial as jest.Mock).mockResolvedValue(true)
;(assertWhiteBackgroundUploadFiles as jest.Mock).mockResolvedValue(undefined)
;(prisma.materialSku.findFirst as jest.Mock).mockResolvedValue({
id: 'sku-1',
skuName: 'XG014799',
})
;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValue([])
txMaterialUpdateMany = jest.fn().mockResolvedValue({ count: 1 })
;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) =>
callback({
material: {
updateMany: txMaterialUpdateMany,
},
materialAsset: {
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
createMany: jest.fn().mockResolvedValue({ count: 1 }),
},
materialSku: {
update: jest.fn().mockResolvedValue({
id: 'sku-1',
whiteBackgroundSourceType: '渲染',
whiteBackgroundUploadedBy: 'user-1',
whiteBackgroundUploadedAt: new Date('2026-04-30T08:00:00.000Z'),
}),
},
}),
)
;(buildWhiteBackgroundWebpVariants as jest.Mock).mockResolvedValue({
original: Buffer.from('original-webp'),
preview: Buffer.from('preview-webp'),
})
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue(
'https://s3.jiansun.vip/gongxiangpan/public-display/white-background/material-1/sku-1/XG014799-鞋柜-右前45°.webp',
)
;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true })
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
function createRequest(formData: FormData) {
return {
headers: new Headers({ 'content-length': '1024' }),
formData: jest.fn().mockResolvedValue(formData),
} as any
}
test('uploads fixed white background image as local original webp and public-display preview', async () => {
const formData = new FormData()
formData.append('skuId', 'sku-1')
formData.append('skuName', 'XG014799')
formData.append('sourceType', '渲染')
formData.append(
'whiteBgRightFront45',
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
)
const response = await POST(createRequest(formData), {
params: { id: 'material-1' },
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(buildWhiteBackgroundWebpVariants).toHaveBeenCalledTimes(1)
expect(uploadPublicDisplayToS3).toHaveBeenCalledWith(
Buffer.from('preview-webp'),
expect.stringMatching(
/^white-background\/material-1\/sku-1\/XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp$/,
),
'image/webp',
)
expect(fsPromises.open).toHaveBeenCalledWith(
expect.stringMatching(
/uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp\.partial-[0-9a-f-]{36}$/,
),
'wx',
0o640,
)
expect(atomicFileHandle.writeFile).toHaveBeenCalledWith(Buffer.from('original-webp'))
expect(atomicFileHandle.sync).toHaveBeenCalledTimes(1)
expect(fsPromises.rename).toHaveBeenCalledWith(
expect.stringContaining('.webp.partial-'),
expect.stringMatching(
/uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp$/,
),
)
expect(prisma.$transaction).toHaveBeenCalled()
expect(txMaterialUpdateMany).toHaveBeenCalledWith({
where: {
id: 'material-1',
previewUrl: '',
},
data: {
previewUrl: expect.stringMatching(
/^https:\/\/s3\.jiansun\.vip\/gongxiangpan\/public-display\/white-background\/material-1\/sku-1\/XG014799-.*\.webp$/,
),
},
})
})
test('sourceType 缺失时应返回 400', async () => {
const formData = new FormData()
formData.append('skuId', 'sku-1')
formData.append('skuName', 'XG014799')
formData.append(
'whiteBgRightFront45',
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
)
const response = await POST(createRequest(formData), {
params: { id: 'material-1' },
})
const body = await response.json()
expect(response.status).toBe(400)
expect(body).toEqual({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' })
})
test('非素材所有者或管理员应在读取 multipart 文件前返回 403', async () => {
;(canManageMaterial as jest.Mock).mockResolvedValue(false)
const request = {
formData: jest.fn(),
} as any
const response = await POST(request, {
params: { id: 'material-1' },
})
expect(response.status).toBe(403)
expect(request.formData).not.toHaveBeenCalled()
expect(assertWhiteBackgroundUploadFiles).not.toHaveBeenCalled()
})
test('图片资源门禁失败时应返回 400 且不进入存储服务', async () => {
const formData = new FormData()
formData.append('skuId', 'sku-1')
formData.append('skuName', 'XG014799')
formData.append('sourceType', '渲染')
formData.append(
'whiteBgRightFront45',
new File([Buffer.from('oversized')], 'oversized.png', { type: 'image/png' }),
)
;(assertWhiteBackgroundUploadFiles as jest.Mock).mockRejectedValueOnce(
new WhiteBackgroundUploadValidationError('图片「oversized.png」不能超过 20MB'),
)
const response = await POST(createRequest(formData), {
params: { id: 'material-1' },
})
const body = await response.json()
expect(response.status).toBe(400)
expect(body).toEqual({ error: '图片「oversized.png」不能超过 20MB' })
expect(prisma.$transaction).not.toHaveBeenCalled()
})
test('缺少请求大小时应在解析 multipart 前拒绝', async () => {
const request = {
headers: new Headers(),
formData: jest.fn(),
} as any
const response = await POST(request, {
params: { id: 'material-1' },
})
expect(response.status).toBe(411)
expect(request.formData).not.toHaveBeenCalled()
})
test('sourceType 非法时应返回 400', async () => {
const formData = new FormData()
formData.append('skuId', 'sku-1')
formData.append('skuName', 'XG014799')
formData.append('sourceType', '手绘')
formData.append(
'whiteBgRightFront45',
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
)
const response = await POST(createRequest(formData), {
params: { id: 'material-1' },
})
const body = await response.json()
expect(response.status).toBe(400)
expect(body).toEqual({ error: '白底图出图方式必填,且只能是 实拍、渲染、AI' })
})
test('cleans up written files and uploaded public-display preview when persistence fails', async () => {
const formData = new FormData()
formData.append('skuId', 'sku-1')
formData.append('skuName', 'XG014799')
formData.append('sourceType', 'AI')
formData.append(
'whiteBgRightFront45',
new File([Buffer.from('png-source')], 'right-front-45.png', { type: 'image/png' }),
)
;(prisma.$transaction as jest.Mock).mockRejectedValueOnce(new Error('db failed'))
const response = await POST(createRequest(formData), {
params: { id: 'material-1' },
})
expect(response.status).toBe(500)
expect(fsPromises.unlink).toHaveBeenCalledTimes(1)
expect(fsPromises.unlink).toHaveBeenCalledWith(
expect.stringMatching(
/uploads\\skus\\material-1\\white-background\\sku-1\\XG014799-鞋柜-右前45°--[0-9a-f-]{36}\.webp$/,
),
)
expect(deleteFromS3).toHaveBeenCalledWith(
'https://s3.jiansun.vip/gongxiangpan/public-display/white-background/material-1/sku-1/XG014799-鞋柜-右前45°.webp',
)
})
})