/** @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 = {}) => ({ 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) }) })