| 1 | """Tests for the Trustpilot source adapter (lib/trustpilot.py). |
| 2 | |
| 3 | Covers the brand-shape gate (the primary quiet-keeper), the browser opt-out, |
| 4 | field mapping from the info envelope, and graceful degradation. |
| 5 | """ |
| 6 | |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | import pytest |
| 10 | |
| 11 | from lib import pipeline, trustpilot |
| 12 | |
| 13 | |
| 14 | # ---- opt-in availability gating (off by default) ---- |
| 15 | |
| 16 | def test_not_available_by_default(monkeypatch): |
| 17 | monkeypatch.setattr(pipeline, "which", lambda name: f"/usr/bin/{name}") |
| 18 | avail = pipeline.available_sources({}) |
| 19 | assert "trustpilot" not in avail # opt-in: absent without INCLUDE_SOURCES |
| 20 | # the zero-auth pair stays default-on |
| 21 | assert "arxiv" in avail and "techmeme" in avail |
| 22 | |
| 23 | |
| 24 | def test_available_when_included(monkeypatch): |
| 25 | monkeypatch.setattr(pipeline, "which", lambda name: f"/usr/bin/{name}") |
| 26 | avail = pipeline.available_sources({"INCLUDE_SOURCES": "trustpilot"}) |
| 27 | assert "trustpilot" in avail |
| 28 | |
| 29 | |
| 30 | def test_available_when_requested(monkeypatch): |
| 31 | monkeypatch.setattr(pipeline, "which", lambda name: f"/usr/bin/{name}") |
| 32 | avail = pipeline.available_sources({}, requested_sources=["trustpilot"]) |
| 33 | assert "trustpilot" in avail |
| 34 | |
| 35 | |
| 36 | # ---- brand-shape gate ---- |
| 37 | |
| 38 | @pytest.mark.parametrize("topic", ["ChowNow", "chownow.com", "Nothing Phone", "OpenAI", "nothing.tech"]) |
| 39 | def test_brand_shaped_topics_fire(topic): |
| 40 | assert trustpilot.is_brand_shaped(topic) |
| 41 | |
| 42 | |
| 43 | @pytest.mark.parametrize("topic", [ |
| 44 | "AI coding agents", # 3 words, generic |
| 45 | "agent memory", # lowercase, generic token |
| 46 | "Golden State Warriors", # 3 words -> not company-shaped |
| 47 | "best phones", # generic + lowercase |
| 48 | "how to use claude", # generic question |
| 49 | "", # empty |
| 50 | ]) |
| 51 | def test_non_brand_topics_stay_quiet(topic): |
| 52 | assert not trustpilot.is_brand_shaped(topic) |
| 53 | |
| 54 | |
| 55 | @pytest.mark.parametrize("topic", [ |
| 56 | "Python", "React", "Docker", "Rust", "Linux", "Swift", "Java", |
| 57 | "Kubernetes", "PostgreSQL", "Node", |
| 58 | ]) |
| 59 | def test_single_word_tech_names_are_not_brands(topic): |
| 60 | # A bare capitalized language/framework/tool name is a technology query, |
| 61 | # not a company-review intent -- it must not trigger the Trustpilot CLI. |
| 62 | assert not trustpilot.is_brand_shaped(topic) |
| 63 | |
| 64 | |
| 65 | def test_tech_company_still_reachable_by_domain(): |
| 66 | # The conservative tech-token gate does not block explicit company intent: |
| 67 | # a domain still resolves (e.g. wanting docker.com's reviews specifically). |
| 68 | assert trustpilot.is_brand_shaped("docker.com") |
| 69 | |
| 70 | |
| 71 | def test_company_identifier_prefers_domain(): |
| 72 | assert trustpilot._company_identifier("reviews of chownow.com please") == "chownow.com" |
| 73 | assert trustpilot._company_identifier("ChowNow") == "ChowNow" |
| 74 | |
| 75 | |
| 76 | # ---- gate short-circuits the CLI (no Chrome on non-brand topics) ---- |
| 77 | |
| 78 | def test_non_brand_topic_never_calls_cli(monkeypatch): |
| 79 | called = [] |
| 80 | monkeypatch.setattr(trustpilot, "_is_available", lambda: True) |
| 81 | monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: called.append(a) or {}) |
| 82 | out = trustpilot.search_trustpilot("AI coding agents", "2026-06-01", "2026-06-27") |
| 83 | assert out == {"results": []} |
| 84 | assert called == [] # CLI (and any Chrome harvest) never invoked |
| 85 | |
| 86 | |
| 87 | # ---- browser opt-out ---- |
| 88 | |
| 89 | def test_browser_opt_out_skips_even_for_brand(monkeypatch): |
| 90 | called = [] |
| 91 | monkeypatch.setattr(trustpilot, "_is_available", lambda: True) |
| 92 | monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: called.append(a) or {}) |
| 93 | config = {trustpilot.NO_BROWSER_ENV: "1"} |
| 94 | out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27", config=config) |
| 95 | assert out == {"results": []} |
| 96 | assert called == [] # opt-out prevents the harvest-prone CLI call |
| 97 | |
| 98 | |
| 99 | def test_browser_opt_out_via_env_var_no_config(monkeypatch): |
| 100 | """The production path: the env var is set but never propagated into the |
| 101 | config dict. The os.environ fallback must still skip the harvest.""" |
| 102 | called = [] |
| 103 | monkeypatch.setattr(trustpilot, "_is_available", lambda: True) |
| 104 | monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: called.append(a) or {}) |
| 105 | monkeypatch.setenv(trustpilot.NO_BROWSER_ENV, "1") |
| 106 | # config is None / lacks the key, mirroring env.get_config's allowlist gap. |
| 107 | out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27", config=None) |
| 108 | assert out == {"results": []} |
| 109 | assert called == [] |
| 110 | |
| 111 | |
| 112 | # ---- happy path + field mapping ---- |
| 113 | |
| 114 | def test_happy_path_maps_info_envelope(): |
| 115 | info = { |
| 116 | "name": "ChowNow", |
| 117 | "trustScore": 1.2, |
| 118 | "reviewCount": 49, |
| 119 | "aiSummary": "Most reviewers were let down: food never arriving, wrong address.", |
| 120 | "domain": "chownow.com", |
| 121 | } |
| 122 | items = trustpilot.parse_trustpilot_response({"results": [info]}, query="ChowNow") |
| 123 | assert len(items) == 1 |
| 124 | it = items[0] |
| 125 | assert it["name"] == "ChowNow" |
| 126 | assert it["trustScore"] == 1.2 |
| 127 | assert it["reviewCount"] == 49 |
| 128 | assert "food never arriving" in it["summary"] |
| 129 | assert it["engagement"]["reviews"] == 49 |
| 130 | assert it["url"].endswith("chownow.com") |
| 131 | |
| 132 | |
| 133 | def test_search_returns_info_for_brand(monkeypatch): |
| 134 | monkeypatch.setattr(trustpilot, "_is_available", lambda: True) |
| 135 | monkeypatch.setattr( |
| 136 | trustpilot, "_run_cli", |
| 137 | lambda *a, **k: {"name": "ChowNow", "trustScore": 1.2, "reviewCount": 49, "aiSummary": "Bad."}, |
| 138 | ) |
| 139 | out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27") |
| 140 | assert len(out["results"]) == 1 |
| 141 | assert out["results"][0]["name"] == "ChowNow" |
| 142 | |
| 143 | |
| 144 | # ---- degradation paths ---- |
| 145 | |
| 146 | def test_cli_error_degrades_to_empty(monkeypatch): |
| 147 | monkeypatch.setattr(trustpilot, "_is_available", lambda: True) |
| 148 | monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: {"error": "no chrome"}) |
| 149 | out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27") |
| 150 | assert out == {"results": []} |
| 151 | |
| 152 | |
| 153 | def test_binary_absent_returns_empty(monkeypatch): |
| 154 | monkeypatch.setattr(trustpilot.shutil, "which", lambda _bin: None) |
| 155 | out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27") |
| 156 | assert out["results"] == [] |
| 157 | |
| 158 | |
| 159 | def test_parse_handles_empty_and_malformed(): |
| 160 | assert trustpilot.parse_trustpilot_response({"results": []}, query="x") == [] |
| 161 | assert trustpilot.parse_trustpilot_response({}, query="x") == [] |
| 162 | assert trustpilot.parse_trustpilot_response({"results": [{}]}, query="x") == [] |
| 163 | |
| 164 | |
| 165 | # ---- shared scaffolding for domain-resolution and warm-up tests ---- |
| 166 | |
| 167 | D1, D2 = "2026-06-01", "2026-06-27" |
| 168 | |
| 169 | THRIFTBOOKS_HITS = { |
| 170 | "hits": [ |
| 171 | {"displayName": "ThriftBooks", "domain": "www.thriftbooks.com", "numberOfReviews": 2843175}, |
| 172 | {"displayName": "Thrift Books", "domain": "thriftbook.com", "numberOfReviews": 130}, |
| 173 | {"displayName": "Thriftybooks", "domain": "thriftybooks.com", "numberOfReviews": 6}, |
| 174 | ] |
| 175 | } |
| 176 | |
| 177 | INFO_OK = {"name": "ThriftBooks", "trustScore": 4.5, "reviewCount": 2843175, "aiSummary": "Great."} |
| 178 | |
| 179 | |
| 180 | @pytest.fixture(autouse=True) |
| 181 | def _reset_trustpilot_state(): |
| 182 | trustpilot._reset_state_for_tests() |
| 183 | yield |
| 184 | trustpilot._reset_state_for_tests() |
| 185 | |
| 186 | |
| 187 | def _capture_cli(monkeypatch, responses): |
| 188 | """Mock _run_cli, recording argv. ``responses`` maps a subcommand key |
| 189 | ("info", "search", "auth status", "auth login") to a dict, or to a list of |
| 190 | dicts consumed in order.""" |
| 191 | calls: list[list[str]] = [] |
| 192 | |
| 193 | def fake(cmd, timeout): |
| 194 | calls.append(list(cmd)) |
| 195 | key = cmd[1] if cmd[1] != "auth" else f"auth {cmd[2]}" |
| 196 | resp = responses.get(key, {}) |
| 197 | if isinstance(resp, list): |
| 198 | return dict(resp.pop(0)) if resp else {} |
| 199 | return dict(resp) |
| 200 | |
| 201 | monkeypatch.setattr(trustpilot, "_is_available", lambda: True) |
| 202 | monkeypatch.setattr(trustpilot, "_run_cli", fake) |
| 203 | return calls |
| 204 | |
| 205 | |
| 206 | def _info_args(calls): |
| 207 | return [c for c in calls if c[1] == "info"] |
| 208 | |
| 209 | |
| 210 | def _search_args(calls): |
| 211 | return [c for c in calls if c[1] == "search"] |
| 212 | |
| 213 | |
| 214 | # ---- explicit domain (--trustpilot-domain) ---- |
| 215 | |
| 216 | def test_explicit_domain_used_verbatim(monkeypatch): |
| 217 | calls = _capture_cli(monkeypatch, {"info": INFO_OK}) |
| 218 | out = trustpilot.search_trustpilot( |
| 219 | "ThriftBooks", D1, D2, explicit_domain="www.thriftbooks.com") |
| 220 | assert out["results"][0]["name"] == "ThriftBooks" |
| 221 | assert _info_args(calls)[0][2] == "www.thriftbooks.com" |
| 222 | assert _search_args(calls) == [] # flag set -> no search call fires |
| 223 | |
| 224 | |
| 225 | def test_explicit_domain_bypasses_brand_gate(monkeypatch): |
| 226 | # A 4-word topic fails is_brand_shaped, but an explicit domain is proof |
| 227 | # of brand intent -- the CLI must still be invoked with the flag value. |
| 228 | calls = _capture_cli(monkeypatch, {"info": INFO_OK}) |
| 229 | out = trustpilot.search_trustpilot( |
| 230 | "Stanley Steemer carpet cleaning", D1, D2, |
| 231 | explicit_domain="stanleysteemer.com") |
| 232 | assert len(out["results"]) == 1 |
| 233 | assert _info_args(calls)[0][2] == "stanleysteemer.com" |
| 234 | |
| 235 | |
| 236 | def test_explicit_domain_beats_domain_shaped_topic(monkeypatch): |
| 237 | calls = _capture_cli(monkeypatch, {"info": INFO_OK}) |
| 238 | trustpilot.search_trustpilot( |
| 239 | "chownow.com", D1, D2, explicit_domain="www.thriftbooks.com") |
| 240 | assert _info_args(calls)[0][2] == "www.thriftbooks.com" |
| 241 | |
| 242 | |
| 243 | def test_user_domain_is_verbatim_final_no_retry(monkeypatch): |
| 244 | # User-set flag (domain_is_hint=False): a miss degrades, never re-resolves. |
| 245 | calls = _capture_cli(monkeypatch, {"info": {"error": "HTTP 404"}}) |
| 246 | out = trustpilot.search_trustpilot( |
| 247 | "ThriftBooks", D1, D2, explicit_domain="thriftbook.com") |
| 248 | assert out == {"results": []} |
| 249 | assert _search_args(calls) == [] |
| 250 | |
| 251 | |
| 252 | def test_hint_domain_retries_via_search_on_miss(monkeypatch): |
| 253 | # Auto-resolved hint (domain_is_hint=True): a 404 falls through to the |
| 254 | # CLI search resolution and retries with the canonical domain. |
| 255 | calls = _capture_cli(monkeypatch, { |
| 256 | "info": [{"error": "HTTP 404"}, INFO_OK], |
| 257 | "search": THRIFTBOOKS_HITS, |
| 258 | }) |
| 259 | out = trustpilot.search_trustpilot( |
| 260 | "ThriftBooks", D1, D2, |
| 261 | explicit_domain="getthriftbooks.io", domain_is_hint=True) |
| 262 | assert len(out["results"]) == 1 |
| 263 | infos = _info_args(calls) |
| 264 | assert infos[0][2] == "getthriftbooks.io" |
| 265 | assert infos[1][2] == "www.thriftbooks.com" |
| 266 | |
| 267 | |
| 268 | # ---- name -> domain search fallback ---- |
| 269 | |
| 270 | def test_bare_name_resolves_via_search(monkeypatch): |
| 271 | calls = _capture_cli(monkeypatch, {"search": THRIFTBOOKS_HITS, "info": INFO_OK}) |
| 272 | out = trustpilot.search_trustpilot("ThriftBooks", D1, D2) |
| 273 | assert len(out["results"]) == 1 |
| 274 | assert _search_args(calls)[0][2] == "ThriftBooks" |
| 275 | assert _info_args(calls)[0][2] == "www.thriftbooks.com" |
| 276 | |
| 277 | |
| 278 | def test_ambiguous_hits_fall_back_to_topic(monkeypatch): |
| 279 | # Two same-named companies with comparable volume: review count must never |
| 280 | # break the tie (silent misattribution is worse than a visible miss). |
| 281 | hits = {"hits": [ |
| 282 | {"displayName": "Mercury", "domain": "mercury.com", "numberOfReviews": 50000}, |
| 283 | {"displayName": "Mercury", "domain": "mercuryinsurance.com", "numberOfReviews": 40000}, |
| 284 | ]} |
| 285 | calls = _capture_cli(monkeypatch, {"search": hits, "info": INFO_OK}) |
| 286 | trustpilot.search_trustpilot("Mercury", D1, D2) |
| 287 | assert _info_args(calls)[0][2] == "Mercury" # legacy fallback identifier |
| 288 | |
| 289 | |
| 290 | def test_review_count_never_overrides_name_mismatch(monkeypatch): |
| 291 | hits = {"hits": [ |
| 292 | {"displayName": "Bolt Technology", "domain": "bolt.eu", "numberOfReviews": 900000}, |
| 293 | ]} |
| 294 | calls = _capture_cli(monkeypatch, {"search": hits, "info": INFO_OK}) |
| 295 | trustpilot.search_trustpilot("Bolt", D1, D2) |
| 296 | assert _info_args(calls)[0][2] == "Bolt" |
| 297 | |
| 298 | |
| 299 | def test_search_error_falls_back_to_topic(monkeypatch): |
| 300 | calls = _capture_cli(monkeypatch, {"search": {"error": "boom"}, "info": INFO_OK}) |
| 301 | out = trustpilot.search_trustpilot("ChowNow", D1, D2) |
| 302 | assert len(out["results"]) == 1 |
| 303 | assert _info_args(calls)[0][2] == "ChowNow" |
| 304 | |
| 305 | |
| 306 | def test_domain_shaped_topic_never_searches(monkeypatch): |
| 307 | calls = _capture_cli(monkeypatch, {"info": INFO_OK}) |
| 308 | trustpilot.search_trustpilot("chownow.com", D1, D2) |
| 309 | assert _search_args(calls) == [] |
| 310 | assert _info_args(calls)[0][2] == "chownow.com" |
| 311 | |
| 312 | |
| 313 | def test_search_cached_per_topic_not_per_process(monkeypatch): |
| 314 | calls = _capture_cli(monkeypatch, {"search": dict(THRIFTBOOKS_HITS), "info": INFO_OK}) |
| 315 | trustpilot.search_trustpilot("ThriftBooks", D1, D2) |
| 316 | trustpilot.search_trustpilot("ThriftBooks", D1, D2) |
| 317 | assert len(_search_args(calls)) == 1 # repeat topic hits the cache |
| 318 | |
| 319 | # A DIFFERENT topic must trigger its own search (vs-mode entities resolve |
| 320 | # independently; a single process-wide slot would cross-contaminate). |
| 321 | responses_seen = _search_args(calls) |
| 322 | trustpilot.search_trustpilot("ChowNow", D1, D2) |
| 323 | assert len(_search_args(calls)) == len(responses_seen) + 1 |
| 324 | |
| 325 | |
| 326 | # ---- session warm-up (ensure_session_ready) ---- |
| 327 | |
| 328 | def test_warmup_fresh_session_skips_login(monkeypatch): |
| 329 | calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True, "hasSession": True}}) |
| 330 | trustpilot.ensure_session_ready("ThriftBooks") |
| 331 | assert [c[2] for c in calls if c[1] == "auth"] == ["status"] |
| 332 | |
| 333 | |
| 334 | def test_warmup_stale_session_logs_in_once(monkeypatch): |
| 335 | calls = _capture_cli(monkeypatch, { |
| 336 | "auth status": {"error": "no session"}, |
| 337 | "auth login": {"ok": True}, |
| 338 | }) |
| 339 | trustpilot.ensure_session_ready("ThriftBooks") |
| 340 | trustpilot.ensure_session_ready("ThriftBooks") # idempotent: no second pass |
| 341 | auth_calls = [c[2] for c in calls if c[1] == "auth"] |
| 342 | assert auth_calls == ["status", "login"] |
| 343 | |
| 344 | |
| 345 | def test_warmup_concurrent_calls_single_warmup(monkeypatch): |
| 346 | import threading as _threading |
| 347 | calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True}}) |
| 348 | threads = [ |
| 349 | _threading.Thread(target=trustpilot.ensure_session_ready, args=("ThriftBooks",)) |
| 350 | for _ in range(8) |
| 351 | ] |
| 352 | for t in threads: |
| 353 | t.start() |
| 354 | for t in threads: |
| 355 | t.join() |
| 356 | assert len([c for c in calls if c[1] == "auth"]) == 1 # vs-mode race guard |
| 357 | |
| 358 | |
| 359 | def test_warmup_skips_non_brand_topic(monkeypatch): |
| 360 | # AE6: generic topic with trustpilot active -> no warm-up, no Chrome. |
| 361 | calls = _capture_cli(monkeypatch, {}) |
| 362 | trustpilot.ensure_session_ready("AI coding agents") |
| 363 | assert calls == [] |
| 364 | |
| 365 | |
| 366 | def test_warmup_domain_bypasses_brand_gate(monkeypatch): |
| 367 | calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True}}) |
| 368 | trustpilot.ensure_session_ready("Stanley Steemer carpet cleaning", has_domain=True) |
| 369 | assert len(calls) == 1 |
| 370 | |
| 371 | |
| 372 | def test_warmup_respects_browser_opt_out(monkeypatch): |
| 373 | calls = _capture_cli(monkeypatch, {}) |
| 374 | trustpilot.ensure_session_ready("ThriftBooks", config={trustpilot.NO_BROWSER_ENV: "1"}) |
| 375 | assert calls == [] |
| 376 | |
| 377 | |
| 378 | def test_warmup_failure_never_raises(monkeypatch): |
| 379 | calls = _capture_cli(monkeypatch, { |
| 380 | "auth status": {"error": "no session"}, |
| 381 | "auth login": {"error": "chrome missing"}, |
| 382 | }) |
| 383 | trustpilot.ensure_session_ready("ThriftBooks") # must not raise |
| 384 | assert [c[2] for c in calls if c[1] == "auth"] == ["status", "login"] |
| 385 | |
| 386 | |
| 387 | def test_warmup_logs_no_token_bytes(monkeypatch): |
| 388 | # The auth status payload carries live WAF-token prefixes; log lines must |
| 389 | # only ever contain structured status strings. |
| 390 | logged: list[str] = [] |
| 391 | monkeypatch.setattr(trustpilot, "_log", lambda msg: logged.append(msg)) |
| 392 | _capture_cli(monkeypatch, { |
| 393 | "auth status": {"isFresh": False, "tokenPrefix": "48372fee-99b"}, |
| 394 | "auth login": {"ok": True, "token": "48372fee-99b1-dead-beef"}, |
| 395 | }) |
| 396 | trustpilot.ensure_session_ready("ThriftBooks") |
| 397 | assert logged and all("48372fee" not in msg for msg in logged) |
| 398 | |
| 399 | |
| 400 | def test_trustpilot_capped_to_single_fetch(): |
| 401 | # N subqueries listing trustpilot must produce exactly one stream: every |
| 402 | # stream would use the identical company identifier, and each extra one |
| 403 | # risks its own WAF-cookie Chrome harvest. |
| 404 | assert pipeline.MAX_SOURCE_FETCHES.get("trustpilot") == 1 |
| 405 | |
| 406 | |
| 407 | # ---- review-driven hardening ---- |
| 408 | |
| 409 | def test_search_trustpilot_warms_session_at_first_touch(monkeypatch): |
| 410 | # The warm-up runs inside the source fetch (never the pipeline fan-out |
| 411 | # setup), so it must precede the info call within one search invocation. |
| 412 | calls = _capture_cli(monkeypatch, { |
| 413 | "auth status": {"isFresh": True}, |
| 414 | "info": INFO_OK, |
| 415 | }) |
| 416 | trustpilot.search_trustpilot( |
| 417 | "ThriftBooks", D1, D2, explicit_domain="www.thriftbooks.com") |
| 418 | assert calls[0][1] == "auth" and calls[0][2] == "status" |
| 419 | assert [c for c in calls if c[1] == "info"] |
| 420 | |
| 421 | |
| 422 | def test_warmup_ttl_lapse_rechecks(monkeypatch): |
| 423 | import time as _time |
| 424 | calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True}}) |
| 425 | trustpilot.ensure_session_ready("ThriftBooks") |
| 426 | assert len(calls) == 1 |
| 427 | # Within the TTL: no re-check. |
| 428 | trustpilot.ensure_session_ready("ThriftBooks") |
| 429 | assert len(calls) == 1 |
| 430 | # After the TTL lapses (long-lived host process): cheap re-check fires. |
| 431 | trustpilot._warmup_at = _time.monotonic() - (trustpilot.WARMUP_TTL_SECONDS + 1) |
| 432 | trustpilot.ensure_session_ready("ThriftBooks") |
| 433 | assert len(calls) == 2 |
| 434 | |
| 435 | |
| 436 | def test_hint_on_non_brand_topic_stays_quiet(monkeypatch): |
| 437 | # An auto-resolved hint must not widen activation beyond brand-shaped |
| 438 | # topics -- only a USER-set domain proves brand intent (AE6 contract). |
| 439 | calls = _capture_cli(monkeypatch, {}) |
| 440 | out = trustpilot.search_trustpilot( |
| 441 | "AI coding agents", D1, D2, |
| 442 | explicit_domain="aicodingagents.com", domain_is_hint=True) |
| 443 | assert out == {"results": []} |
| 444 | assert calls == [] |
| 445 | |
| 446 | |
| 447 | def test_transient_search_error_not_cached(monkeypatch): |
| 448 | # A flaky search must not poison the per-topic cache for the process. |
| 449 | calls = _capture_cli(monkeypatch, { |
| 450 | "search": [{"error": "timeout"}, dict(THRIFTBOOKS_HITS)], |
| 451 | "info": INFO_OK, |
| 452 | }) |
| 453 | assert trustpilot._search_domain("ThriftBooks") is None |
| 454 | assert trustpilot._search_domain("ThriftBooks") == "www.thriftbooks.com" |
| 455 | assert len(_search_args(calls)) == 2 |
| 456 | |
| 457 | |
| 458 | def test_empty_search_payload_not_cached(monkeypatch): |
| 459 | # Empty stdout parses to {} (exit 0, no output): a degenerate payload, |
| 460 | # not a definitive no-match -- it must not become a permanent cache entry. |
| 461 | calls = _capture_cli(monkeypatch, { |
| 462 | "search": [{}, dict(THRIFTBOOKS_HITS)], |
| 463 | "info": INFO_OK, |
| 464 | }) |
| 465 | assert trustpilot._search_domain("ThriftBooks") is None |
| 466 | assert trustpilot._search_domain("ThriftBooks") == "www.thriftbooks.com" |
| 467 | assert len(_search_args(calls)) == 2 |
| 468 | |
| 469 | |
| 470 | def test_definitive_no_match_is_cached(monkeypatch): |
| 471 | # A well-formed empty hits list IS definitive: cache it so repeat lookups |
| 472 | # for a name Trustpilot does not know cost one subprocess, not N. |
| 473 | calls = _capture_cli(monkeypatch, {"search": {"hits": []}, "info": INFO_OK}) |
| 474 | assert trustpilot._search_domain("ChowNow") is None |
| 475 | assert trustpilot._search_domain("ChowNow") is None |
| 476 | assert len(_search_args(calls)) == 1 |
| 477 |