gongxiangp2/lib/__tests__/external-image-assets.test.ts

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)
})
})