spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/**2 * Read-only smoke tests for the wave-3 routes (intelligence, sites, research gap, epidemiology,3 * approvals, pipeline, graph) against the local `cancerindex` database. Skipped when unreachable.4 */5import { afterAll, beforeAll, describe, expect, it } from 'vitest';6import type { FastifyInstance } from 'fastify';7import { loadEnv } from '../src/lib/env.js';89loadEnv();1011let app: FastifyInstance | null = null;12let reachable = false;1314beforeAll(async () => {15 try {16 const { getDb } = await import('@cancerindex/database');17 const { sql } = await import('drizzle-orm');18 await getDb().execute(sql`SELECT 1`);19 reachable = true;20 const { buildApp } = await import('../src/app.js');21 app = await buildApp({ logger: false });22 await app.ready();23 } catch {24 reachable = false;25 }26}, 30_000);2728afterAll(async () => {29 await app?.close();30 const { closeDb } = await import('@cancerindex/database');31 await closeDb().catch(() => {});32});3334const maybe = (name: string, fn: () => Promise<void>) =>35 it(name, async (ctx) => {36 if (!reachable || !app) return ctx.skip();37 await fn();38 });3940const envelope = (body: Record<string, unknown>) => {41 expect(body).toHaveProperty('data');42 expect(Array.isArray(body.sources)).toBe(true);43 expect(body).toHaveProperty('dataRelease');44};4546describe('wave 3 API (real DB, read-only)', () => {47 maybe('GET /v1/trials/intelligence?level=top&limit=5 returns formula-versioned rows', async () => {48 const res = await app!.inject({ url: '/v1/trials/intelligence?level=top&limit=5' });49 expect(res.statusCode).toBe(200);50 const body = res.json();51 envelope(body);52 expect(body.data.length).toBeLessThanOrEqual(5);53 if (body.data.length) {54 expect(body.data[0].formulaVersion).toMatch(/^ci-trial-intel-v\d+$/);55 expect(typeof body.data[0].activeTrials).toBe('number');56 }57 });5859 maybe('GET /v1/trials/terminated?limit=5 classifies stop reasons without inventing them', async () => {60 const res = await app!.inject({ url: '/v1/trials/terminated?limit=5' });61 expect(res.statusCode).toBe(200);62 const body = res.json();63 envelope(body);64 expect(body.data).toHaveProperty('breakdown');65 for (const t of body.data.trials ?? []) {66 expect(typeof t.reasonCategory).toBe('string');67 if (!t.whyStopped) expect(t.reasonCategory).toBe('not_stated');68 }69 });7071 maybe('GET /v1/trials/sites?limit=5 returns country aggregates with ISO codes', async () => {72 const res = await app!.inject({ url: '/v1/trials/sites?limit=5' });73 expect(res.statusCode).toBe(200);74 const body = res.json();75 envelope(body);76 for (const r of body.data) {77 expect(typeof r.country).toBe('string');78 expect(r.sites).toBeGreaterThan(0);79 }80 });8182 maybe('GET /v1/research-gap returns eligible components with log2 ratios', async () => {83 const res = await app!.inject({ url: '/v1/research-gap' });84 expect([200, 404]).toContain(res.statusCode);85 if (res.statusCode !== 200) return;86 const body = res.json();87 envelope(body);88 const rows = Array.isArray(body.data) ? body.data : (body.data.components ?? body.data.rows ?? []);89 for (const r of rows.slice(0, 20)) {90 if (r.eligible && r.trialGapRatio != null) expect(Number.isFinite(r.trialGapRatio)).toBe(true);91 if (r.deathShare != null) expect(r.deathShare).toBeGreaterThanOrEqual(0);92 }93 });9495 maybe('GET /v1/epidemiology/metrics lists metrics present with units', async () => {96 const res = await app!.inject({ url: '/v1/epidemiology/metrics' });97 expect(res.statusCode).toBe(200);98 const body = res.json();99 envelope(body);100 for (const m of body.data) {101 expect(typeof m.metric).toBe('string');102 expect(typeof m.unit).toBe('string');103 }104 });105106 maybe('GET /v1/epidemiology rejects a missing metric or too many cancers', async () => {107 const res = await app!.inject({ url: '/v1/epidemiology?cancer=a,b,c,d,e,f,g,h,i,j' });108 expect(res.statusCode).toBe(400);109 });110111 maybe('GET /v1/approvals?limit=5 is jurisdiction-aware and dated', async () => {112 const res = await app!.inject({ url: '/v1/approvals?limit=5' });113 expect(res.statusCode).toBe(200);114 const body = res.json();115 envelope(body);116 for (const a of body.data) {117 expect(typeof a.jurisdiction).toBe('string');118 expect(typeof a.authority).toBe('string');119 expect(a.drug).toHaveProperty('slug');120 }121 });122123 maybe('GET /v1/approvals/recent?days=3650&limit=5 groups by month', async () => {124 const res = await app!.inject({ url: '/v1/approvals/recent?days=3650&limit=5' });125 expect(res.statusCode).toBe(200);126 const body = res.json();127 envelope(body);128 expect(Array.isArray(body.data.months)).toBe(true);129 });130131 maybe('GET /v1/pipeline/summary returns stage counts', async () => {132 const res = await app!.inject({ url: '/v1/pipeline/summary' });133 expect(res.statusCode).toBe(200);134 envelope(res.json());135 });136137 maybe('GET /v1/graph/gene/TP53?limit=5 returns a neighbourhood with typed edges', async () => {138 const res = await app!.inject({ url: '/v1/graph/gene/TP53?limit=5' });139 expect([200, 404]).toContain(res.statusCode);140 if (res.statusCode !== 200) return;141 const body = res.json();142 envelope(body);143 expect(body.data.node.type).toBe('gene');144 for (const n of body.data.neighbors.slice(0, 10)) {145 expect(n.node).toHaveProperty('type');146 for (const e of n.edges) {147 expect(typeof e.relationshipType).toBe('string');148 expect(typeof e.derived).toBe('boolean');149 }150 }151 });152153 maybe('GET /v1/graph/drug/does-not-exist → 404', async () => {154 const res = await app!.inject({ url: '/v1/graph/drug/does-not-exist-xyz' });155 expect(res.statusCode).toBe(404);156 });157});158