返回 last30days-skill
test_trustpilot_source.py
根目录 / tests / test_trustpilot_source.py
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
477 lines PYTHON