gongxiangp2/lib/__tests__/maixiang-spu-assets-route.test.ts

513 lines
18 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/maixiang-spu-assets/route'
import { generateDownloadUrl } from '@/lib/download-token'
import { logApiCall, validateApiKey } from '@/lib/external-api'
import { prisma } from '@/lib/prisma'
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findMany: jest.fn(),
},
materialSku: {
findMany: jest.fn(),
},
user: {
findMany: jest.fn(),
},
},
}))
jest.mock('@/lib/download-token', () => ({
generateDownloadUrl: jest.fn(
(_baseUrl: string, { materialId, downloadType }: { materialId: string; downloadType: string }) =>
`https://download.example/${materialId}/${downloadType}`,
),
}))
jest.mock('@/lib/public-app-url', () => ({
getPublicAppOrigin: jest.fn(() => 'https://sc.jiansun.vip'),
}))
jest.mock('@/lib/url-rewrite', () => ({
rewriteStorageUrlForClient: jest.fn((url?: string | null) => url || null),
}))
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(),
}
})
describe('external maixiang-spu-assets route', () => {
beforeEach(() => {
jest.clearAllMocks()
;(validateApiKey as jest.Mock).mockResolvedValue({
id: 'api-key-1',
name: 'maixiang',
isActive: true,
})
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
})
function createRequest(url: string, apiKey = 'test-key') {
return {
nextUrl: new URL(url),
headers: new Headers({
...(apiKey ? { 'X-API-Key': apiKey } : {}),
'user-agent': 'jest',
}),
} as any
}
test('缺少 Header API Key 时应返回 401,且不接受 URL 参数 key', async () => {
;(validateApiKey as jest.Mock).mockResolvedValue(null)
const response = await GET(
createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU001&apiKey=query-key', ''),
)
const body = await response.json()
expect(response.status).toBe(401)
expect(body).toEqual({
success: false,
error: {
code: 'INVALID_API_KEY',
message: 'API Key 无效或缺失',
},
})
expect(prisma.material.findMany).not.toHaveBeenCalled()
})
test('缺少 SPU 时应返回 400 并记录调用日志', async () => {
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu='))
const body = await response.json()
expect(response.status).toBe(400)
expect(body.error.code).toBe('MISSING_SPU')
expect(logApiCall).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/external/maixiang-spu-assets',
queryType: 'spu',
queryValue: '',
statusCode: 400,
}),
)
expect(prisma.material.findMany).not.toHaveBeenCalled()
})
test('应使用与首页搜索栏一致的模糊匹配条件', async () => {
;(prisma.material.findMany as jest.Mock).mockResolvedValue([])
;(prisma.materialSku.findMany as jest.Mock).mockResolvedValue([
{ materialId: 'sku-hit-material' },
])
;(prisma.user.findMany as jest.Mock).mockResolvedValue([
{ id: 'author-user-id' },
])
const response = await GET(
createRequest('https://example.com/api/external/maixiang-spu-assets?spu=gbsfy-ad0297%20%2B%20gbsfy-cz0102'),
)
expect(response.status).toBe(404)
expect(prisma.materialSku.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
skuName: {
contains: 'GBSFY-AD0297+GBSFY-CZ0102',
mode: 'insensitive',
},
deletedAt: null,
}),
select: { materialId: true },
take: 1000,
}),
)
expect(prisma.user.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
OR: [
{ realName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ username: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
],
},
select: { id: true },
take: 1000,
}),
)
expect(prisma.material.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
deletedAt: null,
materialType: expect.any(Object),
OR: expect.arrayContaining([
{ productModel: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ reportTitle: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ taskNo: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ categoryName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ sceneName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ authorName: { contains: 'GBSFY-AD0297+GBSFY-CZ0102', mode: 'insensitive' } },
{ id: { in: ['sku-hit-material'] } },
{ userId: { in: ['author-user-id'] } },
]),
}),
}),
)
})
test('买家秀和视频都为空时应返回 404', async () => {
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU001'))
const body = await response.json()
expect(response.status).toBe(404)
expect(body.error.code).toBe('SPU_ASSETS_NOT_FOUND')
expect(logApiCall).toHaveBeenCalledWith(
expect.objectContaining({
queryValue: 'SPU001',
statusCode: 404,
}),
)
})
test('命中买家秀和视频时应返回预览地址与正式下载链接', async () => {
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'buyer-new',
productModel: 'SPU001',
materialType: '买家秀图片',
categoryName: '顾家',
sceneName: '客厅',
zipFileName: 'buyer-new.zip',
zipFilePath: 'uploads/buyer-new.zip',
zipFileSize: BigInt(2048),
previewUrl: 'https://s3.example/buyer-cover.webp',
displayImages: ['https://s3.example/buyer-1.webp', '/uploads/buyer-2.webp'],
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
createdAt: new Date('2026-05-02T10:00:00.000Z'),
},
{
id: 'video-new',
productModel: 'SPU001',
materialType: '抖音-千川素材',
categoryName: '顾家',
sceneName: '客厅',
zipFileName: 'video-new.mp4',
zipFilePath: 'uploads/video-new.mp4',
zipFileSize: BigInt(4096),
previewUrl: 'https://s3.example/video-cover-fallback.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/video-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/video-clip.mp4',
videoCoverUrl: 'https://s3.example/video-cover.jpg',
videoDuration: 18,
createdAt: new Date('2026-05-02T09:00:00.000Z'),
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU001'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(body.data).toEqual({
spu: 'SPU001',
buyerShow: {
total: 1,
items: [
{
materialId: 'buyer-new',
productModel: 'SPU001',
materialType: '买家秀图片',
categoryName: '顾家',
shopName: '顾家',
sceneName: '客厅',
fileName: 'buyer-new.zip',
fileSize: 2048,
uploadedAt: '2026-05-02T10:00:00.000Z',
coverUrl: 'https://s3.example/buyer-cover.webp',
previewUrls: [
'https://s3.example/buyer-cover.webp',
'https://s3.example/buyer-1.webp',
'https://sc.jiansun.vip/uploads/buyer-2.webp',
],
downloadUrl: 'https://download.example/buyer-new/all',
},
],
},
videos: {
total: 1,
items: [
{
materialId: 'video-new',
productModel: 'SPU001',
materialType: '抖音-千川素材',
categoryName: '顾家',
shopName: '顾家',
sceneName: '客厅',
fileName: 'video-new.mp4',
fileSize: 4096,
duration: 18,
uploadedAt: '2026-05-02T09:00:00.000Z',
coverUrl: 'https://s3.example/video-cover.jpg',
previewUrl: 'https://s3.example/video-preview.mp4',
previewClipUrl: 'https://s3.example/video-clip.mp4',
downloadUrl: 'https://download.example/video-new/all',
},
],
},
})
expect(generateDownloadUrl).toHaveBeenCalledWith('https://sc.jiansun.vip', {
materialId: 'buyer-new',
downloadType: 'all',
})
expect(generateDownloadUrl).toHaveBeenCalledWith('https://sc.jiansun.vip', {
materialId: 'video-new',
downloadType: 'all',
})
expect(logApiCall).toHaveBeenCalledWith(
expect.objectContaining({
queryType: 'spu',
queryValue: 'SPU001',
statusCode: 200,
}),
)
})
test('只有单类素材时也应返回 200,另一类为空数组', async () => {
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'buyer-only',
productModel: 'SPU002',
materialType: '买家秀图片',
categoryName: '朴美',
sceneName: '卧室',
zipFileName: 'buyer-only.zip',
zipFilePath: 'uploads/buyer-only.zip',
zipFileSize: 100,
previewUrl: '',
displayImages: [],
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
createdAt: new Date('2026-05-03T10:00:00.000Z'),
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU002'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.buyerShow.total).toBe(1)
expect(body.data.videos).toEqual({
total: 0,
items: [],
})
})
test('安装视频素材不受 15 天保护期限制,始终返回完整下载链接', async () => {
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'install-video',
productModel: 'SPU003',
materialType: '安装视频',
categoryName: '顾家',
sceneName: '安装指导',
zipFileName: 'install-guide.mp4',
zipFilePath: 'uploads/install-guide.mp4',
zipFileSize: BigInt(8192),
previewUrl: 'https://s3.example/install-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/install-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/install-clip.mp4',
videoCoverUrl: 'https://s3.example/install-video-cover.jpg',
videoDuration: 120,
createdAt: new Date(), // 刚刚上传
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU003'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.videos.total).toBe(1)
const videoItem = body.data.videos.items[0]
expect(videoItem.materialId).toBe('install-video')
// 安装视频不受保护期限制,previewUrl 和 downloadUrl 不应为空
expect(videoItem.previewUrl).not.toBeNull()
expect(videoItem.previewClipUrl).not.toBeNull()
expect(videoItem.downloadUrl).not.toBeNull()
// 不应包含 blocked 字段
expect(videoItem.videoBlocked).toBeUndefined()
})
test('非安装视频上传未满 15 天时应返回 blocked 字段并隐藏下载链接', async () => {
const recentDate = new Date()
recentDate.setDate(recentDate.getDate() - 3) // 3 天前,未满 15 天
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'recent-video',
productModel: 'SPU004',
materialType: '淘系-主图视频',
categoryName: '顾家',
sceneName: '客厅',
zipFileName: 'recent-video.mp4',
zipFilePath: 'uploads/recent-video.mp4',
zipFileSize: BigInt(1024),
previewUrl: 'https://s3.example/recent-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/recent-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/recent-clip.mp4',
videoCoverUrl: 'https://s3.example/recent-video-cover.jpg',
videoDuration: 45,
createdAt: recentDate,
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU004'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.videos.total).toBe(1)
const videoItem = body.data.videos.items[0]
expect(videoItem.materialId).toBe('recent-video')
// blocked 字段应完整返回
expect(videoItem.videoBlocked).toBe(true)
expect(videoItem.videoBlockReason).toBe('VIDEO_WAIT_PERIOD')
expect(videoItem.videoRemainingDays).toBeGreaterThan(0)
expect(videoItem.videoRemainingDays).toBeLessThanOrEqual(15)
expect(videoItem.videoBlockMessage).toContain('满 15 天才能下载')
expect(videoItem.videoAvailableAt).not.toBeNull()
// 预览和下载链接应置空
expect(videoItem.previewUrl).toBeNull()
expect(videoItem.previewClipUrl).toBeNull()
expect(videoItem.downloadUrl).toBeNull()
// coverUrl 仍然保留(封面无需保护)
expect(videoItem.coverUrl).not.toBeNull()
})
test('混合场景:部分视频允许、部分视频禁用时各自返回对应字段', async () => {
const recentDate = new Date()
recentDate.setDate(recentDate.getDate() - 5) // 5 天前
const oldDate = new Date()
oldDate.setDate(oldDate.getDate() - 30) // 30 天前
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
// 受保护的视频(未满 15 天)
{
id: 'blocked-video',
productModel: 'SPU005',
materialType: '抖音-千川素材',
categoryName: '朴美',
sceneName: '卧室',
zipFileName: 'blocked.mp4',
zipFilePath: 'uploads/blocked.mp4',
zipFileSize: BigInt(2048),
previewUrl: 'https://s3.example/blocked-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/blocked-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/blocked-clip.mp4',
videoCoverUrl: 'https://s3.example/blocked-video-cover.jpg',
videoDuration: 30,
createdAt: recentDate,
},
// 允许的视频(已满 15 天)
{
id: 'allowed-video',
productModel: 'SPU005',
materialType: '抖音-千川素材',
categoryName: '朴美',
sceneName: '卧室',
zipFileName: 'allowed.mp4',
zipFilePath: 'uploads/allowed.mp4',
zipFileSize: BigInt(4096),
previewUrl: 'https://s3.example/allowed-cover.jpg',
displayImages: [],
videoPreview540Url: 'https://s3.example/allowed-preview.mp4',
videoPreviewClipUrl: 'https://s3.example/allowed-clip.mp4',
videoCoverUrl: 'https://s3.example/allowed-video-cover.jpg',
videoDuration: 60,
createdAt: oldDate,
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU005'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.videos.total).toBe(2)
const blockedItem = body.data.videos.items.find((v: any) => v.materialId === 'blocked-video')
const allowedItem = body.data.videos.items.find((v: any) => v.materialId === 'allowed-video')
// blocked 视频
expect(blockedItem.videoBlocked).toBe(true)
expect(blockedItem.previewUrl).toBeNull()
expect(blockedItem.previewClipUrl).toBeNull()
expect(blockedItem.downloadUrl).toBeNull()
// allowed 视频
expect(allowedItem.videoBlocked).toBeUndefined()
expect(allowedItem.previewUrl).not.toBeNull()
expect(allowedItem.previewClipUrl).not.toBeNull()
expect(allowedItem.downloadUrl).not.toBeNull()
})
test('买家秀素材始终不受视频保护策略影响', async () => {
;(prisma.material.findMany as jest.Mock).mockResolvedValue([
{
id: 'buyer-show-recent',
productModel: 'SPU006',
materialType: '买家秀图片',
categoryName: '顾家',
sceneName: '客厅',
zipFileName: 'buyer-recent.zip',
zipFilePath: 'uploads/buyer-recent.zip',
zipFileSize: BigInt(512),
previewUrl: 'https://s3.example/buyer-cover.jpg',
displayImages: ['https://s3.example/buyer-img-1.jpg'],
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
createdAt: new Date(), // 刚刚上传
},
])
const response = await GET(createRequest('https://example.com/api/external/maixiang-spu-assets?spu=SPU006'))
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.buyerShow.total).toBe(1)
const buyerItem = body.data.buyerShow.items[0]
// 买家秀素材不包含 videoBlocked 字段
expect(buyerItem.videoBlocked).toBeUndefined()
// previewUrls 和 downloadUrl 始终存在
expect(Array.isArray(buyerItem.previewUrls)).toBe(true)
expect(buyerItem.previewUrls.length).toBeGreaterThan(0)
expect(buyerItem.downloadUrl).toMatch(/^https:\/\/download\.example\//)
})
})