SPB Git

spb/cancerindex

Public

API smoke tests for wave-3 routes; repository guide: routes, theme tokens, ref-prop rule

Simon-Pierre Boucher committed (Sep 11, 2026) parent 06b1cf6 Browse files

2 changed files +162 −3

modified CLAUDE.md +5 −3
@@ -33,8 +33,8 @@ regulatory evidence and literature — for every recognized malignant disease en
33 33
34 ## Repository layout 34 ## Repository layout
35 ``` 35 ```
36−apps/web Next.js 16 (webpack build, --webpack), Tailwind v4, server components, /api/v1 proxied to apps/api 36+apps/web Next.js 16 (webpack build, --webpack), Tailwind v4, server components, /api/v1 proxied to apps/api; pages: cancers, explore (data explorer), trials (+intelligence, map, terminated), drugs, genes, approvals, pipeline, rankings, research-gap, graph, pulse, data-updates, countries, compare, taxonomy, sources, methodology
37−apps/api Fastify /v1 public API (JSON envelope { data, sources, dataRelease }) 37+apps/api Fastify /v1 public API (JSON envelope { data, sources, dataRelease }); routes/*.ts incl. epidemiology, intelligence, trial-sites, research-gap, approvals (+pipeline), graph
38 workers/ pg-boss scheduler: connector schedules, counters, rankings 38 workers/ pg-boss scheduler: connector schedules, counters, rankings
39 packages/shared ids, provenance types, normalization, logger 39 packages/shared ids, provenance types, normalization, logger
40 packages/database Drizzle schema (snake_case casing), migrations, seed (metrics + geographies only) 40 packages/database Drizzle schema (snake_case casing), migrations, seed (metrics + geographies only)
@@ -62,7 +62,9 @@ data/raw data lake (gitignored) — {source}/{date}/{entity}/{runId}-
62 - Tests: vitest; connector tests run against **fixtures** (sanitized JSON in `fixtures/`), never 62 - Tests: vitest; connector tests run against **fixtures** (sanitized JSON in `fixtures/`), never
63 live APIs; cover normal / empty / pagination / rate-limit / server error / malformed record. 63 live APIs; cover normal / empty / pagination / rate-limit / server error / malformed record.
64 - UI: scientific-editorial aesthetic (off-white, charcoal, muted neutrals, dense tables, no 64 - UI: scientific-editorial aesthetic (off-white, charcoal, muted neutrals, dense tables, no
65− gradients/cards-everywhere), WCAG-minded, mobile first-class. Every number shows source badge, 65+ gradients/cards-everywhere), WCAG-minded, mobile first-class, light + dark themes (tokens only:
66+ `--color-*` and `--color-series-*` in globals.css; never hard-code hex in components). Server
67+ components must not receive a prop named `ref` (React reserves it; rendering fails silently in prod). Every number shows source badge,
66 unit, population, period and a freshness line. Empty state = "Data not yet available". 68 unit, population, period and a freshness line. Empty state = "Data not yet available".
67 - Language: English UI; code comments English; commit messages English. 69 - Language: English UI; code comments English; commit messages English.
68 70
added apps/api/test/wave3.test.ts +157 −0
@@ -0,0 +1,157 @@
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+ */
5+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
6+import type { FastifyInstance } from 'fastify';
7+import { loadEnv } from '../src/lib/env.js';
8+
9+loadEnv();
10+
11+let app: FastifyInstance | null = null;
12+let reachable = false;
13+
14+beforeAll(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);
27+
28+afterAll(async () => {
29+ await app?.close();
30+ const { closeDb } = await import('@cancerindex/database');
31+ await closeDb().catch(() => {});
32+});
33+
34+const maybe = (name: string, fn: () => Promise<void>) =>
35+ it(name, async (ctx) => {
36+ if (!reachable || !app) return ctx.skip();
37+ await fn();
38+ });
39+
40+const envelope = (body: Record<string, unknown>) => {
41+ expect(body).toHaveProperty('data');
42+ expect(Array.isArray(body.sources)).toBe(true);
43+ expect(body).toHaveProperty('dataRelease');
44+};
45+
46+describe('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+ });
58+
59+ 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+ });
70+
71+ 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+ });
81+
82+ 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+ });
94+
95+ 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+ });
105+
106+ 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+ });
110+
111+ 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+ });
122+
123+ 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+ });
130+
131+ 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+ });
136+
137+ 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+ });
152+
153+ 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
Menu

Theme