spb/cancerindex
Public
TypeScript 97.2%
SQL 1.5%
CSS 0.6%
JavaScript 0.5%
1/**2 * Read-only smoke tests against the local `cancerindex` database. Skipped when the database is3 * unreachable so CI without Postgres still passes.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 });3940describe('API smoke (real DB, read-only)', () => {41 maybe('GET /healthz', async () => {42 const res = await app!.inject({ url: '/healthz' });43 expect(res.statusCode).toBe(200);44 const body = res.json();45 expect(body.ok).toBe(true);46 expect(body.db).toBe(true);47 expect(body.dataRelease).toMatch(/^CancerIndex \d{4}-\d{2}$/);48 expect(res.headers['x-request-id']).toBeTruthy();49 });5051 maybe('GET /v1/cancers?limit=2 returns the envelope', async () => {52 const res = await app!.inject({ url: '/v1/cancers?limit=2' });53 expect(res.statusCode).toBe(200);54 const body = res.json();55 expect(Array.isArray(body.data)).toBe(true);56 expect(body.data.length).toBeLessThanOrEqual(2);57 expect(body).toHaveProperty('sources');58 expect(body).toHaveProperty('dataRelease');59 expect(body).toHaveProperty('total');60 expect(body.limit).toBe(2);61 if (body.data.length) {62 expect(body.data[0].id).toMatch(/^CI-CAN-\d{8}$/);63 expect(body.data[0]).toHaveProperty('counters');64 }65 });6667 maybe('GET /v1/cancers?limit=500 is rejected (limit ≤ 200)', async () => {68 const res = await app!.inject({ url: '/v1/cancers?limit=500' });69 expect(res.statusCode).toBe(400);70 expect(res.json().error.code).toBe('bad_request');71 });7273 maybe('GET /v1/search?q=lung returns typed, ordered results', async () => {74 const res = await app!.inject({ url: '/v1/search?q=lung' });75 expect(res.statusCode).toBe(200);76 const body = res.json();77 expect(body.data.length).toBeLessThanOrEqual(20);78 for (const r of body.data) expect(['cancer', 'gene', 'variant', 'drug', 'trial', 'publication']).toContain(r.type);79 const tiers = body.data.map((r: { match: string }) => ['exact', 'alias', 'prefix', 'fuzzy'].indexOf(r.match));80 expect([...tiers].sort((a, b) => a - b)).toEqual(tiers);81 });8283 maybe('GET /v1/rankings/metrics lists the metric catalog', async () => {84 const res = await app!.inject({ url: '/v1/rankings/metrics' });85 expect(res.statusCode).toBe(200);86 const body = res.json();87 expect(body.data.length).toBeGreaterThanOrEqual(17);88 const mir = body.data.find((m: { slug: string }) => m.slug === 'mortality_incidence_ratio');89 expect(mir.formulaVersion).toBe('ci-mir-v1');90 expect(Array.isArray(mir.scopes)).toBe(true);91 });9293 maybe('GET /v1/cancers/does-not-exist → 404 envelope-less error', async () => {94 const res = await app!.inject({ url: '/v1/cancers/does-not-exist' });95 expect(res.statusCode).toBe(404);96 expect(res.json().error.code).toBe('not_found');97 });9899 maybe('admin routes require x-admin-token', async () => {100 const res = await app!.inject({ url: '/v1/admin/connectors' });101 expect([401, 503]).toContain(res.statusCode);102 });103});104