返回 last30days-skill
test_bird_x.py
根目录 / tests / test_bird_x.py
1 import json
2 import os
3 import shutil
4 import subprocess
5 import textwrap
6 import unittest
7 from pathlib import Path
8 from unittest import mock
9
10 from lib.bird_x import parse_bird_response
11
12 REPO_ROOT = Path(__file__).resolve().parents[1]
13 VENDORED_BIRD = REPO_ROOT / "skills" / "last30days" / "scripts" / "lib" / "vendor" / "bird-search" / "bird-search.mjs"
14
15
16 class TestSubprocessEnv(unittest.TestCase):
17 """_subprocess_env() passes only the vendored client's env surface (issue #1063)."""
18
19 def _ambient(self, **overrides):
20 base = {
21 "PATH": "/usr/bin:/bin",
22 "HOME": "/home/test",
23 "NODE_ENV": "production",
24 "AUTH_TOKEN": "ambient-token",
25 "CT0": "ambient-ct0",
26 "TWITTER_AUTH_TOKEN": "ambient-tw-token",
27 "TWITTER_CT0": "ambient-tw-ct0",
28 "LAST30DAYS_DISABLE_BROWSER_COOKIES": "0",
29 "BIRD_QUERY_IDS_CACHE": "/tmp/ids.json",
30 "SCRAPECREATORS_API_KEY": "sc-secret",
31 "AWS_SECRET_ACCESS_KEY": "aws-secret",
32 }
33 base.update(overrides)
34 return base
35
36 def _call(self, ambient, credentials=None):
37 from lib import bird_x
38 old = bird_x._credentials
39 try:
40 bird_x._credentials = dict(credentials or {})
41 # None means "absent" - os.environ cannot hold None values.
42 patch_env = {k: v for k, v in ambient.items() if v is not None}
43 with mock.patch.dict(os.environ, patch_env, clear=True):
44 return bird_x._subprocess_env()
45 finally:
46 bird_x._credentials = old
47
48 def test_unrelated_ambient_secrets_are_excluded(self):
49 out = self._call(self._ambient())
50 self.assertNotIn("SCRAPECREATORS_API_KEY", out)
51 self.assertNotIn("AWS_SECRET_ACCESS_KEY", out)
52
53 def test_runtime_and_client_vars_pass_through(self):
54 out = self._call(self._ambient())
55 self.assertEqual("/usr/bin:/bin", out.get("PATH"))
56 self.assertEqual("/home/test", out.get("HOME"))
57 self.assertEqual("production", out.get("NODE_ENV"))
58 self.assertEqual("ambient-token", out.get("AUTH_TOKEN"))
59 self.assertEqual("ambient-ct0", out.get("CT0"))
60 self.assertEqual("ambient-tw-token", out.get("TWITTER_AUTH_TOKEN"))
61 self.assertEqual("ambient-tw-ct0", out.get("TWITTER_CT0"))
62 self.assertEqual("0", out.get("LAST30DAYS_DISABLE_BROWSER_COOKIES"))
63 self.assertEqual("/tmp/ids.json", out.get("BIRD_QUERY_IDS_CACHE"))
64
65 def test_absent_vars_are_omitted(self):
66 out = self._call(self._ambient(NODE_ENV=None))
67 self.assertNotIn("NODE_ENV", out)
68 out = self._call(self._ambient(LAST30DAYS_DISABLE_BROWSER_COOKIES=None))
69 self.assertNotIn("LAST30DAYS_DISABLE_BROWSER_COOKIES", out)
70
71 def test_injected_credentials_override_ambient(self):
72 out = self._call(self._ambient(), credentials={"AUTH_TOKEN": "injected", "CT0": "injected-ct0"})
73 self.assertEqual("injected", out.get("AUTH_TOKEN"))
74 self.assertEqual("injected-ct0", out.get("CT0"))
75
76 def test_disable_flag_always_hard_set(self):
77 out = self._call(self._ambient(), credentials={"AUTH_TOKEN": "t", "CT0": "c"})
78 self.assertEqual("1", out.get("BIRD_DISABLE_BROWSER_COOKIES"))
79 # ambient 0 is overridden to 1
80 out = self._call(self._ambient(BIRD_DISABLE_BROWSER_COOKIES="0"))
81 self.assertEqual("1", out.get("BIRD_DISABLE_BROWSER_COOKIES"))
82
83 def test_ambient_bird_vars_pass_through(self):
84 out = self._call(self._ambient(BIRD_FEATURES_PATH="/tmp/features.json"))
85 self.assertEqual("/tmp/features.json", out.get("BIRD_FEATURES_PATH"))
86
87 def test_allowlist_covers_vendored_client_env_reads(self):
88 """Every process.env name the vendored client reads is reachable.
89
90 Guards the allowlist against vendor drift: a future bird-search bump
91 that reads a new non-BIRD_ env var must either be added to the
92 allowlist or fail here, keeping the child env surface explicit
93 (issue #1063).
94 """
95 import re
96
97 from lib import bird_x
98
99 vendor_dir = REPO_ROOT / "skills" / "last30days" / "scripts" / "lib" / "vendor" / "bird-search"
100 reads = set()
101 for path in list(vendor_dir.rglob("*.js")) + list(vendor_dir.rglob("*.mjs")):
102 text = path.read_text(encoding="utf-8")
103 for m in re.finditer(
104 r"process\.env\[\s*['\"]([A-Z0-9_]+)['\"]\s*\]|process\.env\.([A-Z0-9_]+)",
105 text,
106 ):
107 reads.add(m.group(1) or m.group(2))
108 # cookies.js reads via helpers with the keys passed as arguments:
109 # envFlagEnabled('NAME') and readEnvCookie(cookies, ['A', 'B'], ...).
110 for m in re.finditer(r"envFlagEnabled\(\s*['\"]([A-Z0-9_]+)['\"]\s*\)", text):
111 reads.add(m.group(1))
112 for m in re.finditer(r"readEnvCookie\(\s*\w+\s*,\s*\[([^\]]*)\]", text):
113 reads.update(re.findall(r"['\"]([A-Z0-9_]+)['\"]", m.group(1)))
114 self.assertTrue(reads, "vendored client env reads not found")
115 allowlist = set(bird_x._SUBPROCESS_ENV_ALLOWLIST)
116 uncovered = {
117 name for name in reads
118 if not name.startswith("BIRD_") and name not in allowlist
119 }
120 self.assertEqual(set(), uncovered)
121
122
123 class TestBirdXEngagementZero(unittest.TestCase):
124 def test_zero_likes_preserved(self):
125 tweets = [
126 {
127 "id": "1",
128 "text": "test",
129 "permanent_url": "https://x.com/u/status/1",
130 "likeCount": 0,
131 "retweetCount": 5,
132 }
133 ]
134 items = parse_bird_response(tweets, "test query")
135 self.assertEqual(0, items[0]["engagement"]["likes"])
136 self.assertEqual(5, items[0]["engagement"]["reposts"])
137
138 @unittest.skipUnless(shutil.which("node"), "node is required for vendored Bird tests")
139 class TestVendoredBirdRuntime(unittest.TestCase):
140 def test_check_uses_env_credentials_without_browser_cookie_dependency(self):
141 env = os.environ.copy()
142 env["AUTH_TOKEN"] = "dummy-auth"
143 env["CT0"] = "dummy-ct0"
144
145 result = subprocess.run(
146 ["node", str(VENDORED_BIRD), "--check"],
147 cwd=REPO_ROOT,
148 env=env,
149 capture_output=True,
150 text=True,
151 check=False,
152 )
153
154 self.assertEqual(0, result.returncode, result.stderr)
155 payload = json.loads(result.stdout)
156 self.assertTrue(payload["authenticated"])
157 self.assertEqual("env AUTH_TOKEN", payload["source"])
158
159 def test_check_with_browser_lookup_disabled_returns_json_warnings(self):
160 env = os.environ.copy()
161 env.pop("AUTH_TOKEN", None)
162 env.pop("CT0", None)
163 env["BIRD_DISABLE_BROWSER_COOKIES"] = "1"
164
165 result = subprocess.run(
166 ["node", str(VENDORED_BIRD), "--check"],
167 cwd=REPO_ROOT,
168 env=env,
169 capture_output=True,
170 text=True,
171 check=False,
172 )
173
174 self.assertEqual(1, result.returncode, result.stderr)
175 payload = json.loads(result.stdout)
176 self.assertFalse(payload["authenticated"])
177 self.assertTrue(payload["warnings"])
178 self.assertIn("Missing auth_token", " ".join(payload["warnings"]))
179
180 def test_browser_cookie_helpers_lazy_load_sweet_cookie(self):
181 sweet_cookie_dir = (
182 REPO_ROOT
183 / "skills"
184 / "last30days"
185 / "scripts"
186 / "lib"
187 / "vendor"
188 / "bird-search"
189 / "lib"
190 / "node_modules"
191 / "@steipete"
192 / "sweet-cookie"
193 )
194 if sweet_cookie_dir.exists():
195 self.skipTest("vendored sweet-cookie test stub already exists")
196
197 sweet_cookie_dir.mkdir(parents=True)
198 (sweet_cookie_dir / "package.json").write_text(
199 json.dumps(
200 {
201 "name": "@steipete/sweet-cookie",
202 "type": "module",
203 "exports": "./index.js",
204 }
205 ),
206 encoding="utf-8",
207 )
208 (sweet_cookie_dir / "index.js").write_text(
209 textwrap.dedent(
210 """
211 export async function getCookies(options) {
212 const browser = options.browsers?.[0] ?? "unknown";
213 return {
214 cookies: [
215 { name: "auth_token", value: `${browser}-auth`, domain: "x.com" },
216 { name: "ct0", value: `${browser}-ct0`, domain: "x.com" },
217 ],
218 warnings: [],
219 };
220 }
221 """
222 ),
223 encoding="utf-8",
224 )
225
226 try:
227 result = subprocess.run(
228 [
229 "node",
230 "--input-type=module",
231 "-e",
232 textwrap.dedent(
233 """
234 import {
235 extractCookiesFromSafari,
236 extractCookiesFromChrome,
237 extractCookiesFromFirefox,
238 } from "./skills/last30days/scripts/lib/vendor/bird-search/lib/cookies.js";
239
240 const payload = await Promise.all([
241 extractCookiesFromSafari(),
242 extractCookiesFromChrome("Profile 1"),
243 extractCookiesFromFirefox("default-release"),
244 ]);
245 process.stdout.write(JSON.stringify(payload));
246 """
247 ),
248 ],
249 cwd=REPO_ROOT,
250 capture_output=True,
251 text=True,
252 check=False,
253 )
254
255 self.assertEqual(0, result.returncode, result.stderr)
256 payload = json.loads(result.stdout)
257 self.assertEqual("Safari", payload[0]["cookies"]["source"])
258 self.assertEqual('Chrome profile "Profile 1"', payload[1]["cookies"]["source"])
259 self.assertEqual(
260 'Firefox profile "default-release"', payload[2]["cookies"]["source"]
261 )
262 self.assertEqual("safari-auth", payload[0]["cookies"]["authToken"])
263 self.assertEqual("chrome-auth", payload[1]["cookies"]["authToken"])
264 self.assertEqual("firefox-auth", payload[2]["cookies"]["authToken"])
265 finally:
266 shutil.rmtree(sweet_cookie_dir, ignore_errors=True)
267 for path in [sweet_cookie_dir.parent, sweet_cookie_dir.parent.parent]:
268 try:
269 path.rmdir()
270 except OSError:
271 pass
272
273 def test_none_likes_when_missing(self):
274 tweets = [
275 {
276 "id": "1",
277 "text": "test tweet with no engagement fields",
278 "permanent_url": "https://x.com/u/status/1",
279 # no likeCount, like_count, or favorite_count
280 }
281 ]
282 items = parse_bird_response(tweets, "test query")
283 self.assertIsNone(items[0]["engagement"])
284
285 def test_fallback_to_second_key(self):
286 tweets = [
287 {
288 "id": "1",
289 "text": "test",
290 "permanent_url": "https://x.com/u/status/1",
291 "like_count": 7,
292 }
293 ]
294 items = parse_bird_response(tweets, "test query")
295 self.assertEqual(7, items[0]["engagement"]["likes"])
296
297 def test_zero_does_not_fall_through(self):
298 """likeCount=0 should not fall through to like_count=10."""
299 tweets = [
300 {
301 "id": "1",
302 "text": "test",
303 "permanent_url": "https://x.com/u/status/1",
304 "likeCount": 0,
305 "like_count": 10,
306 }
307 ]
308 items = parse_bird_response(tweets, "test query")
309 self.assertEqual(0, items[0]["engagement"]["likes"])
310
311 def test_engagement_none_when_all_fields_missing(self):
312 """All-None engagement dict should become None, not propagate."""
313 tweets = [
314 {
315 "id": "1",
316 "text": "test",
317 "permanent_url": "https://x.com/u/status/1",
318 }
319 ]
320 items = parse_bird_response(tweets, "test query")
321 self.assertIsNone(items[0]["engagement"])
322
323 def test_engagement_preserved_when_any_field_present(self):
324 """Engagement dict kept when at least one metric exists."""
325 tweets = [
326 {
327 "id": "1",
328 "text": "test",
329 "permanent_url": "https://x.com/u/status/1",
330 "likeCount": 5,
331 }
332 ]
333 items = parse_bird_response(tweets, "test query")
334 self.assertIsNotNone(items[0]["engagement"])
335 self.assertEqual(5, items[0]["engagement"]["likes"])
336
337
338 class TestRunBirdSearchJsonDecodeRetry(unittest.TestCase):
339 """When bird-search returns non-JSON stdout, retry the subprocess.
340
341 Twitter's edge sometimes serves an HTML anti-bot interstitial in place of
342 JSON. Before this fix, that response made json.loads raise JSONDecodeError
343 and the function returned {"items": []} with no diagnostic — silent-empty
344 against an orchestrator that can't distinguish "Twitter blocked us" from
345 "no tweets matched the query."
346 """
347
348 def _make_result(self, stdout: str, stderr: str = "", returncode: int = 0):
349 from lib.subproc import SubprocResult
350 return SubprocResult(returncode=returncode, stdout=stdout, stderr=stderr)
351
352 def test_retries_subprocess_on_html_interstitial_then_succeeds(self):
353 """First subprocess attempt returns HTML; second returns JSON → success."""
354 from unittest import mock
355 from lib import bird_x
356
357 html_interstitial = "<!DOCTYPE html><html><body>Rate limited</body></html>"
358 json_success = '[{"id": "1", "text": "tweet"}]'
359
360 results = [
361 (self._make_result(stdout=html_interstitial), None),
362 (self._make_result(stdout=json_success), None),
363 ]
364
365 with mock.patch.object(bird_x, "_invoke_bird_subprocess", side_effect=results), \
366 mock.patch.object(bird_x.time, "sleep") as mock_sleep:
367 response = bird_x._run_bird_search("test", count=10, timeout=30)
368
369 self.assertNotIn("error", response)
370 self.assertEqual(response["items"], [{"id": "1", "text": "tweet"}])
371 # Should have slept between the failed first attempt and the retry.
372 mock_sleep.assert_called_once_with(bird_x.JSON_DECODE_RETRY_DELAY)
373
374 def test_returns_error_after_all_retries_exhausted(self):
375 """All attempts return HTML → error dict with diagnostic + items=[]."""
376 from unittest import mock
377 from lib import bird_x
378
379 html_interstitial = "<!DOCTYPE html><html>blocked</html>"
380 results = [
381 (self._make_result(stdout=html_interstitial), None),
382 (self._make_result(stdout=html_interstitial), None),
383 ]
384
385 with mock.patch.object(bird_x, "_invoke_bird_subprocess", side_effect=results), \
386 mock.patch.object(bird_x.time, "sleep"):
387 response = bird_x._run_bird_search("test", count=10, timeout=30)
388
389 self.assertIn("error", response)
390 self.assertIn("Invalid JSON response", response["error"])
391 # Diagnostic message names the anti-bot interstitial so it's
392 # distinguishable from a genuine no-results case in logs.
393 self.assertIn("anti-bot interstitial", response["error"].lower())
394 self.assertEqual(response["items"], [])
395
396 def test_terminal_subprocess_error_is_not_retried(self):
397 """Subprocess timeout / spawn failure → terminal error, no retry."""
398 from unittest import mock
399 from lib import bird_x
400
401 timeout_error = {"error": "Search timed out after 30s", "items": []}
402 results = [(None, timeout_error)]
403
404 with mock.patch.object(bird_x, "_invoke_bird_subprocess", side_effect=results), \
405 mock.patch.object(bird_x.time, "sleep") as mock_sleep:
406 response = bird_x._run_bird_search("test", count=10, timeout=30)
407
408 self.assertEqual(response, timeout_error)
409 mock_sleep.assert_not_called()
410
411 if __name__ == "__main__":
412 unittest.main()
413
414
415 class TestXFromAndAboutLanes(unittest.TestCase):
416 """U7/U8: FROM lane drops the topic-AND; ABOUT lane queries @handle and
417 excludes the handle's own tweets."""
418
419 def _result(self, body_items):
420 import json as _j
421 class _R:
422 returncode = 0
423 stderr = ""
424 r = _R()
425 r.stdout = _j.dumps({"items": body_items})
426 return r
427
428 def test_from_lane_drops_topic_and(self):
429 from unittest import mock
430 from lib import bird_x
431 captured = []
432
433 def fake_run(cmd, timeout=None, env=None):
434 captured.append(cmd[2]) # the query string arg
435 return self._result([])
436
437 with mock.patch.object(bird_x.subproc, "run_with_timeout", side_effect=fake_run):
438 bird_x.search_handles(["xuezhao"], "lan xuezhao", "2026-05-19", count_per=1)
439 self.assertEqual(captured[0], "from:xuezhao since:2026-05-19")
440 self.assertNotIn("lan xuezhao", captured[0])
441
442 def test_mention_lane_queries_at_handle(self):
443 from unittest import mock
444 from lib import bird_x
445 captured = []
446
447 def fake_run(cmd, timeout=None, env=None):
448 captured.append(cmd[2])
449 return self._result([])
450
451 with mock.patch.object(bird_x.subproc, "run_with_timeout", side_effect=fake_run):
452 bird_x.search_mentions(["xuezhao"], "2026-05-19", count_per=1)
453 self.assertEqual(captured[0], "@xuezhao since:2026-05-19")
454
455 def test_mention_lane_excludes_own_tweets(self):
456 from unittest import mock
457 from lib import bird_x
458 parsed = [
459 {"url": "https://x.com/xuezhao/status/1", "title": "own tweet"},
460 {"url": "https://twitter.com/xuezhao/status/3", "title": "own legacy-domain tweet"},
461 {"url": "https://x.com/fan99/status/2", "title": "mention of them"},
462 ]
463 with mock.patch.object(bird_x.subproc, "run_with_timeout",
464 return_value=self._result([{"id": "x"}])), \
465 mock.patch.object(bird_x, "parse_bird_response", return_value=parsed):
466 out = bird_x.search_mentions(["xuezhao"], "2026-05-19", count_per=5)
467 urls = [it["url"] for it in out]
468 self.assertNotIn("https://x.com/xuezhao/status/1", urls) # own (x.com) excluded
469 self.assertNotIn("https://twitter.com/xuezhao/status/3", urls) # own (twitter.com) excluded
470 self.assertIn("https://x.com/fan99/status/2", urls) # mention kept
471
472 def test_mention_lane_empty_when_no_mentions(self):
473 from unittest import mock
474 from lib import bird_x
475 with mock.patch.object(bird_x.subproc, "run_with_timeout",
476 return_value=self._result([])), \
477 mock.patch.object(bird_x, "parse_bird_response", return_value=[]):
478 out = bird_x.search_mentions(["xuezhao"], "2026-05-19")
479 self.assertEqual(out, [])
480 class TestProbeAndDiagnoseHonesty(unittest.TestCase):
481 """U5: --diagnose probe + true auth lane; X is not reported green when dead."""
482
483 def setUp(self):
484 from lib import bird_x
485 bird_x._probe_cache = "unset"
486 bird_x._credentials = {"AUTH_TOKEN": "t", "CT0": "c"} # injected creds present
487
488 def tearDown(self):
489 from lib import bird_x
490 bird_x._probe_cache = "unset"
491 bird_x._credentials = {}
492
493 def test_probe_true_when_response_ok(self):
494 from unittest import mock
495 from lib import bird_x
496 with mock.patch.object(bird_x, "_run_bird_search", return_value={"items": [{"id": "1"}]}):
497 self.assertTrue(bird_x.probe_works())
498
499 def test_probe_false_on_auth_error(self):
500 from unittest import mock
501 from lib import bird_x
502 with mock.patch.object(bird_x, "_run_bird_search",
503 return_value={"error": "Missing auth_token", "items": []}):
504 self.assertIs(bird_x.probe_works(), False)
505
506 def test_probe_none_on_timeout_inconclusive(self):
507 from unittest import mock
508 from lib import bird_x
509 with mock.patch.object(bird_x, "_run_bird_search",
510 return_value={"error": "Search timed out after 8s", "items": []}):
511 self.assertIsNone(bird_x.probe_works())
512
513 def test_probe_false_when_no_credentials(self):
514 from unittest import mock
515 from lib import bird_x
516 bird_x._credentials = {}
517 with mock.patch.dict(os.environ, {}, clear=True):
518 self.assertIs(bird_x.probe_works(), False)
519
520 def test_probe_cached_per_process(self):
521 from unittest import mock
522 from lib import bird_x
523 with mock.patch.object(bird_x, "_run_bird_search",
524 return_value={"items": [{"id": "1"}]}) as m:
525 bird_x.probe_works()
526 bird_x.probe_works()
527 self.assertEqual(m.call_count, 1) # cached, not re-run
528
529 def test_get_x_source_status_reports_true_lane(self):
530 from unittest import mock
531 from lib import env, bird_x
532 cfg = {"AUTH_TOKEN": "t", "CT0": "c", "_AUTH_TOKEN_SOURCE": "browser"}
533 with mock.patch.object(bird_x, "get_bird_status",
534 return_value={"installed": True, "authenticated": True,
535 "username": "env AUTH_TOKEN", "can_install": True}):
536 status = env.get_x_source_status(cfg, probe=False)
537 self.assertEqual(status["bird_username"], "browser AUTH_TOKEN")
538
539 def test_diagnose_probe_downgrades_when_dead(self):
540 from unittest import mock
541 from lib import env, bird_x
542 cfg = {"AUTH_TOKEN": "t", "CT0": "c", "_AUTH_TOKEN_SOURCE": "browser"}
543 with mock.patch.object(bird_x, "get_bird_status",
544 return_value={"installed": True, "authenticated": True,
545 "username": "env AUTH_TOKEN", "can_install": True}), \
546 mock.patch.object(bird_x, "probe_works", return_value=False):
547 status = env.get_x_source_status(cfg, probe=True)
548 self.assertFalse(status["bird_authenticated"])
549 self.assertIn("no working X auth", status["bird_username"])
550
551
552 class TestHandleSearchLogsOnSuccess(unittest.TestCase):
553 """U6: handle searches log query + count on success, not only on failure."""
554
555 def test_search_handles_logs_on_success(self):
556 from unittest import mock
557 from lib import bird_x
558
559 class _R:
560 returncode = 0
561 stdout = '{"items": [{"id": "1"}]}'
562 stderr = ""
563
564 logged = []
565 with mock.patch.object(bird_x.subproc, "run_with_timeout", return_value=_R()), \
566 mock.patch.object(bird_x, "_log", side_effect=lambda m: logged.append(m)):
567 bird_x.search_handles(["mvanhorn"], "matt van horn", "2026-05-19", count_per=1)
568 self.assertTrue(any("Searching:" in m for m in logged),
569 f"expected a Searching: log on success, got {logged}")
570
571
572 class TestStrongestTokenRetryAnchored(unittest.TestCase):
573 """The last-chance retry must keep an entity anchor, not collapse to a bare
574 generic token (e.g. 'compound') that floods the X pool with off-topic noise.
575 """
576
577 def test_last_chance_retry_keeps_entity_anchor(self):
578 from unittest import mock
579 from lib import bird_x
580
581 queries = []
582
583 def fake_run(query, count, timeout):
584 queries.append(query)
585 return {"items": []} # always 0 → forces every retry tier
586
587 # extract_compound_terms may run; let it. Force all bird calls empty.
588 with mock.patch.object(bird_x, "_run_bird_search", side_effect=fake_run):
589 bird_x.search_x("trevin chow ai agents compound", "2026-05-19", "2026-06-18")
590
591 self.assertTrue(queries, "expected at least one bird query")
592 last = queries[-1]
593 # The final (last-chance) query keeps the entity anchor ...
594 self.assertIn("trevin", last)
595 # ... and is NOT a bare generic token query.
596 self.assertFalse(last.startswith("compound "), f"bare generic retry: {last!r}")
597 self.assertNotEqual(last, "compound since:2026-05-19")
598
599 def test_retry_with_single_distinctive_token_no_crash(self):
600 from unittest import mock
601 from lib import bird_x
602
603 queries = []
604
605 def fake_run(query, count, timeout):
606 queries.append(query)
607 return {"items": []}
608
609 with mock.patch.object(bird_x, "_run_bird_search", side_effect=fake_run):
610 # 'trending tools' is all low-signal except nothing distinctive ->
611 # whatever survives, the retry must not crash and stays anchored.
612 bird_x.search_x("agentcookie", "2026-05-19", "2026-06-18")
613
614 self.assertTrue(queries)
615 self.assertIn("agentcookie", queries[-1])
616
617
618 class TestBirdRetryQueryCorrectness(unittest.TestCase):
619 def test_quoted_topic_only_generates_balanced_retry_queries(self):
620 from lib import bird_x
621
622 queries = []
623
624 def fake_run(query, count, timeout):
625 queries.append(query)
626 if len(queries) == 1:
627 return {"items": []}
628 return {"error": "Bird search failed", "items": []}
629
630 with mock.patch.object(
631 bird_x,
632 "_extract_core_subject",
633 return_value='immobilienmakler(berlin "mixed-use',
634 ), mock.patch(
635 "lib.query.extract_compound_terms",
636 return_value=['"immobilienmakler berlin"'],
637 ), mock.patch.object(bird_x, "_run_bird_search", side_effect=fake_run):
638 response = bird_x.search_x(
639 '"Immobilienmakler Berlin" competitors',
640 "2026-07-12",
641 "2026-07-19",
642 )
643
644 self.assertGreaterEqual(len(queries), 2)
645 self.assertEqual(
646 "immobilienmakler berlin mixed-use since:2026-07-12",
647 queries[0],
648 )
649 for query in queries:
650 self.assertEqual(0, query.count('"') % 2, query)
651 self.assertEqual(query.count("("), query.count(")"), query)
652 self.assertNotIn("error", response)
653 self.assertEqual([], response["items"])
654
655 def test_every_failed_attempt_still_reports_backend_failure(self):
656 from lib import bird_x
657
658 with mock.patch.object(
659 bird_x, "_extract_core_subject", return_value="immobilienmakler berlin market"
660 ), mock.patch.object(
661 bird_x,
662 "_run_bird_search",
663 return_value={"error": "Bird search failed", "items": []},
664 ):
665 response = bird_x.search_x(
666 "Immobilienmakler Berlin market", "2026-07-12", "2026-07-19"
667 )
668
669 self.assertEqual("Bird search failed", response["error"])
670
671
672 class LeadingMentionsTests(unittest.TestCase):
673 """U5: leading @mentions parsed from post text identify reply targets."""
674
675 def test_single_leading_mention(self):
676 from lib import bird_x
677 self.assertEqual(["alpha"], bird_x._leading_mentions("@alpha thanks so much!"))
678
679 def test_multiple_leading_mentions(self):
680 from lib import bird_x
681 self.assertEqual(["alpha", "beta"], bird_x._leading_mentions("@alpha @beta hi"))
682
683 def test_in_body_mention_not_collected(self):
684 from lib import bird_x
685 self.assertEqual([], bird_x._leading_mentions("hello @gamma nice work"))
686
687 def test_punctuation_stripped(self):
688 from lib import bird_x
689 self.assertEqual(["alpha"], bird_x._leading_mentions("@alpha, nice"))
690
691 def test_empty_text(self):
692 from lib import bird_x
693 self.assertEqual([], bird_x._leading_mentions(""))
694 self.assertEqual([], bird_x._leading_mentions(None))
695
696
697 if __name__ == "__main__":
698 unittest.main()
699
699 lines PYTHON