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
489 lines
17 KiB
Python
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
|