Samad14 commited on
Commit
44c4b80
·
1 Parent(s): 85a7101

feat(uniprot,domains): reviewed/organism search filters + ScanProsite raw-sequence motif scan (3c,3d)

Browse files
app/routers/domains.py CHANGED
@@ -15,7 +15,8 @@ Provides comprehensive protein feature analysis:
15
  - Combined analysis endpoint
16
  """
17
  from fastapi import APIRouter, HTTPException
18
- from pydantic import BaseModel
 
19
  from app.tools.domain_analysis import (
20
  _sanitize,
21
  fetch_interpro_domains,
@@ -31,6 +32,7 @@ from app.tools.domain_analysis import (
31
  extract_go_terms,
32
  extract_pathways,
33
  full_analysis,
 
34
  )
35
 
36
  router = APIRouter(prefix="/api/domains", tags=["domains"])
@@ -278,6 +280,38 @@ async def get_all_features(accession: str):
278
  return FullAnalysisResponse(**result)
279
 
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  # ---------------------------------------------------------------------------
282
  # Helpers
283
  # ---------------------------------------------------------------------------
 
15
  - Combined analysis endpoint
16
  """
17
  from fastapi import APIRouter, HTTPException
18
+ from pydantic import BaseModel, Field
19
+ from app.config import settings
20
  from app.tools.domain_analysis import (
21
  _sanitize,
22
  fetch_interpro_domains,
 
32
  extract_go_terms,
33
  extract_pathways,
34
  full_analysis,
35
+ scan_prosite_sequence,
36
  )
37
 
38
  router = APIRouter(prefix="/api/domains", tags=["domains"])
 
280
  return FullAnalysisResponse(**result)
281
 
282
 
283
+ # ---------------------------------------------------------------------------
284
+ # ScanProsite — raw-sequence motif scanning
285
+ # ---------------------------------------------------------------------------
286
+
287
+ class ScanPrositeRequest(BaseModel):
288
+ sequence: str = Field(..., min_length=1, description="Raw protein sequence (no FASTA header required)")
289
+
290
+
291
+ class ScanPrositeMatch(BaseModel):
292
+ signature_ac: str
293
+ name: str = ""
294
+ start: int
295
+ stop: int
296
+ level_tag: str = ""
297
+
298
+
299
+ class ScanPrositeResponse(BaseModel):
300
+ sequence_length: int
301
+ count: int
302
+ matches: list[ScanPrositeMatch]
303
+
304
+
305
+ @router.post("/scan", response_model=ScanPrositeResponse)
306
+ async def scan_prosite(req: ScanPrositeRequest):
307
+ """Scan a raw protein sequence against PROSITE signatures (best-effort)."""
308
+ email = settings.NCBI_EMAIL or ""
309
+ result = await scan_prosite_sequence(req.sequence, email)
310
+ if "error" in result:
311
+ raise HTTPException(status_code=400, detail=result["error"])
312
+ return ScanPrositeResponse(**result)
313
+
314
+
315
  # ---------------------------------------------------------------------------
316
  # Helpers
317
  # ---------------------------------------------------------------------------
app/routers/uniprot.py CHANGED
@@ -14,6 +14,8 @@ ncbi_service = NCBIService()
14
  class UniprotSearchRequest(BaseModel):
15
  query: str = Field(..., min_length=2, description="Free-text search (gene name, protein name, keyword)")
16
  max_results: int = Field(20, ge=1, le=50)
 
 
17
 
18
 
19
  class UniprotAccessionRequest(BaseModel):
@@ -27,7 +29,12 @@ class UniprotCDSRequest(BaseModel):
27
  @router.post("/search")
28
  async def search_uniprot(req: UniprotSearchRequest):
29
  url = f"{settings.UNIPROT_BASE_URL}/search"
30
- params = {"query": req.query, "format": "json", "size": req.max_results}
 
 
 
 
 
31
  async with httpx.AsyncClient(timeout=15) as client:
32
  resp = await client.get(url, params=params)
