File size: 19,672 Bytes
cd0c7a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
"""
Comprehensive BLAST test suite β€” loops over multiple datasets to verify
all BLAST features work end-to-end against live NCBI/EBI APIs.

Tests:
  1. NCBI BLAST integration (submit β†’ poll β†’ fetch)
  2. EBI BLAST tool (submit β†’ poll β†’ fetch)
  3. Multiple protein sequences of varying lengths
  4. Error handling (bad input, edge cases)
  5. Pipeline v2 BLAST step
  6. Retry logic verification

Run:  pytest tests/test_blast_comprehensive.py -v -s --timeout=900
"""

import asyncio
import random
import string
import pytest

# ---------------------------------------------------------------------------
# Test datasets β€” real protein sequences of varying lengths
# ---------------------------------------------------------------------------
BLAST_TEST_SEQUENCES = [
    {
        "name": "crambin_short",
        "sequence": "TTCCPSIVARSNFNVCRLPG",
        "description": "Crambin first 20 residues (20 aa)",
        "expected_program": "blastp",
        "min_hits": 1,
    },
    {
        "name": "human_insulin",
        "sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
        "description": "Human insulin preproinsulin (110 aa)",
        "expected_program": "blastp",
        "min_hits": 1,
    },
    {
        "name": "gfp_fragment",
        "sequence": "MSKGEELFTGVVPILVELDGDVNGHKFSVSGEGEGDATYGKLTLKFICTTGKLPVPWPTLVTTFSYGVQCFSRYPDHMKQHDFFKSAMPEGYVQERTIFFKDDGNYKTRAEVKFEGDTLVNRIELKGIDFKEDGNILGHKLEYNYNSHNVYIMADKQKNGIKVNFKIRHNIEDGSVQLADHYQQNTPIGDGPVLLPDNHYLSTQSALSKDPNEKRDHMVLLEFVTAAGITHGMDELYK",
        "description": "Green fluorescent protein (238 aa)",
        "expected_program": "blastp",
        "min_hits": 1,
    },
    {
        "name": "lysozyme",
        "sequence": "KVFGRCELAAAMKRHGLDNYRGYSLGNWVCAAKFESNFNTQATNRNTDGSTDYGILQINSRWWCNDGRTPGSRNLCNIPCSALLSSDITASVNCAKKIVSDGNGMNAWVAWRNRCKGTDVQAWIRGCRL",
        "description": "Hen egg white lysozyme (129 aa)",
        "expected_program": "blastp",
        "min_hits": 1,
    },
    {
        "name": "human_hemoglobin_beta",
        "sequence": "MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
        "description": "Human hemoglobin beta subunit (147 aa)",
        "expected_program": "blastp",
        "min_hits": 1,
    },
]

# DNA sequence for blastn testing
DNA_TEST_SEQUENCES = [
    {
        "name": "small_rna",
        "sequence": "ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
        "description": "Short DNA fragment (33 bp)",
        "expected_program": "blastn",
        "min_hits": 0,  # DNA may or may not hit depending on db
    },
]

