返回 last30days-skill
test_competitors_resolve_integration.py
根目录 / tests / test_competitors_resolve_integration.py
1 """Integration tests for per-entity Step 0.55 resolution inside competitor fan-out."""
2
3 from __future__ import annotations
4
5 import io
6 import sys
7 import unittest
8 from contextlib import redirect_stderr
9 from unittest import mock
10
11
12 def _fake_report(topic: str):
13 """Minimal Report stand-in for runner return values."""
14 class _R:
15 pass
16
17 r = _R()
18 r.topic = topic
19 r.artifacts = {}
20 return r
21
22
23 def _build_main_args(*overrides):
24 """Minimal argparse.Namespace-like object for the competitor path."""
25 import argparse
26 ns = argparse.Namespace(
27 topic=["Kanye West"],
28 mock=False,
29 competitors=2,
30 competitors_list=None,
31 quick=False,
32 deep=False,
33 emit="compact",
34 search=None,
35 debug=False,
36 diagnose=False,
37 save_dir=None,
38 save_suffix=None,
39 store=False,
40 x_handle=None,
41 x_related=None,
42 web_backend="auto",
43 deep_research=False,
44 plan=None,
45 subreddits=None,
46 tiktok_hashtags=None,
47 tiktok_creators=None,
48 ig_creators=None,
49 lookback_days=30,
50 auto_resolve=False,
51 github_user=None,
52 github_repo=None,
53 )
54 return ns
55
56
57 class PerEntityResolveTests(unittest.TestCase):
58 """Verify each competitor sub-run calls auto_resolve with its own topic and
59 that the resolved fields are threaded into pipeline.run."""
60
61 def test_auto_resolve_called_per_competitor(self):
62 from lib import resolve as resolve_mod
63 from lib import pipeline as pipeline_mod
64
65 config = {"BRAVE_API_KEY": "test-key"}
66
67 captured_resolve_topics: list[str] = []
68 captured_pipeline_kwargs: list[dict] = []
69
70 def fake_resolve(topic, _cfg):
71 captured_resolve_topics.append(topic)
72 per_topic = {
73 "Drake": {
74 "x_handle": "Drake",
75 "subreddits": ["DrakeTheType", "hiphopheads"],
76 "github_user": "",
77 "github_repos": [],
78 "context": "Drake ICEMAN rollout",
79 "category": None,
80 "searches_run": 4,
81 },
82 "Kendrick Lamar": {
83 "x_handle": "kendricklamar",
84 "subreddits": ["KendrickLamar", "hiphopheads"],
85 "github_user": "",
86 "github_repos": [],
87 "context": "Meet The Grahams revival",
88 "category": None,
89 "searches_run": 4,
90 },
91 }
92 return per_topic.get(topic, {
93 "x_handle": "", "subreddits": [], "github_user": "",
94 "github_repos": [], "context": "",
95 "category": None, "searches_run": 0,
96 })
97
98 def fake_pipeline_run(**kwargs):
99 captured_pipeline_kwargs.append(kwargs)
100 return _fake_report(kwargs["topic"])
101
102 with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
103 mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
104 mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
105 # Exercise the competitor_runner closure pattern from main() by
106 # calling it directly with two competitors.
107 self._run_competitor_closure(
108 config=config,
109 competitors=["Drake", "Kendrick Lamar"],
110 mock_flag=False,
111 )
112
113 # auto_resolve was called once per competitor
114 self.assertEqual(sorted(captured_resolve_topics), ["Drake", "Kendrick Lamar"])
115 # pipeline.run received resolved fields per entity
116 by_topic = {kw["topic"]: kw for kw in captured_pipeline_kwargs}
117 self.assertEqual(by_topic["Drake"]["x_handle"], "Drake")
118 self.assertEqual(
119 by_topic["Drake"]["subreddits"], ["DrakeTheType", "hiphopheads"],
120 )
121 self.assertEqual(by_topic["Kendrick Lamar"]["x_handle"], "kendricklamar")
122 # internal_subrun=True on all competitor sub-runs
123 self.assertTrue(all(kw["internal_subrun"] for kw in captured_pipeline_kwargs))
124
125 def test_mock_mode_skips_auto_resolve(self):
126 from lib import resolve as resolve_mod
127 from lib import pipeline as pipeline_mod
128
129 resolve_called = []
130
131 def fake_resolve(*a, **k):
132 resolve_called.append((a, k))
133 return {}
134
135 with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
136 mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
137 self._run_competitor_closure(
138 config={"BRAVE_API_KEY": "test-key"},
139 competitors=["Anthropic"],
140 mock_flag=True,
141 )
142
143 self.assertEqual(resolve_called, [])
144
145 def test_no_backend_skips_auto_resolve(self):
146 from lib import resolve as resolve_mod
147 from lib import pipeline as pipeline_mod
148
149 resolve_called = []
150
151 def fake_resolve(*a, **k):
152 resolve_called.append((a, k))
153 return {}
154
155 with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
156 mock.patch.object(resolve_mod, "_has_backend", return_value=False), \
157 mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
158 self._run_competitor_closure(
159 config={},
160 competitors=["Anthropic"],
161 mock_flag=False,
162 )
163
164 self.assertEqual(resolve_called, [])
165
166 def test_resolve_failure_degrades_gracefully(self):
167 from lib import resolve as resolve_mod
168 from lib import pipeline as pipeline_mod
169
170 captured_pipeline_kwargs: list[dict] = []
171
172 def fake_resolve(_topic, _cfg):
173 raise RuntimeError("upstream offline")
174
175 def fake_pipeline_run(**kwargs):
176 captured_pipeline_kwargs.append(kwargs)
177 return _fake_report(kwargs["topic"])
178
179 err = io.StringIO()
180 with redirect_stderr(err), \
181 mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
182 mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
183 mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
184 self._run_competitor_closure(
185 config={"BRAVE_API_KEY": "test-key"},
186 competitors=["Anthropic"],
187 mock_flag=False,
188 )
189
190 # Warning logged but run continues with planner defaults
191 self.assertIn("auto_resolve failed for 'Anthropic'", err.getvalue())
192 self.assertEqual(len(captured_pipeline_kwargs), 1)
193 self.assertIsNone(captured_pipeline_kwargs[0]["x_handle"])
194 self.assertIsNone(captured_pipeline_kwargs[0]["subreddits"])
195
196 def test_resolved_artifact_stored_on_report(self):
197 from lib import resolve as resolve_mod
198 from lib import pipeline as pipeline_mod
199
200 with mock.patch.object(resolve_mod, "auto_resolve", return_value={
201 "x_handle": "Drake",
202 "subreddits": ["DrakeTheType"],
203 "github_user": "",
204 "github_repos": [],
205 "context": "Drake context",
206 "category": None,
207 "searches_run": 4,
208 }), \
209 mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
210 mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
211 results = self._run_competitor_closure(
212 config={"BRAVE_API_KEY": "test-key"},
213 competitors=["Drake"],
214 mock_flag=False,
215 )
216
217 self.assertIn("resolved", results[0].artifacts)
218 resolved = results[0].artifacts["resolved"]
219 self.assertEqual(resolved["entity"], "Drake")
220 self.assertEqual(resolved["x_handle"], "Drake")
221 self.assertEqual(resolved["subreddits"], ["DrakeTheType"])
222 self.assertEqual(resolved["context"], "Drake context")
223
224 def test_config_not_mutated_across_sub_runs(self):
225 """_auto_resolve_context from entity A must not leak into entity B."""
226 from lib import resolve as resolve_mod
227 from lib import pipeline as pipeline_mod
228
229 captured_contexts: list[str] = []
230
231 def fake_resolve(topic, _cfg):
232 per_topic = {
233 "Drake": {"x_handle": "Drake", "subreddits": [], "github_user": "",
234 "github_repos": [], "context": "Drake unique context",
235 "category": None, "searches_run": 4},
236 "Kendrick Lamar": {"x_handle": "kendricklamar", "subreddits": [],
237 "github_user": "", "github_repos": [],
238 "context": "Kendrick unique context",
239 "category": None, "searches_run": 4},
240 }
241 return per_topic[topic]
242
243 def fake_pipeline_run(**kwargs):
244 captured_contexts.append(
245 kwargs["config"].get("_auto_resolve_context", "")
246 )
247 return _fake_report(kwargs["topic"])
248
249 shared_config = {"BRAVE_API_KEY": "test-key"}
250 with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
251 mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
252 mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
253 self._run_competitor_closure(
254 config=shared_config,
255 competitors=["Drake", "Kendrick Lamar"],
256 mock_flag=False,
257 )
258
259 # Each sub-run received its own entity's context — no cross-leak.
260 self.assertIn("Drake unique context", captured_contexts)
261 self.assertIn("Kendrick unique context", captured_contexts)
262 # The shared outer config was not mutated
263 self.assertNotIn("_auto_resolve_context", shared_config)
264
265 # --- test helpers -----------------------------------------------------
266
267 def _run_competitor_closure(self, *, config, competitors, mock_flag):
268 """Replicate the competitor_runner closure from last30days.main() and
269 call it against each competitor. Returns the list of Reports."""
270 from lib import pipeline, resolve as resolve_mod
271
272 class _Args:
273 pass
274 args = _Args()
275 args.mock = mock_flag
276 args.web_backend = "auto"
277 args.lookback_days = 30
278
279 def runner(entity: str):
280 entity_config = dict(config)
281 resolved = {
282 "entity": entity,
283 "x_handle": "",
284 "subreddits": [],
285 "github_user": "",
286 "github_repos": [],
287 "context": "",
288 }
289 if not args.mock and resolve_mod._has_backend(entity_config):
290 try:
291 r = resolve_mod.auto_resolve(entity, entity_config)
292 except Exception as exc:
293 sys.stderr.write(
294 f"[Competitors] auto_resolve failed for {entity!r}: "
295 f"{type(exc).__name__}: {exc}\n"
296 )
297 r = {}
298 resolved["x_handle"] = r.get("x_handle", "") or ""
299 resolved["subreddits"] = list(r.get("subreddits") or [])
300 resolved["github_user"] = r.get("github_user", "") or ""
301 resolved["github_repos"] = list(r.get("github_repos") or [])
302 resolved["context"] = r.get("context", "") or ""
303 if resolved["context"]:
304 entity_config["_auto_resolve_context"] = resolved["context"]
305 report = pipeline.run(
306 topic=entity,
307 config=entity_config,
308 depth="default",
309 requested_sources=None,
310 mock=args.mock,
311 x_handle=resolved["x_handle"] or None,
312 subreddits=resolved["subreddits"] or None,
313 github_user=resolved["github_user"] or None,
314 github_repos=resolved["github_repos"] or None,
315 web_backend=args.web_backend,
316 lookback_days=args.lookback_days,
317 internal_subrun=True,
318 )
319 report.artifacts["resolved"] = resolved
320 return report
321
322 return [runner(c) for c in competitors]
323
324 if __name__ == "__main__":
325 unittest.main()
326
326 lines PYTHON