fix: overhaul the platform skeleton, add a serverless deployment track

An end-to-end audit found the repo could not build, test or run as shipped. This
fixes every finding, then adds a Cloud Run track so the demo costs about £1/month
idle instead of ~£150.

CI (red on its first run)
- api: setuptools could not build the package (flat layout with app/ and alembic/)
- web: missing @types/node; `vitest run` exited 1 with no test files
- pipeline: the stub run needed a gitignored VCF, and no process had a stub block
- ruff pinned, mypy configured, DB tests on real Postgres (pgserver locally, service in CI)

ML serving (scores were meaningless)
- the registered model now carries its own feature engineering and returns predict_proba,
  so serving sends raw columns and cannot drift from training
- resolve by registry alias (stages are deprecated in MLflow 3) and record the real
  version; re-scoring upserts instead of failing on the unique constraint
- ClinVar labels parsed from VEP's lowercase terms

Pipeline
- exact ref/alt recovered from a CHROM_POS_REF_ALT VCF ID; loading is idempotent
- job status reaches running/failed/succeeded, so the UI stops polling dead jobs
- DATABASE_URL travels in the environment or a Nextflow secret, never on a command line
- VEP cache and plugins staged as inputs; the gcp profile runs tasks on Google Batch

Deployment
- the API serves /api (matching the ingress); the web app reads its API URL at runtime
- migrations run in an init container under a Postgres advisory lock
- terraform: custom VPC shared with Batch, private Cloud SQL, API enablement, Workload
  Identity bindings, Secret Manager, deletion protection
- serverless track, now the default: Cloud Run services scaling to zero, a Cloud Run job
  for the Nextflow driver, and Neon or Cloud SQL behind one DATABASE_URL secret. GKE and
  Argo remain, behind -var deploy_kubernetes=true. See docs/cloud.md.

Correctness and security
- 409 on duplicate sample names, 422 on bad paging, natural chromosome ordering, wider
  VEP text columns, enum dropped on downgrade, the sample's assembly actually used
- vcf_uri restricted to gs:// objects or files under the data root, blocking option injection
- CORS restricted to configured origins; `make down` no longer deletes volumes

Data
- docs/data.md records the peer-reviewed, openly licensed sources (GIAB HG002, ClinVar,
  gnomAD) with citations and an honest evaluation plan; `make data` fetches a chr22 slice

