| |
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 | |