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%

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

Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent 06b1cf6

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 34 ## Repository layout
35 35 ```
36 −apps/web Next.js 16 (webpack build, --webpack), Tailwind v4, server components, /api/v1 proxied to apps/api
37 −apps/api Fastify /v1 public API (JSON envelope { data, sources, dataRelease })
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 }); routes/*.ts incl. epidemiology, intelligence, trial-sites, research-gap, approvals (+pipeline), graph
38 38 workers/ pg-boss scheduler: connector schedules, counters, rankings
39 39 packages/shared ids, provenance types, normalization, logger
40 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 62 - Tests: vitest; connector tests run against **fixtures** (sanitized JSON in `fixtures/`), never
63 63 live APIs; cover normal / empty / pagination / rate-limit / server error / malformed record.
64 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 68 unit, population, period and a freshness line. Empty state = "Data not yet available".
67 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