# ---------------------------------------------------------------------------
# 1. NCBI BLAST integration β€” direct API tests
# ---------------------------------------------------------------------------
class TestNCBIBlastIntegration:
    """Test the NCBI BLAST integration module directly (submit β†’ poll β†’ fetch)."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "dataset",
        BLAST_TEST_SEQUENCES[:3],  # First 3 for speed
        ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:3]],
    )
    async def test_ncbi_blast_submit_and_poll(self, dataset):
        """Submit a BLAST job and poll until READY or TIMEOUT."""
        from app.integrations.ncbi.blast import submit_blast, check_status_until_ready

        seq = dataset["sequence"]
        program = dataset["expected_program"]
        db = "swissprot"  # Use swissprot for faster results in tests

        submit_result = await submit_blast(
            seq, program=program, database=db, hitlist_size=10,
        )

        if "error" in submit_result:
            # NCBI may rate-limit; skip if submit fails
            pytest.skip(f"NCBI submit failed: {submit_result['error']}")

        rid = submit_result["rid"]
        assert rid, "RID should not be empty"
        assert len(rid) > 5, f"RID looks too short: {rid}"

        # Poll with generous timeout (2 min for tests)
        status_result = await check_status_until_ready(rid, max_wait_seconds=120)
        status = status_result.get("status", "UNKNOWN")
        assert status in ("READY", "TIMEOUT", "POLL_FAILED", "ERROR", "FAILED"), \
            f"Unexpected status: {status}"

    @pytest.mark.asyncio
    async def test_ncbi_blast_full_roundtrip_short_seq(self):
        """Full roundtrip: submit β†’ poll β†’ fetch results for crambin fragment."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        result = await run_blast_with_retry(
            "TTCCPSIVARSNFNVCRLPG",
            retries=2,
            max_wait_seconds=180,
            program="blastp",
            database="swissprot",
            hitlist_size=10,
        )

        if "error" in result:
            pytest.skip(f"NCBI BLAST roundtrip failed (external API issue): {result['error']}")

        assert "raw" in result, "Should return raw XML"
        assert len(result["raw"]) > 100, "Raw XML too short"

    @pytest.mark.asyncio
    async def test_ncbi_blast_parse_xml(self):
        """Verify XML parsing of NCBI BLAST results."""
        from app.integrations.ncbi.blast import run_blast_with_retry
        from app.integrations.ncbi.parser import parse_blast_xml

        result = await run_blast_with_retry(
            "MVHLTPEEKSAVTALWGKVNVDEVGGEALGRLLVVYPWTQRFFESFGDLSTPDAVMGNPKVKAHGKKVLGAFSDGLAHLDNLKGTFATLSELHCDKLHVDPENFRLLGNVLVCVLAHHFGKEFTPPVQAAYQKVVAGVANALAHKYH",
            retries=2,
            max_wait_seconds=180,
            program="blastp",
            database="swissprot",
            hitlist_size=10,
        )

        if "error" in result:
            pytest.skip(f"BLAST failed: {result['error']}")

        parsed = parse_blast_xml(result["raw"])
        assert "error" not in parsed, f"Parse error: {parsed.get('error')}"
        assert parsed["count"] > 0, "Should find at least one hit"
        assert parsed["query_length"] > 0, "Query length should be positive"

        hit = parsed["hits"][0]
        assert hit["accession"], "Hit should have accession"
        assert hit["evalue"] >= 0, "E-value should be non-negative"
        assert hit["bit_score"] > 0, "Bit score should be positive"

    @pytest.mark.asyncio
    async def test_ncbi_blast_retry_on_failure(self):
        """Verify that retry logic works β€” submitting with invalid sequence should fail gracefully."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        # Submit with clearly invalid sequence (too short / garbage)
        result = await run_blast_with_retry(
            "XXXX",
            retries=1,
            max_wait_seconds=30,
            program="blastp",
            database="swissprot",
        )

        # Should either get an error or no hits β€” both are acceptable
        # The key is it shouldn't crash with an unhandled exception
        assert isinstance(result, dict), "Result should be a dict"

    @pytest.mark.asyncio
    async def test_ncbi_blast_timeout_handling(self):
        """Verify timeout returns proper error, not an unhandled exception."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        result = await run_blast_with_retry(
            "TTCCPSIVARSNFNVCRLPG",
            retries=0,
            max_wait_seconds=5,  # Very short timeout β€” will almost certainly time out
            program="blastp",
            database="nr",  # nr is slower than swissprot
        )

        # Should return a dict with error or hits β€” never crash
        assert isinstance(result, dict), "Should return dict even on timeout"
        # Either it finished very fast (unlikely) or it timed out gracefully
        assert "error" in result or "raw" in result


