| 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 |