""" 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"