SPB Git forge

spb/cancerindex

Public
37commits 1branches 0releases
2.9 MBsize
maindefault branch
10 days agolast push
TypeScript 97.2% SQL 1.5% CSS 0.6% JavaScript 0.5%
5.7 KB · 158 lines typescript
Raw Blame History
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