import uuid import numpy as np import pandas as pd import pytest from factories import seed_case from httpx import AsyncClient from sqlalchemy import select from app.db import SessionLocal from app.models import JobStatus, Prediction, 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]) # Frequency is scored by the ranking, auditably; sending it here too counted it twice. assert "gnomad_af" not in frame.columns 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_case(status: JobStatus, n_variants: int) -> tuple[uuid.UUID, uuid.UUID]: return await seed_case( status=status, variants=[{"pos": i + 1, "annotations": {}} for i in range(n_variants)], ) 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: case_id, job_id = await make_case(JobStatus.succeeded, n_variants=3) monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.9), "7")) r = await client.post(f"/api/cases/{case_id}/score") assert r.status_code == 200, r.text assert r.json() == {"case_id": str(case_id), "scored": 3, "model_version": "7"} monkeypatch.setattr(scoring, "load_model", lambda: (FakeModel(0.2), "8")) r = await client.post(f"/api/cases/{case_id}/score") 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_an_unknown_case_is_404(client: AsyncClient) -> None: r = await client.post(f"/api/cases/{uuid.uuid4()}/score") assert r.status_code == 404 @pytest.mark.usefixtures("db") async def test_scoring_before_the_annotation_finishes_is_409(client: AsyncClient) -> None: case_id, _ = await make_case(JobStatus.running, n_variants=1) r = await client.post(f"/api/cases/{case_id}/score") assert r.status_code == 409 @pytest.mark.usefixtures("db") async def test_an_unreachable_model_registry_is_explained_not_a_500( client: AsyncClient, monkeypatch: pytest.MonkeyPatch ) -> None: case_id, _ = await make_case(JobStatus.succeeded, n_variants=1) def unreachable() -> tuple: raise ConnectionError("connection refused to http://localhost:5000") monkeypatch.setattr(scoring, "load_model", unreachable) r = await client.post(f"/api/cases/{case_id}/score") assert r.status_code == 503 detail = r.json()["detail"] # Names what is missing; the exception itself belongs in the server log, not the UI. assert "rarelens-pathogenicity@production" in detail assert "Max retries" not in detail and "Traceback" not in detail