/** * Read-only smoke tests against the local `cancerindex` database. Skipped when the database is * unreachable so CI without Postgres still passes. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { FastifyInstance } from 'fastify'; import { loadEnv } from '../src/lib/env.js'; loadEnv(); let app: FastifyInstance | null = null; let reachable = false; beforeAll(async () => { try { const { getDb } = await import('@cancerindex/database'); const { sql } = await import('drizzle-orm'); await getDb().execute(sql`SELECT 1`); reachable = true; const { buildApp } = await import('../src/app.js'); app = await buildApp({ logger: false }); await app.ready(); } catch { reachable = false; } }, 30_000); afterAll(async () => { await app?.close(); const { closeDb } = await import('@cancerindex/database'); await closeDb().catch(() => {}); }); const maybe = (name: string, fn: () => Promise) => it(name, async (ctx) => { if (!reachable || !app) return ctx.skip(); await fn(); }); describe('API smoke (real DB, read-only)', () => { maybe('GET /healthz', async () => { const res = await app!.inject({ url: '/healthz' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.ok).toBe(true); expect(body.db).toBe(true); expect(body.dataRelease).toMatch(/^CancerIndex \d{4}-\d{2}$/); expect(res.headers['x-request-id']).toBeTruthy(); }); maybe('GET /v1/cancers?limit=2 returns the envelope', async () => { const res = await app!.inject({ url: '/v1/cancers?limit=2' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(Array.isArray(body.data)).toBe(true); expect(body.data.length).toBeLessThanOrEqual(2); expect(body).toHaveProperty('sources'); expect(body).toHaveProperty('dataRelease'); expect(body).toHaveProperty('total'); expect(body.limit).toBe(2); if (body.data.length) { expect(body.data[0].id).toMatch(/^CI-CAN-\d{8}$/); expect(body.data[0]).toHaveProperty('counters'); } }); maybe('GET /v1/cancers?limit=500 is rejected (limit ≤ 200)', async () => { const res = await app!.inject({ url: '/v1/cancers?limit=500' }); expect(res.statusCode).toBe(400); expect(res.json().error.code).toBe('bad_request'); }); maybe('GET /v1/search?q=lung returns typed, ordered results', async () => { const res = await app!.inject({ url: '/v1/search?q=lung' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.length).toBeLessThanOrEqual(20); for (const r of body.data) expect(['cancer', 'gene', 'variant', 'drug', 'trial', 'publication']).toContain(r.type); const tiers = body.data.map((r: { match: string }) => ['exact', 'alias', 'prefix', 'fuzzy'].indexOf(r.match)); expect([...tiers].sort((a, b) => a - b)).toEqual(tiers); }); maybe('GET /v1/rankings/metrics lists the metric catalog', async () => { const res = await app!.inject({ url: '/v1/rankings/metrics' }); expect(res.statusCode).toBe(200); const body = res.json(); expect(body.data.length).toBeGreaterThanOrEqual(17); const mir = body.data.find((m: { slug: string }) => m.slug === 'mortality_incidence_ratio'); expect(mir.formulaVersion).toBe('ci-mir-v1'); expect(Array.isArray(mir.scopes)).toBe(true); }); maybe('GET /v1/cancers/does-not-exist → 404 envelope-less error', async () => { const res = await app!.inject({ url: '/v1/cancers/does-not-exist' }); expect(res.statusCode).toBe(404); expect(res.json().error.code).toBe('not_found'); }); maybe('admin routes require x-admin-token', async () => { const res = await app!.inject({ url: '/v1/admin/connectors' }); expect([401, 503]).toContain(res.statusCode); }); });