443b05f330
* fix(trustpilot): resolve name->domain, warm session pre-fanout, cap to one fetch Trustpilot returned 0 items on company topics: the engine passed the raw topic name to a CLI keyed by domain (info ThriftBooks -> HTTP 404), and N parallel subqueries each raced their own Chrome WAF-cookie harvest. - --trustpilot-domain flag (verbatim, bypasses the brand-shape gate; flows into competitors-plan sub-runs with provenance: user-set is final, resolved hints retry via search on a miss) - name->domain resolution via the CLI's search, cached per topic; name-match mandatory, ambiguous multi-hit falls back rather than misattributing - ensure_session_ready: one process-global, lock-serialized auth status / auth login before the fan-out; brand-gated so generic topics never launch Chrome; logs structured status strings only - MAX_SOURCE_FETCHES trustpilot=1: N streams used identical identifiers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhBKEWrZV9cpfzNu7rmRzR * feat(resolve): auto-resolve a Trustpilot domain hint from official-site URLs Headless --auto-resolve runs fill args.trustpilot_domain (hint provenance) from news/handle search-result URLs whose registrable label matches the topic. Hints are tier-2: the engine retries via CLI search when they miss. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhBKEWrZV9cpfzNu7rmRzR * docs(skill): Step 0.5d Trustpilot-domain resolution + CONFIGURATION.md mirror Pre-flight checklist row, Step 0.5d resolution subsection, Resolved-block Trustpilot line, per-entity trustpilot_domain in --competitors-plan, and the CONFIGURATION.md source-table row reflecting domain resolution and the pre-fan-out session warm-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhBKEWrZV9cpfzNu7rmRzR * fix(review): harden trustpilot resolution per 10-reviewer code review - exclude trustpilot from the thin-source retry: it returns at most one item by design, so '<3 items' re-fetched it after every success -- bypassing the fetch cap and re-resolving without --trustpilot-domain (lookalike-misattribution path) [cross-model adversarial, verified] - move the session warm-up from pipeline.run's pre-fan-out slot into search_trustpilot's first fetch: never delays other sources' streams, never fires when the plan fetches no trustpilot [reliability, adversarial] - replace the warm-up's boolean-forever flag with a 240s monotonic TTL: long-lived host processes re-check after the token window and retry a previously failed login [cross-model adversarial, correctness] - only USER-set domains bypass the brand-shape gate; an auto-resolved hint no longer widens activation to generic topics [security] - do not cache transient search errors as permanent negative resolutions [cross-model adversarial, correctness] - bound the hint-retry chain: skip when the first lookup already consumed a full single-call budget [reliability] - SKILL.md: --trustpilot-domain in the engine-command flag list [project-standards], vs-mode per-entity lookup type 5 + main-topic outer-flag clarification [agent-native, correctness] - tests: thin-retry exclusion, main()-level flag plumbing, warm-up-at- first-touch, TTL lapse, hint-on-generic-topic quiet, transient-error not cached [testing] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhBKEWrZV9cpfzNu7rmRzR * fix(trustpilot): address Greptile P2s - degenerate payloads uncached, retry timer after warm-up - an empty-stdout CLI response ({}) is a degenerate payload, not a definitive no-match: skip the cache write so it retries next lookup (a well-formed empty hits list still caches) - start the hint-retry budget timer after the warm-up so a slow Chrome harvest cannot consume the retry budget of a fast info call Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PhBKEWrZV9cpfzNu7rmRzR --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
212 lines
8.2 KiB
Python
212 lines
8.2 KiB
Python
"""Tests for --competitors-plan JSON parsing and per-entity kwargs threading."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import json
|
|
import tempfile
|
|
import unittest
|
|
from contextlib import redirect_stderr
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
import last30days as cli
|
|
|
|
|
|
class ParseCompetitorsPlanTests(unittest.TestCase):
|
|
def test_none_returns_empty(self):
|
|
self.assertEqual(cli.parse_competitors_plan(None), {})
|
|
|
|
def test_empty_string_returns_empty(self):
|
|
self.assertEqual(cli.parse_competitors_plan(""), {})
|
|
|
|
def test_inline_json_parsed(self):
|
|
raw = '{"Drake": {"x_handle": "Drake", "subreddits": ["Drizzy"]}}'
|
|
out = cli.parse_competitors_plan(raw)
|
|
self.assertIn("drake", out)
|
|
self.assertEqual(out["drake"]["x_handle"], "Drake")
|
|
self.assertEqual(out["drake"]["subreddits"], ["Drizzy"])
|
|
|
|
def test_file_path_accepted(self):
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", suffix=".json", delete=False,
|
|
) as f:
|
|
json.dump(
|
|
{"Anthropic": {"x_handle": "AnthropicAI", "github_user": "anthropics"}},
|
|
f,
|
|
)
|
|
path = f.name
|
|
try:
|
|
out = cli.parse_competitors_plan(path)
|
|
self.assertEqual(out["anthropic"]["x_handle"], "AnthropicAI")
|
|
self.assertEqual(out["anthropic"]["github_user"], "anthropics")
|
|
finally:
|
|
Path(path).unlink(missing_ok=True)
|
|
|
|
def test_file_with_non_ascii_content(self):
|
|
"""Plan file with non-ASCII characters (e.g. accented names) reads without UnicodeDecodeError."""
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="wb", suffix=".json", delete=False,
|
|
) as f:
|
|
f.write(
|
|
b'{"Nestl\xc3\xa9": {"x_handle": "Nestle", "subreddits": ["nestle"]}}'
|
|
)
|
|
path = f.name
|
|
try:
|
|
out = cli.parse_competitors_plan(path)
|
|
self.assertIn("nestlé", out)
|
|
self.assertEqual(out["nestlé"]["x_handle"], "Nestle")
|
|
finally:
|
|
Path(path).unlink(missing_ok=True)
|
|
|
|
def test_case_insensitive_key_normalization(self):
|
|
raw = '{"DRAKE": {"x_handle": "Drake"}}'
|
|
out = cli.parse_competitors_plan(raw)
|
|
self.assertIn("drake", out)
|
|
self.assertNotIn("DRAKE", out)
|
|
|
|
def test_unknown_fields_warned_and_ignored(self):
|
|
raw = '{"Drake": {"x_handle": "Drake", "bogus_field": 42}}'
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
out = cli.parse_competitors_plan(raw)
|
|
self.assertIn("drake", out)
|
|
self.assertNotIn("bogus_field", out["drake"])
|
|
self.assertIn("Unknown fields", err.getvalue())
|
|
|
|
def test_malformed_json_exits_2(self):
|
|
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err:
|
|
cli.parse_competitors_plan("{not valid json")
|
|
self.assertEqual(cm.exception.code, 2)
|
|
self.assertIn("Invalid JSON", err.getvalue())
|
|
|
|
def test_top_level_list_rejected(self):
|
|
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()):
|
|
cli.parse_competitors_plan('["Drake", "Kendrick"]')
|
|
self.assertEqual(cm.exception.code, 2)
|
|
|
|
def test_entry_non_dict_skipped_with_warning(self):
|
|
raw = '{"Drake": "not-a-dict", "Kendrick": {"x_handle": "kendricklamar"}}'
|
|
err = io.StringIO()
|
|
with redirect_stderr(err):
|
|
out = cli.parse_competitors_plan(raw)
|
|
self.assertNotIn("drake", out)
|
|
self.assertIn("kendrick", out)
|
|
self.assertIn("must be a dict", err.getvalue())
|
|
|
|
def test_all_six_fields_accepted(self):
|
|
raw = json.dumps({
|
|
"OpenAI": {
|
|
"x_handle": "OpenAI",
|
|
"x_related": ["sama", "gdb"],
|
|
"subreddits": ["OpenAI", "MachineLearning"],
|
|
"github_user": "openai",
|
|
"github_repos": ["openai/gpt-5"],
|
|
"context": "GPT-5 launch imminent",
|
|
}
|
|
})
|
|
out = cli.parse_competitors_plan(raw)
|
|
entry = out["openai"]
|
|
self.assertEqual(entry["x_handle"], "OpenAI")
|
|
self.assertEqual(entry["x_related"], ["sama", "gdb"])
|
|
self.assertEqual(entry["subreddits"], ["OpenAI", "MachineLearning"])
|
|
self.assertEqual(entry["github_user"], "openai")
|
|
self.assertEqual(entry["github_repos"], ["openai/gpt-5"])
|
|
self.assertEqual(entry["context"], "GPT-5 launch imminent")
|
|
|
|
|
|
class SubrunKwargsForTests(unittest.TestCase):
|
|
def test_plan_wins_over_auto_resolve(self):
|
|
plan_entry = {"x_handle": "Drake", "subreddits": ["Drizzy"]}
|
|
resolved = {"x_handle": "wrong", "subreddits": ["wrong"]}
|
|
kwargs = cli.subrun_kwargs_for("Drake", plan_entry, resolved=resolved)
|
|
self.assertEqual(kwargs["x_handle"], "Drake")
|
|
self.assertEqual(kwargs["subreddits"], ["Drizzy"])
|
|
|
|
def test_auto_resolve_used_when_plan_missing(self):
|
|
resolved = {
|
|
"x_handle": "Drake",
|
|
"subreddits": ["Drizzy", "hiphopheads"],
|
|
"github_user": "",
|
|
"github_repos": [],
|
|
}
|
|
kwargs = cli.subrun_kwargs_for("Drake", {}, resolved=resolved)
|
|
self.assertEqual(kwargs["x_handle"], "Drake")
|
|
self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"])
|
|
|
|
def test_both_empty_yields_all_none(self):
|
|
kwargs = cli.subrun_kwargs_for("Drake", {}, resolved={})
|
|
self.assertIsNone(kwargs["x_handle"])
|
|
self.assertIsNone(kwargs["subreddits"])
|
|
self.assertIsNone(kwargs["github_user"])
|
|
self.assertIsNone(kwargs["github_repos"])
|
|
self.assertIsNone(kwargs["x_related"])
|
|
self.assertEqual(kwargs["_context"], "")
|
|
|
|
def test_trustpilot_domain_plan_wins_and_is_not_hint(self):
|
|
kwargs = cli.subrun_kwargs_for(
|
|
"ThriftBooks",
|
|
{"trustpilot_domain": "www.thriftbooks.com"},
|
|
resolved={"trustpilot_domain": "wrong.com"},
|
|
)
|
|
self.assertEqual(kwargs["trustpilot_domain"], "www.thriftbooks.com")
|
|
self.assertFalse(kwargs["_trustpilot_domain_is_hint"])
|
|
|
|
def test_trustpilot_domain_from_auto_resolve_is_hint(self):
|
|
kwargs = cli.subrun_kwargs_for(
|
|
"ThriftBooks", {}, resolved={"trustpilot_domain": "thriftbooks.com"},
|
|
)
|
|
self.assertEqual(kwargs["trustpilot_domain"], "thriftbooks.com")
|
|
self.assertTrue(kwargs["_trustpilot_domain_is_hint"])
|
|
|
|
def test_trustpilot_domain_absent_is_none(self):
|
|
kwargs = cli.subrun_kwargs_for("ThriftBooks", {}, resolved={})
|
|
self.assertIsNone(kwargs["trustpilot_domain"])
|
|
self.assertFalse(kwargs["_trustpilot_domain_is_hint"])
|
|
|
|
def test_x_handle_strips_at_sign(self):
|
|
kwargs = cli.subrun_kwargs_for(
|
|
"Drake", {"x_handle": "@Drake"}, resolved={},
|
|
)
|
|
self.assertEqual(kwargs["x_handle"], "Drake")
|
|
|
|
def test_subreddits_strip_r_prefix(self):
|
|
kwargs = cli.subrun_kwargs_for(
|
|
"Drake", {"subreddits": ["r/Drizzy", "hiphopheads"]}, resolved={},
|
|
)
|
|
self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"])
|
|
|
|
def test_github_repos_filter_non_slash(self):
|
|
kwargs = cli.subrun_kwargs_for(
|
|
"Drake",
|
|
{"github_repos": ["drake/ovo", "not-a-repo"]},
|
|
resolved={},
|
|
)
|
|
self.assertEqual(kwargs["github_repos"], ["drake/ovo"])
|
|
|
|
def test_x_related_list_normalized(self):
|
|
kwargs = cli.subrun_kwargs_for(
|
|
"Drake",
|
|
{"x_related": ["@pnd", "drakefan"]},
|
|
resolved={},
|
|
)
|
|
self.assertEqual(kwargs["x_related"], ["pnd", "drakefan"])
|
|
|
|
def test_github_user_lowercased(self):
|
|
kwargs = cli.subrun_kwargs_for(
|
|
"OpenAI", {"github_user": "@OpenAI"}, resolved={},
|
|
)
|
|
self.assertEqual(kwargs["github_user"], "openai")
|
|
|
|
def test_context_from_plan_or_resolved(self):
|
|
plan_entry = {"context": "Plan context"}
|
|
resolved = {"context": "Resolved context"}
|
|
kwargs = cli.subrun_kwargs_for("X", plan_entry, resolved=resolved)
|
|
self.assertEqual(kwargs["_context"], "Plan context")
|
|
|
|
kwargs = cli.subrun_kwargs_for("X", {}, resolved=resolved)
|
|
self.assertEqual(kwargs["_context"], "Resolved context")
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|