"""Unit tests for BLAST pipeline resilience. Covers: - check_status_until_ready: jobs are not declared STUCK before they get a meaningful share of their poll budget (NCBI routinely exceeds RTOE). - _run_blast (pipeline_v2): NCBI failure falls back to EBI BLAST instead of failing the whole pipeline; result shape is normalized across providers. - BlastTool._submit stype mapping for blastx/tblastn. Network calls are mocked; these tests never touch the real providers. """ import pytest from app.integrations.ncbi import blast as ncbi_blast SAMPLE_XML = """ blastp 7 1 1 sp|P12345|FOO_HUMAN sp|P12345|FOO_HUMAN Foo protein [Homo sapiens] P12345 100 1 50.0 100 1e-5 1 7 10 16 1 1 5 6 1 8 AAAAA-- AAAAA-- AAAAA """ PROTEIN_SEQ = "MEEPQSDPSVEPPLSQETFSDLWKLLPENNVLSPLPSQAMDDLMLSPDDIEQWFTEDPGPDEA" class _FakeClock: """Deterministic clock: asyncio.sleep() advances it instead of waiting.""" def __init__(self): self.now = 0.0 def time(self): return self.now @pytest.fixture def fake_clock(monkeypatch): clock = _FakeClock() async def _fake_sleep(seconds): clock.now += seconds monkeypatch.setattr(ncbi_blast.asyncio, "sleep", _fake_sleep) monkeypatch.setattr(ncbi_blast.asyncio, "get_event_loop", lambda: clock) return clock async def _always_waiting(rid, fmt="XML"): return {"status": "WAITING", "raw": "Status=WAITING", "rid": rid} async def _ready_after(rid, threshold, fmt="XML"): clock = ncbi_blast.asyncio.get_event_loop() if clock.time() > threshold: return {"status": "READY", "raw": "Status=READY", "rid": rid} return {"status": "WAITING", "raw": "Status=WAITING", "rid": rid} class TestStuckThreshold: def test_slow_job_is_not_abandoned_before_half_budget(self, monkeypatch, fake_clock): # Regression: a job WAITING for ~188s used to be declared STUCK with a # 900s budget (old threshold max(RTOE*5, 180)). It must keep polling. async def waiting_then_ready(rid, fmt="XML"): return await _ready_after(rid, threshold=300) monkeypatch.setattr(ncbi_blast, "check_status", waiting_then_ready) result = asyncio_run(ncbi_blast.check_status_until_ready( "RID1", max_wait_seconds=900, estimated_seconds=10, )) assert result["status"] == "READY", result assert fake_clock.now >= 300 def test_truly_stuck_job_declared_stuck_after_half_budget(self, monkeypatch, fake_clock): monkeypatch.setattr(ncbi_blast, "check_status", _always_waiting) result = asyncio_run(ncbi_blast.check_status_until_ready( "RID2", max_wait_seconds=900, estimated_seconds=10, )) assert result["status"] == "STUCK", result assert fake_clock.now >= 450, f"STUCK fired too early at {fake_clock.now:.0f}s" def test_budget_exhaustion_returns_timeout_before_stuck(self, monkeypatch, fake_clock): # With a small budget, TIMEOUT (end of budget) must win over STUCK. monkeypatch.setattr(ncbi_blast, "check_status", _always_waiting) result = asyncio_run(ncbi_blast.check_status_until_ready( "RID3", max_wait_seconds=120, estimated_seconds=1000, )) assert result["status"] == "TIMEOUT", result assert fake_clock.now >= 120 class TestPipelineBlastFallback: def test_ncbi_success_shape(self, monkeypatch): from app.routers import pipeline_v2 async def fake_run_blast_with_retry(*args, **kwargs): return {"raw": SAMPLE_XML, "rid": "RID-X"} monkeypatch.setattr(pipeline_v2.ncbi_blast, "run_blast_with_retry", fake_run_blast_with_retry) result = asyncio_run(pipeline_v2._run_blast(PROTEIN_SEQ)) assert result["source"] == "ncbi" assert result["count"] == 1 assert result["query_sequence_type"] == "protein" assert result["database"] == "nr" assert result["top_hit"]["accession"] == "P12345" assert result["top_hit"]["evalue"] == 1e-5 assert result["hits"][0]["hit_alignment"] == "AAAAA--" assert result["hits"][0]["query_alignment"] == "AAAAA--" assert result["hits"][0]["midline"] == "AAAAA " def test_ncbi_failure_falls_back_to_ebi(self, monkeypatch): from app.routers import pipeline_v2 async def fake_run_blast_with_retry(*args, **kwargs): return {"error": "BLAST STUCK after polling (attempt 3/3): Job stuck in WAITING for 188.75s"} class FakeEbiTool: async def run_uncached(self, input): assert input["database"] == "uniprotkb" # nr mapped to EBI db assert input["program"] == "blastp" return { "hits": [{ "accession": "Q9H2H9", "id": "tr|Q9H2H9|Q9H2H9_HUMAN", "description": "CCHC-type zinc finger protein 3", "organism": "Homo sapiens", "evalue": 1e-30, "bit_score": 210.0, "identity_pct": 98.7, "alignment_length": 152, "query_coverage_pct": 0, "query_from": 1, "query_to": 152, "hit_from": 1, "hit_to": 152, }], "count": 1, "source": "EBI BLAST", "database": "uniprotkb", } monkeypatch.setattr(pipeline_v2.ncbi_blast, "run_blast_with_retry", fake_run_blast_with_retry) monkeypatch.setattr(pipeline_v2, "BlastTool", FakeEbiTool) result = asyncio_run(pipeline_v2._run_blast(PROTEIN_SEQ)) assert result["source"] == "ebi" assert result["count"] == 1 assert result["top_hit"]["accession"] == "Q9H2H9" assert result["database"] == "nr" # reports the requested db, not EBI's assert result["hits"][0]["organism"] == "Homo sapiens" assert result["hits"][0]["hit_alignment"] == "" # EBI lacks alignment text assert result["hits"][0]["query_coverage_pct"] == pytest.approx(round(152 / len(PROTEIN_SEQ) * 100, 1)) def test_both_providers_fail_returns_error(self, monkeypatch): from app.routers import pipeline_v2 async def fake_run_blast_with_retry(*args, **kwargs): return {"error": "BLAST STUCK after polling (attempt 3/3): Job stuck in WAITING for 188.75s"} class EmptyEbiTool: async def run_uncached(self, input): return {"error": "EBI down", "hits": []} monkeypatch.setattr(pipeline_v2.ncbi_blast, "run_blast_with_retry", fake_run_blast_with_retry) monkeypatch.setattr(pipeline_v2, "BlastTool", EmptyEbiTool) result = asyncio_run(pipeline_v2._run_blast(PROTEIN_SEQ)) assert result["error"] assert "STUCK" in result["error"] assert result["count"] == 0 assert result["hits"] == [] def test_ebi_fallback_skips_unmapped_database(self): from app.routers.pipeline_v2 import _run_ebi_blast_fallback result = asyncio_run(_run_ebi_blast_fallback(PROTEIN_SEQ, "blastp", "no_such_db", "protein", 10)) assert result is None class TestEbiToolSubmit: async def test_stype_mapping(self, monkeypatch): from app.tools.blast import BlastTool captured = {} class FakeResp: text = "RID=abc\nRTOE=5" def raise_for_status(self): pass class FakeClient: def __init__(self, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, *args): pass async def post(self, url, **kwargs): captured["data"] = kwargs["data"] return FakeResp() monkeypatch.setattr("app.tools.blast.httpx.AsyncClient", lambda **kw: FakeClient()) tool = BlastTool() await tool._submit("MKTAYIAKQRQISFVKSHFSRQDIL", "blastx", "nr") assert captured["data"]["stype"] == "protein" # blastx queries a protein await tool._submit("ATGCATGC", "tblastn", "nt") assert captured["data"]["stype"] == "dna" def asyncio_run(coro): import asyncio return asyncio.run(coro)