| """ |
| 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 |
|
|
| |
| |
| |
| 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_TEST_SEQUENCES = [ |
| { |
| "name": "small_rna", |
| "sequence": "ATGGCGACCGGCGCTCCCGCCGGGATCGCCATG", |
| "description": "Short DNA fragment (33 bp)", |
| "expected_program": "blastn", |
| "min_hits": 0, |
| }, |
| ] |
|
|
| |
| |
| |
| class TestNCBIBlastIntegration: |
| """Test the NCBI BLAST integration module directly (submit → poll → fetch).""" |
|
|
| @pytest.mark.asyncio |
| @pytest.mark.parametrize( |
| "dataset", |
| BLAST_TEST_SEQUENCES[:3], |
| 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" |
|
|
| submit_result = await submit_blast( |
| seq, program=program, database=db, hitlist_size=10, |
| ) |
|
|
| if "error" in submit_result: |
| |
| 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}" |
|
|
| |
| 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 |
|
|
| |
| result = await run_blast_with_retry( |
| "XXXX", |
| retries=1, |
| max_wait_seconds=30, |
| program="blastp", |
| database="swissprot", |
| ) |
|
|
| |
| |
| 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, |
| program="blastp", |
| database="nr", |
| ) |
|
|
| |
| assert isinstance(result, dict), "Should return dict even on timeout" |
| |
| assert "error" in result or "raw" in result |
|
|
|
|
| |
| |
| |
| 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() |
| |
| result = await tool.run({ |
| "sequence": "MALWMRLLPLLALLALWGPDPAAAFVNQHLCGSHLVEALYLVCGERGFFYTPKTRREAEDLQVGQVELGGGPGAGSLQPLALEGSLQKRGIVEQCCTSICSLYQLENYCN", |
| "program": "blastp", |
| "database": "uniprotkb_swissprot", |
| "max_hits": 5, |
| }) |
|
|
| |
| assert isinstance(result, dict) |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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), |
| }) |
|
|
| |
| await asyncio.sleep(2) |
|
|
| |
| 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')})") |
|
|
| |
| ok_count = sum(1 for r in results_summary if r["status"] == "ok") |
| assert ok_count >= 1, f"No BLAST tests succeeded: {results_summary}" |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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, |
| ) |
|
|
| |
| assert isinstance(result, dict), f"BLAST crashed on random seq: {random_seq}" |
| |
| 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))), |
| ("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) |
|
|
|
|
| |
| |
| |
| 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) |
|
|
| |
| 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 ===") |
|
|
| |
| assert successes >= 2, f"Too many BLAST failures: {failures}/3 failed" |
|
|