TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import { afterAll, beforeAll, describe, expect, it } from 'vitest';2import type { FastifyInstance } from 'fastify';3import { closeDb, getDb, apiKeys, eq } from '@rareindex/database';4import { buildApp } from './app.js';5import { mintApiKey } from './lib/keys.js';6import { toCsv, encodeCursor, decodeCursor } from './lib/envelope.js';78/** Integration tests against DATABASE_URL (rareindex_ai locally). They only read except for a temporary API key. */9let app: FastifyInstance;10let key: { id: string; key: string };1112beforeAll(async () => {13 process.env.LOG_LEVEL = 'silent';14 app = await buildApp({ logger: false });15 key = await mintApiKey({ name: 'vitest', tier: 'hobby' });16});17afterAll(async () => {18 await getDb().delete(apiKeys).where(eq(apiKeys.id, key.id));19 await app.close();20 await closeDb();21});2223describe('api', () => {24 it('health and root', async () => {25 const h = await app.inject({ method: 'GET', url: '/healthz' });26 expect(h.statusCode).toBe(200);27 expect(h.json().ok).toBe(true);28 const r = await app.inject({ method: 'GET', url: '/' });29 expect(r.json().openapi).toBe('/v1/openapi.json');30 });31 it('serves openapi', async () => {32 const r = await app.inject({ method: 'GET', url: '/v1/openapi.json' });33 expect(r.statusCode).toBe(200);34 expect(r.json().paths['/v1/assets/search']).toBeTruthy();35 });36 it('anonymous requests use the public tier with rate-limit headers', async () => {37 const r = await app.inject({ method: 'GET', url: '/v1/categories' });38 expect(r.statusCode).toBe(200);39 expect(r.headers['x-ratelimit-limit']).toBe('20');40 expect(r.headers['x-request-id']).toBeTruthy();41 const body = r.json();42 expect(body.meta.count).toBeGreaterThan(100);43 expect(body.meta.attribution).toMatch(/RareIndex/);44 expect(body.data.find((c: { slug: string }) => c.slug === 'pokemon')).toBeTruthy();45 });46 it('rejects unknown keys and accepts minted keys with tier limits', async () => {47 const bad = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: 'Bearer ri_live_nope_nope' } });48 expect(bad.statusCode).toBe(401);49 expect(bad.headers['content-type']).toMatch(/problem\+json/);50 const good = await app.inject({ method: 'GET', url: '/v1/indices', headers: { authorization: `Bearer ${key.key}` } });51 expect(good.statusCode).toBe(200);52 expect(good.headers['x-ratelimit-limit']).toBe('120');53 const idx = good.json();54 expect(idx.data.find((i: { ticker: string }) => i.ticker === 'RARE')).toBeTruthy();55 expect(idx.data.every((i: { published: boolean; value: unknown }) => i.published === (i.value !== null))).toBe(true);56 });57 it('validates queries and returns problem+json', async () => {58 const r = await app.inject({ method: 'GET', url: '/v1/assets/search?limit=9999' });59 expect(r.statusCode).toBe(400);60 expect(r.json().title).toBe('Invalid query');61 });62 it('404 on unknown asset/market', async () => {63 expect((await app.inject({ method: 'GET', url: '/v1/assets/does-not-exist' })).statusCode).toBe(404);64 expect((await app.inject({ method: 'GET', url: '/v1/markets/does-not-exist' })).statusCode).toBe(404);65 });66 it('search, trending, sales, records, markets and csv export respond', async () => {67 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']) {68 const r = await app.inject({ method: 'GET', url });69 expect(r.statusCode, url).toBe(200);70 }71 const csv = await app.inject({ method: 'GET', url: '/v1/categories?format=csv' });72 expect(csv.headers['content-type']).toMatch(/text\/csv/);73 expect(csv.body.split('\n')[0]).toContain('slug');74 });75 it('rate limits by key', async () => {76 const small = await mintApiKey({ name: 'vitest-small', tier: 'free' });77 try {78 let last = 200;79 for (let i = 0; i < 65; i++) {80 const r = await app.inject({ method: 'GET', url: '/v1/stats', headers: { authorization: `Bearer ${small.key}` } });81 last = r.statusCode;82 if (last === 429) break;83 }84 expect(last).toBe(429);85 } finally {86 await getDb().delete(apiKeys).where(eq(apiKeys.id, small.id));87 }88 });89});9091describe('envelope helpers', () => {92 it('csv + cursor', () => {93 expect(toCsv([{ a: 1, b: 'x,y' }, { a: 2, b: null }])).toBe('a,b\n1,"x,y"\n2,\n');94 expect(decodeCursor(encodeCursor(150))).toBe(150);95 expect(decodeCursor('garbage')).toBe(0);96 });97});98