File size: 9,779 Bytes
3f4c075
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
"""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 = """<?xml version="1.0"?>
<!DOCTYPE BlastOutput PUBLIC "-//NCBI//NCBI BlastOutput/EN" "http://www.ncbi.nlm.nih.gov/dtd/NCBI_BlastOutput.dtd">
<BlastOutput>
  <BlastOutput_program>blastp</BlastOutput_program>
  <BlastOutput_query-len>7</BlastOutput_query-len>
  <BlastOutput_iterations>
    <Iteration>
      <Iteration_iter-num>1</Iteration_iter-num>
      <Iteration_hits>
        <Hit>
          <Hit_num>1</Hit_num>
          <Hit_id>sp|P12345|FOO_HUMAN</Hit_id>
          <Hit_def>sp|P12345|FOO_HUMAN Foo protein [Homo sapiens]</Hit_def>
          <Hit_accession>P12345</Hit_accession>
          <Hit_len>100</Hit_len>
          <Hit_hsps>
            <Hsp>
              <Hsp_num>1</Hsp_num>
              <Hsp_bit-score>50.0</Hsp_bit-score>
              <Hsp_score>100</Hsp_score>
              <Hsp_evalue>1e-5</Hsp_evalue>
              <Hsp_query-from>1</Hsp_query-from>
              <Hsp_query-to>7</Hsp_query-to>
              <Hsp_hit-from>10</Hsp_hit-from>
              <Hsp_hit-to>16</Hsp_hit-to>
              <Hsp_query-frame>1</Hsp_query-frame>
              <Hsp_hit-frame>1</Hsp_hit-frame>
              <Hsp_identity>5</Hsp_identity>
              <Hsp_positive>6</Hsp_positive>
              <Hsp_gaps>1</Hsp_gaps>
              <Hsp_align-len>8</Hsp_align-len>
              <Hsp_qseq>AAAAA--</Hsp_qseq>
              <Hsp_hseq>AAAAA--</Hsp_hseq>
              <Hsp_midline>AAAAA  </Hsp_midline>
            </Hsp>
          </Hit_hsps>
        </Hit>
      </Iteration_hits>
    </Iteration>
  </BlastOutput_iterations>
</BlastOutput>
"""

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)