/** @jest-environment node */ import { GET } from '@/app/api/external/standard-model-white-background/route' import { prisma } from '@/lib/prisma' import { logApiCall, validateApiKey } from '@/lib/external-api' import { resolveWhiteBackgroundPreviewFileUrl } from '@/lib/white-background-preview' jest.mock('@/lib/external-api', () => { const { NextResponse } = require('next/server') return { getApiKeyFromRequest: jest.fn((request: { headers: Headers }) => request.headers.get('X-API-Key')), getClientIp: jest.fn(() => '127.0.0.1'), logApiCall: jest.fn().mockResolvedValue(undefined), successResponse: jest.fn((data: unknown, message?: string) => NextResponse.json({ success: true, ...(message ? { message } : {}), data, }), ), validateApiKey: jest.fn(), } }) jest.mock('@/lib/prisma', () => ({ prisma: { material: { findFirst: jest.fn(), findMany: jest.fn(), }, materialSku: { findMany: jest.fn(), }, materialAsset: { findMany: jest.fn(), }, }, })) jest.mock('@/lib/download-token', () => ({ generateDownloadUrl: jest.fn(() => 'https://download.example/token'), })) jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: jest.fn(() => 'https://sc.jiansun.vip'), })) jest.mock('@/lib/url-rewrite', () => ({ rewriteStorageUrlForClient: jest.fn((url?: string | null) => { if (!url) { return null } return /^https?:\/\//i.test(url) ? `rewritten:${url}` : url }), })) jest.mock('@/lib/white-background-preview', () => ({ resolveWhiteBackgroundPreviewFileUrl: jest.fn((url?: string | null) => { if (!url) { return null } if (/^https?:\/\//i.test(url)) { return url } const normalized = url.trim().replace(/\\/g, '/').replace(/^\/+/, '') if (normalized.includes('/other/')) { return `/${normalized.replace('/other/', '/preview/other/').replace(/\.[^/.]+$/, '.webp')}` } return `/${normalized.replace(/\/white-background\/([^/]+)\//, '/white-background/$1/preview/').replace(/\.[^/.]+$/, '.webp')}` }), })) describe('external standard-model-white-background route', () => { beforeEach(() => { jest.clearAllMocks() ;(prisma.material.findFirst as jest.Mock).mockReset() ;(prisma.material.findMany as jest.Mock).mockReset() ;(prisma.materialSku.findMany as jest.Mock).mockReset() ;(prisma.materialAsset.findMany as jest.Mock).mockReset() ;(logApiCall as jest.Mock).mockReset().mockResolvedValue(undefined) ;(validateApiKey as jest.Mock).mockReset() ;(resolveWhiteBackgroundPreviewFileUrl as jest.Mock).mockClear() ;(validateApiKey as jest.Mock).mockResolvedValue({ id: 'api-key-1', name: 'test-key', isActive: true, }) }) function createRequest(url: string) { return { nextUrl: new URL(url), headers: new Headers({ 'X-API-Key': 'test-key', 'user-agent': 'jest', }), } as any } test('resourceType 非法时应返回 RESOURCE_TYPE_INVALID', async () => { const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?resourceType=bad'), ) const body = await response.json() expect(response.status).toBe(400) expect(body).toEqual({ success: false, error: { code: 'RESOURCE_TYPE_INVALID', message: 'resourceType 仅支持 white-background 或 task-detail', }, }) expect(response.headers.get('X-Trace-Id')).toBeTruthy() expect(response.headers.get('X-Server-Duration-Ms')).toMatch(/^\d+$/) expect(response.headers.get('X-Resource-Type')).toBe('white-background') expect(logApiCall).toHaveBeenCalled() }) test('task-detail spu 查询应直接按任务详情素材返回整套候选和预览直链', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce(null) ;(prisma.material.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'material-2', taskNo: 'TASK202601141713525624', productModel: 'SPU001', categoryName: '旗舰店', sceneName: '客厅', createdAt: new Date('2026-03-18T10:00:00.000Z'), isFullSet: false, assets: [ { assetType: 'main-preview', fileUrl: 'https://s3.example/main-1.jpg', sortOrder: 2 }, { assetType: 'main-preview', fileUrl: 'https://s3.example/main-0.jpg', sortOrder: 1 }, { assetType: 'sku-preview', fileUrl: 'https://s3.example/sku-1.jpg', sortOrder: 1 }, { assetType: 'detail-preview', fileUrl: 'https://s3.example/detail-1.jpg', sortOrder: 1 }, ], }, { id: 'material-1', taskNo: 'TASK202601141713525623', productModel: 'SPU001', categoryName: '旗舰店', sceneName: '卧室', createdAt: new Date('2026-03-17T10:00:00.000Z'), isFullSet: false, assets: [], }, ]) const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?resourceType=task-detail&spu=spu001'), ) const body = await response.json() expect(response.status).toBe(200) expect(body.success).toBe(true) expect(body.data).toEqual({ resourceType: 'task-detail', spu: 'SPU001', sku: null, categoryName: '旗舰店', shopName: '旗舰店', candidateCount: 1, candidates: [ { materialId: 'material-2', taskNo: 'TASK202601141713525624', productModel: 'SPU001', categoryName: '旗舰店', shopName: '旗舰店', sceneName: '客厅', uploadedAt: '2026-03-18T10:00:00.000Z', isFullSet: true, downloadUrls: { main: [ 'rewritten:https://s3.example/main-0.jpg', 'rewritten:https://s3.example/main-1.jpg', ], sku: ['rewritten:https://s3.example/sku-1.jpg'], detail: ['rewritten:https://s3.example/detail-1.jpg'], all: [ 'rewritten:https://s3.example/main-0.jpg', 'rewritten:https://s3.example/main-1.jpg', 'rewritten:https://s3.example/sku-1.jpg', 'rewritten:https://s3.example/detail-1.jpg', ], }, }, ], }) expect(prisma.material.findFirst).not.toHaveBeenCalled() }) test('task-detail sku 查询应先解析 spu 再按实际资产返回整套候选', async () => { ;(prisma.materialSku.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'sku-1', skuName: 'SKU001', material: { productModel: 'SPU001', categoryName: '通用', }, }, ]) ;(prisma.material.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'material-2', taskNo: 'TASK202601141713525624', productModel: 'SPU001', categoryName: '旗舰店', sceneName: '客厅', createdAt: new Date('2026-03-18T10:00:00.000Z'), isFullSet: false, assets: [ { assetType: 'main-preview', fileUrl: 'https://s3.example/main-1.jpg', sortOrder: 1 }, { assetType: 'sku-preview', fileUrl: 'https://s3.example/sku-1.jpg', sortOrder: 1 }, { assetType: 'detail-preview', fileUrl: 'https://s3.example/detail-1.jpg', sortOrder: 1 }, ], }, ]) const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?resourceType=task-detail&sku=sku001'), ) const body = await response.json() expect(response.status).toBe(200) expect(body.data.resourceType).toBe('task-detail') expect(body.data.spu).toBe('SPU001') expect(body.data.sku).toBe('SKU001') expect(body.data.shopName).toBe('旗舰店') expect(body.data.candidateCount).toBe(1) expect(body.data.candidates[0]).toEqual( expect.objectContaining({ materialId: 'material-2', taskNo: 'TASK202601141713525624', shopName: '旗舰店', isFullSet: true, }), ) expect(prisma.materialSku.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ deletedAt: null, skuName: 'SKU001', }), }), ) expect(response.headers.get('X-Request-Sku')).toBe('SKU001') expect(response.headers.get('X-Request-Spu')).toBe('SPU001') expect(response.headers.get('X-Resource-Type')).toBe('task-detail') }) test('task-detail 资产不齐全时应返回 FULL_SET_NOT_FOUND', async () => { ;(prisma.material.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'material-2', taskNo: 'TASK202601141713525624', productModel: 'SPU001', categoryName: '旗舰店', sceneName: '客厅', createdAt: new Date('2026-03-18T10:00:00.000Z'), isFullSet: true, assets: [ { assetType: 'main-preview', fileUrl: 'https://s3.example/main-1.jpg', sortOrder: 1 }, { assetType: 'detail-preview', fileUrl: 'https://s3.example/detail-1.jpg', sortOrder: 1 }, ], }, ]) const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?resourceType=task-detail&spu=spu001'), ) const body = await response.json() expect(response.status).toBe(404) expect(body).toEqual({ success: false, error: { code: 'FULL_SET_NOT_FOUND', message: '该 SPU 下暂无主图、SKU 图、详情图齐全的任务-详情素材', }, }) }) test('white-background spu 查询应使用标准化后的精确 SPU 匹配并返回诊断头', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'std-1', productModel: 'SPU001', categoryName: '旗舰店', skus: [], }) const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?spu=spu001'), ) expect(prisma.material.findFirst).toHaveBeenCalledWith( expect.objectContaining({ where: expect.objectContaining({ productModel: 'SPU001', materialType: '标准模型', deletedAt: null, }), }), ) expect(response.headers.get('X-Request-Spu')).toBe('SPU001') expect(response.headers.get('X-Resource-Type')).toBe('white-background') }) test('white-background sku 查询应返回适合直接预览的 previewUrl,同时保留 downloadUrl', async () => { ;(prisma.materialSku.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'sku-1', skuName: 'XG014799', materialId: 'std-1', material: { id: 'std-1', productModel: 'SPU001', categoryName: '鞋柜', deletedAt: null, }, }, ]) ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'asset-fixed-1', assetType: 'standard-model-white-bg-view:sku-1:rightFront45', fileUrl: 'uploads/skus/std-1/white-background/sku-1/XG014799-鞋柜-右前45°.webp', fileName: 'XG014799-鞋柜-右前45°.webp', fileSize: BigInt(123456), sortOrder: 1, }, { id: 'asset-other-1', assetType: 'standard-model-white-bg-other:sku-1:1', fileUrl: '/uploads/skus/std-1/white-background/sku-1/other/01-侧面 补充图.webp', fileName: '01-侧面 补充图.webp', fileSize: BigInt(654321), sortOrder: 1000, }, ]) const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?sku=xg014799'), ) const body = await response.json() expect(response.status).toBe(200) expect(body.success).toBe(true) expect(body.data.resourceType).toBe('white-background') expect(body.data.sku).toBe('XG014799') expect(body.data.whiteBackground.fixed).toEqual([ { key: 'rightFront45', label: '右前45°', fileName: 'XG014799-鞋柜-右前45°.webp', fileSize: 123456, previewUrl: new URL( '/uploads/skus/std-1/white-background/sku-1/preview/XG014799-鞋柜-右前45°.webp', 'https://sc.jiansun.vip', ).toString(), downloadUrl: 'https://download.example/token', }, ]) expect(body.data.whiteBackground.other).toEqual([ { key: 'other-1', label: '其他图片1', fileName: '01-侧面 补充图.webp', fileSize: 654321, previewUrl: new URL( '/uploads/skus/std-1/white-background/sku-1/preview/other/01-侧面 补充图.webp', 'https://sc.jiansun.vip', ).toString(), downloadUrl: 'https://download.example/token', }, ]) expect(body.data.downloadUrls.zip).toBe('https://download.example/token') }) test('white-background sku 查询在预览文件缺失时应回退到原图路径', async () => { ;(resolveWhiteBackgroundPreviewFileUrl as jest.Mock).mockImplementationOnce((url?: string | null) => url ? `/${String(url).replace(/^\/+/, '')}` : null, ) ;(prisma.materialSku.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'sku-1', skuName: 'XG014799', materialId: 'std-1', material: { id: 'std-1', productModel: 'SPU001', categoryName: '鞋柜', deletedAt: null, }, }, ]) ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'asset-fixed-1', assetType: 'standard-model-white-bg-view:sku-1:rightFront45', fileUrl: 'uploads/skus/std-1/white-background/sku-1/XG014799-鞋柜-右前45°.webp', fileName: 'XG014799-鞋柜-右前45°.webp', fileSize: BigInt(123456), sortOrder: 1, }, ]) const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?sku=xg014799'), ) const body = await response.json() expect(response.status).toBe(200) expect(body.data.whiteBackground.fixed[0].previewUrl).toBe( new URL('/uploads/skus/std-1/white-background/sku-1/XG014799-鞋柜-右前45°.webp', 'https://sc.jiansun.vip').toString(), ) }) test('white-background spu 查询的预览图应返回 previewUrl,便于列表直接展示', async () => { ;(prisma.material.findFirst as jest.Mock).mockResolvedValueOnce({ id: 'std-1', productModel: 'SPU001', categoryName: '旗舰店', skus: [ { id: 'sku-1', skuName: 'XG014799', previewUrl: null, }, ], }) ;(prisma.materialAsset.findMany as jest.Mock).mockResolvedValueOnce([ { id: 'asset-fixed-1', assetType: 'standard-model-white-bg-view:sku-1:rightFront45', fileUrl: 'uploads/skus/std-1/white-background/sku-1/XG014799-鞋柜-右前45°.webp', fileName: 'XG014799-鞋柜-右前45°.webp', fileSize: BigInt(123456), sortOrder: 1, }, ]) const response = await GET( createRequest('https://example.com/api/external/standard-model-white-background?spu=spu001'), ) const body = await response.json() expect(response.status).toBe(200) expect(body.data.skuList).toEqual([ { skuName: 'XG014799', skuId: 'sku-1', hasWhiteBackground: true, whiteBackgroundCount: 1, previewImage: { downloadUrl: 'https://download.example/token', previewUrl: new URL( '/uploads/skus/std-1/white-background/sku-1/preview/XG014799-鞋柜-右前45°.webp', 'https://sc.jiansun.vip', ).toString(), fileName: 'XG014799-鞋柜-右前45°.webp', fileSize: 123456, }, }, ]) }) })