127 lines
11 KiB
TypeScript
127 lines
11 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 { GET } from '@/app/api/external/certificate-reports/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, generateDownloadUrl } from '@/lib/download-token'
|
|
import { parseCertificateReportQuery as parse, buildCertificateReportWhere as where, certificateToday, certificateExpiryStatus, findExternalCertificateReports, resolveCertificateReportSource } from '@/lib/external-certificate-reports'
|
|
|
|
jest.mock('@/lib/prisma', () => ({ prisma: { material: { findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), update: jest.fn(), updateMany: jest.fn() }, $transaction: jest.fn() } }))
|
|
jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.jiansun.vip' }))
|
|
jest.mock('@/lib/external-api', () => ({ ...jest.requireActual('@/lib/external-api'), validateApiKey: jest.fn(), logApiCall: jest.fn().mockResolvedValue(undefined) }))
|
|
const db = prisma as unknown as { material: { findMany: jest.Mock; count: jest.Mock; findUnique: jest.Mock; update: jest.Mock; updateMany: jest.Mock }; $transaction: jest.Mock }
|
|
const id = '02000000-0000-4000-8000-000000000001'
|
|
const a = (assetType = 'report-pdf', fileName = '报告.pdf', fileUrl = '/uploads/report.pdf') => ({ id: 'file-1', assetType, fileName, fileUrl, fileSize: BigInt(100), sortOrder: 0 })
|
|
const material = (extra: Record<string, unknown> = {}) => ({ id, materialType: '证书-检测报告', reportKind: '原材料', reportTitle: '板材报告', productModel: '',
|
|
productCategoryName: '板材', platformName: '大货', certificateNumber: 'CERT-01', certificateTestedOn: new Date('2026-01-01Z'), certificateExpiresOn: new Date('2027-01-01Z'),
|
|
certificateUsageInstructions: null, reportUsageRules: '核对检测项目和使用范围', createdAt: new Date('2026-01-02Z'), updatedAt: new Date('2026-01-03Z'), taskStatus: 'completed', assets: [a()], ...extra })
|
|
const q = (value = '') => parse(new URLSearchParams(value))
|
|
const request = (value = '', key = 'test') => new NextRequest('https://sc.jiansun.vip/api/external/certificate-reports?' + value, { headers: key ? { 'X-API-Key': key } : {} })
|
|
const oldSecret = process.env.DOWNLOAD_TOKEN_SECRET
|
|
beforeEach(() => {
|
|
jest.clearAllMocks(); process.env.DOWNLOAD_TOKEN_SECRET = 'local-certificate-report-test-secret'
|
|
;(validateApiKey as jest.Mock).mockResolvedValue({ id: 'key-1' })
|
|
db.material.count.mockResolvedValue(1); db.material.findMany.mockResolvedValue([material()]); db.material.findUnique.mockResolvedValue(material())
|
|
db.$transaction.mockImplementation((operations: unknown[]) => Promise.all(operations))
|
|
})
|
|
afterAll(() => { if (oldSecret === undefined) delete process.env.DOWNLOAD_TOKEN_SECRET; else process.env.DOWNLOAD_TOKEN_SECRET = oldSecret })
|
|
|
|
test.each(['sku=A', 'barcode=A', 'search=', 'search=%00', 'page=0', 'pageSize=51', 'page=1&page=2', 'reportKind=其他', 'platform=顾家',
|
|
'fileKind=zip', 'expiryStatus=valid', 'testedFrom=2026-02-30', 'testedTo=2026-10-01&testedFrom=2026-10-02',
|
|
'expiresFrom=2027-01-01&expiresTo=2026-01-01', 'expiryStatus=unknown&expiresFrom=2026-01-01', 'materialId=not-uuid'])('rejects invalid condition %s', value => {
|
|
expect(() => q(value)).toThrow()
|
|
})
|
|
test('combines all report fields, inclusive date ranges and status without overwriting conditions', () => {
|
|
const query = q(`search=板材&reportKind=成品&platform=品牌&productCategory=桌子&spu=spu-01&certificateNumber=Cert-01&materialId=${id}&testedFrom=2026-01-01&testedTo=2026-10-01&expiresFrom=2026-01-01&expiresTo=2027-01-01&expiryStatus=unexpired&fileKind=pdf&page=2&pageSize=5`)
|
|
const actual = where(query, new Date('2026-10-16Z'))
|
|
expect(actual).toMatchObject({ materialType: '证书-检测报告', deletedAt: null, isIncompleteShell: false, taskDetailPrivate: false, id,
|
|
reportTitle: { contains: '板材', mode: 'insensitive' }, reportKind: '成品', platformName: '品牌', productCategoryName: '桌子',
|
|
productModel: { equals: 'spu-01', mode: 'insensitive' }, certificateNumber: { equals: 'Cert-01', mode: 'insensitive' },
|
|
certificateTestedOn: { gte: new Date('2026-01-01Z'), lte: new Date('2026-10-01Z') },
|
|
AND: [{ certificateExpiresOn: { gte: new Date('2026-01-01Z'), lte: new Date('2027-01-01Z') } }, { certificateExpiresOn: { gte: new Date('2026-10-16Z') } }],
|
|
assets: { some: { assetType: { in: ['report-pdf'] } } } })
|
|
})
|
|
test('expiry date is inclusive through the UTC+8 business day and missing dates remain unknown', () => {
|
|
expect(certificateToday(new Date('2026-10-15T15:59:59Z')).toISOString()).toBe('2026-10-15T00:00:00.000Z')
|
|
const today = certificateToday(new Date('2026-10-15T16:00:00Z'))
|
|
expect(certificateExpiryStatus(new Date('2026-10-16Z'), today)).toBe('unexpired')
|
|
expect(certificateExpiryStatus(new Date('2026-10-15Z'), today)).toBe('expired')
|
|
expect(certificateExpiryStatus(null, today)).toBe('unknown')
|
|
expect(where(q('expiryStatus=unknown')).certificateExpiresOn).toBeNull()
|
|
})
|
|
test('auth precedes lookup and query validation; credentials in URL are not accepted', async () => {
|
|
;(validateApiKey as jest.Mock).mockResolvedValue(null)
|
|
expect((await GET(request('apiKey=secret', ''))).status).toBe(401)
|
|
expect(validateApiKey).toHaveBeenCalledWith(null); expect(db.$transaction).not.toHaveBeenCalled()
|
|
})
|
|
test('returns raw-material report without SPU, original files only, metadata and isolated tokens; does not mutate task state', async () => {
|
|
db.material.findMany.mockResolvedValue([material({ assets: [a(), { ...a('report-image', '报告.png'), id: 'file-2', sortOrder: 1 }, a('preview', 'preview.jpg'), a('zip', 'all.zip'), a('report-pdf', 'bad.exe')] })])
|
|
const response = await GET(request('reportKind=原材料'))
|
|
expect(response.status).toBe(200); expect(response.headers.get('cache-control')).toContain('no-store')
|
|
const data = (await response.json()).data, item = data.items[0]
|
|
expect(item).toMatchObject({ spu: null, reportKind: '原材料', testedOn: '2026-01-01', expiresOn: '2027-01-01', reportUsageRules: '核对检测项目和使用范围' })
|
|
expect(item.files).toHaveLength(2)
|
|
expect(verifyDownloadToken(new URL(item.files[0].downloadUrl).searchParams.get('token')!)).toMatchObject({ materialId: id, assetId: 'file-1', downloadType: 'certificate-report-source' })
|
|
expect(JSON.stringify(data)).not.toContain('/uploads/'); expect(item.files.map((f: { fileKind: string }) => f.fileKind)).toEqual(['pdf', 'image'])
|
|
expect(db.material.update).not.toHaveBeenCalled(); expect(db.material.updateMany).not.toHaveBeenCalled()
|
|
expect(logApiCall).toHaveBeenCalledWith(expect.objectContaining({ endpoint: '/api/external/certificate-reports', statusCode: 200 }))
|
|
})
|
|
test('uses bounded report pagination and consistent count/list snapshot; an unknown ID returns an empty list', async () => {
|
|
db.material.findMany.mockResolvedValue([]); db.material.count.mockResolvedValue(0)
|
|
const result = await findExternalCertificateReports(q(`materialId=${id}&page=2&pageSize=5`))
|
|
expect(result).toMatchObject({ items: [], pagination: { totalReports: 0, hasMore: false } })
|
|
expect(db.material.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 5, take: 5, orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }] }))
|
|
expect(db.$transaction.mock.calls[0][1]).toEqual({ isolationLevel: 'RepeatableRead' })
|
|
})
|
|
test('reports missing originals explicitly and preserves null legacy dates', async () => {
|
|
db.material.findMany.mockResolvedValue([material({ assets: [], certificateTestedOn: null, certificateExpiresOn: null })])
|
|
expect((await findExternalCertificateReports(q())).items[0]).toMatchObject({ testedOn: null, expiresOn: null, expiryStatus: 'unknown', files: [], issues: [{ code: 'REPORT_FILES_NOT_AVAILABLE' }] })
|
|
})
|
|
test('invalid queries and database failures are safely reported', async () => {
|
|
expect((await GET(request('pageSize=100'))).status).toBe(400)
|
|
expect(db.$transaction).not.toHaveBeenCalled()
|
|
db.$transaction.mockRejectedValue(new Error('postgres://secret@private'))
|
|
const log = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
const response = await GET(request())
|
|
expect(response.status).toBe(500); expect(JSON.stringify(await response.json())).not.toContain('secret')
|
|
log.mockRestore()
|
|
})
|
|
test.each([{ deletedAt: new Date() }, { taskDetailPrivate: true }, { isIncompleteShell: true }, { materialType: '任务-AI原图' }])('rechecks download visibility %j', extra => {
|
|
expect(resolveCertificateReportSource(material(extra), 'file-1')).toBeNull()
|
|
})
|
|
test('rejects assets of another report, ZIPs and preview substitutes', () => {
|
|
expect(resolveCertificateReportSource(material(), 'foreign')).toBeNull()
|
|
expect(resolveCertificateReportSource(material(), undefined)).toBeNull()
|
|
expect(resolveCertificateReportSource(material({ assets: [a('preview', 'preview.png')] }), 'file-1')).toBeNull()
|
|
expect(resolveCertificateReportSource(material({ assets: [a('zip', 'source.zip')] }), 'file-1')).toBeNull()
|
|
})
|
|
|
|
describe('report originals through existing streaming downloader', () => {
|
|
let dir: string
|
|
beforeAll(async () => { dir = await mkdtemp(path.join(tmpdir(), 'certificate-api-test-')) })
|
|
afterAll(async () => { await rm(dir, { recursive: true, force: true }) })
|
|
test.each(['pdf', 'png'])('delivers exact %s bytes, Range and invalid-range response', async kind => {
|
|
const bytes = kind === 'pdf' ? Buffer.from('%PDF-1.4\n' + 'report '.repeat(100)) : Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j5nUAAAAASUVORK5CYII=', 'base64')
|
|
const file = path.join(dir, '原文件.' + kind); await writeFile(file, bytes)
|
|
db.material.findUnique.mockResolvedValue(material({ assets: [a(kind === 'pdf' ? 'report-pdf' : 'report-image', '原文件.' + kind, file)] }))
|
|
const url = generateDownloadUrl('https://sc.jiansun.vip', { materialId: id, assetId: 'file-1', downloadType: 'certificate-report-source' })
|
|
const full = await download(new NextRequest(url))
|
|
expect(full.status).toBe(200); expect(Buffer.from(await full.arrayBuffer())).toEqual(bytes)
|
|
expect(full.headers.get('content-type')).toBe(kind === 'pdf' ? 'application/pdf' : 'image/png')
|
|
expect(full.headers.get('cache-control')).toContain('no-store')
|
|
const part = await download(new NextRequest(url, { headers: { Range: 'bytes=0-15' } }))
|
|
expect(part.status).toBe(206); expect(Buffer.from(await part.arrayBuffer())).toEqual(bytes.subarray(0, 16))
|
|
const invalid = await download(new NextRequest(url, { headers: { Range: 'bytes=99999-' } }))
|
|
expect(invalid.status).toBe(416)
|
|
})
|
|
test('a previously issued report token cannot download a different asset type', async () => {
|
|
db.material.findUnique.mockResolvedValue(material({ assets: [a('zip', 'all.zip')] }))
|
|
const url = generateDownloadUrl('https://sc.jiansun.vip', { materialId: id, assetId: 'file-1', downloadType: 'certificate-report-source' })
|
|
expect((await download(new NextRequest(url))).status).toBe(404)
|
|
})
|
|
})
|