33
  if resp.status_code != 200:
@@ -36,12 +43,14 @@ async def search_uniprot(req: UniprotSearchRequest):
36
  results = data.get("results", [])
37
  out = []
38
  for r in results:
 
39
  out.append({
40
  "accession": r.get("primaryAccession", ""),
41
  "name": ((r.get("proteinDescription", {}) or {}).get("recommendedName", {}) or {}).get("fullName", {}).get("value", ""),
42
  "gene_names": [g.get("geneName", {}).get("value", "") for g in (r.get("genes") or []) if g.get("geneName")],
43
  "organism": (r.get("organism", {}) or {}).get("scientificName", ""),
44
  "length": ((r.get("sequence", {}) or {}).get("length", 0)),
 
45
  })
46
  return {"results": out, "count": len(out)}
47
 
 
14
  class UniprotSearchRequest(BaseModel):
15
  query: str = Field(..., min_length=2, description="Free-text search (gene name, protein name, keyword)")
16
  max_results: int = Field(20, ge=1, le=50)
17
+ reviewed: bool = Field(False, description="Only Swiss-Prot (reviewed) entries")
18
+ organism: str = Field("", description="Restrict results to an organism (e.g. Homo sapiens)")
19
 
20
 
21
  class UniprotAccessionRequest(BaseModel):
 
29
  @router.post("/search")
30
  async def search_uniprot(req: UniprotSearchRequest):
31
  url = f"{settings.UNIPROT_BASE_URL}/search"
32
+ query = req.query.strip()
33
+ if req.organism.strip():
34
+ query = f'{query} AND organism_name:"{req.organism.strip()}"'
35
+ if req.reviewed:
36
+ query = f"{query} AND reviewed:true"
37
+ params = {"query": query, "format": "json", "size": req.max_results}
38
  async with httpx.AsyncClient(timeout=15) as client:
39
  resp = await client.get(url, params=params)
40
  if resp.status_code != 200:
 
43
  results = data.get("results", [])
44
  out = []
45
  for r in results:
46
+ entry_type = (r.get("entryType", "") or "").lower()
47
  out.append({
48
  "accession": r.get("primaryAccession", ""),
49
  "name": ((r.get("proteinDescription", {}) or {}).get("recommendedName", {}) or {}).get("fullName", {}).get("value", ""),
50
  "gene_names": [g.get("geneName", {}).get("value", "") for g in (r.get("genes") or []) if g.get("geneName")],
51
  "organism": (r.get("organism", {}) or {}).get("scientificName", ""),
52
  "length": ((r.get("sequence", {}) or {}).get("length", 0)),
53
+ "reviewed": "reviewed" in entry_type and "unreviewed" not in entry_type,
54
  })
55
  return {"results": out, "count": len(out)}
56
 
app/tools/domain_analysis.py CHANGED
@@ -7,19 +7,28 @@ functional sites, PTMs, topology, motifs, variants, GO terms, pathways).
7
  Provides the shared analysis functions used by both the standalone router
8
  and the pipeline_v2 orchestrator.
9
  """
 
10
  import re
11
  import httpx
12
  from typing import Any
13
 
 
 
14
 
15
  INTERPRO_API = "https://www.ebi.ac.uk/interpro/api/entry/all/protein/UniProt/{accession}/?format=json&page_size=50"
16
  UNIPROT_API = "https://rest.uniprot.org/uniprotkb/{accession}.json"
 
 
17
 
18
 
19
  def _sanitize(s: str) -> str:
20
  return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s).strip().upper()
21
 
22
 
 
 
 
 
23
  async def fetch_uniprot_raw(accession: str) -> dict:
24
  """Fetch raw UniProt JSON for an accession."""
25
  accession = _sanitize(accession)
@@ -384,7 +393,6 @@ def extract_pathways(raw: dict) -> list[dict]:
384
  # ---------------------------------------------------------------------------
385
  # 12. Combined Analysis (all features at once)
386
  # ---------------------------------------------------------------------------
387
-
388
  async def full_analysis(accession: str) -> dict:
389
  """Run all domain/motif analyses for a UniProt accession."""
390
  accession = _sanitize(accession)
@@ -430,3 +438,71 @@ async def full_analysis(accession: str) -> dict:
430
  cat: len(items) for cat, items in features.items() if items
431
  },
432
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  Provides the shared analysis functions used by both the standalone router
8
  and the pipeline_v2 orchestrator.
9
  """
