163 lines
13 KiB
TypeScript
163 lines
13 KiB
TypeScript
/** @jest-environment node */
|
|
import { NextRequest } from 'next/server'
|
|
import { mkdtemp, writeFile, rm } from 'fs/promises'
|
|
import { tmpdir } from 'os'
|
|
import path from 'path'
|
|
import AdmZip from 'adm-zip'
|
|
import { GET } from '@/app/api/external/image-assets/route'
|
|
import { GET as download } from '@/app/api/external/download/route'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { validateApiKey, logApiCall } from '@/lib/external-api'
|
|
import { verifyDownloadToken } from '@/lib/download-token'
|
|
import { findExternalImageAssets, parseExternalImageQuery, resolveExternalImageDownloadSource } from '@/lib/external-image-assets'
|
|
|
|
jest.mock('@/lib/prisma', () => ({ prisma: {
|
|
material: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn() },
|
|
materialSku: { findMany: jest.fn(), findFirst: jest.fn() }, materialAsset: { findMany: jest.fn() },
|
|
} }))
|
|
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.jiansun.vip' }))
|
|
jest.mock('@/lib/external-api', () => {
|
|
const actual = jest.requireActual('@/lib/external-api')
|
|
return { ...actual, validateApiKey: jest.fn(), logApiCall: jest.fn().mockResolvedValue(undefined) }
|
|
})
|
|
const db = prisma as unknown as { material: { findMany: jest.Mock; count: jest.Mock; findUnique: jest.Mock }; materialSku: { findMany: jest.Mock }; materialAsset: { findMany: jest.Mock } }
|
|
const request = (query: string, key = 'test-key') => new NextRequest(`https://sc.jiansun.vip/api/external/image-assets?${query}`, { headers: key ? { 'X-API-Key': key } : {} })
|
|
const query = (q = 'sku=SKU-01') => parseExternalImageQuery(new URLSearchParams(q))
|
|
const material = (overrides: Record<string, unknown> = {}) => ({ id: 'material-1', productModel: 'SPU-01', materialType: '任务-AI原图', taskNo: null,
|
|
categoryName: '品牌', zipFilePath: 'uploads/originals/source.zip', zipFileName: 'source.zip', zipFileSize: BigInt(123),
|
|
createdAt: new Date('2026-10-01T00:00:00Z'), assets: [], skus: [], ...overrides })
|
|
const asset = (id: string, assetType: string, fileName: string) => ({ id, assetType, fileName, fileUrl: `uploads/originals/${fileName}`, fileSize: BigInt(100), sortOrder: 0 })
|
|
const savedSecret = process.env.DOWNLOAD_TOKEN_SECRET
|
|
beforeEach(() => {
|
|
jest.clearAllMocks(); process.env.DOWNLOAD_TOKEN_SECRET = 'test-only-original-image-download-secret'
|
|
;(validateApiKey as jest.Mock).mockResolvedValue({ id: 'key-1', isActive: true })
|
|
db.materialSku.findMany.mockResolvedValue([{ skuName: 'SKU-01', material: { productModel: 'SPU-01' } }])
|
|
db.materialAsset.findMany.mockResolvedValue([])
|
|
db.material.findMany.mockResolvedValue([material()]); db.material.count.mockResolvedValue(1)
|
|
})
|
|
afterAll(() => { if (savedSecret === undefined) delete process.env.DOWNLOAD_TOKEN_SECRET; else process.env.DOWNLOAD_TOKEN_SECRET = savedSecret })
|
|
|
|
describe('external image resource lookup', () => {
|
|
test.each(['', 'sku=A&barcode=B', 'sku=A&sku=B', 'sku=A&resourceType=video', 'sku=A&page=0', 'sku=A&pageSize=51', 'sku=A&unknown=B', 'sku=%00A'])('rejects invalid/ambiguous query %s', value => {
|
|
expect(() => query(value)).toThrow()
|
|
})
|
|
test('preserves leading zeros and barcode case; normalizes SKU only', () => {
|
|
expect(query('barcode=0012Ab').value).toBe('0012Ab')
|
|
expect(query('sku=sku-01').value).toBe('SKU-01')
|
|
})
|
|
test('rejects missing, disabled or invalid key before product resolution; URL keys are not accepted', async () => {
|
|
;(validateApiKey as jest.Mock).mockResolvedValue(null)
|
|
const response = await GET(request('sku=SKU-01&apiKey=url-secret', ''))
|
|
expect(response.status).toBe(401); expect(validateApiKey).toHaveBeenCalledWith(null)
|
|
expect(db.materialSku.findMany).not.toHaveBeenCalled(); expect(db.material.findMany).not.toHaveBeenCalled()
|
|
})
|
|
test('returns original archive without storage paths or preview URLs and does not update materials', async () => {
|
|
const response = await GET(request('sku=sku-01'))
|
|
expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toContain('no-store')
|
|
const data = (await response.json()).data
|
|
expect(data.resolution).toEqual({ sku: 'SKU-01', spu: 'SPU-01', resourceScope: 'spu' })
|
|
expect(data.items[0]).toMatchObject({ kind: 'archive', fileName: 'source.zip', matchScope: 'spu', skuCode: null })
|
|
expect(verifyDownloadToken(new URL(data.items[0].downloadUrl).searchParams.get('token')!)).toMatchObject({ materialId: 'material-1', downloadType: 'image-source' })
|
|
expect(JSON.stringify(data)).not.toContain('uploads/'); expect(JSON.stringify(data)).not.toContain('previewUrl')
|
|
expect(db.material.findMany.mock.calls[0][0].where).toMatchObject({ productModel: { equals: 'SPU-01', mode: 'insensitive' }, deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false })
|
|
expect(logApiCall).toHaveBeenCalledWith(expect.objectContaining({ endpoint: '/api/external/image-assets', queryType: 'sku', statusCode: 200 }))
|
|
})
|
|
test('resolves barcode using active database bindings and enforces visibility at both lookups', async () => {
|
|
await findExternalImageAssets(query('barcode=0000123'))
|
|
expect(db.materialSku.findMany.mock.calls[0][0].where).toMatchObject({ deletedAt: null, barcodes: { some: { barcode: '0000123', deletedAt: null } }, material: { is: { deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false } } })
|
|
expect(db.materialAsset.findMany.mock.calls[0][0].where).toMatchObject({ barcodes: { some: { barcode: '0000123' } }, material: { is: { taskDetailPrivate: false } } })
|
|
})
|
|
test('also resolves SKU/barcode bindings on task-detail asset rows', async () => {
|
|
db.materialSku.findMany.mockResolvedValue([])
|
|
db.materialAsset.findMany.mockResolvedValue([{ skuCode: 'SKU-01', material: { productModel: 'SPU-01' } }])
|
|
expect((await findExternalImageAssets(query('barcode=00001'))).resolution.sku).toBe('SKU-01')
|
|
})
|
|
test('does not invent a SKU to SPU relation if binding is absent', async () => {
|
|
db.materialSku.findMany.mockResolvedValue([])
|
|
const response = await GET(request('sku=SKU-01'))
|
|
expect(response.status).toBe(404); expect(db.material.findMany).not.toHaveBeenCalled()
|
|
})
|
|
test.each([
|
|
[{ skuName: 'SKU-01', material: { productModel: 'SPU-01' } }, { skuName: 'SKU-01', material: { productModel: 'SPU-02' } }],
|
|
[{ skuName: 'SKU-01', material: { productModel: 'SPU-01' } }, { skuName: 'SKU-02', material: { productModel: 'SPU-01' } }],
|
|
])('refuses conflicting product/variant bindings', async (...bindings) => {
|
|
db.materialSku.findMany.mockResolvedValue(bindings)
|
|
await expect(findExternalImageAssets(query())).rejects.toMatchObject({ status: 409, code: 'PRODUCT_BINDING_AMBIGUOUS' })
|
|
expect(db.material.findMany).not.toHaveBeenCalled()
|
|
})
|
|
test('only includes image resource types and paginates without title contains searches', async () => {
|
|
await findExternalImageAssets(query('sku=SKU-01&resourceType=render-original&page=2&pageSize=5'))
|
|
expect(db.material.findMany.mock.calls[0][0]).toMatchObject({ where: { materialType: { in: ['任务-3D原图'] } }, skip: 5, take: 5 })
|
|
})
|
|
test('white-background images are returned individually for the exact active SKU; model ZIP and previews excluded', async () => {
|
|
db.material.findMany.mockResolvedValue([material({ materialType: '标准模型', skus: [{ id: 'sku-a', skuName: 'SKU-01' }], assets: [
|
|
asset('a', 'standard-model-white-bg-view:sku-a:rightFront45', '原图.png'), asset('b', 'standard-model-white-bg-view:sku-b:rightFront45', '另一规格.png'),
|
|
asset('c', 'standard-model-white-bg-zip:sku-a', '3d-model.zip'), asset('d', 'preview', 'preview.webp'),
|
|
] })])
|
|
const data = await findExternalImageAssets(query())
|
|
expect(data.items).toHaveLength(1); expect(data.items[0]).toMatchObject({ assetId: 'a', kind: 'image', skuCode: 'SKU-01', matchScope: 'sku' })
|
|
expect(verifyDownloadToken(new URL(data.items[0].downloadUrl).searchParams.get('token')!)).toMatchObject({ downloadType: 'image-source', assetId: 'a' })
|
|
})
|
|
test('task-detail returns stored category archives unchanged, excluding PSD and preview slices', async () => {
|
|
db.material.findMany.mockResolvedValue([material({ materialType: '任务-详情', assets: [asset('m', 'main', 'main.rar'), asset('s', 'sku', 'sku.zip'), asset('d', 'detail', 'detail.zip'), asset('p', 'psd', 'source.psd'), asset('v', 'detail-preview', 'slice.webp')] })])
|
|
const data = await findExternalImageAssets(query())
|
|
expect(data.items.map(i => i.group)).toEqual(['main', 'sku', 'detail'])
|
|
expect(data.items.every(i => i.kind === 'archive')).toBe(true)
|
|
})
|
|
test('does not substitute previews and gives each stored original package its own download token', async () => {
|
|
db.material.findMany.mockResolvedValue([material({ zipFilePath: '', assets: [asset('p', 'preview', 'preview.webp')] }),
|
|
material({ id: 'ambiguous', assets: [asset('z1', 'zip', 'one.zip'), asset('z2', 'zip', 'two.rar')] })])
|
|
const data = await findExternalImageAssets(query())
|
|
expect(data.items).toHaveLength(2); expect(data.issues.map(i => i.code)).toEqual(['SOURCE_NOT_AVAILABLE'])
|
|
expect(data.items.map(i => verifyDownloadToken(new URL(i.downloadUrl).searchParams.get('token')!)?.assetId)).toEqual(['z1', 'z2'])
|
|
})
|
|
test('returns an empty page for a known product without image resources', async () => {
|
|
db.material.findMany.mockResolvedValue([]); db.material.count.mockResolvedValue(0)
|
|
expect(await findExternalImageAssets(query())).toMatchObject({ count: 0, items: [], pagination: { totalMaterials: 0, hasMore: false } })
|
|
})
|
|
test('does not expose database errors or credentials in failure responses', async () => {
|
|
const log = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
db.materialSku.findMany.mockRejectedValue(new Error('postgres://private:secret@database/production'))
|
|
const response = await GET(request('sku=SKU-01'))
|
|
expect(response.status).toBe(500); expect(JSON.stringify(await response.json())).not.toContain('private')
|
|
log.mockRestore()
|
|
})
|
|
test.each([{ taskDetailPrivate: true }, { deletedAt: new Date() }, { isIncompleteShell: true }, { materialType: '3D场景' }])('rechecks original-download visibility %j', async denied => {
|
|
expect(await resolveExternalImageDownloadSource(material(denied))).toBeNull()
|
|
})
|
|
test('new download type rejects another material asset, previews and model sources', async () => {
|
|
expect(await resolveExternalImageDownloadSource(material(), 'foreign')).toBeNull()
|
|
expect(await resolveExternalImageDownloadSource(material({ assets: [asset('v', 'preview', 'v.webp')] }), 'v')).toBeNull()
|
|
expect(await resolveExternalImageDownloadSource(material({ materialType: '标准模型' }))).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('new lookup to existing download integration', () => {
|
|
let dir: string
|
|
beforeAll(async () => { dir = await mkdtemp(path.join(tmpdir(), 'image-resource-download-')) })
|
|
afterAll(async () => { await rm(dir, { recursive: true, force: true }) })
|
|
test.each(['zip', 'rar', 'png'])('%s is delivered byte-for-byte through the old download route, with Range support', async extension => {
|
|
const zip = new AdmZip(); zip.addFile('原图.png', Buffer.alloc(4096, 7))
|
|
const original = extension === 'zip' ? zip.toBuffer() : extension === 'rar'
|
|
? Buffer.concat([Buffer.from('526172211a070100', 'hex'), Buffer.alloc(2048, 17)])
|
|
: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j5nUAAAAASUVORK5CYII=', 'base64')
|
|
const filePath = path.join(dir, `original.${extension}`); await writeFile(filePath, original)
|
|
const row = material({ materialType: '任务-3D原图', assets: [{ ...asset('source', 'zip', `original.${extension}`), fileUrl: filePath, fileSize: BigInt(original.length) }] })
|
|
db.material.findMany.mockResolvedValue([row]); db.material.findUnique.mockResolvedValue(row)
|
|
const listed = await findExternalImageAssets(query()), url = listed.items[0].downloadUrl
|
|
expect(listed.items[0].kind).toBe(extension === 'png' ? 'image' : 'archive')
|
|
const response = await download(new NextRequest(url))
|
|
expect(response.status).toBe(200); expect(Buffer.from(await response.arrayBuffer())).toEqual(original)
|
|
const ranged = await download(new NextRequest(url, { headers: { Range: 'bytes=0-31' } }))
|
|
expect(ranged.status).toBe(206); expect(Buffer.from(await ranged.arrayBuffer())).toEqual(original.subarray(0, 32))
|
|
expect(ranged.headers.get('Content-Range')).toBe(`bytes 0-31/${original.length}`)
|
|
})
|
|
test('a missing original returns 404 even when preview files exist; it never invokes archive fallback', async () => {
|
|
const row = material({ zipFilePath: path.join(dir, 'does-not-exist.rar'), assets: [asset('v', 'preview', 'preview.webp')] })
|
|
db.material.findMany.mockResolvedValue([row]); db.material.findUnique.mockResolvedValue(row)
|
|
const listed = await findExternalImageAssets(query())
|
|
const response = await download(new NextRequest(listed.items[0].downloadUrl))
|
|
expect(response.status).toBe(404)
|
|
})
|
|
})
|