File size: 20,254 Bytes
e44fdef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73c1aa6
 
 
 
 
e44fdef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f493ed5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73c1aa6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
"""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"