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%
3.7 KB · 104 lines typescript
Raw Blame History
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