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