Spaces:
Running
Running
Download tests/test_search_service.py from OrganizedProgrammers/SERPent: direct link, hf CLI and curl.
- Browser
- Download file 20.3 kB
-
https://huggingface.co/spaces/OrganizedProgrammers/SERPent/resolve/main/tests/test_search_service.py
- Command line
-
hf download hf://spaces/OrganizedProgrammers/SERPent/tests/test_search_service.py
-
curl -L -o test_search_service.py https://huggingface.co/spaces/OrganizedProgrammers/SERPent/resolve/main/tests/test_search_service.py
20.3 kB
| """SearchService: the backend orchestration policy. | |
| Which backend to try, in what order, when to fall back, and what counts as | |
| evidence a backend is unhealthy. None of this needs an HTTP layer, so none | |
| of these tests use one. | |
| Each test builds a service with its own circuit breaker. That replaces the | |
| autouse fixture that used to reset a process-wide singleton between tests - | |
| the breaker being shared across every request is correct in production and | |
| was only a problem because tests had no way to opt out of it. | |
| """ | |
| import services as services_module | |
| from circuit_breaker import CircuitBreaker | |
| from serp import DuckDuckGoBlockedException, SerpQuery | |
| from services import SearchService, shape_serp_results | |
| class FakeOPSTokens: | |
| def __init__(self, configured: bool): | |
| self.configured = configured | |
| def make_service(*, ops_configured: bool = False, breaker: CircuitBreaker = None, | |
| browser=None) -> SearchService: | |
| return SearchService( | |
| http_client=None, | |
| browser_provider=lambda: browser, | |
| circuit_breaker=breaker or CircuitBreaker(), | |
| ops_tokens=FakeOPSTokens(ops_configured), | |
| ) | |
| # --------------------------------- result shaping --------------------------------- | |
| class TestShapeSerpResults: | |
| def test_flattens_successful_lists(self): | |
| result = shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]]) | |
| assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}] | |
| assert result.error is None | |
| def test_surfaces_last_error_when_all_failed(self): | |
| result = shape_serp_results([ValueError("first"), RuntimeError("second")]) | |
| assert result.results == [] | |
| assert "second" in result.error | |
| def test_ignores_failures_when_some_succeed(self): | |
| result = shape_serp_results([[{"a": 1}], RuntimeError("boom")]) | |
| assert result.results == [{"a": 1}] | |
| assert result.error is None | |
| def test_is_total_for_an_empty_list(self): | |
| """Defence in depth: the request models bound the query list, but | |
| this helper is shared by every search path and must not depend on | |
| its caller having validated anything.""" | |
| result = shape_serp_results([]) | |
| assert result.results == [] | |
| assert result.error is not None | |
| # ------------------------------- per-query dispatch ------------------------------- | |
| async def test_every_query_is_dispatched_with_the_result_count(): | |
| calls = [] | |
| async def fn(q, n): | |
| calls.append((q, n)) | |
| return [{"q": q}] | |
| result = await make_service()._run_queries( | |
| fn, SerpQuery(queries=["x", "y"], n_results=5), "test context") | |
| assert sorted(calls) == [("x", 5), ("y", 5)] | |
| assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str) | |
| async def test_arxiv_flattens_results_from_all_queries(monkeypatch): | |
| async def fake_query_arxiv(client, q, n): | |
| return [{"title": f"paper about {q}"}] | |
| monkeypatch.setattr(services_module, "query_arxiv", fake_query_arxiv) | |
| result = await make_service().arxiv(SerpQuery(queries=["a", "b"], n_results=5)) | |
| assert {r["title"] for r in result.results} == {"paper about a", "paper about b"} | |
| assert result.error is None | |
| async def test_arxiv_reports_an_error_when_every_query_fails(monkeypatch): | |
| async def failing(client, q, n): | |
| raise RuntimeError(f"boom for {q}") | |
| monkeypatch.setattr(services_module, "query_arxiv", failing) | |
| result = await make_service().arxiv(SerpQuery(queries=["a"], n_results=5)) | |
| assert result.results == [] | |
| assert "boom for a" in result.error | |
| # ------------------------------ patents + OPS fallback ------------------------------ | |
| async def test_patents_does_not_fall_back_when_google_patents_succeeds(monkeypatch): | |
| ops_called = False | |
| async def fake_ops(client, q, n): | |
| nonlocal ops_called | |
| ops_called = True | |
| return [] | |
| monkeypatch.setattr( | |
| services_module, "query_google_patents", | |
| lambda b, q, n: _ok([{"id": "US1", "title": "t"}])) | |
| monkeypatch.setattr(services_module, "ops_search", fake_ops) | |
| result = await make_service(ops_configured=True).patents( | |
| SerpQuery(queries=["widget"], n_results=5)) | |
| assert result.results == [{"id": "US1", "title": "t"}] | |
| assert ops_called is False | |
| async def _ok(value): | |
| return value | |
| async def test_patents_falls_back_to_ops_when_google_patents_is_empty(monkeypatch): | |
| monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([])) | |
| monkeypatch.setattr( | |
| services_module, "ops_search", lambda c, q, n: _ok([{"id": "US123", "title": "OPS"}])) | |
| result = await make_service(ops_configured=True).patents( | |
| SerpQuery(queries=["widget"], n_results=5)) | |
| assert result.results == [{"id": "US123", "title": "OPS"}] | |
| async def test_patents_does_not_fall_back_when_ops_is_unconfigured(monkeypatch): | |
| monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([])) | |
| result = await make_service(ops_configured=False).patents( | |
| SerpQuery(queries=["widget"], n_results=5)) | |
| assert result.results == [] | |
| assert result.error is None | |
| async def test_ops_fallback_runs_concurrently_not_one_query_at_a_time(monkeypatch): | |
| """This path exists for when Google Patents is unavailable, which is | |
| exactly when a request can least afford N sequential round-trips to the | |
| slower backend.""" | |
| import asyncio | |
| in_flight = 0 | |
| peak = 0 | |
| async def slow_ops(client, q, n): | |
| nonlocal in_flight, peak | |
| in_flight += 1 | |
| peak = max(peak, in_flight) | |
| await asyncio.sleep(0.02) | |
| in_flight -= 1 | |
| return [{"id": q}] | |
| monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([])) | |
| monkeypatch.setattr(services_module, "ops_search", slow_ops) | |
| await make_service(ops_configured=True).patents( | |
| SerpQuery(queries=["a", "b", "c", "d"], n_results=5)) | |
| assert peak > 1, "OPS fallback queries were issued one at a time" | |
| async def test_a_failing_ops_fallback_does_not_lose_other_queries_results(monkeypatch): | |
| async def selective_ops(client, q, n): | |
| if q == "bad": | |
| raise RuntimeError("OPS down") | |
| return [{"id": q}] | |
| monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([])) | |
| monkeypatch.setattr(services_module, "ops_search", selective_ops) | |
| result = await make_service(ops_configured=True).patents( | |
| SerpQuery(queries=["good", "bad"], n_results=5)) | |
| assert result.results == [{"id": "good"}] | |
| # -------------------------------- the fallback chain -------------------------------- | |
| async def test_returns_the_first_backend_that_succeeds(monkeypatch): | |
| calls = [] | |
| async def fake_ddg(q, n): | |
| calls.append("ddg") | |
| return [{"title": "from ddg"}] | |
| async def fake_brave(browser, q, n): | |
| calls.append("brave") | |
| return [{"title": "from brave"}] | |
| monkeypatch.setattr(services_module, "query_ddg_search", fake_ddg) | |
| monkeypatch.setattr(services_module, "query_brave_search", fake_brave) | |
| q, results, err = await make_service()._search_one("widget", 5) | |
| assert calls == ["ddg"] | |
| assert results == [{"title": "from ddg"}] | |
| assert err is None | |
| async def test_falls_through_to_the_next_backend_on_failure(monkeypatch): | |
| calls = [] | |
| async def failing_ddg(q, n): | |
| calls.append("ddg") | |
| raise RuntimeError("ddg blocked") | |
| async def fake_brave(browser, q, n): | |
| calls.append("brave") | |
| return [{"title": "from brave"}] | |
| monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg) | |
| monkeypatch.setattr(services_module, "query_brave_search", fake_brave) | |
| q, results, err = await make_service()._search_one("widget", 5) | |
| assert calls == ["ddg", "brave"] | |
| assert results == [{"title": "from brave"}] | |
| assert err is None | |
| async def test_reports_an_error_when_every_backend_fails(monkeypatch): | |
| async def failing(*args): | |
| raise RuntimeError("blocked") | |
| for name in ("query_ddg_search", "query_brave_search", "query_bing_search"): | |
| monkeypatch.setattr(services_module, name, failing) | |
| q, results, err = await make_service()._search_one("widget", 5) | |
| assert results == [] | |
| assert "widget" in err | |
| # Each backend is named with what it did, rather than collapsed into a | |
| # single "all backends failed" with only the last error attached. | |
| for backend in ("DuckDuckGo", "Brave Search", "Bing"): | |
| assert f"{backend}: failed" in err | |
| assert "blocked" in err | |
| # ------------------------------ the circuit breaker ------------------------------ | |
| async def test_skips_a_backend_whose_circuit_is_open(monkeypatch): | |
| """A backend that's failed `failure_threshold` times in a row shouldn't | |
| be attempted again until its cooldown elapses - repeatedly hammering a | |
| backend that's already rate-limiting us only makes that worse. | |
| """ | |
| ddg_calls = [] | |
| async def failing_ddg(q, n): | |
| ddg_calls.append(q) | |
| raise RuntimeError("ddg blocked") | |
| async def fake_brave(browser, q, n): | |
| return [{"title": "from brave"}] | |
| monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg) | |
| monkeypatch.setattr(services_module, "query_brave_search", fake_brave) | |
| service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60)) | |
| await service._search_one("first query", 5) | |
| assert ddg_calls == ["first query"] | |
| q, results, err = await service._search_one("second query", 5) | |
| assert ddg_calls == ["first query"] # unchanged - DDG was skipped | |
| assert results == [{"title": "from brave"}] | |
| async def test_a_zero_result_query_does_not_open_the_circuit(monkeypatch): | |
| """An empty result is ambiguous - it can mean the query genuinely has no | |
| hits, or that the backend served an anti-bot page that parsed to | |
| nothing. The breaker is shared across every request, so counting the | |
| ambiguous case meant a few unusual-but-legitimate queries could disable | |
| the primary backend for everyone for a full cooldown. | |
| """ | |
| ddg_calls = [] | |
| async def empty_ddg(q, n): | |
| ddg_calls.append(q) | |
| raise DuckDuckGoBlockedException() | |
| monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg) | |
| monkeypatch.setattr( | |
| services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}])) | |
| service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60)) | |
| await service._search_one("obscure", 5) | |
| await service._search_one("another obscure", 5) | |
| assert ddg_calls == ["obscure", "another obscure"] | |
| async def test_an_empty_result_still_falls_through_to_the_next_backend(monkeypatch): | |
| """Not counting it against the backend must not stop the fallback: the | |
| caller still needs results from somewhere.""" | |
| async def empty_ddg(q, n): | |
| raise DuckDuckGoBlockedException() | |
| monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg) | |
| monkeypatch.setattr( | |
| services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "from brave"}])) | |
| _q, results, err = await make_service()._search_one("widget", 5) | |
| assert results == [{"title": "from brave"}] | |
| assert err is None | |
| async def test_a_real_backend_error_still_opens_the_circuit(monkeypatch): | |
| """The distinction has to cut both ways: a backend raising a genuine | |
| error (a rate limit, a transport failure) is still evidence.""" | |
| ddg_calls = [] | |
| async def failing_ddg(q, n): | |
| ddg_calls.append(q) | |
| raise RuntimeError("rate limited") | |
| monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg) | |
| monkeypatch.setattr( | |
| services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}])) | |
| service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60)) | |
| await service._search_one("first", 5) | |
| await service._search_one("second", 5) | |
| assert ddg_calls == ["first"] | |
| # ------------------------------- search aggregation ------------------------------- | |
| async def test_search_merges_results_from_every_query(monkeypatch): | |
| monkeypatch.setattr( | |
| services_module, "query_ddg_search", lambda q, n: _ok([{"title": f"result for {q}"}])) | |
| result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5)) | |
| assert {r["title"] for r in result.results} == {"result for a", "result for b"} | |
| assert result.error is None | |
| async def test_search_reports_partial_failure_alongside_results(monkeypatch): | |
| """A query no backend could answer must be reported even when another | |
| query succeeded - otherwise the caller silently receives fewer results | |
| than they asked for with no indication why.""" | |
| async def selective_ddg(q, n): | |
| if q == "bad": | |
| raise RuntimeError("blocked") | |
| return [{"title": f"result for {q}"}] | |
| async def failing(*args): | |
| raise RuntimeError("blocked") | |
| monkeypatch.setattr(services_module, "query_ddg_search", selective_ddg) | |
| monkeypatch.setattr(services_module, "query_brave_search", failing) | |
| monkeypatch.setattr(services_module, "query_bing_search", failing) | |
| result = await make_service().search(SerpQuery(queries=["good", "bad"], n_results=5)) | |
| assert [r["title"] for r in result.results] == ["result for good"] | |
| assert "bad" in result.error | |
| async def test_search_names_every_failed_query_not_just_the_last(monkeypatch): | |
| async def failing(*args): | |
| raise RuntimeError("blocked") | |
| for name in ("query_ddg_search", "query_brave_search", "query_bing_search"): | |
| monkeypatch.setattr(services_module, name, failing) | |
| result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5)) | |
| assert result.results == [] | |
| assert "'a'" in result.error and "'b'" in result.error | |
| async def test_a_scholar_block_is_reported_as_such_to_the_caller(monkeypatch): | |
| """The point of the dedicated exception is the message the caller sees: | |
| "Google Scholar blocked the request" tells an operator their IP is | |
| rate-limited, where the bare "Timeout 30000ms exceeded" it replaced read | |
| like a broken selector. | |
| """ | |
| from serp import GoogleScholarBlockedException | |
| async def blocked(browser, q, n): | |
| raise GoogleScholarBlockedException() | |
| monkeypatch.setattr(services_module, "query_google_scholar", blocked) | |
| result = await make_service().google_scholar(SerpQuery(queries=["anything"])) | |
| assert result.results == [] | |
| assert "blocked" in result.error.lower() | |
| assert "timeout" not in result.error.lower() | |
| # ------------------- an empty answer is not an answer ------------------- | |
| # A backend returning [] without raising used to end the fallback chain and | |
| # be reported as success. Brave does that whenever its block page lacks the | |
| # word "suspicious", and Bing whenever no result item yields a title+href. | |
| # The caller then got zero results and the invented explanation "All | |
| # backends are rate-limited." - a guess, not evidence. Observed once | |
| # against the live deployment. | |
| async def test_an_empty_backend_result_falls_through_to_the_next_backend(monkeypatch): | |
| calls = [] | |
| async def empty_ddg(q, n): | |
| calls.append("ddg") | |
| return [] | |
| async def fake_brave(browser, q, n): | |
| calls.append("brave") | |
| return [{"title": "from brave"}] | |
| monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg) | |
| monkeypatch.setattr(services_module, "query_brave_search", fake_brave) | |
| _q, results, err = await make_service()._search_one("widget", 5) | |
| assert calls == ["ddg", "brave"], "an empty result ended the chain" | |
| assert results == [{"title": "from brave"}] | |
| assert err is None | |
| async def test_every_backend_empty_reports_that_and_not_rate_limiting(monkeypatch): | |
| """The message has to describe what happened. "Rate-limited" was | |
| asserted without evidence, and sent whoever read it looking for a | |
| throttling problem that may not exist. | |
| """ | |
| async def empty(*args): | |
| return [] | |
| for name in ("query_ddg_search", "query_brave_search", "query_bing_search"): | |
| monkeypatch.setattr(services_module, name, empty) | |
| _q, results, err = await make_service()._search_one("widget", 5) | |
| assert results == [] | |
| assert "rate-limit" not in err.lower() | |
| for backend in ("DuckDuckGo", "Brave Search", "Bing"): | |
| assert backend in err, f"{backend}'s outcome is missing from {err!r}" | |
| async def test_the_error_distinguishes_failing_from_returning_nothing(monkeypatch): | |
| """"Failed" and "answered with nothing" are different problems and lead | |
| somewhere different - one is an outage, the other may just be an | |
| obscure query.""" | |
| async def failing_ddg(q, n): | |
| raise RuntimeError("connection reset") | |
| async def empty_brave(browser, q, n): | |
| return [] | |
| async def failing_bing(browser, q, n): | |
| raise RuntimeError("connection reset") | |
| monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg) | |
| monkeypatch.setattr(services_module, "query_brave_search", empty_brave) | |
| monkeypatch.setattr(services_module, "query_bing_search", failing_bing) | |
| _q, _results, err = await make_service()._search_one("widget", 5) | |
| assert "connection reset" in err, "a real failure must still surface its cause" | |
| assert "no results" in err.lower(), "an empty answer must be described as such" | |
| async def test_a_skipped_backend_is_reported_as_skipped_not_failed(monkeypatch): | |
| """A backend whose circuit is open was never asked, so saying it failed | |
| for this query is wrong.""" | |
| async def empty(*args): | |
| return [] | |
| for name in ("query_ddg_search", "query_brave_search", "query_bing_search"): | |
| monkeypatch.setattr(services_module, name, empty) | |
| breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60) | |
| breaker.record_failure("Bing") | |
| service = make_service(breaker=breaker) | |
| _q, _results, err = await service._search_one("widget", 5) | |
| assert "skipped" in err.lower() | |
| async def test_an_empty_answer_does_not_clear_an_accumulating_failure_count(monkeypatch): | |
| """Empty is ambiguous, so it is evidence neither way. Recording it as a | |
| success would reset a backend part-way to tripping its breaker, which | |
| is how a soft-blocking backend stays in rotation forever. | |
| """ | |
| breaker = CircuitBreaker(failure_threshold=2, cooldown_seconds=60) | |
| breaker.record_failure("DuckDuckGo") # one strike already | |
| async def empty_ddg(q, n): | |
| return [] | |
| monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg) | |
| monkeypatch.setattr( | |
| services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}])) | |
| await make_service(breaker=breaker)._search_one("widget", 5) | |
| assert breaker._states["DuckDuckGo"].failure_count == 1, ( | |
| "an empty result cleared DuckDuckGo's failure count") | |
| async def test_search_never_invents_a_rate_limit_explanation(monkeypatch): | |
| """The endpoint-level guard for the same thing.""" | |
| async def empty(*args): | |
| return [] | |
| for name in ("query_ddg_search", "query_brave_search", "query_bing_search"): | |
| monkeypatch.setattr(services_module, name, empty) | |
| result = await make_service().search(SerpQuery(queries=["a", "b"])) | |
| assert result.results == [] | |
| assert result.error and "rate-limit" not in result.error.lower() | |
| async def test_the_aggregate_fallback_message_states_a_fact_not_a_diagnosis(): | |
| """`_search_one` always explains itself, so `search`'s fallback text is | |
| unreachable in practice - which is exactly how the rate-limit guess | |
| survived unnoticed for so long. Reach it directly so the defensive | |
| branch says something defensible too. | |
| """ | |
| service = make_service() | |
| async def silent_search_one(q, n_results): | |
| return q, [], None | |
| service._search_one = silent_search_one | |
| result = await service.search(SerpQuery(queries=["a"])) | |
| assert result.results == [] | |
| assert "rate-limit" not in result.error.lower() | |
| assert result.error, "an empty result set must still carry an explanation" | |