Verified: api 50 tests, ml 18, loader 16, web 12; ruff, mypy, svelte-check, terraform
validate and both kustomize overlays clean.
This commit is contained in:
Kemal Yaylali
2026-09-12 07:21:11 +01:00
parent 5463f489a3
commit 11fb6b3d73
100 changed files with 3431 additions and 340 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Test bootstrap.
CI provides DATABASE_URL (a Postgres service container). Locally, when it is unset, a throwaway
Postgres is started with `pgserver` so `pytest` needs no Docker. Set REQUIRE_DB=1 to turn a missing
database into a failure instead of a skip (CI does this).
DATABASE_URL must be settled before anything imports `app`, because `app.config.settings` and the
engine in `app.db` are created at import time.
"""
import os
import tempfile
from collections.abc import AsyncIterator, Iterator
from pathlib import Path
from typing import TYPE_CHECKING
import pytest
from httpx import ASGITransport, AsyncClient
if TYPE_CHECKING:
from alembic.config import Config
API_DIR = Path(__file__).resolve().parents[1]
_pg_server = None # keeps the pgserver handle alive for the whole session
def _resolve_database_url() -> str | None:
global _pg_server
if url := os.environ.get("DATABASE_URL"):
return url
try:
import pgserver
except ImportError:
return None
_pg_server = pgserver.get_server(tempfile.mkdtemp(prefix="rarelens-pg-"), cleanup_mode="delete")
socket_dir = _pg_server.get_uri().split("host=", 1)[1]
return f"postgresql+asyncpg://postgres@/postgres?host={socket_dir}"
DATABASE_URL = _resolve_database_url()
if DATABASE_URL:
os.environ["DATABASE_URL"] = DATABASE_URL
@pytest.fixture(scope="session")
def alembic_cfg() -> "Config":
if not DATABASE_URL:
if os.environ.get("REQUIRE_DB"):
pytest.fail("REQUIRE_DB is set but no database is available")
pytest.skip("no DATABASE_URL and pgserver is not installed")
from alembic.config import Config
cfg = Config(str(API_DIR / "alembic.ini"))
cfg.set_main_option("script_location", str(API_DIR / "alembic"))
return cfg
@pytest.fixture(scope="session")
def migrated_db(alembic_cfg: "Config") -> Iterator[None]:
from alembic import command
command.upgrade(alembic_cfg, "head")
yield
@pytest.fixture
async def db(migrated_db: None) -> AsyncIterator[None]:
from sqlalchemy import text
from app.db import engine
async with engine.begin() as conn:
await conn.execute(
text("TRUNCATE samples, jobs, variants, predictions RESTART IDENTITY CASCADE")
)
yield
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
+21
View File
@@ -0,0 +1,21 @@
import uuid
from typing import Any
from app.db import SessionLocal
from app.models import Job, JobStatus, Sample, Variant
async def seed_job(
variants: list[dict[str, Any]], status: JobStatus = JobStatus.succeeded
) -> uuid.UUID:
"""Insert a sample, a job and its variants; each variant dict overrides the defaults."""
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=status)
rows = [
Variant(job=job, **{"chrom": "22", "pos": 1, "ref": "A", "alt": "G", "annotations": {}} | v)
for v in variants
]
s.add_all([sample, job, *rows])
await s.commit()
return job.id
+126
View File
@@ -0,0 +1,126 @@
import uuid
from typing import Any
import pytest
from httpx import AsyncClient
from app.config import settings
from app.services import events
async def new_sample(client: AsyncClient) -> str:
r = await client.post(
"/api/samples",
json={"name": f"s-{uuid.uuid4()}", "vcf_uri": "gs://bucket/x.vcf.gz", "assembly": "GRCh37"},
)
assert r.status_code == 201, r.text
return r.json()["id"]
@pytest.mark.usefixtures("db")
async def test_local_without_nextflow_fails_fast_with_instructions(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "pubsub_topic", None)
monkeypatch.setattr(events.shutil, "which", lambda _: None)
sample_id = await new_sample(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
assert r.status_code == 202
job = r.json()
assert job["status"] == "failed"
assert "make annotate" in job["log"]
assert job["finished_at"] is not None
class FakeProcess:
def __init__(self, returncode: int, stderr: bytes) -> None:
self.returncode = returncode
self._stderr = stderr
self.pid = 4242
async def communicate(self) -> tuple[bytes, bytes]:
return b"", self._stderr
@pytest.mark.usefixtures("db")
async def test_local_run_is_watched_and_a_crash_marks_the_job_failed(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "pubsub_topic", None)
monkeypatch.setattr(events.shutil, "which", lambda _: "/usr/bin/nextflow")
launched: dict[str, Any] = {}
async def fake_exec(*cmd: str, **kw: Any) -> FakeProcess:
launched["cmd"], launched["env"] = cmd, kw["env"]
return FakeProcess(1, b"ERROR ~ VEP cache not found")
monkeypatch.setattr(events.asyncio, "create_subprocess_exec", fake_exec)
sample_id = await new_sample(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
assert r.status_code == 202
await events.drain()
job = (await client.get(f"/api/jobs/{r.json()['id']}")).json()
assert job["status"] == "failed"
assert "VEP cache not found" in job["log"]
# The DB password travels in the environment, never on the command line.
assert settings.database_url not in " ".join(launched["cmd"])
assert launched["env"]["DATABASE_URL"] == settings.database_url
assert launched["cmd"][launched["cmd"].index("--assembly") + 1] == "GRCh37"
class FakeFuture:
def __init__(self, result: str | Exception) -> None:
self._result = result
def result(self, timeout: float | None = None) -> str:
if isinstance(self._result, Exception):
raise self._result
return self._result
class FakePublisher:
def __init__(self, result: str | Exception) -> None:
self.result = result
self.published: list[tuple[str, bytes]] = []
def topic_path(self, project: str, topic: str) -> str:
return f"projects/{project}/topics/{topic}"
def publish(self, topic: str, data: bytes) -> FakeFuture:
self.published.append((topic, data))
return FakeFuture(self.result)
@pytest.mark.usefixtures("db")
async def test_pubsub_publishes_to_the_full_topic_path(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
publisher = FakePublisher("msg-123")
monkeypatch.setattr(settings, "pubsub_topic", "vcf-uploaded")
monkeypatch.setattr(settings, "gcp_project", "my-proj")
monkeypatch.setattr(events, "_publisher", lambda: publisher)
sample_id = await new_sample(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
job = r.json()
assert (job["status"], job["workflow_ref"]) == ("running", "pubsub:msg-123")
[(topic, data)] = publisher.published
assert topic == "projects/my-proj/topics/vcf-uploaded"
assert b'"assembly": "GRCh37"' in data and job["id"].encode() in data
@pytest.mark.usefixtures("db")
async def test_pubsub_failure_marks_the_job_failed(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "pubsub_topic", "vcf-uploaded")
monkeypatch.setattr(settings, "gcp_project", "my-proj")
monkeypatch.setattr(events, "_publisher", lambda: FakePublisher(RuntimeError("403 denied")))
sample_id = await new_sample(client)
job = (await client.post(f"/api/samples/{sample_id}/annotate")).json()
assert job["status"] == "failed"
assert "403 denied" in job["log"]
+97
View File
@@ -0,0 +1,97 @@
"""The serverless track: the API starts a Cloud Run job instead of publishing to Pub/Sub."""
import uuid
from typing import Any
import pytest
from httpx import AsyncClient
from app.config import settings
from app.services import events
class FakeOperation:
def __init__(self, execution: str) -> None:
self.metadata = type("Meta", (), {"name": execution})()
class FakeJobsClient:
def __init__(self, result: Any = None) -> None:
self.result = result
self.requests: list[dict] = []
def run_job(self, request: dict) -> FakeOperation:
self.requests.append(request)
if isinstance(self.result, Exception):
raise self.result
return self.result
async def new_sample(client: AsyncClient) -> str:
r = await client.post(
"/api/samples",
json={"name": f"s-{uuid.uuid4()}", "vcf_uri": "gs://bucket/x.vcf.gz", "assembly": "GRCh37"},
)
assert r.status_code == 201, r.text
return r.json()["id"]
@pytest.fixture
def cloudrun(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "pubsub_topic", None)
monkeypatch.setattr(settings, "cloudrun_job", "rarelens-nextflow")
monkeypatch.setattr(settings, "gcp_project", "my-proj")
monkeypatch.setattr(settings, "gcp_region", "europe-west2")
@pytest.mark.usefixtures("db", "cloudrun")
async def test_annotate_executes_the_job_with_pipeline_arguments(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
jobs = FakeJobsClient(FakeOperation("projects/p/locations/l/executions/rarelens-nextflow-abc12"))
monkeypatch.setattr(events, "_jobs_client", lambda: jobs)
sample_id = await new_sample(client)
r = await client.post(f"/api/samples/{sample_id}/annotate")
assert r.status_code == 202
job = r.json()
assert job["status"] == "running"
assert job["workflow_ref"] == "cloudrun:rarelens-nextflow-abc12"
[request] = jobs.requests
assert request["name"] == "projects/my-proj/locations/europe-west2/jobs/rarelens-nextflow"
[override] = request["overrides"]["container_overrides"]
args = override["args"]
assert args[:4] == ["run", "/pipeline/main.nf", "-profile", "gcp"]
assert args[args.index("--job_id") + 1] == job["id"]
assert args[args.index("--assembly") + 1] == "GRCh37"
assert args[args.index("--vcf") + 1] == "gs://bucket/x.vcf.gz"
@pytest.mark.usefixtures("db", "cloudrun")
async def test_a_failed_execution_call_marks_the_job_failed(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(
events, "_jobs_client", lambda: FakeJobsClient(RuntimeError("403 permission denied"))
)
sample_id = await new_sample(client)
job = (await client.post(f"/api/samples/{sample_id}/annotate")).json()
assert job["status"] == "failed"
assert "403 permission denied" in job["log"]
@pytest.mark.usefixtures("db", "cloudrun")
async def test_cloud_run_job_takes_precedence_over_pubsub(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "pubsub_topic", "vcf-uploaded")
jobs = FakeJobsClient(FakeOperation("projects/p/locations/l/executions/x-1"))
monkeypatch.setattr(events, "_jobs_client", lambda: jobs)
monkeypatch.setattr(
events, "_publisher", lambda: pytest.fail("Pub/Sub must not be used in the serverless track")
)
sample_id = await new_sample(client)
assert (await client.post(f"/api/samples/{sample_id}/annotate")).json()["status"] == "running"
assert len(jobs.requests) == 1
+34
View File
@@ -0,0 +1,34 @@
"""Neon-style URLs use libpq's sslmode, which asyncpg does not understand."""
import pytest
from app.db import normalize_async_url
@pytest.mark.parametrize(
("raw", "expected"),
[
(
"postgresql+asyncpg://u:[email protected]/neondb?sslmode=require",
"postgresql+asyncpg://u:[email protected]/neondb?ssl=require",
),
# Already asyncpg-shaped, or nothing to do.
(
"postgresql+asyncpg://u:p@h/db?ssl=require",
"postgresql+asyncpg://u:p@h/db?ssl=require",
),
("postgresql+asyncpg://u:p@h:5432/db", "postgresql+asyncpg://u:p@h:5432/db"),
# Only the asyncpg driver needs the rewrite.
("postgresql+psycopg://u:p@h/db?sslmode=require", "postgresql+psycopg://u:p@h/db?sslmode=require"),
],
)
def test_normalize_async_url(raw: str, expected: str) -> None:
assert normalize_async_url(raw) == expected
def test_disable_is_preserved() -> None:
assert normalize_async_url("postgresql+asyncpg://u:p@h/db?sslmode=disable").endswith("ssl=disable")
def test_unix_socket_url_is_untouched() -> None:
raw = "postgresql+asyncpg://postgres@/postgres?host=/tmp/pg"
assert normalize_async_url(raw) == raw
+3 -8
View File
@@ -1,12 +1,7 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_health():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
r = await c.get("/health")
async def test_health(client: AsyncClient) -> None:
r = await client.get("/health")
assert r.status_code == 200
assert r.json() == {"status": "ok"}
+15
View File
@@ -0,0 +1,15 @@
import asyncio
from alembic import command
from alembic.config import Config
from app.db import engine
async def test_downgrade_to_base_then_upgrade_again(alembic_cfg: Config) -> None:
# alembic's env.py runs its own event loop, so keep it off this one.
await asyncio.to_thread(command.downgrade, alembic_cfg, "base")
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
# The recreated enum type has a new OID that pooled asyncpg connections have not seen;
# reconnect so later tests (and the database left at head) are unaffected.
await engine.dispose()
+47
View File
@@ -0,0 +1,47 @@
"""A model artifact URI (gs://...) lets the API score without an MLflow server running."""
# Imported eagerly: mlflow loads .pyfunc lazily, so patching it by name can hit the proxy.
import mlflow.pyfunc
import pytest
from app.config import settings
from app.services import scoring
@pytest.fixture(autouse=True)
def clear_cache() -> None:
scoring._models.clear()
def no_registry(**kwargs: object) -> None:
pytest.fail("the registry must not be contacted when MODEL_URI is set")
def test_model_uri_skips_the_registry(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "model_uri", "gs://bucket/models/pathogenicity/3")
monkeypatch.setattr(scoring, "MlflowClient", no_registry)
monkeypatch.setattr(mlflow.pyfunc, "load_model", lambda uri: f"model@{uri}")
model, version = scoring.load_model()
assert model == "model@gs://bucket/models/pathogenicity/3"
assert version == "3"
def test_model_uri_without_a_version_segment_still_labels_the_prediction(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "model_uri", "gs://bucket/models/pathogenicity/")
monkeypatch.setattr(scoring, "MlflowClient", no_registry)
monkeypatch.setattr(mlflow.pyfunc, "load_model", lambda uri: "model")
assert scoring.load_model()[1] == "pathogenicity"
def test_model_uri_is_loaded_once(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "model_uri", "gs://bucket/models/pathogenicity/3")
monkeypatch.setattr(scoring, "MlflowClient", no_registry)
calls: list[str] = []
monkeypatch.setattr(mlflow.pyfunc, "load_model", lambda uri: calls.append(uri) or "model")
scoring.load_model()
scoring.load_model()
assert len(calls) == 1
+13
View File
@@ -0,0 +1,13 @@
import pytest
from httpx import AsyncClient
@pytest.mark.usefixtures("db")
async def test_resources_live_under_api_prefix(client: AsyncClient) -> None:
# The ingress forwards /api/* unchanged, so the app itself must serve that prefix.
assert (await client.get("/api/samples")).status_code == 200
assert (await client.get("/samples")).status_code == 404
async def test_health_stays_at_root_for_probes(client: AsyncClient) -> None:
assert (await client.get("/health")).status_code == 200
+18
View File
@@ -0,0 +1,18 @@
import pytest
from httpx import AsyncClient
@pytest.mark.usefixtures("db")
async def test_duplicate_sample_name_is_409_not_500(client: AsyncClient) -> None:
body = {"name": "HG002", "vcf_uri": "gs://bucket/hg002.vcf.gz"}
assert (await client.post("/api/samples", json=body)).status_code == 201
r = await client.post("/api/samples", json=body)
assert r.status_code == 409
assert "HG002" in r.json()["detail"]
async def test_unknown_assembly_is_rejected(client: AsyncClient) -> None:
r = await client.post(
"/api/samples", json={"name": "a", "vcf_uri": "gs://bucket/a.vcf.gz", "assembly": "hg19"}
)
assert r.status_code == 422
+90
View File
@@ -0,0 +1,90 @@
import math
import uuid
import numpy as np
import pandas as pd
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from app.db import SessionLocal
from app.models import Job, JobStatus, Prediction, Sample, Variant
from app.services import scoring
def variant(**kw: object) -> Variant:
fields: dict = {"chrom": "22", "pos": 1, "ref": "A", "alt": "G", "annotations": {}}
fields.update(kw)
return Variant(**fields)
def test_raw_frame_sends_the_model_contract_columns() -> None:
frame = scoring.raw_frame([
variant(impact="HIGH", consequence="stop_gained", gnomad_af=None,
annotations={"CADD_PHRED": "35", "am_pathogenicity": "0.98"}),
variant(impact="LOW", consequence="synonymous_variant", gnomad_af=0.2, annotations={}),
])
assert list(frame.columns) == scoring.RAW_COLUMNS
assert frame["impact"].tolist() == ["HIGH", "LOW"]
assert frame["cadd_phred"].iloc[0] == "35"
assert pd.isna(frame["cadd_phred"].iloc[1])
assert math.isnan(frame["gnomad_af"].iloc[0])
class FakeModel:
def __init__(self, score: float) -> None:
self.score = score
def predict(self, frame: pd.DataFrame) -> np.ndarray:
assert list(frame.columns) == scoring.RAW_COLUMNS
return np.full(len(frame), self.score)
async def make_job(status: JobStatus, n_variants: int) -> uuid.UUID:
async with SessionLocal() as s:
sample = Sample(name=f"s-{uuid.uuid4()}", vcf_uri="gs://b/x.vcf.gz", assembly="GRCh38")
job = Job(sample=sample, status=status)
s.add_all([sample, job, *(variant(job=job, pos=i + 1) for i in range(n_variants))])
await s.commit()
return job.id
async def predictions(job_id: uuid.UUID) -> list[Prediction]:
async with SessionLocal() as s:
rows = await s.scalars(
select(Prediction).join(Variant).where(Variant.job_id == job_id)
)
return list(rows)
@pytest.mark.usefixtures("db")
async def test_scoring_twice_updates_instead_of_failing(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
job_id = await make_job(JobStatus.succeeded, n_variants=3)
monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.9), "7"))
r = await client.post(f"/api/predictions/score/{job_id}")
assert r.status_code == 200, r.text
assert r.json() == {"job_id": str(job_id), "scored": 3, "model_version": "7"}
monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.2), "8"))
r = await client.post(f"/api/predictions/score/{job_id}")
assert r.status_code == 200, r.text
preds = await predictions(job_id)
assert len(preds) == 3
assert {(p.score, p.model_version) for p in preds} == {(0.2, "8")}
@pytest.mark.usefixtures("db")
async def test_scoring_unknown_job_is_404(client: AsyncClient) -> None:
r = await client.post(f"/api/predictions/score/{uuid.uuid4()}")
assert r.status_code == 404
@pytest.mark.usefixtures("db")
async def test_scoring_unfinished_job_is_409(client: AsyncClient) -> None:
job_id = await make_job(JobStatus.running, n_variants=1)
r = await client.post(f"/api/predictions/score/{job_id}")
assert r.status_code == 409
+51
View File
@@ -0,0 +1,51 @@
import pytest
from httpx import AsyncClient
from pydantic import ValidationError
from app.schemas import SampleCreate
@pytest.mark.parametrize(
"uri",
[
"gs://bucket/dir/sample.vcf.gz",
"gs://my.bucket-1/a/b.bcf",
"gs://bucket/x.vcf",
"/data/example.vcf.gz",
"/data/giab/hg002.chr22.vcf.bgz",
],
)
def test_vcf_uri_accepts_gcs_objects_and_files_under_the_data_root(uri: str) -> None:
assert SampleCreate(name="s", vcf_uri=uri).vcf_uri == uri
@pytest.mark.parametrize(
"uri",
[
"-c/tmp/evil.config", # would be parsed as a Nextflow option
"--outdir=/etc",
"/etc/passwd", # outside the data root
"/data/../etc/shadow.vcf", # traversal out of it
"data/example.vcf.gz", # relative: depends on the API's working directory
"gs://bucket/notes.txt", # not a VCF/BCF
"https://example.com/x.vcf.gz",
"gs:///x.vcf.gz",
"/data/x.vcf.gz\n--foo", # control characters
"",
],
)
def test_vcf_uri_rejects_everything_else(uri: str) -> None:
with pytest.raises(ValidationError):
SampleCreate(name="s", vcf_uri=uri)
async def test_bad_vcf_uri_is_422_at_the_api(client: AsyncClient) -> None:
r = await client.post("/api/samples", json={"name": "s", "vcf_uri": "/etc/passwd"})
assert r.status_code == 422
async def test_cors_allows_only_configured_origins(client: AsyncClient) -> None:
allowed = await client.get("/health", headers={"Origin": "http://localhost:5173"})
assert allowed.headers.get("access-control-allow-origin") == "http://localhost:5173"
other = await client.get("/health", headers={"Origin": "https://evil.example"})
assert "access-control-allow-origin" not in other.headers
+46
View File
@@ -0,0 +1,46 @@
import uuid
import pytest
from factories import seed_job
from httpx import AsyncClient
@pytest.mark.parametrize(
"paging", [{"limit": 0}, {"limit": -1}, {"limit": 501}, {"offset": -1}]
)
async def test_out_of_range_paging_is_422_not_500(client: AsyncClient, paging: dict) -> None:
r = await client.get("/api/variants", params={"job_id": str(uuid.uuid4()), **paging})
assert r.status_code == 422
async def positions(client: AsyncClient, job_id: uuid.UUID) -> list[tuple[str, int, str]]:
r = await client.get("/api/variants", params={"job_id": str(job_id)})
assert r.status_code == 200, r.text
return [(v["chrom"], v["pos"], v["alt"]) for v in r.json()["items"]]
@pytest.mark.usefixtures("db")
async def test_chromosomes_sort_naturally(client: AsyncClient) -> None:
job_id = await seed_job([{"chrom": c} for c in ["10", "MT", "2", "X", "chr3", "1", "Y"]])
assert [c for c, _, _ in await positions(client, job_id)] == [
"1", "2", "chr3", "10", "X", "Y", "MT",
]
@pytest.mark.usefixtures("db")
async def test_same_position_keeps_insertion_order_across_pages(client: AsyncClient) -> None:
# Split multiallelics share chrom/pos; without a tiebreak, pages can repeat or skip rows.
job_id = await seed_job([{"pos": 5, "alt": a} for a in "CGT"])
assert [a for _, _, a in await positions(client, job_id)] == ["C", "G", "T"]
@pytest.mark.usefixtures("db")
async def test_long_vep_strings_are_stored(client: AsyncClient) -> None:
clin_sig = ",".join(["conflicting_classifications_of_pathogenicity"] * 8)
consequence = (
"splice_region_variant&splice_polypyrimidine_tract_variant&intron_variant"
"&NMD_transcript_variant&non_coding_transcript_variant"
)
job_id = await seed_job([{"clinvar_sig": clin_sig, "consequence": consequence}])
[v] = (await client.get("/api/variants", params={"job_id": str(job_id)})).json()["items"]
assert (v["clinvar_sig"], v["consequence"]) == (clin_sig, consequence)