/** * Read-only smoke tests for the wave-3 routes (intelligence, sites, research gap, epidemiology, * approvals, pipeline, graph) against the local `cancerindex` database. Skipped when unreachable. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { loadEnv } from '../src/lib/env.js'; loadEnv(); let app: FastifyInstance | null = null; let reachable = false; beforeAll(async () => { try { const { getDb } = await import('@cancerindex/database'); const { sql } = await import('drizzle-orm'); await getDb().execute(sql`SELECT 1`); reachable = true; const { buildApp } = await import('../src/app.js'); app = await buildApp({ logger: false }); await app.ready(); } catch { reachable = false; } }, 30_000); afterAll(async () => { await app?.close(); const { closeDb } = await import('@cancerindex/database'); await closeDb().catch(() => {}); }); const maybe = (name: string, fn: () => Promise) => it(name, async (ctx) => { if (!reachable || !app) return ctx.skip(); await fn(); }); const envelope = (body: Record) => { expect(body).toHaveProperty('data'); expect(Array.isArray(body.sources)).toBe(true); expect(body).toHaveProperty('dataRelease'); }; describe('wave 3 API (real DB, read-only)', () => { maybe('GET /v1/trials/intelligence?level=top&limit=5 returns formula-versioned rows', async () => { const res = await app!.inject({ url: '/v1/trials/intelligence?level=top&limit=5' }); expect(res.statusCode).toBe(200); const body = res.json(); envelope(body); expect(body.data.length).toBeLessThanOrEqual(5); if (body.data.length) { expect(body.data[0].formulaVersion).toMatch(/^ci-trial-intel-v\d+$/); expect(typeof body.data[0].activeTrials).toBe('number'); } }); maybe('GET /v1/trials/terminated?limit=5 classifies stop reasons without inventing them', async () => { const res = await app!.inject({ url: '/v1/trials/terminated?limit=5' }); expect(res.statusCode).toBe(200); const body = res.json(); envelope(body); expect(body.data).toHaveProperty('breakdown'); for (const t of body.data.trials ?? []) { expect(typeof t.reasonCategory).toBe('string'); if (!t.whyStopped) expect(t.reasonCategory).toBe('not_stated'); } }); maybe('GET /v1/trials/sites?limit=5 returns country aggregates with ISO codes', async () => { const res = await app!.inject({ url: '/v1/trials/sites?limit=5' }); expect(res.statusCode).toBe(200); const body = res.json(); envelope(body); for (const r of body.data) { expect(typeof r.country).toBe('string'); expect(r.sites).toBeGreaterThan(0); } }); maybe('GET /v1/research-gap returns eligible components with log2 ratios', async () => { const res = await app!.inject({ url: '/v1/research-gap' }); expect([200, 404]).toContain(res.statusCode); if (res.statusCode !== 200) return; const body = res.json(); envelope(body); const rows = Array.isArray(body.data) ? body.data : (body.data.components ?? body.data.rows ?? []); for (const r of rows.slice(0, 20)) { if (r.eligible && r.trialGapRatio != null) expect(Number.isFinite(r.trialGapRatio)).toBe(true); if (r.deathShare != null) expect(r.deathShare).toBeGreaterThanOrEqual(0); } }); maybe('GET /v1/epidemiology/metrics lists metrics present with units', async () => { const res = await app!.inject({ url: '/v1/epidemiology/metrics' }); expect(res.statusCode).toBe(200); const body = res.json(); envelope(body); for (const m of body.data) { expect(typeof m.metric).toBe('string'); expect(typeof m.unit).toBe('string'); } }); maybe('GET /v1/epidemiology rejects a missing metric or too many cancers', async () => { const res = await app!.inject({ url: '/v1/epidemiology?cancer=a,b,c,d,e,f,g,h,i,j' }); expect(res.statusCode).toBe(400); }); maybe('GET /v1/approvals?limit=5 is jurisdiction-aware and dated', async () => { const res = await app!.inject({ url: '/v1/approvals?limit=5' }); expect(res.statusCode).toBe(200); const body = res.json(); envelope(body); for (const a of body.data) { expect(typeof a.jurisdiction).toBe('string'); expect(typeof a.authority).toBe('string'); expect(a.drug).toHaveProperty('slug'); } }); maybe('GET /v1/approvals/recent?days=3650&limit=5 groups by month', async () => { const res = await app!.inject({ url: '/v1/approvals/recent?days=3650&limit=5' }); expect(res.statusCode).toBe(200); const body = res.json(); envelope(body); expect(Array.isArray(body.data.months)).toBe(true); }); maybe('GET /v1/pipeline/summary returns stage counts', async () => { const res = await app!.inject({ url: '/v1/pipeline/summary' }); expect(res.statusCode).toBe(200); envelope(res.json()); }); maybe('GET /v1/graph/gene/TP53?limit=5 returns a neighbourhood with typed edges', async () => { const res = await app!.inject({ url: '/v1/graph/gene/TP53?limit=5' }); expect([200, 404]).toContain(res.statusCode); if (res.statusCode !== 200) return; const body = res.json(); envelope(body); expect(body.data.node.type).toBe('gene'); for (const n of body.data.neighbors.slice(0, 10)) { expect(n.node).toHaveProperty('type'); for (const e of n.edges) { expect(typeof e.relationshipType).toBe('string'); expect(typeof e.derived).toBe('boolean'); } } }); maybe('GET /v1/graph/drug/does-not-exist → 404', async () => { const res = await app!.inject({ url: '/v1/graph/drug/does-not-exist-xyz' }); expect(res.statusCode).toBe(404); }); });