2860fb5d18
Security / Dependency review (push) Has been skipped
Scorecard / Scorecard analysis (push) Failing after 0s
Validate / eval (push) Failing after 0s
Security / Dependency audit (push) Failing after 1s
Security / Secret scan (push) Failing after 1s
Validate / tests (push) Failing after 0s
Validate / mcp-tests (push) Failing after 1s
GitHub Actions Security Analysis with zizmor 🌈 / zizmor (push) Failing after 1s
Security / SAST scan (push) Failing after 13m52s
477 lines
18 KiB
Python
477 lines
18 KiB
Python
"""Tests for the Trustpilot source adapter (lib/trustpilot.py).
|
|
|
|
Covers the brand-shape gate (the primary quiet-keeper), the browser opt-out,
|
|
field mapping from the info envelope, and graceful degradation.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from lib import pipeline, trustpilot
|
|
|
|
|
|
# ---- opt-in availability gating (off by default) ----
|
|
|
|
def test_not_available_by_default(monkeypatch):
|
|
monkeypatch.setattr(pipeline, "which", lambda name: f"/usr/bin/{name}")
|
|
avail = pipeline.available_sources({})
|
|
assert "trustpilot" not in avail # opt-in: absent without INCLUDE_SOURCES
|
|
# the zero-auth pair stays default-on
|
|
assert "arxiv" in avail and "techmeme" in avail
|
|
|
|
|
|
def test_available_when_included(monkeypatch):
|
|
monkeypatch.setattr(pipeline, "which", lambda name: f"/usr/bin/{name}")
|
|
avail = pipeline.available_sources({"INCLUDE_SOURCES": "trustpilot"})
|
|
assert "trustpilot" in avail
|
|
|
|
|
|
def test_available_when_requested(monkeypatch):
|
|
monkeypatch.setattr(pipeline, "which", lambda name: f"/usr/bin/{name}")
|
|
avail = pipeline.available_sources({}, requested_sources=["trustpilot"])
|
|
assert "trustpilot" in avail
|
|
|
|
|
|
# ---- brand-shape gate ----
|
|
|
|
@pytest.mark.parametrize("topic", ["ChowNow", "chownow.com", "Nothing Phone", "OpenAI", "nothing.tech"])
|
|
def test_brand_shaped_topics_fire(topic):
|
|
assert trustpilot.is_brand_shaped(topic)
|
|
|
|
|
|
@pytest.mark.parametrize("topic", [
|
|
"AI coding agents", # 3 words, generic
|
|
"agent memory", # lowercase, generic token
|
|
"Golden State Warriors", # 3 words -> not company-shaped
|
|
"best phones", # generic + lowercase
|
|
"how to use claude", # generic question
|
|
"", # empty
|
|
])
|
|
def test_non_brand_topics_stay_quiet(topic):
|
|
assert not trustpilot.is_brand_shaped(topic)
|
|
|
|
|
|
@pytest.mark.parametrize("topic", [
|
|
"Python", "React", "Docker", "Rust", "Linux", "Swift", "Java",
|
|
"Kubernetes", "PostgreSQL", "Node",
|
|
])
|
|
def test_single_word_tech_names_are_not_brands(topic):
|
|
# A bare capitalized language/framework/tool name is a technology query,
|
|
# not a company-review intent -- it must not trigger the Trustpilot CLI.
|
|
assert not trustpilot.is_brand_shaped(topic)
|
|
|
|
|
|
def test_tech_company_still_reachable_by_domain():
|
|
# The conservative tech-token gate does not block explicit company intent:
|
|
# a domain still resolves (e.g. wanting docker.com's reviews specifically).
|
|
assert trustpilot.is_brand_shaped("docker.com")
|
|
|
|
|
|
def test_company_identifier_prefers_domain():
|
|
assert trustpilot._company_identifier("reviews of chownow.com please") == "chownow.com"
|
|
assert trustpilot._company_identifier("ChowNow") == "ChowNow"
|
|
|
|
|
|
# ---- gate short-circuits the CLI (no Chrome on non-brand topics) ----
|
|
|
|
def test_non_brand_topic_never_calls_cli(monkeypatch):
|
|
called = []
|
|
monkeypatch.setattr(trustpilot, "_is_available", lambda: True)
|
|
monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: called.append(a) or {})
|
|
out = trustpilot.search_trustpilot("AI coding agents", "2026-06-01", "2026-06-27")
|
|
assert out == {"results": []}
|
|
assert called == [] # CLI (and any Chrome harvest) never invoked
|
|
|
|
|
|
# ---- browser opt-out ----
|
|
|
|
def test_browser_opt_out_skips_even_for_brand(monkeypatch):
|
|
called = []
|
|
monkeypatch.setattr(trustpilot, "_is_available", lambda: True)
|
|
monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: called.append(a) or {})
|
|
config = {trustpilot.NO_BROWSER_ENV: "1"}
|
|
out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27", config=config)
|
|
assert out == {"results": []}
|
|
assert called == [] # opt-out prevents the harvest-prone CLI call
|
|
|
|
|
|
def test_browser_opt_out_via_env_var_no_config(monkeypatch):
|
|
"""The production path: the env var is set but never propagated into the
|
|
config dict. The os.environ fallback must still skip the harvest."""
|
|
called = []
|
|
monkeypatch.setattr(trustpilot, "_is_available", lambda: True)
|
|
monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: called.append(a) or {})
|
|
monkeypatch.setenv(trustpilot.NO_BROWSER_ENV, "1")
|
|
# config is None / lacks the key, mirroring env.get_config's allowlist gap.
|
|
out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27", config=None)
|
|
assert out == {"results": []}
|
|
assert called == []
|
|
|
|
|
|
# ---- happy path + field mapping ----
|
|
|
|
def test_happy_path_maps_info_envelope():
|
|
info = {
|
|
"name": "ChowNow",
|
|
"trustScore": 1.2,
|
|
"reviewCount": 49,
|
|
"aiSummary": "Most reviewers were let down: food never arriving, wrong address.",
|
|
"domain": "chownow.com",
|
|
}
|
|
items = trustpilot.parse_trustpilot_response({"results": [info]}, query="ChowNow")
|
|
assert len(items) == 1
|
|
it = items[0]
|
|
assert it["name"] == "ChowNow"
|
|
assert it["trustScore"] == 1.2
|
|
assert it["reviewCount"] == 49
|
|
assert "food never arriving" in it["summary"]
|
|
assert it["engagement"]["reviews"] == 49
|
|
assert it["url"].endswith("chownow.com")
|
|
|
|
|
|
def test_search_returns_info_for_brand(monkeypatch):
|
|
monkeypatch.setattr(trustpilot, "_is_available", lambda: True)
|
|
monkeypatch.setattr(
|
|
trustpilot, "_run_cli",
|
|
lambda *a, **k: {"name": "ChowNow", "trustScore": 1.2, "reviewCount": 49, "aiSummary": "Bad."},
|
|
)
|
|
out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27")
|
|
assert len(out["results"]) == 1
|
|
assert out["results"][0]["name"] == "ChowNow"
|
|
|
|
|
|
# ---- degradation paths ----
|
|
|
|
def test_cli_error_degrades_to_empty(monkeypatch):
|
|
monkeypatch.setattr(trustpilot, "_is_available", lambda: True)
|
|
monkeypatch.setattr(trustpilot, "_run_cli", lambda *a, **k: {"error": "no chrome"})
|
|
out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27")
|
|
assert out == {"results": []}
|
|
|
|
|
|
def test_binary_absent_returns_empty(monkeypatch):
|
|
monkeypatch.setattr(trustpilot.shutil, "which", lambda _bin: None)
|
|
out = trustpilot.search_trustpilot("ChowNow", "2026-06-01", "2026-06-27")
|
|
assert out["results"] == []
|
|
|
|
|
|
def test_parse_handles_empty_and_malformed():
|
|
assert trustpilot.parse_trustpilot_response({"results": []}, query="x") == []
|
|
assert trustpilot.parse_trustpilot_response({}, query="x") == []
|
|
assert trustpilot.parse_trustpilot_response({"results": [{}]}, query="x") == []
|
|
|
|
|
|
# ---- shared scaffolding for domain-resolution and warm-up tests ----
|
|
|
|
D1, D2 = "2026-06-01", "2026-06-27"
|
|
|
|
THRIFTBOOKS_HITS = {
|
|
"hits": [
|
|
{"displayName": "ThriftBooks", "domain": "www.thriftbooks.com", "numberOfReviews": 2843175},
|
|
{"displayName": "Thrift Books", "domain": "thriftbook.com", "numberOfReviews": 130},
|
|
{"displayName": "Thriftybooks", "domain": "thriftybooks.com", "numberOfReviews": 6},
|
|
]
|
|
}
|
|
|
|
INFO_OK = {"name": "ThriftBooks", "trustScore": 4.5, "reviewCount": 2843175, "aiSummary": "Great."}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_trustpilot_state():
|
|
trustpilot._reset_state_for_tests()
|
|
yield
|
|
trustpilot._reset_state_for_tests()
|
|
|
|
|
|
def _capture_cli(monkeypatch, responses):
|
|
"""Mock _run_cli, recording argv. ``responses`` maps a subcommand key
|
|
("info", "search", "auth status", "auth login") to a dict, or to a list of
|
|
dicts consumed in order."""
|
|
calls: list[list[str]] = []
|
|
|
|
def fake(cmd, timeout):
|
|
calls.append(list(cmd))
|
|
key = cmd[1] if cmd[1] != "auth" else f"auth {cmd[2]}"
|
|
resp = responses.get(key, {})
|
|
if isinstance(resp, list):
|
|
return dict(resp.pop(0)) if resp else {}
|
|
return dict(resp)
|
|
|
|
monkeypatch.setattr(trustpilot, "_is_available", lambda: True)
|
|
monkeypatch.setattr(trustpilot, "_run_cli", fake)
|
|
return calls
|
|
|
|
|
|
def _info_args(calls):
|
|
return [c for c in calls if c[1] == "info"]
|
|
|
|
|
|
def _search_args(calls):
|
|
return [c for c in calls if c[1] == "search"]
|
|
|
|
|
|
# ---- explicit domain (--trustpilot-domain) ----
|
|
|
|
def test_explicit_domain_used_verbatim(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"info": INFO_OK})
|
|
out = trustpilot.search_trustpilot(
|
|
"ThriftBooks", D1, D2, explicit_domain="www.thriftbooks.com")
|
|
assert out["results"][0]["name"] == "ThriftBooks"
|
|
assert _info_args(calls)[0][2] == "www.thriftbooks.com"
|
|
assert _search_args(calls) == [] # flag set -> no search call fires
|
|
|
|
|
|
def test_explicit_domain_bypasses_brand_gate(monkeypatch):
|
|
# A 4-word topic fails is_brand_shaped, but an explicit domain is proof
|
|
# of brand intent -- the CLI must still be invoked with the flag value.
|
|
calls = _capture_cli(monkeypatch, {"info": INFO_OK})
|
|
out = trustpilot.search_trustpilot(
|
|
"Stanley Steemer carpet cleaning", D1, D2,
|
|
explicit_domain="stanleysteemer.com")
|
|
assert len(out["results"]) == 1
|
|
assert _info_args(calls)[0][2] == "stanleysteemer.com"
|
|
|
|
|
|
def test_explicit_domain_beats_domain_shaped_topic(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"info": INFO_OK})
|
|
trustpilot.search_trustpilot(
|
|
"chownow.com", D1, D2, explicit_domain="www.thriftbooks.com")
|
|
assert _info_args(calls)[0][2] == "www.thriftbooks.com"
|
|
|
|
|
|
def test_user_domain_is_verbatim_final_no_retry(monkeypatch):
|
|
# User-set flag (domain_is_hint=False): a miss degrades, never re-resolves.
|
|
calls = _capture_cli(monkeypatch, {"info": {"error": "HTTP 404"}})
|
|
out = trustpilot.search_trustpilot(
|
|
"ThriftBooks", D1, D2, explicit_domain="thriftbook.com")
|
|
assert out == {"results": []}
|
|
assert _search_args(calls) == []
|
|
|
|
|
|
def test_hint_domain_retries_via_search_on_miss(monkeypatch):
|
|
# Auto-resolved hint (domain_is_hint=True): a 404 falls through to the
|
|
# CLI search resolution and retries with the canonical domain.
|
|
calls = _capture_cli(monkeypatch, {
|
|
"info": [{"error": "HTTP 404"}, INFO_OK],
|
|
"search": THRIFTBOOKS_HITS,
|
|
})
|
|
out = trustpilot.search_trustpilot(
|
|
"ThriftBooks", D1, D2,
|
|
explicit_domain="getthriftbooks.io", domain_is_hint=True)
|
|
assert len(out["results"]) == 1
|
|
infos = _info_args(calls)
|
|
assert infos[0][2] == "getthriftbooks.io"
|
|
assert infos[1][2] == "www.thriftbooks.com"
|
|
|
|
|
|
# ---- name -> domain search fallback ----
|
|
|
|
def test_bare_name_resolves_via_search(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"search": THRIFTBOOKS_HITS, "info": INFO_OK})
|
|
out = trustpilot.search_trustpilot("ThriftBooks", D1, D2)
|
|
assert len(out["results"]) == 1
|
|
assert _search_args(calls)[0][2] == "ThriftBooks"
|
|
assert _info_args(calls)[0][2] == "www.thriftbooks.com"
|
|
|
|
|
|
def test_ambiguous_hits_fall_back_to_topic(monkeypatch):
|
|
# Two same-named companies with comparable volume: review count must never
|
|
# break the tie (silent misattribution is worse than a visible miss).
|
|
hits = {"hits": [
|
|
{"displayName": "Mercury", "domain": "mercury.com", "numberOfReviews": 50000},
|
|
{"displayName": "Mercury", "domain": "mercuryinsurance.com", "numberOfReviews": 40000},
|
|
]}
|
|
calls = _capture_cli(monkeypatch, {"search": hits, "info": INFO_OK})
|
|
trustpilot.search_trustpilot("Mercury", D1, D2)
|
|
assert _info_args(calls)[0][2] == "Mercury" # legacy fallback identifier
|
|
|
|
|
|
def test_review_count_never_overrides_name_mismatch(monkeypatch):
|
|
hits = {"hits": [
|
|
{"displayName": "Bolt Technology", "domain": "bolt.eu", "numberOfReviews": 900000},
|
|
]}
|
|
calls = _capture_cli(monkeypatch, {"search": hits, "info": INFO_OK})
|
|
trustpilot.search_trustpilot("Bolt", D1, D2)
|
|
assert _info_args(calls)[0][2] == "Bolt"
|
|
|
|
|
|
def test_search_error_falls_back_to_topic(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"search": {"error": "boom"}, "info": INFO_OK})
|
|
out = trustpilot.search_trustpilot("ChowNow", D1, D2)
|
|
assert len(out["results"]) == 1
|
|
assert _info_args(calls)[0][2] == "ChowNow"
|
|
|
|
|
|
def test_domain_shaped_topic_never_searches(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"info": INFO_OK})
|
|
trustpilot.search_trustpilot("chownow.com", D1, D2)
|
|
assert _search_args(calls) == []
|
|
assert _info_args(calls)[0][2] == "chownow.com"
|
|
|
|
|
|
def test_search_cached_per_topic_not_per_process(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"search": dict(THRIFTBOOKS_HITS), "info": INFO_OK})
|
|
trustpilot.search_trustpilot("ThriftBooks", D1, D2)
|
|
trustpilot.search_trustpilot("ThriftBooks", D1, D2)
|
|
assert len(_search_args(calls)) == 1 # repeat topic hits the cache
|
|
|
|
# A DIFFERENT topic must trigger its own search (vs-mode entities resolve
|
|
# independently; a single process-wide slot would cross-contaminate).
|
|
responses_seen = _search_args(calls)
|
|
trustpilot.search_trustpilot("ChowNow", D1, D2)
|
|
assert len(_search_args(calls)) == len(responses_seen) + 1
|
|
|
|
|
|
# ---- session warm-up (ensure_session_ready) ----
|
|
|
|
def test_warmup_fresh_session_skips_login(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True, "hasSession": True}})
|
|
trustpilot.ensure_session_ready("ThriftBooks")
|
|
assert [c[2] for c in calls if c[1] == "auth"] == ["status"]
|
|
|
|
|
|
def test_warmup_stale_session_logs_in_once(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {
|
|
"auth status": {"error": "no session"},
|
|
"auth login": {"ok": True},
|
|
})
|
|
trustpilot.ensure_session_ready("ThriftBooks")
|
|
trustpilot.ensure_session_ready("ThriftBooks") # idempotent: no second pass
|
|
auth_calls = [c[2] for c in calls if c[1] == "auth"]
|
|
assert auth_calls == ["status", "login"]
|
|
|
|
|
|
def test_warmup_concurrent_calls_single_warmup(monkeypatch):
|
|
import threading as _threading
|
|
calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True}})
|
|
threads = [
|
|
_threading.Thread(target=trustpilot.ensure_session_ready, args=("ThriftBooks",))
|
|
for _ in range(8)
|
|
]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
assert len([c for c in calls if c[1] == "auth"]) == 1 # vs-mode race guard
|
|
|
|
|
|
def test_warmup_skips_non_brand_topic(monkeypatch):
|
|
# AE6: generic topic with trustpilot active -> no warm-up, no Chrome.
|
|
calls = _capture_cli(monkeypatch, {})
|
|
trustpilot.ensure_session_ready("AI coding agents")
|
|
assert calls == []
|
|
|
|
|
|
def test_warmup_domain_bypasses_brand_gate(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True}})
|
|
trustpilot.ensure_session_ready("Stanley Steemer carpet cleaning", has_domain=True)
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_warmup_respects_browser_opt_out(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {})
|
|
trustpilot.ensure_session_ready("ThriftBooks", config={trustpilot.NO_BROWSER_ENV: "1"})
|
|
assert calls == []
|
|
|
|
|
|
def test_warmup_failure_never_raises(monkeypatch):
|
|
calls = _capture_cli(monkeypatch, {
|
|
"auth status": {"error": "no session"},
|
|
"auth login": {"error": "chrome missing"},
|
|
})
|
|
trustpilot.ensure_session_ready("ThriftBooks") # must not raise
|
|
assert [c[2] for c in calls if c[1] == "auth"] == ["status", "login"]
|
|
|
|
|
|
def test_warmup_logs_no_token_bytes(monkeypatch):
|
|
# The auth status payload carries live WAF-token prefixes; log lines must
|
|
# only ever contain structured status strings.
|
|
logged: list[str] = []
|
|
monkeypatch.setattr(trustpilot, "_log", lambda msg: logged.append(msg))
|
|
_capture_cli(monkeypatch, {
|
|
"auth status": {"isFresh": False, "tokenPrefix": "48372fee-99b"},
|
|
"auth login": {"ok": True, "token": "48372fee-99b1-dead-beef"},
|
|
})
|
|
trustpilot.ensure_session_ready("ThriftBooks")
|
|
assert logged and all("48372fee" not in msg for msg in logged)
|
|
|
|
|
|
def test_trustpilot_capped_to_single_fetch():
|
|
# N subqueries listing trustpilot must produce exactly one stream: every
|
|
# stream would use the identical company identifier, and each extra one
|
|
# risks its own WAF-cookie Chrome harvest.
|
|
assert pipeline.MAX_SOURCE_FETCHES.get("trustpilot") == 1
|
|
|
|
|
|
# ---- review-driven hardening ----
|
|
|
|
def test_search_trustpilot_warms_session_at_first_touch(monkeypatch):
|
|
# The warm-up runs inside the source fetch (never the pipeline fan-out
|
|
# setup), so it must precede the info call within one search invocation.
|
|
calls = _capture_cli(monkeypatch, {
|
|
"auth status": {"isFresh": True},
|
|
"info": INFO_OK,
|
|
})
|
|
trustpilot.search_trustpilot(
|
|
"ThriftBooks", D1, D2, explicit_domain="www.thriftbooks.com")
|
|
assert calls[0][1] == "auth" and calls[0][2] == "status"
|
|
assert [c for c in calls if c[1] == "info"]
|
|
|
|
|
|
def test_warmup_ttl_lapse_rechecks(monkeypatch):
|
|
import time as _time
|
|
calls = _capture_cli(monkeypatch, {"auth status": {"isFresh": True}})
|
|
trustpilot.ensure_session_ready("ThriftBooks")
|
|
assert len(calls) == 1
|
|
# Within the TTL: no re-check.
|
|
trustpilot.ensure_session_ready("ThriftBooks")
|
|
assert len(calls) == 1
|
|
# After the TTL lapses (long-lived host process): cheap re-check fires.
|
|
trustpilot._warmup_at = _time.monotonic() - (trustpilot.WARMUP_TTL_SECONDS + 1)
|
|
trustpilot.ensure_session_ready("ThriftBooks")
|
|
assert len(calls) == 2
|
|
|
|
|
|
def test_hint_on_non_brand_topic_stays_quiet(monkeypatch):
|
|
# An auto-resolved hint must not widen activation beyond brand-shaped
|
|
# topics -- only a USER-set domain proves brand intent (AE6 contract).
|
|
calls = _capture_cli(monkeypatch, {})
|
|
out = trustpilot.search_trustpilot(
|
|
"AI coding agents", D1, D2,
|
|
explicit_domain="aicodingagents.com", domain_is_hint=True)
|
|
assert out == {"results": []}
|
|
assert calls == []
|
|
|
|
|
|
def test_transient_search_error_not_cached(monkeypatch):
|
|
# A flaky search must not poison the per-topic cache for the process.
|
|
calls = _capture_cli(monkeypatch, {
|
|
"search": [{"error": "timeout"}, dict(THRIFTBOOKS_HITS)],
|
|
"info": INFO_OK,
|
|
})
|
|
assert trustpilot._search_domain("ThriftBooks") is None
|
|
assert trustpilot._search_domain("ThriftBooks") == "www.thriftbooks.com"
|
|
assert len(_search_args(calls)) == 2
|
|
|
|
|
|
def test_empty_search_payload_not_cached(monkeypatch):
|
|
# Empty stdout parses to {} (exit 0, no output): a degenerate payload,
|
|
# not a definitive no-match -- it must not become a permanent cache entry.
|
|
calls = _capture_cli(monkeypatch, {
|
|
"search": [{}, dict(THRIFTBOOKS_HITS)],
|
|
"info": INFO_OK,
|
|
})
|
|
assert trustpilot._search_domain("ThriftBooks") is None
|
|
assert trustpilot._search_domain("ThriftBooks") == "www.thriftbooks.com"
|
|
assert len(_search_args(calls)) == 2
|
|
|
|
|
|
def test_definitive_no_match_is_cached(monkeypatch):
|
|
# A well-formed empty hits list IS definitive: cache it so repeat lookups
|
|
# for a name Trustpilot does not know cost one subprocess, not N.
|
|
calls = _capture_cli(monkeypatch, {"search": {"hits": []}, "info": INFO_OK})
|
|
assert trustpilot._search_domain("ChowNow") is None
|
|
assert trustpilot._search_domain("ChowNow") is None
|
|
assert len(_search_args(calls)) == 1
|