gongxiangp2/lib/__tests__/standard-model-white-background-route.test.ts
chenpei 4712175cb0 feat(素材链路): 发布白底图双轨与AI生图回传能力
- 完成白底图 WebP 双轨接口、上传链路与预览缓存改造
- 新增 AI 生图平台成品回传接口、任务状态与表单规则
- 补齐 AI 生成标签、任务详情补全、相关测试与对接文档
2026-03-25 10:28:02 +08:00

474 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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