10
+ import hashlib
11
  import re
12
  import httpx
13
  from typing import Any
14
 
15
+ from app.config import settings
16
+ from app.services.cache import cache_get, cache_set
17
 
18
  INTERPRO_API = "https://www.ebi.ac.uk/interpro/api/entry/all/protein/UniProt/{accession}/?format=json&page_size=50"
19
  UNIPROT_API = "https://rest.uniprot.org/uniprotkb/{accession}.json"
20
+ SCANPROSITE_URL = "https://prosite.expasy.org/cgi-bin/prosite/scanprosite/PSScan.cgi"
21
+ INTERPRO_ENTRY_API = "https://www.ebi.ac.uk/interpro/api/entry/prosite/{signature}/"
22
 
23
 
24
  def _sanitize(s: str) -> str:
25
  return re.sub(r'[\x00-\x1f\x7f-\x9f]', '', s).strip().upper()
26
 
27
 
28
+ def _clean_sequence(sequence: str) -> str:
29
+ return "".join(c for c in _sanitize(sequence) if c.isalpha())
30
+
31
+
32
  async def fetch_uniprot_raw(accession: str) -> dict:
33
  """Fetch raw UniProt JSON for an accession."""
34
  accession = _sanitize(accession)
 
393
  # ---------------------------------------------------------------------------
394
  # 12. Combined Analysis (all features at once)
395
  # ---------------------------------------------------------------------------
 
396
  async def full_analysis(accession: str) -> dict:
397
  """Run all domain/motif analyses for a UniProt accession."""
398
  accession = _sanitize(accession)
 
438
  cat: len(items) for cat, items in features.items() if items
439
  },
440
  }
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # 13. ScanProsite — raw-sequence motif scan
445
+ # ---------------------------------------------------------------------------
446
+
447
+ def _signature_cache_key(signature_ac: str) -> str:
448
+ return f"prosite_signature_name:{hashlib.sha256(signature_ac.encode()).hexdigest()[:16]}"
449
+
450
+
451
+ async def _prosite_signature_name(signature_ac: str) -> str:
452
+ """Best-effort PROSITE signature name resolution via InterPro (cached)."""
453
+ cached = cache_get(_signature_cache_key(signature_ac))
454
+ if cached is not None:
455
+ return cached
456
+ name = ""
457
+ try:
458
+ async with httpx.AsyncClient(timeout=15) as client:
459
+ resp = await client.get(INTERPRO_ENTRY_API.format(signature=signature_ac))
460
+ if resp.status_code == 200:
461
+ md = resp.json().get("metadata") or {}
462
+ nm = md.get("name") or {}
463
+ name = str(nm.get("name") or "")
464
+ except Exception:
465
+ pass
466
+ if name:
467
+ cache_set(_signature_cache_key(signature_ac), name, ttl=86400)
468
+ return name
469
+
470
+
471
+ async def scan_prosite_sequence(sequence: str, email: str = "") -> dict:
472
+ """Scan a raw protein sequence against PROSITE signatures.
473
+
474
+ Contract verified live (2026-08-03): POST the sequence to ScanProsite with
475
+ ``output=json``; the response is ``{"n_match", "n_seq", "matchset": [...]}``
476
+ where each match has ``sequence_ac``/``start``/``stop``/``signature_ac``/
477
+ ``level_tag``. Returns ``{"sequence_length", "count", "matches"}``.
478
+ """
479
+ clean = _clean_sequence(sequence)
480
+ if len(clean) < 10:
481
+ return {"error": "Sequence too short (min 10 amino acids)"}
482
+
483
+ async with httpx.AsyncClient(timeout=60) as client:
484
+ resp = await client.post(
485
+ SCANPROSITE_URL,
486
+ data={"seq": clean, "output": "json", "email": email or "bioflow@example.com"},
487
+ )
488
+ if resp.status_code != 200:
489
+ return {"error": f"ScanProsite returned HTTP {resp.status_code}"}
490
+ data = resp.json()
491
+
492
+ matchset = data.get("matchset") or []
493
+ matches: list[dict] = []
494
+ for m in matchset:
495
+ signature_ac = str(m.get("signature_ac", ""))
496
+ matches.append({
497
+ "signature_ac": signature_ac,
498
+ "name": await _prosite_signature_name(signature_ac),
499
+ "start": int(m.get("start", 0)),
500
+ "stop": int(m.get("stop", 0)),
501
+ "level_tag": str(m.get("level_tag", "")),
502
+ })
503
+
504
+ return {
505
+ "sequence_length": len(clean),
506
+ "count": len(matches),
507
+ "matches": matches,
508
+ }
app/tools/uniprot.py CHANGED
@@ -21,6 +21,7 @@ class UniprotTool(BaseTool):
21
 
