Files
learningcircuit--local-deep…/tests/search_engines/test_search_engine_pubchem_deep_coverage.py
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

489 lines
17 KiB
Python

"""
Tests for uncovered code paths in PubChemSearchEngine.
Targets:
- _get_compound_properties: error handling, rate limit, empty properties
- _get_compound_description: 404, no description, rate limit
- _get_compound_synonyms: entire method (untested)
- get_compound: entire method (untested)
- search_by_formula: entire method (untested)
- _get_previews: direct lookup fallback, deduplication, compound processing errors
"""
from unittest.mock import Mock, patch
import pytest
from local_deep_research.web_search_engines.engines.search_engine_pubchem import (
PubChemSearchEngine,
)
from local_deep_research.web_search_engines.rate_limiting import RateLimitError
MODULE = "local_deep_research.web_search_engines.engines.search_engine_pubchem"
@pytest.fixture
def engine():
"""Create a PubChem engine with mocked rate tracker."""
eng = PubChemSearchEngine(max_results=5, include_synonyms=True)
eng.rate_tracker = Mock()
eng.rate_tracker.apply_rate_limit.return_value = 0
return eng
def _mock_response(status_code=200, json_data=None):
resp = Mock()
resp.status_code = status_code
resp.json.return_value = json_data or {}
resp.raise_for_status = Mock()
if status_code >= 400:
resp.raise_for_status.side_effect = Exception(f"HTTP {status_code}")
return resp
# ---------------------------------------------------------------------------
# _get_compound_properties
# ---------------------------------------------------------------------------
class TestGetCompoundProperties:
@patch(f"{MODULE}.safe_get")
def test_successful_properties(self, mock_get, engine):
"""Returns first property dict from successful response."""
mock_get.return_value = _mock_response(
200,
{
"PropertyTable": {
"Properties": [
{"CID": 2519, "MolecularFormula": "C8H10N4O2"}
]
}
},
)
result = engine._get_compound_properties(2519)
assert result["MolecularFormula"] == "C8H10N4O2"
@patch(f"{MODULE}.safe_get")
def test_empty_properties(self, mock_get, engine):
"""Returns empty dict when Properties list is empty."""
mock_get.return_value = _mock_response(
200, {"PropertyTable": {"Properties": []}}
)
result = engine._get_compound_properties(999)
assert result == {}
@patch(f"{MODULE}.safe_get")
def test_missing_property_table(self, mock_get, engine):
"""Returns empty dict when PropertyTable is missing."""
mock_get.return_value = _mock_response(200, {})
result = engine._get_compound_properties(999)
assert result == {}
@patch(f"{MODULE}.safe_get")
def test_http_error_returns_empty(self, mock_get, engine):
"""Returns empty dict on HTTP error."""
mock_get.return_value = _mock_response(500)
result = engine._get_compound_properties(999)
assert result == {}
@patch(f"{MODULE}.safe_get")
def test_rate_limit_error_propagates(self, mock_get, engine):
"""RateLimitError is re-raised, not caught."""
resp = _mock_response(429)
mock_get.return_value = resp
engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))
with pytest.raises(RateLimitError):
engine._get_compound_properties(2519)
# ---------------------------------------------------------------------------
# _get_compound_description
# ---------------------------------------------------------------------------
class TestGetCompoundDescription:
@patch(f"{MODULE}.safe_get")
def test_successful_description(self, mock_get, engine):
"""Returns first description from response."""
mock_get.return_value = _mock_response(
200,
{
"InformationList": {
"Information": [
{
"CID": 2519,
"Description": "Caffeine is a stimulant.",
},
{
"CID": 2519,
"Title": "Some title without description",
},
]
}
},
)
result = engine._get_compound_description(2519)
assert result == "Caffeine is a stimulant."
@patch(f"{MODULE}.safe_get")
def test_404_returns_empty(self, mock_get, engine):
"""Returns empty string on 404."""
resp = _mock_response(404)
resp.raise_for_status = Mock() # Don't raise for 404
mock_get.return_value = resp
result = engine._get_compound_description(99999)
assert result == ""
@patch(f"{MODULE}.safe_get")
def test_no_description_in_info(self, mock_get, engine):
"""Returns empty string when no Information has Description field."""
mock_get.return_value = _mock_response(
200,
{
"InformationList": {
"Information": [
{"CID": 2519, "Title": "No desc here"},
]
}
},
)
result = engine._get_compound_description(2519)
assert result == ""
@patch(f"{MODULE}.safe_get")
def test_empty_information_list(self, mock_get, engine):
"""Returns empty string when InformationList is empty."""
mock_get.return_value = _mock_response(
200, {"InformationList": {"Information": []}}
)
result = engine._get_compound_description(2519)
assert result == ""
@patch(f"{MODULE}.safe_get")
def test_rate_limit_propagates(self, mock_get, engine):
"""RateLimitError propagates from description fetch."""
resp = _mock_response(429)
mock_get.return_value = resp
engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))
with pytest.raises(RateLimitError):
engine._get_compound_description(2519)
@patch(f"{MODULE}.safe_get")
def test_exception_returns_empty(self, mock_get, engine):
"""Other exceptions return empty string."""
mock_get.side_effect = ConnectionError("Network error")
result = engine._get_compound_description(2519)
assert result == ""
# ---------------------------------------------------------------------------
# _get_compound_synonyms
# ---------------------------------------------------------------------------
class TestGetCompoundSynonyms:
@patch(f"{MODULE}.safe_get")
def test_successful_synonyms(self, mock_get, engine):
"""Returns synonym list from response."""
mock_get.return_value = _mock_response(
200,
{
"InformationList": {
"Information": [
{
"CID": 2519,
"Synonym": [
"Caffeine",
"1,3,7-Trimethylxanthine",
"Theine",
"Guaranine",
],
}
]
}
},
)
result = engine._get_compound_synonyms(2519, limit=3)
assert len(result) == 3
assert result[0] == "Caffeine"
@patch(f"{MODULE}.safe_get")
def test_404_returns_empty_list(self, mock_get, engine):
"""Returns empty list on 404."""
resp = _mock_response(404)
resp.raise_for_status = Mock()
mock_get.return_value = resp
result = engine._get_compound_synonyms(99999)
assert result == []
@patch(f"{MODULE}.safe_get")
def test_empty_information(self, mock_get, engine):
"""Returns empty list when Information is empty."""
mock_get.return_value = _mock_response(
200, {"InformationList": {"Information": []}}
)
result = engine._get_compound_synonyms(2519)
assert result == []
@patch(f"{MODULE}.safe_get")
def test_no_synonym_key(self, mock_get, engine):
"""Returns empty list when no Synonym key in first info entry."""
mock_get.return_value = _mock_response(
200, {"InformationList": {"Information": [{"CID": 2519}]}}
)
result = engine._get_compound_synonyms(2519)
assert result == []
@patch(f"{MODULE}.safe_get")
def test_respects_limit(self, mock_get, engine):
"""Respects the limit parameter."""
synonyms = [f"Syn{i}" for i in range(20)]
mock_get.return_value = _mock_response(
200,
{
"InformationList": {
"Information": [{"CID": 2519, "Synonym": synonyms}]
}
},
)
result = engine._get_compound_synonyms(2519, limit=5)
assert len(result) == 5
@patch(f"{MODULE}.safe_get")
def test_rate_limit_propagates(self, mock_get, engine):
"""RateLimitError propagates."""
resp = _mock_response(429)
mock_get.return_value = resp
engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))
with pytest.raises(RateLimitError):
engine._get_compound_synonyms(2519)
@patch(f"{MODULE}.safe_get")
def test_exception_returns_empty_list(self, mock_get, engine):
"""Other exceptions return empty list."""
mock_get.side_effect = ConnectionError("fail")
result = engine._get_compound_synonyms(2519)
assert result == []
# ---------------------------------------------------------------------------
# get_compound
# ---------------------------------------------------------------------------
class TestGetCompound:
def test_successful_compound(self, engine):
"""Returns compound dict with all fields."""
engine._get_compound_properties = Mock(
return_value={"MolecularFormula": "C8H10N4O2"}
)
engine._get_compound_description = Mock(return_value="A stimulant")
engine._get_compound_synonyms = Mock(
return_value=["Caffeine", "Theine"]
)
result = engine.get_compound(2519)
assert result is not None
assert result["cid"] == 2519
assert result["properties"]["MolecularFormula"] == "C8H10N4O2"
assert result["description"] == "A stimulant"
assert result["synonyms"] == ["Caffeine", "Theine"]
def test_rate_limit_propagates(self, engine):
"""RateLimitError propagates from get_compound."""
engine._get_compound_properties = Mock(
side_effect=RateLimitError("429")
)
with pytest.raises(RateLimitError):
engine.get_compound(2519)
def test_exception_returns_none(self, engine):
"""Other exceptions return None."""
engine._get_compound_properties = Mock(side_effect=ValueError("bad"))
result = engine.get_compound(2519)
assert result is None
# ---------------------------------------------------------------------------
# search_by_formula
# ---------------------------------------------------------------------------
class TestSearchByFormula:
@patch(f"{MODULE}.safe_get")
def test_successful_search(self, mock_get, engine):
"""Returns compounds for matching formula."""
mock_get.return_value = _mock_response(
200, {"IdentifierList": {"CID": [2519, 1234]}}
)
engine.get_compound = Mock(
side_effect=[
{
"cid": 2519,
"properties": {},
"description": "",
"synonyms": [],
},
{
"cid": 1234,
"properties": {},
"description": "",
"synonyms": [],
},
]
)
result = engine.search_by_formula("C8H10N4O2")
assert len(result) == 2
assert result[0]["cid"] == 2519
@patch(f"{MODULE}.safe_get")
def test_404_returns_empty(self, mock_get, engine):
"""Returns empty list when formula not found."""
resp = _mock_response(404)
resp.raise_for_status = Mock()
mock_get.return_value = resp
result = engine.search_by_formula("INVALID")
assert result == []
@patch(f"{MODULE}.safe_get")
def test_respects_max_results(self, mock_get, engine):
"""Only processes up to max_results CIDs."""
engine.max_results = 2
mock_get.return_value = _mock_response(
200, {"IdentifierList": {"CID": [1, 2, 3, 4, 5]}}
)
engine.get_compound = Mock(
return_value={
"cid": 1,
"properties": {},
"description": "",
"synonyms": [],
}
)
engine.search_by_formula("H2O")
# Should only call get_compound for first 2 CIDs
assert engine.get_compound.call_count == 2
@patch(f"{MODULE}.safe_get")
def test_skips_none_compounds(self, mock_get, engine):
"""Skips compounds that return None."""
mock_get.return_value = _mock_response(
200, {"IdentifierList": {"CID": [1, 2]}}
)
engine.get_compound = Mock(
side_effect=[
None,
{"cid": 2, "properties": {}, "description": "", "synonyms": []},
]
)
result = engine.search_by_formula("H2O")
assert len(result) == 1
@patch(f"{MODULE}.safe_get")
def test_rate_limit_propagates(self, mock_get, engine):
"""RateLimitError propagates."""
resp = _mock_response(429)
mock_get.return_value = resp
engine._raise_if_rate_limit = Mock(side_effect=RateLimitError("429"))
with pytest.raises(RateLimitError):
engine.search_by_formula("H2O")
@patch(f"{MODULE}.safe_get")
def test_exception_returns_empty(self, mock_get, engine):
"""Other exceptions return empty list."""
mock_get.side_effect = ConnectionError("fail")
result = engine.search_by_formula("H2O")
assert result == []
# ---------------------------------------------------------------------------
# _get_previews: direct lookup fallback and deduplication
# ---------------------------------------------------------------------------
class TestGetPreviewsEdgeCases:
def test_direct_lookup_fallback(self, engine):
"""Falls back to direct lookup when autocomplete returns nothing."""
# _search_compounds returns empty -> triggers direct lookup
engine._search_compounds = Mock(return_value=[])
engine._get_compound_by_name = Mock(
return_value={
"cid": 2519,
"properties": {
"MolecularFormula": "C8H10N4O2",
"IUPACName": "caffeine",
},
"description": "A stimulant",
}
)
previews = engine._get_previews("caffeine")
assert len(previews) == 1
assert "2519" in str(previews[0]["id"])
def test_deduplication_by_cid(self, engine):
"""Compounds with duplicate CIDs are deduplicated."""
engine._search_compounds = Mock(return_value=["Caffeine", "caffeine"])
engine._get_compound_by_name = Mock(
return_value={
"cid": 2519,
"properties": {
"MolecularFormula": "C8H10N4O2",
"IUPACName": "caffeine",
},
"description": "A stimulant",
}
)
previews = engine._get_previews("caffeine")
# Both names resolve to same CID, so only one preview
assert len(previews) == 1
def test_stops_at_max_results(self, engine):
"""Processing stops at max_results."""
engine.max_results = 2
names = [f"Compound{i}" for i in range(10)]
engine._search_compounds = Mock(return_value=names)
engine._get_compound_by_name = Mock(
side_effect=lambda n: {
"cid": hash(n) % 10000,
"properties": {"IUPACName": n},
"description": "",
}
)
previews = engine._get_previews("test")
assert len(previews) == 2
def test_compound_processing_error_continues(self, engine):
"""Error processing one compound doesn't break the loop."""
engine._search_compounds = Mock(return_value=["Bad", "Good"])
def get_by_name(name):
if name == "Bad":
raise ValueError("Parse error")
return {
"cid": 123,
"properties": {"IUPACName": "good"},
"description": "ok",
}
engine._get_compound_by_name = Mock(side_effect=get_by_name)
previews = engine._get_previews("test")
assert len(previews) == 1