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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user