import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { closeDb, getDb, apiKeys, eq } from '@rareindex/database'; import { buildApp } from './app.js'; import { mintApiKey } from './lib/keys.js'; import { toCsv, encodeCursor, decodeCursor } from './lib/envelope.js'; /** Integration tests against DATABASE_URL (rareindex_ai locally). They only read except for a temporary API key. */ let app: FastifyInstance; let key: { id: string; key: string }; beforeAll(async () => { process.env.LOG_LEVEL = 'silent'; app = await buildApp({ logger: false }); key = await mintApiKey({ name: 'vitest', tier: 'hobby' }); }); afterAll(async () => { await getDb().delete(apiKeys).where(eq(apiKeys.id, key.id)); await app.close(); await closeDb(); }); describe('api', () => { it('health and root', async () => { const h = await app.inject({ method: 'GET', url: '/healthz' }); expect(h.statusCode).toBe(200); expect(h.json().ok).toBe(true); const r = await app.inject({ method: 'GET', url: '/' }); expect(r.json().openapi).toBe('/v1/openapi.json'); }); it('serves openapi', async () => { const r = await app.inject({ method: 'GET', url: '/v1/openapi.json' }); expect(r.statusCode).toBe(200); expect(r.json().paths['/v1/assets/search']).toBeTruthy(); }); it('anonymous requests use the public tier with rate-limit headers', async () => { const r = await app.inject({ method: 'GET', url: '/v1/categories' }); expect(r.statusCode).toBe(200); expect(r.headers['x-ratelimit-limit']).toBe('20'); expect(r.headers['x-request-id']).toBeTruthy(); const body = r.json(); expect(body.meta.count).toBeGreaterThan(100); expect(body.meta.attribution).toMatch(/RareIndex/); expect(body.data.find((c: { slug: string }) => c.slug === 'pokemon')).toBeTruthy(); }); it('rejects unknown keys and accepts minted keys with tier limits', async () => { const bad = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: 'Bearer ri_live_nope_nope' } }); expect(bad.statusCode).toBe(401); expect(bad.headers['content-type']).toMatch(/problem\+json/); const good = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: `Bearer ${key.key}` } }); expect(good.statusCode).toBe(200); expect(good.headers['x-ratelimit-limit']).toBe('120'); const idx = good.json(); expect(idx.data.find((i: { ticker: string }) => i.ticker === 'RARE')).toBeTruthy(); expect(idx.data.every((i: { published: boolean; value: unknown }) => i.published === (i.value !== null))).toBe(true); }); it('validates queries and returns problem+json', async () => { const r = await app.inject({ method: 'GET', url: '/v1/assets/search?limit=9999' }); expect(r.statusCode).toBe(400); expect(r.json().title).toBe('Invalid query'); }); it('404 on unknown asset/market', async () => { expect((await app.inject({ method: 'GET', url: '/v1/assets/does-not-exist' })).statusCode).toBe(404); expect((await app.inject({ method: 'GET', url: '/v1/markets/does-not-exist' })).statusCode).toBe(404); }); it('search, trending, sales, records, markets and csv export respond', async () => { for (const url of ['/v1/assets/search?q=charizard', '/v1/trending', '/v1/sales/latest', '/v1/records', '/v1/markets', '/v1/markets/pokemon', '/v1/indices/RARE-TCG/history', '/v1/stats']) { const r = await app.inject({ method: 'GET', url }); expect(r.statusCode, url).toBe(200); } const csv = await app.inject({ method: 'GET', url: '/v1/categories?format=csv' }); expect(csv.headers['content-type']).toMatch(/text\/csv/); expect(csv.body.split('\n')[0]).toContain('slug'); }); it('rate limits by key', async () => { const small = await mintApiKey({ name: 'vitest-small', tier: 'free' }); try { let last = 200; for (let i = 0; i < 65; i++) { const r = await app.inject({ method: 'GET', url: '/v1/stats', headers: { authorization: `Bearer ${small.key}` } }); last = r.statusCode; if (last === 429) break; } expect(last).toBe(429); } finally { await getDb().delete(apiKeys).where(eq(apiKeys.id, small.id)); } }); }); describe('envelope helpers', () => { it('csv + cursor', () => { expect(toCsv([{ a: 1, b: 'x,y' }, { a: 2, b: null }])).toBe('a,b\n1,"x,y"\n2,\n'); expect(decodeCursor(encodeCursor(150))).toBe(150); expect(decodeCursor('garbage')).toBe(0); }); });