513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
/** @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\//)
|
||
})
|
||
})
|