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