| 1 | """Regression tests: main-topic flags must not leak into competitor sub-runs. |
| 2 | |
| 3 | Based on 2026-04-22 Kanye West --competitors receipt where Drake and |
| 4 | Kendrick Lamar sub-runs logged Kanye's resolved subreddit list as their own |
| 5 | targeted search. Per-entity sub-runs must never inherit main-topic targeting |
| 6 | via closure capture, config mutation, or any other path. |
| 7 | """ |
| 8 | |
| 9 | from __future__ import annotations |
| 10 | |
| 11 | import io |
| 12 | import unittest |
| 13 | from contextlib import redirect_stderr |
| 14 | from unittest import mock |
| 15 | |
| 16 | |
| 17 | def _fake_report(topic: str): |
| 18 | class _R: |
| 19 | pass |
| 20 | |
| 21 | r = _R() |
| 22 | r.topic = topic |
| 23 | r.artifacts = {} |
| 24 | return r |
| 25 | |
| 26 | |
| 27 | class SubRunIsolationTests(unittest.TestCase): |
| 28 | """Exercise the _competitor_runner closure pattern from main() directly. |
| 29 | |
| 30 | Builds the same closure shape main() uses, then invokes it with |
| 31 | captured-in-scope main-topic flags to verify they do NOT leak into |
| 32 | sub-run pipeline.run kwargs. |
| 33 | """ |
| 34 | |
| 35 | def _run_closure(self, main_flags, competitors, config=None, mock_flag=False): |
| 36 | """Replicate _competitor_runner closure from last30days.py main(). |
| 37 | |
| 38 | main_flags: dict of {x_handle, x_related, subreddits, tiktok_hashtags, |
| 39 | tiktok_creators, ig_creators, github_user, github_repos} |
| 40 | as they would exist in outer scope after argparse. |
| 41 | competitors: list of entity names to run. |
| 42 | Returns the list of kwargs dicts pipeline.run was called with. |
| 43 | """ |
| 44 | from lib import pipeline, resolve as resolve_mod |
| 45 | |
| 46 | captured: list[dict] = [] |
| 47 | |
| 48 | def fake_run(**kwargs): |
| 49 | captured.append(kwargs) |
| 50 | return _fake_report(kwargs["topic"]) |
| 51 | |
| 52 | # Simulate main scope variables |
| 53 | outer_subreddits = main_flags.get("subreddits") |
| 54 | outer_x_handle = main_flags.get("x_handle") |
| 55 | outer_x_related = main_flags.get("x_related") |
| 56 | outer_tiktok_hashtags = main_flags.get("tiktok_hashtags") |
| 57 | outer_tiktok_creators = main_flags.get("tiktok_creators") |
| 58 | outer_ig_creators = main_flags.get("ig_creators") |
| 59 | outer_github_user = main_flags.get("github_user") |
| 60 | outer_github_repos = main_flags.get("github_repos") |
| 61 | |
| 62 | class _Args: |
| 63 | pass |
| 64 | args = _Args() |
| 65 | args.mock = mock_flag |
| 66 | args.web_backend = "auto" |
| 67 | args.lookback_days = 30 |
| 68 | |
| 69 | cfg = config or {} |
| 70 | |
| 71 | # This mirrors the real _competitor_runner closure structure. |
| 72 | def competitor_runner(entity): |
| 73 | entity_config = dict(cfg) |
| 74 | resolved = { |
| 75 | "entity": entity, |
| 76 | "x_handle": "", |
| 77 | "subreddits": [], |
| 78 | "github_user": "", |
| 79 | "github_repos": [], |
| 80 | "context": "", |
| 81 | } |
| 82 | if not args.mock and resolve_mod._has_backend(entity_config): |
| 83 | try: |
| 84 | r = resolve_mod.auto_resolve(entity, entity_config) |
| 85 | except Exception: |
| 86 | r = {} |
| 87 | resolved["x_handle"] = r.get("x_handle", "") or "" |
| 88 | resolved["subreddits"] = list(r.get("subreddits") or []) |
| 89 | resolved["github_user"] = r.get("github_user", "") or "" |
| 90 | resolved["github_repos"] = list(r.get("github_repos") or []) |
| 91 | resolved["context"] = r.get("context", "") or "" |
| 92 | if resolved["context"]: |
| 93 | entity_config["_auto_resolve_context"] = resolved["context"] |
| 94 | pipeline.run( |
| 95 | topic=entity, |
| 96 | config=entity_config, |
| 97 | depth="default", |
| 98 | requested_sources=None, |
| 99 | mock=args.mock, |
| 100 | x_handle=resolved["x_handle"] or None, |
| 101 | subreddits=resolved["subreddits"] or None, |
| 102 | github_user=resolved["github_user"] or None, |
| 103 | github_repos=resolved["github_repos"] or None, |
| 104 | web_backend=args.web_backend, |
| 105 | lookback_days=args.lookback_days, |
| 106 | internal_subrun=True, |
| 107 | ) |
| 108 | |
| 109 | with mock.patch.object(pipeline, "run", side_effect=fake_run): |
| 110 | for entity in competitors: |
| 111 | competitor_runner(entity) |
| 112 | |
| 113 | return captured |
| 114 | |
| 115 | def test_main_subreddits_do_not_leak_to_peers(self): |
| 116 | """Kanye receipt: main --subreddits=Kanye,hiphopheads leaked to Drake/Kendrick.""" |
| 117 | main_flags = { |
| 118 | "subreddits": ["Kanye", "hiphopheads", "Music", "popheads", "kanyewest"], |
| 119 | "x_handle": "kanyewest", |
| 120 | } |
| 121 | captured = self._run_closure(main_flags, ["Drake", "Kendrick Lamar"]) |
| 122 | self.assertEqual(len(captured), 2) |
| 123 | for kwargs in captured: |
| 124 | self.assertIsNone( |
| 125 | kwargs["subreddits"], |
| 126 | f"Main subreddits leaked into {kwargs['topic']!r}'s sub-run: " |
| 127 | f"{kwargs['subreddits']}", |
| 128 | ) |
| 129 | |
| 130 | def test_main_x_handle_does_not_leak(self): |
| 131 | main_flags = {"x_handle": "kanyewest"} |
| 132 | captured = self._run_closure(main_flags, ["Drake"]) |
| 133 | self.assertIsNone(captured[0]["x_handle"]) |
| 134 | |
| 135 | def test_main_github_does_not_leak(self): |
| 136 | main_flags = { |
| 137 | "github_user": "someuser", |
| 138 | "github_repos": ["someuser/someproject"], |
| 139 | } |
| 140 | captured = self._run_closure(main_flags, ["Drake"]) |
| 141 | self.assertIsNone(captured[0]["github_user"]) |
| 142 | self.assertIsNone(captured[0]["github_repos"]) |
| 143 | |
| 144 | def test_auto_resolve_context_does_not_leak_across_peers(self): |
| 145 | """Per-entity auto_resolve context must not bleed between sub-runs.""" |
| 146 | from lib import resolve as resolve_mod |
| 147 | |
| 148 | def fake_resolve(entity, _cfg): |
| 149 | per_topic = { |
| 150 | "Drake": {"x_handle": "Drake", "subreddits": [], "github_user": "", |
| 151 | "github_repos": [], "context": "Drake ICEMAN rollout", |
| 152 | "category": None, "searches_run": 4}, |
| 153 | "Kendrick Lamar": {"x_handle": "kendricklamar", "subreddits": [], |
| 154 | "github_user": "", "github_repos": [], |
| 155 | "context": "Meet The Grahams revival", |
| 156 | "category": None, "searches_run": 4}, |
| 157 | } |
| 158 | return per_topic.get(entity, {}) |
| 159 | |
| 160 | with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \ |
| 161 | mock.patch.object(resolve_mod, "_has_backend", return_value=True): |
| 162 | captured = self._run_closure( |
| 163 | main_flags={}, |
| 164 | competitors=["Drake", "Kendrick Lamar"], |
| 165 | config={"BRAVE_API_KEY": "test"}, |
| 166 | ) |
| 167 | |
| 168 | by_topic = {kw["topic"]: kw for kw in captured} |
| 169 | # Each sub-run's config got its own context string. |
| 170 | self.assertEqual( |
| 171 | by_topic["Drake"]["config"].get("_auto_resolve_context"), |
| 172 | "Drake ICEMAN rollout", |
| 173 | ) |
| 174 | self.assertEqual( |
| 175 | by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context"), |
| 176 | "Meet The Grahams revival", |
| 177 | ) |
| 178 | # Cross-entity check: neither config contains the other's context. |
| 179 | self.assertNotIn( |
| 180 | "Meet The Grahams", |
| 181 | by_topic["Drake"]["config"].get("_auto_resolve_context", ""), |
| 182 | ) |
| 183 | self.assertNotIn( |
| 184 | "ICEMAN", |
| 185 | by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context", ""), |
| 186 | ) |
| 187 | |
| 188 | if __name__ == "__main__": |
| 189 | unittest.main() |
| 190 |