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