22
  return {
23
  "accession": data.get("primaryAccession", ""),
 
24
  "full_name": self._extract_name(data),
25
  "ec_number": (data.get("proteinDescription", {}) or {}).get("ecNumbers", [{}])[0].get("ecNumber", "") if data.get("proteinDescription") else "",
26
  "gene_names": [g.get("geneName", {}).get("value", "") for g in (data.get("genes") or []) if g.get("geneName")],
 
21
 
22
  return {
23
  "accession": data.get("primaryAccession", ""),
24
+ "reviewed": "reviewed" in (data.get("entryType", "") or "").lower() and "unreviewed" not in (data.get("entryType", "") or "").lower(),
25
  "full_name": self._extract_name(data),
26
  "ec_number": (data.get("proteinDescription", {}) or {}).get("ecNumbers", [{}])[0].get("ecNumber", "") if data.get("proteinDescription") else "",
27
  "gene_names": [g.get("geneName", {}).get("value", "") for g in (data.get("genes") or []) if g.get("geneName")],
tests/test_scanprosite.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for 3d ScanProsite raw-sequence motif scanning:
3
+
4
+ - scan_prosite_sequence parses the verified ScanProsite JSON contract
5
+ - short sequences return a clear error
6
+ - empty matchset -> count 0
7
+ - POST /api/domains/scan router: success + 400 on short sequence
8
+
9
+ Network calls are mocked; these run fully offline.
10
+ """
11
+
12
+ import pytest
13
+ from fastapi import HTTPException
14
+
15
+ from app.tools import domain_analysis as da
16
+ from app.routers import domains as domains_router
17
+ from app.routers.domains import ScanPrositeRequest, scan_prosite
18
+
19
+
20
+ class FakeResp:
21
+ def __init__(self, status_code, json_data):
22
+ self.status_code = status_code
23
+ self._json = json_data
24
+
25
+ def json(self):
26
+ return self._json
27
+
28
+
29
+ class FakeClient:
30
+ def __init__(self, *args, **kwargs):
31
+ self.calls = []
32
+
33
+ async def __aenter__(self):
34
+ return self
35
+
36
+ async def __aexit__(self, *args):
37
+ return None
38
+
39
+ async def post(self, url, data=None, **kwargs):
40
+ self.calls.append((url, data))
41
+ return FakeResp(200, {
42
+ "n_match": 1,
43
+ "n_seq": 1,
44
+ "matchset": [
45
+ {"sequence_ac": "USERSEQ1", "start": 237, "stop": 249,
46
+ "signature_ac": "PS00348", "level_tag": "(0)"},
47
+ ],
48
+ })
49
+
50
+
51
+ class TestScanPrositeSequence:
52
+ @pytest.mark.asyncio
53
+ async def test_parses_json_contract(self, monkeypatch):
54
+ fake = FakeClient()
55
+ monkeypatch.setattr(da.httpx, "AsyncClient", lambda *a, **k: fake)
56
+ monkeypatch.setattr(da, "_prosite_signature_name",
57
+ lambda sig: _fake_name(sig))
58
+
59
+ seq = "M" * 250
60
+ result = await da.scan_prosite_sequence(seq)
61
+ assert result["sequence_length"] == 250
62
+ assert result["count"] == 1
63
+ m = result["matches"][0]
64
+ assert m["signature_ac"] == "PS00348"
65
+ assert m["start"] == 237 and m["stop"] == 249
66
+ assert m["name"] == "p53 family signature"
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_empty_matchset(self, monkeypatch):
70
+ class FakeClientEmpty(FakeClient):
71
+ async def post(self, url, data=None, **kwargs):
72
+ self.calls.append((url, data))
73
+ return FakeResp(200, {"n_match": 0, "n_seq": 1})
74
+
75
+ monkeypatch.setattr(da.httpx, "AsyncClient", lambda *a, **k: FakeClientEmpty())
76
+ result = await da.scan_prosite_sequence("M" * 50)
77
+ assert result["count"] == 0
78
+ assert result["matches"] == []
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_short_sequence_rejected(self):
82
+ result = await da.scan_prosite_sequence("MEEPQ")
83
+ assert "error" in result
84
+ assert "too short" in result["error"]
85
+
86
+ @pytest.mark.asyncio
87
+ async def test_http_error(self, monkeypatch):
88
+ class FakeClientFail(FakeClient):
89
+ async def post(self, url, data=None, **kwargs):
90
+ return FakeResp(500, {})
91
+
92
+ monkeypatch.setattr(da.httpx, "AsyncClient", lambda *a, **k: FakeClientFail())
93
+ result = await da.scan_prosite_sequence("M" * 50)
94
+ assert "error" in result
95
+ assert "HTTP 500" in result["error"]
96
+
97
+
98
+ class TestScanPrositeEndpoint:
99
+ @pytest.mark.asyncio
100
+ async def test_success(self, monkeypatch):
101
+ async def fake_scan(sequence, email=""):
102
+ assert email == "bioflow@example.com"
103
+ return {"sequence_length": 10, "count": 1, "matches": [
104
+ {"signature_ac": "PS00001", "name": "N-glycosylation site",
105
+ "start": 2, "stop": 5, "level_tag": "(0)"},
106
+ ]}
107
+
108
+ monkeypatch.setattr(domains_router, "scan_prosite_sequence", fake_scan)
109
+ resp = await scan_prosite(ScanPrositeRequest(sequence="MEEPQSDPSV"))
110
+ assert resp.sequence_length == 10
111
+ assert resp.count == 1
112
+ assert resp.matches[0].name == "N-glycosylation site"
113
+
114
+ @pytest.mark.asyncio
115
+ async def test_short_sequence_400(self, monkeypatch):
116
+ async def fake_scan(sequence, email=""):
117
+ return {"error": "Sequence too short (min 10 amino acids)"}
118
+
119
+ monkeypatch.setattr(domains_router, "scan_prosite_sequence", fake_scan)
120
+ with pytest.raises(HTTPException) as exc:
121
+ await scan_prosite(ScanPrositeRequest(sequence="MEEPQ"))
122
+ assert exc.value.status_code == 400
123
+ assert "too short" in exc.value.detail
124
+
125
+
126
+ async def _fake_name(sig: str) -> str:
127
+ return "p53 family signature"
tests/test_uniprot_search.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for 3c UniProt search polish:
3
+
4
+ - reviewed/organism filters are folded into the UniProt query string
5
+ - search results carry a `reviewed` flag derived from entryType
6
+
7
+ Network calls are mocked; these run fully offline.
8
+ """
9
+
10
+ import pytest
11
+ from fastapi import HTTPException
12
+
13
+ from app.routers import uniprot as uniprot_router
14
+ from app.routers.uniprot import UniprotSearchRequest, search_uniprot
15
+
16
+
17
+ class FakeResp:
18
+ def __init__(self, status_code, json_data):
19
+ self.status_code = status_code
20
+ self._json = json_data
21
+
22
+ def json(self):
23
+ return self._json
24
+
25
+
26
+ class FakeClient:
27
+ def __init__(self, *args, **kwargs):
28
+ self.calls = []
29
+
30
+ async def __aenter__(self):
31
+ return self
32
+
33
+ async def __aexit__(self, *args):
34
+ return None
35
+
36
+ async def get(self, url, params=None, **kwargs):
37
+ self.calls.append((url, params))
38
+ results = []
39
+ for i in range(params.get("size", 1)):
40
+ results.append({
41
+ "primaryAccession": f"P{i:05d}",
42
+ "proteinDescription": {"recommendedName": {"fullName": {"value": "Test protein"}}},
43
+ "genes": [{"geneName": {"value": "TP53"}}],
44
+ "organism": {"scientificName": "Homo sapiens"},
45
+ "sequence": {"length": 100},
46
+ "entryType": "UniProtKB reviewed (Swiss-Prot)",
47
+ })
48
+ return FakeResp(200, {"results": results})
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_reviewed_and_organism_fold_into_query(monkeypatch):
53
+ fake = FakeClient()
54
+ monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
55
+ req = UniprotSearchRequest(query="p53", max_results=1, reviewed=True, organism="Homo sapiens")
56
+ res = await search_uniprot(req)
57
+ assert res["count"] == 1
58
+ assert res["results"][0]["reviewed"] is True
59
+ _, params = fake.calls[0]
60
+ assert "AND organism_name:\"Homo sapiens\"" in params["query"]
61
+ assert "AND reviewed:true" in params["query"]
62
+
63
+
64
+ @pytest.mark.asyncio
65
+ async def test_no_filters_query_unchanged(monkeypatch):
66
+ fake = FakeClient()
67
+ monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
68
+ req = UniprotSearchRequest(query="BRCA1")
69
+ res = await search_uniprot(req)
70
+ assert res["results"][0]["reviewed"] is True
71
+ _, params = fake.calls[0]
72
+ assert params["query"] == "BRCA1"
73
+
74
+
75
+ @pytest.mark.asyncio
76
+ async def test_reviewed_flag_false_for_trembl(monkeypatch):
77
+ class FakeClientTrEmbl(FakeClient):
78
+ async def get(self, url, params=None, **kwargs):
79
+ self.calls.append((url, params))
80
+ return FakeResp(200, {"results": [{
81
+ "primaryAccession": "A0A1111111",
82
+ "proteinDescription": {},
83
+ "genes": [],
84
+ "organism": {"scientificName": "Unknown"},
85
+ "sequence": {"length": 50},
86
+ "entryType": "UniProtKB unreviewed (TrEMBL)",
87
+ }]})
88
+
89
+ fake = FakeClientTrEmbl()
90
+ monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
91
+ req = UniprotSearchRequest(query="hypothetical")
92
+ res = await search_uniprot(req)
93
+ assert res["results"][0]["reviewed"] is False
94
+
95
+
96
+ @pytest.mark.asyncio
97
+ async def test_search_failure_raises_502(monkeypatch):
98
+ class FakeClientFail(FakeClient):
99
+ async def get(self, url, params=None, **kwargs):
100
+ self.calls.append((url, params))
101
+ return FakeResp(500, {})
102
+
103
+ fake = FakeClientFail()
104
+ monkeypatch.setattr(uniprot_router.httpx, "AsyncClient", lambda *a, **k: fake)
105
+ with pytest.raises(HTTPException) as exc:
106
+ await search_uniprot(UniprotSearchRequest(query="p53"))
107
+ assert exc.value.status_code == 502