/** @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', ) }) })