# ---------------------------------------------------------------------------
# 2. EBI BLAST tool tests
# ---------------------------------------------------------------------------
class TestEBIBlastTool:
    """Test the EBI BLAST tool (BlastTool class)."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize(
        "dataset",
        BLAST_TEST_SEQUENCES[:2],
        ids=[d["name"] for d in BLAST_TEST_SEQUENCES[:2]],
    )
    async def test_ebi_blast_returns_hits(self, dataset):
        """EBI BLAST should find hits for known proteins."""
        from app.tools.blast import BlastTool

        tool = BlastTool()
        result = await tool.run({
            "sequence": dataset["sequence"],
            "program": "blastp",
            "database": "uniprotkb_swissprot",
            "max_hits": 5,
        })

        assert "hits" in result or "error" in result
        if "hits" in result:
            assert len(result["hits"]) > 0, "Should find at least one hit"
            assert result["count"] == len(result["hits"])

    @pytest.mark.asyncio
    async def test_ebi_blast_hit_structure(self):
        """Verify BLAST hit data structure is correct."""
        from app.tools.blast import BlastTool

        tool = BlastTool()
        result = await tool.run({
            "sequence": "TTCCPSIVARSNFNVCRLPG",
            "program": "blastp",
            "database": "uniprotkb_swissprot",
            "max_hits": 3,
        })

        if "error" in result:
            pytest.skip(f"EBI BLAST failed: {result['error']}")

        for hit in result["hits"]:
            assert "accession" in hit
            assert "evalue" in hit
            assert "bit_score" in hit
            assert "identity_pct" in hit
            assert hit["evalue"] < 1.0, f"E-value too high: {hit['evalue']}"

    @pytest.mark.asyncio
    async def test_ebi_blast_poll_resilience(self):
        """Verify EBI BLAST poll handles transient failures."""
        from app.tools.blast import BlastTool

        tool = BlastTool()
        # This tests the enhanced poll with failure tolerance
        result = await tool.run({
            "sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN",
            "program": "blastp",
            "database": "uniprotkb_swissprot",
            "max_hits": 5,
        })

        # Should not crash β€” either hits or graceful error
        assert isinstance(result, dict)


# ---------------------------------------------------------------------------
# 3. Pipeline v2 BLAST step integration
# ---------------------------------------------------------------------------
class TestPipelineV2BLAST:
    """Test BLAST as part of the pipeline v2 flow."""

    @pytest.mark.asyncio
    async def test_pipeline_blast_step_only(self):
        """Run only the BLAST step via pipeline_v2."""
        from app.integrations.ncbi.blast import run_blast_with_retry
        from app.integrations.ncbi.parser import parse_blast_xml

        # Simulate what pipeline_v2._run_blast does
        sequence = "TTCCPSIVARSNFNVCRLPG"
        database = "swissprot"

        results = await run_blast_with_retry(
            sequence,
            retries=2,
            max_wait_seconds=180,
            database=database,
        )

        if "error" in results:
            pytest.skip(f"Pipeline BLAST step failed: {results['error']}")

        parsed = parse_blast_xml(results["raw"])
        assert "error" not in parsed
        assert parsed["count"] > 0

        hits = parsed.get("hits", [])
        top_hit = hits[0] if hits else None

        # Verify the data structure matches what pipeline_v2 expects
        if top_hit:
            assert "accession" in top_hit
            assert "evalue" in top_hit
            assert "identity_pct" in top_hit
            assert "bit_score" in top_hit

    @pytest.mark.asyncio
    async def test_pipeline_blast_multiple_sequences_loop(self):
        """Loop: run BLAST for each test sequence and verify results."""
        from app.integrations.ncbi.blast import run_blast_with_retry
        from app.integrations.ncbi.parser import parse_blast_xml

        results_summary = []

        for dataset in BLAST_TEST_SEQUENCES:
            sequence = dataset["sequence"]
            name = dataset["name"]

            try:
                blast_result = await run_blast_with_retry(
                    sequence,
                    retries=2,
                    max_wait_seconds=180,
                    program=dataset["expected_program"],
                    database="swissprot",
                    hitlist_size=10,
                )

                if "error" in blast_result:
                    results_summary.append({
                        "name": name,
                        "status": "error",
                        "error": blast_result["error"],
                    })
                    continue

                parsed = parse_blast_xml(blast_result["raw"])
                hit_count = parsed.get("count", 0)
                results_summary.append({
                    "name": name,
                    "status": "ok" if hit_count >= dataset["min_hits"] else "low_hits",
                    "hits": hit_count,
                })

            except Exception as e:
                results_summary.append({
                    "name": name,
                    "status": "exception",
                    "error": str(e),
                })

            # Rate limit: wait between requests
            await asyncio.sleep(2)

        # Report results
        print("\n=== BLAST Loop Test Results ===")
        for r in results_summary:
            print(f"  {r['name']}: {r['status']} (hits={r.get('hits', 'N/A')}, error={r.get('error', 'none')})")

        # At least some should succeed (unless NCBI is completely down)
        ok_count = sum(1 for r in results_summary if r["status"] == "ok")
        assert ok_count >= 1, f"No BLAST tests succeeded: {results_summary}"


# ---------------------------------------------------------------------------
# 4. Error handling & edge cases
# ---------------------------------------------------------------------------
class TestBlastErrorHandling:
    """Test BLAST error handling with bad inputs."""

    @pytest.mark.asyncio
    async def test_empty_sequence(self):
        """BLAST should handle empty sequence gracefully."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        result = await run_blast_with_retry(
            "",
            retries=0,
            max_wait_seconds=10,
            database="swissprot",
        )

        assert isinstance(result, dict)
        assert "error" in result or "raw" in result

    @pytest.mark.asyncio
    async def test_invalid_characters(self):
        """BLAST should handle non-biological characters."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        result = await run_blast_with_retry(
            "12345!@#$%^&*()",
            retries=0,
            max_wait_seconds=30,
            program="blastp",
            database="swissprot",
        )

        assert isinstance(result, dict)

    @pytest.mark.asyncio
    async def test_very_long_sequence(self):
        """BLAST should handle long sequences (may be slow but shouldn't crash)."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        # Generate a 500 aa random protein sequence
        aa_chars = "ACDEFGHIKLMNPQRSTVWY"
        long_seq = "".join(random.choice(aa_chars) for _ in range(500))

        result = await run_blast_with_retry(
            long_seq,
            retries=1,
            max_wait_seconds=120,
            program="blastp",
            database="swissprot",
        )

        assert isinstance(result, dict)

    @pytest.mark.asyncio
    async def test_dna_blastn(self):
        """Test blastn with a short DNA sequence."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        result = await run_blast_with_retry(
            "ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG",
            retries=1,
            max_wait_seconds=120,
            program="blastn",
            database="nt",
            hitlist_size=5,
        )

        assert isinstance(result, dict)


# ---------------------------------------------------------------------------
# 5. Random data fuzz testing
# ---------------------------------------------------------------------------
class TestBlastRandomFuzz:
    """Fuzz test: BLAST with random sequences to verify no crashes."""

    @pytest.mark.asyncio
    @pytest.mark.parametrize("i", range(5))
    async def test_random_protein_sequence(self, i):
        """Submit 5 random protein sequences β€” none should crash."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        aa_chars = "ACDEFGHIKLMNPQRSTVWY"
        length = random.randint(10, 80)
        random_seq = "".join(random.choice(aa_chars) for _ in range(length))

        result = await run_blast_with_retry(
            random_seq,
            retries=1,
            max_wait_seconds=90,
            program="blastp",
            database="swissprot",
            hitlist_size=5,
        )

        # Must return a dict β€” no unhandled exceptions
        assert isinstance(result, dict), f"BLAST crashed on random seq: {random_seq}"
        # Should have either error or raw (results)
        assert "error" in result or "raw" in result

    @pytest.mark.asyncio
    async def test_random_edge_case_lengths(self):
        """Test BLAST with edge-case sequence lengths."""
        from app.integrations.ncbi.blast import run_blast_with_retry

        aa_chars = "ACDEFGHIKLMNPQRSTVWY"
        edge_cases = [
            ("min_valid", "".join(random.choice(aa_chars) for _ in range(6))),  # minimum
            ("medium", "".join(random.choice(aa_chars) for _ in range(50))),
            ("long", "".join(random.choice(aa_chars) for _ in range(200))),
        ]

        for name, seq in edge_cases:
            result = await run_blast_with_retry(
                seq,
                retries=1,
                max_wait_seconds=90,
                program="blastp",
                database="swissprot",
                hitlist_size=5,
            )
            assert isinstance(result, dict), f"BLAST crashed on {name} ({len(seq)} aa)"
            await asyncio.sleep(2)


# ---------------------------------------------------------------------------
# 6. Consecutive reliability test
# ---------------------------------------------------------------------------
class TestBlastReliability:
    """Run BLAST multiple times in sequence to verify consistent behavior."""

    @pytest.mark.asyncio
    async def test_consecutive_blast_runs(self):
        """Run BLAST 3 times with the same sequence β€” should all succeed or all fail consistently."""
        from app.integrations.ncbi.blast import run_blast_with_retry
        from app.integrations.ncbi.parser import parse_blast_xml

        sequence = "TTCCPSIVARSNFNVCRLPG"
        results = []

        for i in range(3):
            result = await run_blast_with_retry(
                sequence,
                retries=2,
                max_wait_seconds=120,
                program="blastp",
                database="swissprot",
                hitlist_size=5,
            )
            results.append(result)
            await asyncio.sleep(3)

        # Count successes vs failures
        successes = sum(1 for r in results if "raw" in r)
        failures = sum(1 for r in results if "error" in r)

        print(f"\n=== Reliability: {successes} successes, {failures} failures out of 3 ===")

        # At least 2/3 should succeed (NCBI occasional hiccups are OK)
        assert successes >= 2, f"Too many BLAST failures: {failures}/3 failed"