474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
/** @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,
|
||
},
|
||
},
|
||
])
|
||
})
|
||
})
|