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