返回 last30days-skill
test_doctor.py
根目录 / tests / test_doctor.py
1 """U4: unified `doctor` command (lib/doctor.py + topic-word dispatch).
2
3 Covers the plan's U4 scenarios:
4 1. Fully keyless env -> free sources (reddit, hackernews, polymarket,
5 github) tier ok; key-gated sources tier off with prescriptions; exit 0.
6 2. `--json` per-source shape for every registered source (chained and
7 single-backend), tier/status rollup rows asserted.
8 3. One probe timing out -> that source status `timeout`, tier `error`,
9 all other sources still render (plus per-source exception isolation).
10 4. No-secrets invariant: seeded fake credentials never appear in text or
11 JSON output.
12 5. Topic-word dispatch: `doctor` triggers the report; a longer research
13 topic containing the word does not (setup's exact-match collision rule).
14 6. Native-search host + no web keys -> web tier off with a host-native
15 note, never a false-alarm error.
16 """
17
18 import datetime
19 import io
20 import itertools
21 import json
22 import os
23 import re
24 import sys
25 import tempfile
26 import unittest
27 import urllib.error
28 from contextlib import redirect_stderr, redirect_stdout
29 from pathlib import Path
30 from unittest import mock
31
32 import last30days as cli
33 from lib import backends, doctor, env, grok_x, health, http, prescriptions
34
35 BIRD_STATUS_OFF = {
36 "installed": False,
37 "authenticated": False,
38 "username": None,
39 "can_install": True,
40 }
41
42 # Obvious dummies only (repo security hygiene).
43 FAKE_SECRETS = {
44 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
45 "XAI_API_KEY": "dummy-xai-secret-000",
46 "BRAVE_API_KEY": "dummy-brave-secret-000",
47 "GROQ_API_KEY": "dummy-groq-secret-000",
48 "AUTH_TOKEN": "dummy-auth-token-secret-000",
49 "CT0": "dummy-ct0-secret-000",
50 "BSKY_HANDLE": "dummy.example.social",
51 "BSKY_APP_PASSWORD": "dummy-bsky-secret-000",
52 "TRUTHSOCIAL_TOKEN": "dummy-truth-secret-000",
53 "GITHUB_TOKEN": "dummy-github-secret-000",
54 "X_BEARER_TOKEN": "dummy-x-bearer-secret-000",
55 }
56
57 VALID_TIERS = {"ok", "warn", "off", "error"}
58 VALID_STATUSES = {
59 "ok", "degraded", "opt-in", "unconfigured", "missing", "broken", "timeout", "error",
60 }
61 # The R1 rollup table, row by row.
62 TIER_BY_STATUS = {
63 "ok": "ok",
64 "degraded": "warn",
65 "opt-in": "off",
66 "unconfigured": "off",
67 "missing": "error",
68 "broken": "error",
69 "timeout": "error",
70 "error": "error",
71 }
72
73
74 def _probe_dep(status_map=None, default_status=health.MISSING):
75 """Fake health.probe_dependency honoring a per-name status map."""
76 status_map = status_map or {}
77
78 def fake(name, timeout=health.PROBE_TIMEOUT):
79 status = status_map.get(name, default_status)
80 if status == health.OK:
81 return health.DependencyProbe(name=name, status=health.OK, detail=f"{name} 1.0.0")
82 return health.DependencyProbe(
83 name=name,
84 status=status,
85 detail=f"{name} probe simulated {status}",
86 prescription=(
87 f"install {name}" if status == health.MISSING else f"reinstall {name}"
88 ),
89 owner_pkg_manager="brew",
90 )
91
92 return fake
93
94
95 class _Hermetic:
96 """Context manager stack making doctor runs machine-independent."""
97
98 def __init__(self, probe_map=None, default_status=health.MISSING):
99 # yt-dlp now backs YouTube comments (free, keyless), so the comment
100 # gate reads env.is_ytdlp_available() -> shutil.which on the real host.
101 # Pin it to the same yt-dlp the probe_map declares, or doctor's comment
102 # branch would silently depend on whether the dev box has yt-dlp.
103 ytdlp_ok = (probe_map or {}).get("yt-dlp", default_status) == health.OK
104 self._patches = [
105 mock.patch("lib.health.probe_dependency", _probe_dep(probe_map, default_status)),
106 mock.patch("lib.env.is_ytdlp_available", return_value=ytdlp_ok),
107 mock.patch("lib.bird_x.is_bird_installed", return_value=False),
108 mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None),
109 mock.patch("lib.bird_x.get_bird_status", return_value=dict(BIRD_STATUS_OFF)),
110 # The doctor path is local-only for xurl: the live `xurl whoami`
111 # network check must never run (no-network guarantee).
112 mock.patch(
113 "lib.xurl_x.is_available",
114 side_effect=AssertionError(
115 "doctor path ran the live `xurl whoami` network check"
116 ),
117 ),
118 mock.patch("lib.xurl_x.has_stored_auth", return_value=False),
119 mock.patch(
120 "lib.xurl_x.stored_auth_status",
121 return_value=("missing", "no token store at ~/.xurl"),
122 ),
123 mock.patch("lib.backends.which", lambda name: None),
124 # Hermetic library: never glob the user's real saved-research dir.
125 # Tests that assert a specific brief count override this.
126 mock.patch("lib.doctor._count_saved_briefs", return_value=0),
127 # Hermetic run-evidence: never read the user's real last-report.json.
128 # Tests that inject run evidence override this with a temp file.
129 mock.patch("lib.doctor._last_report_path", return_value=None),
130 # Hermetic live probe: never make a real network call. Tests that
131 # exercise probing override this with canned results.
132 mock.patch("lib.doctor._probe_sources", return_value={}),
133 # FTS5 is present on CI/dev SQLite; pin it so the library record's
134 # branch is deterministic regardless of the host's SQLite build.
135 mock.patch("lib.library_index.fts5_available", return_value=True),
136 # Snapshot os.environ so the CLAUDECODE scrub below is restored on
137 # exit. The real test shell (Claude Code) sets CLAUDECODE=1, which
138 # would otherwise make doctor's host-native web detection fire in
139 # every test and mask the keyless-degraded path. Tests that want the
140 # host-native path pass CLAUDECODE explicitly in their config dict.
141 mock.patch.dict(os.environ, {}, clear=False),
142 ]
143
144 def __enter__(self):
145 for p in self._patches:
146 p.start()
147 os.environ.pop("CLAUDECODE", None)
148 return self
149
150 def __exit__(self, *exc):
151 for p in reversed(self._patches):
152 p.stop()
153 return False
154
155
156 def _build(config, **kwargs):
157 with _Hermetic(**kwargs):
158 return doctor.build_report(dict(config))
159
160
161 def _run_cli_doctor(argv, config):
162 with _Hermetic(), \
163 mock.patch.object(cli.env, "get_config", return_value=dict(config)), \
164 mock.patch.object(sys, "argv", ["last30days.py"] + argv):
165 stdout = io.StringIO()
166 stderr = io.StringIO()
167 with redirect_stdout(stdout), redirect_stderr(stderr):
168 rc = cli.main()
169 return rc, stdout.getvalue()
170
171
172 class KeylessEnvironment(unittest.TestCase):
173 """Scenario 1: fully keyless env."""
174
175 def setUp(self):
176 self.report = _build({})
177
178 def test_free_sources_tier_ok(self):
179 for name in ("reddit", "hackernews", "polymarket", "github"):
180 self.assertEqual("ok", self.report["sources"][name]["tier"], name)
181 self.assertEqual("ok", self.report["sources"][name]["status"], name)
182
183 def test_key_gated_sources_off_with_prescriptions(self):
184 for name in ("x", "tiktok", "instagram", "threads", "bluesky", "truthsocial"):
185 record = self.report["sources"][name]
186 self.assertEqual("off", record["tier"], name)
187 self.assertIn(record["status"], ("unconfigured", "opt-in"), name)
188 self.assertTrue(record["fix"], f"{name} must carry a fix prescription")
189
190 def test_youtube_off_when_ytdlp_missing_and_no_key(self):
191 record = self.report["sources"]["youtube"]
192 self.assertEqual("off", record["tier"])
193 self.assertEqual("unconfigured", record["status"])
194 self.assertTrue(record["fix"])
195
196 def test_web_keyless_floor_is_degraded_not_error(self):
197 record = self.report["sources"]["web"]
198 self.assertEqual("warn", record["tier"])
199 self.assertEqual("degraded", record["status"])
200 self.assertEqual("keyless", record["active_backend"])
201
202 def test_cli_exit_code_zero_even_with_problems(self):
203 rc, out = _run_cli_doctor(["doctor"], {})
204 self.assertEqual(0, rc)
205 self.assertIn("last30days doctor", out)
206
207
208 class PerplexityKeyBoundary(unittest.TestCase):
209 def test_openrouter_only_enables_sonar_fallback(self):
210 record = _build(
211 {
212 "OPENROUTER_API_KEY": "dummy-openrouter-secret-000",
213 "INCLUDE_SOURCES": "perplexity",
214 }
215 )["sources"]["perplexity"]
216
217 self.assertEqual("ok", record["status"])
218 self.assertIn("PERPLEXITY_API_KEY", record["requires"])
219 self.assertIn("OPENROUTER_API_KEY", record["requires"])
220
221 def test_direct_perplexity_key_with_opt_in_is_ready(self):
222 record = _build(
223 {
224 "PERPLEXITY_API_KEY": "dummy-perplexity-secret-000",
225 "INCLUDE_SOURCES": "perplexity",
226 }
227 )["sources"]["perplexity"]
228
229 self.assertEqual("ok", record["status"])
230
231
232 class GitHubAuthDetection(unittest.TestCase):
233 """GitHub doctor auth must mirror the real fetcher token source."""
234
235 def test_github_env_token_without_gh_reports_authenticated_tier(self):
236 with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "dummy-github-secret-000"}), \
237 mock.patch("lib.doctor.shutil.which", return_value=None):
238 record = _build({})["sources"]["github"]
239
240 self.assertEqual("ok", record["tier"])
241 self.assertEqual("ok", record["status"])
242 self.assertEqual("authenticated tier (GITHUB_TOKEN or gh CLI)", record["detail"])
243
244 def test_github_without_env_token_or_gh_reports_unauthenticated_tier(self):
245 with mock.patch.dict("os.environ", {"GITHUB_TOKEN": ""}), \
246 mock.patch("lib.doctor.shutil.which", return_value=None):
247 record = _build({})["sources"]["github"]
248
249 self.assertEqual("ok", record["tier"])
250 self.assertEqual("ok", record["status"])
251 self.assertIn("unauthenticated REST tier", record["detail"])
252
253
254 class UnconfiguredXWithBrokenNode(unittest.TestCase):
255 """F9 repro: no X configuration + a broken node runtime must read as
256 off/unconfigured with the cookie fix on bird — never a configured-but-
257 broken error carrying a node prescription."""
258
259 def test_x_rolls_up_off_with_cookie_prescription(self):
260 report = _build({}, probe_map={"node": health.BROKEN})
261 record = report["sources"]["x"]
262 self.assertEqual("off", record["tier"])
263 self.assertEqual("unconfigured", record["status"])
264 bird = next(b for b in record["backends"] if b["name"] == "bird")
265 self.assertEqual("missing", bird["status"])
266 self.assertIn("cookie", (bird["detail"] + bird["fix"]).lower())
267 self.assertNotIn("node", bird["fix"].lower())
268
269
270 class CookieBackedXReadiness(unittest.TestCase):
271 """U2: when bird is installed and FROM_BROWSER will authenticate X at run
272 time, doctor reports X as Ready (not Off) with an honest, unverified note -
273 matching the real run behavior where browser cookies serve X fine even
274 though diagnose loads config in plan_only mode."""
275
276 def test_x_ready_when_bird_installed_and_from_browser(self):
277 with _Hermetic(), mock.patch("lib.bird_x.is_bird_installed", return_value=True):
278 report = doctor.build_report({"FROM_BROWSER": "auto"})
279 record = report["sources"]["x"]
280 self.assertEqual("ok", record["tier"])
281 self.assertEqual("ok", record["status"])
282 note = record["note"].lower()
283 self.assertIn("browser cookies", note)
284 self.assertIn("not verified", note)
285 self.assertIn("xai_api_key", note)
286
287 def test_x_stays_off_when_bird_installed_but_no_consent(self):
288 # bird installed but FROM_BROWSER=off -> no cookie path -> genuinely off.
289 with _Hermetic(), mock.patch("lib.bird_x.is_bird_installed", return_value=True):
290 report = doctor.build_report({"FROM_BROWSER": "off"})
291 record = report["sources"]["x"]
292 self.assertEqual("off", record["tier"])
293 self.assertEqual("unconfigured", record["status"])
294
295 def test_x_stays_off_when_consent_but_bird_missing(self):
296 # FROM_BROWSER set but bird not installed -> no runtime path -> off.
297 report = _build({"FROM_BROWSER": "auto"})
298 record = report["sources"]["x"]
299 self.assertEqual("off", record["tier"])
300 self.assertEqual("unconfigured", record["status"])
301
302
303 class LibraryDoctorLine(unittest.TestCase):
304 """U5: doctor reports the local research library so the report's
305 'From your library' block is explained on the health surface."""
306
307 def test_library_reports_indexed_brief_count(self):
308 with _Hermetic(), mock.patch("lib.doctor._count_saved_briefs", return_value=3):
309 record = doctor.build_report({})["sources"]["library"]
310 self.assertEqual("ok", record["status"])
311 self.assertIn("3 saved briefs", record["note"])
312
313 def test_library_empty_store_is_informational_ok(self):
314 record = _build({})["sources"]["library"] # count stubbed to 0
315 self.assertEqual("ok", record["status"])
316 self.assertIn("no saved briefs yet", record["note"])
317
318 def test_library_without_fts5_degrades_informationally(self):
319 # Inner patch overrides the _Hermetic FTS5 pin.
320 with _Hermetic(), mock.patch("lib.library_index.fts5_available", return_value=False):
321 record = doctor.build_report({})["sources"]["library"]
322 self.assertEqual("ok", record["status"])
323 self.assertIn("FTS5", record["note"])
324
325 def test_library_scan_failure_is_informational_ok(self):
326 # A glob/OS error must never fail the run - it degrades to an OK line.
327 with _Hermetic(), mock.patch(
328 "lib.doctor._count_saved_briefs", side_effect=OSError("permission denied")
329 ):
330 record = doctor.build_report({})["sources"]["library"]
331 self.assertEqual("ok", record["status"])
332 self.assertIn("local research library", record["note"])
333
334 def test_library_line_present_in_text_render(self):
335 text = doctor.render_text(_build({}))
336 self.assertTrue(
337 any("library" in l for l in text.splitlines()),
338 "doctor text output must carry a library line",
339 )
340
341
342 class JsonShape(unittest.TestCase):
343 """Scenario 2: documented per-source shape for every registered source."""
344
345 def setUp(self):
346 self.report = _build(dict(FAKE_SECRETS))
347
348 def test_every_registered_source_present(self):
349 self.assertEqual(set(doctor.SOURCE_ORDER), set(self.report["sources"].keys()))
350
351 def test_per_source_record_shape(self):
352 for name, record in self.report["sources"].items():
353 for key in ("tier", "status", "backends", "mode", "active_backend", "fix", "requires"):
354 self.assertIn(key, record, f"{name} missing {key}")
355 self.assertIn(record["tier"], VALID_TIERS, name)
356 self.assertIn(record["status"], VALID_STATUSES, name)
357
358 def test_tier_status_rollup_rows(self):
359 for name, record in self.report["sources"].items():
360 self.assertEqual(
361 TIER_BY_STATUS[record["status"]], record["tier"],
362 f"{name}: status {record['status']} must roll up to "
363 f"{TIER_BY_STATUS[record['status']]}",
364 )
365
366 def test_chained_sources_expose_backends_and_mode(self):
367 for name in ("x", "youtube", "web"):
368 record = self.report["sources"][name]
369 self.assertEqual("alternative", record["mode"], name)
370 self.assertIsInstance(record["backends"], list, name)
371 self.assertTrue(record["backends"], name)
372 self.assertEqual("conditional", self.report["sources"]["reddit"]["mode"])
373 self.assertIsInstance(self.report["sources"]["reddit"]["backends"], list)
374
375 def test_single_backend_sources_have_single_mode(self):
376 for name in ("hackernews", "polymarket", "github", "bluesky"):
377 record = self.report["sources"][name]
378 self.assertEqual("single", record["mode"], name)
379 self.assertIsNone(record["backends"], name)
380
381 def test_conditional_reddit_never_picks_a_winner(self):
382 record = self.report["sources"]["reddit"]
383 self.assertIsNone(record["active_backend"])
384 # Conditional wording is U2's, verbatim.
385 with _Hermetic():
386 expected = backends.resolve("reddit", dict(FAKE_SECRETS)).conditional
387 self.assertEqual(expected, record["note"])
388
389 def test_web_pin_is_flag_only_no_env_pin(self):
390 # Web search has NO env pin; only the --web-backend flag.
391 record = self.report["sources"]["web"]
392 self.assertIsNone(record["pin_var"])
393 self.assertEqual("--web-backend", record["pin_flag"])
394
395 def test_chained_ok_source_predicts_will_use(self):
396 record = self.report["sources"]["web"]
397 self.assertEqual("ok", record["tier"])
398 self.assertEqual("brave", record["active_backend"])
399 self.assertIn("will use: brave", record["note"])
400
401 def test_top_level_block(self):
402 for key in ("engine_version", "config", "setup", "permissions", "sources"):
403 self.assertIn(key, self.report)
404 self.assertIsInstance(self.report["engine_version"], str)
405 self.assertTrue(self.report["engine_version"])
406 setup = self.report["setup"]
407 self.assertIsInstance(setup["setup_complete"], bool)
408 for name, present in setup["keys_present"].items():
409 self.assertIsInstance(present, bool, name)
410 self.assertIn("status", self.report["permissions"])
411
412 def test_json_renderer_round_trips(self):
413 payload = json.loads(doctor.render_json(self.report))
414 self.assertEqual(set(doctor.SOURCE_ORDER), set(payload["sources"].keys()))
415
416
417 class ProbeFailureIsolation(unittest.TestCase):
418 """Scenario 3: one bad probe cannot blank the report."""
419
420 def test_timeout_probe_maps_to_timeout_status_error_tier(self):
421 report = _build({}, probe_map={"yt-dlp": health.TIMEOUT})
422 record = report["sources"]["youtube"]
423 self.assertEqual("timeout", record["status"])
424 self.assertEqual("error", record["tier"])
425 self.assertTrue(record["fix"])
426 # Everything else still renders.
427 self.assertEqual("ok", report["sources"]["reddit"]["tier"])
428 self.assertEqual("ok", report["sources"]["hackernews"]["tier"])
429
430 def test_broken_probe_maps_to_broken(self):
431 report = _build({}, probe_map={"yt-dlp": health.BROKEN})
432 record = report["sources"]["youtube"]
433 self.assertEqual("broken", record["status"])
434 self.assertEqual("error", record["tier"])
435
436 def test_chained_failure_requires_names_the_failed_backend(self):
437 """F4: chain[0] merely MISSING while a later backend is BROKEN ->
438 the record's requires is the BROKEN backend's (mirroring how the
439 OK/WARN branches use the active finding), never chain[0]'s."""
440 config = {
441 "AUTH_TOKEN": "dummy-auth-token-secret-000",
442 "CT0": "dummy-ct0-secret-000",
443 }
444 with _Hermetic(probe_map={"node": health.BROKEN}), \
445 mock.patch("lib.bird_x.is_bird_installed", return_value=True):
446 report = doctor.build_report(dict(config))
447 record = report["sources"]["x"]
448 self.assertEqual("broken", record["status"])
449 self.assertEqual("error", record["tier"])
450 by_name = {b["name"]: b for b in record["backends"]}
451 # chain[0] (xai) is merely unconfigured; bird is the broken one.
452 self.assertEqual("missing", by_name["xai"]["status"])
453 self.assertEqual("broken", by_name["bird"]["status"])
454 self.assertEqual(by_name["bird"]["requires"], record["requires"])
455 self.assertNotEqual(by_name["xai"]["requires"], record["requires"])
456
457 def test_source_exception_is_isolated(self):
458 real_resolve = backends.resolve
459
460 def exploding(source, config, pin=None):
461 if source == "x":
462 raise RuntimeError("probe blew up")
463 return real_resolve(source, config, pin)
464
465 with _Hermetic(), mock.patch("lib.backends.resolve", exploding):
466 report = doctor.build_report({})
467 record = report["sources"]["x"]
468 self.assertEqual("error", record["status"])
469 self.assertEqual("error", record["tier"])
470 self.assertIn("RuntimeError", record["detail"])
471 # The rest of the report survives.
472 self.assertEqual("ok", report["sources"]["reddit"]["tier"])
473 self.assertEqual(set(doctor.SOURCE_ORDER), set(report["sources"].keys()))
474 # And the whole report still renders as text and JSON.
475 self.assertTrue(doctor.render_text(report))
476 json.loads(doctor.render_json(report))
477
478
479 class NoSecretsInvariant(unittest.TestCase):
480 """Scenario 4: seeded fake credentials never appear in any output."""
481
482 def test_no_secret_values_in_text_or_json(self):
483 report = _build(dict(FAKE_SECRETS))
484 text = doctor.render_text(report)
485 raw_json = doctor.render_json(report)
486 for var, secret in FAKE_SECRETS.items():
487 if var == "BSKY_HANDLE":
488 continue # a handle is an identifier, not a credential
489 self.assertNotIn(secret, text, var)
490 self.assertNotIn(secret, raw_json, var)
491
492 def test_keys_present_are_booleans(self):
493 report = _build(dict(FAKE_SECRETS))
494 for name, value in report["setup"]["keys_present"].items():
495 self.assertIsInstance(value, bool, name)
496
497
498 class TopicWordDispatch(unittest.TestCase):
499 """Scenario 5: `doctor` dispatches exactly like `setup` (exact match only)."""
500
501 def test_doctor_topic_triggers_report(self):
502 with mock.patch("lib.doctor.run", return_value=0) as run, \
503 mock.patch.object(cli.env, "get_config", return_value={}), \
504 mock.patch.object(sys, "argv", ["last30days.py", "doctor"]):
505 stdout, stderr = io.StringIO(), io.StringIO()
506 with redirect_stdout(stdout), redirect_stderr(stderr):
507 rc = cli.main()
508 self.assertEqual(0, rc)
509 self.assertTrue(run.called)
510
511 def test_doctor_json_flag_passes_through(self):
512 with mock.patch("lib.doctor.run", return_value=0) as run, \
513 mock.patch.object(cli.env, "get_config", return_value={}), \
514 mock.patch.object(sys, "argv", ["last30days.py", "doctor", "--json"]):
515 stdout, stderr = io.StringIO(), io.StringIO()
516 with redirect_stdout(stdout), redirect_stderr(stderr):
517 rc = cli.main()
518 self.assertEqual(0, rc)
519 self.assertTrue(run.call_args.kwargs.get("emit_json"))
520
521 def test_doctor_emit_json_also_works(self):
522 rc, out = _run_cli_doctor(["doctor", "--emit=json"], {})
523 self.assertEqual(0, rc)
524 payload = json.loads(out)
525 self.assertIn("sources", payload)
526
527 def test_multiword_topic_containing_doctor_is_research_not_report(self):
528 # Same collision rule as setup: exact single-word match only. A real
529 # research topic goes down the research path (sentinel raised there).
530 with mock.patch("lib.doctor.run", side_effect=AssertionError("doctor must not run")), \
531 mock.patch.object(cli.env, "get_config", return_value={}), \
532 mock.patch.object(
533 cli.pipeline, "diagnose", side_effect=RuntimeError("research path reached")
534 ), \
535 mock.patch.object(sys, "argv", ["last30days.py", "doctor", "who", "reviews"]):
536 stdout, stderr = io.StringIO(), io.StringIO()
537 with redirect_stdout(stdout), redirect_stderr(stderr):
538 with self.assertRaises(RuntimeError):
539 cli.main()
540
541 def test_json_flag_rejected_for_research_topics(self):
542 with mock.patch.object(
543 cli.env, "get_config", side_effect=AssertionError("config should not load")
544 ), mock.patch.object(sys, "argv", ["last30days.py", "some", "topic", "--json"]):
545 stderr = io.StringIO()
546 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
547 cli.main()
548 self.assertEqual(2, exc.exception.code)
549 self.assertIn("--json", stderr.getvalue())
550
551
552 class IncludeSourcesTokenParsing(unittest.TestCase):
553 """Opt-in gates match whole INCLUDE_SOURCES tokens, never substrings."""
554
555 def test_substring_token_does_not_enable_linkedin(self):
556 report = _build({
557 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
558 "INCLUDE_SOURCES": "notlinkedincorp",
559 })
560 record = report["sources"]["linkedin"]
561 self.assertEqual("opt-in", record["status"])
562 self.assertEqual("off", record["tier"])
563
564 def test_exact_token_enables_linkedin(self):
565 report = _build({
566 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
567 "INCLUDE_SOURCES": "linkedin",
568 })
569 record = report["sources"]["linkedin"]
570 self.assertEqual("ok", record["status"])
571 self.assertEqual("ok", record["tier"])
572
573
574 class YoutubeTranscriptionNote(unittest.TestCase):
575 """F7: yt-dlp probes OK but no GROQ_API_KEY/OPENAI_API_KEY -> the ok
576 youtube record carries the caption-free note plus the
577 transcription_key_missing fix, and (F14) the text renderer surfaces
578 that fix even though the record's tier is ok."""
579
580 def setUp(self):
581 self.report = _build({}, probe_map={"yt-dlp": health.OK})
582 self.entry = prescriptions.get("youtube", "transcription_key_missing")
583
584 def test_ok_record_carries_note_and_fix(self):
585 record = self.report["sources"]["youtube"]
586 self.assertEqual("ok", record["tier"])
587 self.assertEqual("ok", record["status"])
588 note = record["note"].lower()
589 # Honest note: affirms the working path, scopes the key to caption-free.
590 self.assertIn("search + transcripts work", note)
591 self.assertIn("caption-free", note)
592 # Does not read as broken and does not attribute comment text to yt-dlp.
593 self.assertNotIn("no transcription key for caption-free videos", note)
594 self.assertIn(self.entry.fix_nl, record["fix"])
595 self.assertIn(self.entry.fix_cli, record["fix"])
596
597 def test_no_paid_comment_prescription_when_ytdlp_is_installed(self):
598 # yt-dlp fetches comment text free. With it installed, doctor must NOT
599 # tell the user to buy a ScrapeCreators key for comments — that would
600 # be selling a fix for a problem they do not have.
601 note = self.report["sources"]["youtube"]["note"].lower()
602 self.assertNotIn("comment text needs", note)
603 self.assertNotIn("scrapecreators", note)
604
605 def test_text_line_includes_the_fix_on_the_ok_line(self):
606 text = doctor.render_text(self.report)
607 # Located by source name, not glyph: the four-state audit sorts a
608 # no-run-evidence ok source to UNVERIFIED, but the transcription fix
609 # must still ride the youtube line.
610 line = next(
611 l
612 for l in text.splitlines()
613 if " youtube" in l and "search + transcripts work" in l
614 )
615 self.assertIn("search + transcripts work", line)
616 self.assertIn(f"fix: {self.entry.fix_nl}", line)
617 self.assertIn(self.entry.fix_cli, line)
618
619
620 class YoutubeCommentsFixLine(unittest.TestCase):
621 """Greptile P2: when only the comment-text caveat fires (transcription key
622 present), the record still carries an actionable fix line."""
623
624 def test_no_comment_caveat_when_ytdlp_present(self):
625 """yt-dlp installed -> comments are free -> nothing to prescribe."""
626 record = _build(
627 {"GROQ_API_KEY": "dummy-groq-secret-000"},
628 probe_map={"yt-dlp": health.OK},
629 )["sources"]["youtube"]
630 self.assertEqual("ok", record["status"])
631 note = record["note"].lower()
632 self.assertNotIn("comment text needs", note)
633
634 def test_comment_caveat_fires_when_ytdlp_absent_but_sc_backs_youtube(self):
635 """No yt-dlp, but an SC key keeps YouTube itself alive. Comments then
636 still need the youtube_comments opt-in, so the caveat must surface —
637 and must name yt-dlp as the free way out, not only the paid one.
638
639 (With neither yt-dlp nor a key, YouTube has no backend at all and the
640 record short-circuits to 'no backend configured' — no video, no
641 comments to caveat.)
642 """
643 record = _build(
644 {
645 "GROQ_API_KEY": "dummy-groq-secret-000",
646 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
647 },
648 probe_map={"yt-dlp": health.MISSING},
649 )["sources"]["youtube"]
650 note = record["note"].lower()
651 self.assertIn("comment text needs", note)
652 self.assertIn("yt-dlp (free)", note)
653 self.assertTrue(record["fix"], "comment-text caveat must carry a fix")
654
655 def test_transcription_fix_takes_precedence_when_both_fire(self):
656 record = _build({}, probe_map={"yt-dlp": health.OK})["sources"]["youtube"]
657 entry = prescriptions.get("youtube", "transcription_key_missing")
658 self.assertIn(entry.fix_nl, record["fix"])
659
660
661 class YoutubeHealthyWhenFullyConfigured(unittest.TestCase):
662 """U3: with a transcription key AND comment access, the YouTube note carries
663 no caveat - it is cleanly Ready."""
664
665 def test_no_caveats_when_transcription_and_comments_available(self):
666 report = _build(
667 {
668 "GROQ_API_KEY": "dummy-groq-secret-000",
669 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
670 "INCLUDE_SOURCES": "youtube_comments",
671 },
672 probe_map={"yt-dlp": health.OK},
673 )
674 record = report["sources"]["youtube"]
675 self.assertEqual("ok", record["status"])
676 note = record["note"].lower()
677 self.assertNotIn("caption-free", note)
678 self.assertNotIn("comment text needs", note)
679
680
681 class NativeSearchHost(unittest.TestCase):
682 """Scenario 6: native-search host with no web keys -> off, not error."""
683
684 def test_web_maps_to_off_with_host_native_note(self):
685 report = _build({"LAST30DAYS_NATIVE_SEARCH": "1"})
686 record = report["sources"]["web"]
687 self.assertEqual("off", record["tier"])
688 self.assertEqual("unconfigured", record["status"])
689 self.assertIn("host-native search", record["note"])
690
691 def test_web_on_claudecode_host_is_native_not_degraded(self):
692 # CLAUDECODE set but LAST30DAYS_NATIVE_SEARCH unset (the standalone
693 # `doctor` case) -> host-native note, not "degraded/keyless".
694 report = _build({"CLAUDECODE": "1"})
695 record = report["sources"]["web"]
696 self.assertEqual("off", record["tier"])
697 self.assertEqual("unconfigured", record["status"])
698 note = record["note"]
699 self.assertIn("Claude Code", note)
700 # Must NOT cite an env var the user never set.
701 self.assertNotIn("LAST30DAYS_NATIVE_SEARCH", note)
702
703 def test_web_native_via_real_env_var_not_just_config(self):
704 # Production path: env.get_config() never puts CLAUDECODE in the config
705 # dict, so the os.environ branch is the ONLY one a real Claude Code
706 # session hits. Set the process env var (config has no CLAUDECODE key).
707 with _Hermetic(), mock.patch.dict(os.environ, {"CLAUDECODE": "1"}):
708 record = doctor.build_report({})["sources"]["web"]
709 self.assertEqual("off", record["tier"])
710 self.assertIn("Claude Code", record["note"])
711 self.assertNotIn("LAST30DAYS_NATIVE_SEARCH", record["note"])
712
713 def test_web_stays_degraded_keyless_without_native_signal(self):
714 # No CLAUDECODE, no LAST30DAYS_NATIVE_SEARCH -> genuine keyless floor.
715 record = _build({})["sources"]["web"]
716 self.assertEqual("warn", record["tier"])
717 self.assertEqual("degraded", record["status"])
718 self.assertEqual("keyless", record["active_backend"])
719
720 def test_web_with_key_stays_ok_on_native_host(self):
721 report = _build({
722 "LAST30DAYS_NATIVE_SEARCH": "1",
723 "EXA_API_KEY": "dummy-exa-secret-000",
724 })
725 record = report["sources"]["web"]
726 self.assertEqual("ok", record["tier"])
727 self.assertEqual("exa", record["active_backend"])
728
729
730 class TextReport(unittest.TestCase):
731 """Grouped text rendering: four-state audit."""
732
733 def test_groups_and_lines(self):
734 report = _build({}, probe_map={"yt-dlp": health.BROKEN})
735 text = doctor.render_text(report)
736 self.assertIn("last30days doctor", text)
737 for header in (
738 "WORKING",
739 "TURNED ON - UNVERIFIED",
740 "NOT WORKING",
741 "COULD BE ON",
742 ):
743 self.assertIn(header, text)
744 # One line per source: glyph + source name; fix on non-ok lines.
745 self.assertIn("reddit", text)
746 self.assertIn("youtube", text)
747 self.assertIn("reinstall yt-dlp", text)
748 # Reddit renders U2's conditional wording verbatim, no single winner.
749 with _Hermetic():
750 conditional = backends.resolve("reddit", {}).conditional
751 self.assertIn(conditional, text)
752
753 def test_will_use_rendered_for_chained_ok_source(self):
754 report = _build({"BRAVE_API_KEY": "dummy-brave-secret-000"})
755 text = doctor.render_text(report)
756 self.assertIn("will use: brave", text)
757
758
759 def _write_last_report(dir_path, *, source_status, topic="wordpress", fresh=True):
760 """Write a minimal last-report.json the run-evidence loader can read."""
761 ts = datetime.datetime.now(datetime.timezone.utc)
762 if not fresh:
763 ts = ts - datetime.timedelta(
764 seconds=doctor.DEFAULT_REPORT_CACHE_TTL_SECONDS + 600
765 )
766 iso = ts.isoformat()
767 payload = {
768 "schema": doctor.REPORT_CACHE_SCHEMA_VERSION,
769 "timestamp": iso,
770 "topic": topic,
771 "reports": [
772 {
773 "entity": "",
774 "report": {
775 "generated_at": iso,
776 "source_status": {
777 src: {
778 "source": src,
779 "state": st.get("state"),
780 "items_returned": st.get("items_returned", 0),
781 "detail": st.get("detail"),
782 "at": iso,
783 "fix_hint": st.get("fix_hint"),
784 }
785 for src, st in source_status.items()
786 },
787 },
788 }
789 ],
790 }
791 path = Path(dir_path) / doctor.REPORT_CACHE_FILENAME
792 path.write_text(json.dumps(payload), encoding="utf-8")
793 return path
794
795
796 class RunEvidenceOverlay(unittest.TestCase):
797 """U1: build_report overlays last-report.json per-source outcomes."""
798
799 def _build_with_evidence(self, source_status, fresh=True):
800 tmp = tempfile.mkdtemp()
801 path = _write_last_report(tmp, source_status=source_status, fresh=fresh)
802 with _Hermetic(), mock.patch(
803 "lib.doctor._last_report_path", return_value=path
804 ):
805 return doctor.build_report({})
806
807 def test_failed_source_outcome_overlaid(self):
808 report = self._build_with_evidence(
809 {
810 "youtube": {"state": "error", "items_returned": 0, "detail": "HTTP 500"},
811 "reddit": {"state": "ok", "items_returned": 13},
812 }
813 )
814 yt = report["sources"]["youtube"]["run_outcome"]
815 self.assertIsNotNone(yt)
816 self.assertEqual("error", yt["state"])
817 self.assertIn("HTTP 500", yt["detail"])
818 self.assertEqual(
819 13, report["sources"]["reddit"]["run_outcome"]["items_returned"]
820 )
821 self.assertTrue(report["run_evidence"]["fresh"])
822 self.assertTrue(report["run_evidence"]["present"])
823
824 def test_no_cache_yields_no_outcomes(self):
825 with _Hermetic(): # _last_report_path -> None
826 report = doctor.build_report({})
827 self.assertIsNone(report["sources"]["reddit"]["run_outcome"])
828 self.assertFalse(report["run_evidence"]["present"])
829
830 def test_corrupt_cache_treated_as_absent(self):
831 tmp = tempfile.mkdtemp()
832 path = Path(tmp) / doctor.REPORT_CACHE_FILENAME
833 path.write_text("{not valid json", encoding="utf-8")
834 with _Hermetic(), mock.patch(
835 "lib.doctor._last_report_path", return_value=path
836 ):
837 report = doctor.build_report({})
838 self.assertIsNone(report["sources"]["reddit"]["run_outcome"])
839 self.assertFalse(report["run_evidence"]["present"])
840
841 def test_stale_cache_present_but_not_overlaid(self):
842 report = self._build_with_evidence(
843 {"youtube": {"state": "error", "items_returned": 0}}, fresh=False
844 )
845 # Present but not fresh: overlay withheld from plain doctor (R4),
846 # while --postmortem (U4) can still read it by age.
847 self.assertIsNone(report["sources"]["youtube"]["run_outcome"])
848 self.assertTrue(report["run_evidence"]["present"])
849 self.assertFalse(report["run_evidence"]["fresh"])
850
851
852 class FourStateAudit(unittest.TestCase):
853 """U2: audit_state derivation + grouped render."""
854
855 def test_keyless_ok_no_evidence_is_working(self):
856 rec = {"tier": "ok", "status": "ok"}
857 self.assertEqual(doctor.AUDIT_WORKING, doctor.audit_state("reddit", rec))
858
859 def test_configured_ok_no_evidence_is_unverified(self):
860 rec = {"tier": "ok", "status": "ok"}
861 self.assertEqual(doctor.AUDIT_UNVERIFIED, doctor.audit_state("tiktok", rec))
862
863 def test_fresh_run_items_is_working(self):
864 rec = {"tier": "ok", "status": "ok"}
865 ro = {"state": "ok", "items_returned": 13}
866 self.assertEqual(doctor.AUDIT_WORKING, doctor.audit_state("tiktok", rec, ro))
867
868 def test_fresh_run_error_is_not_working(self):
869 rec = {"tier": "ok", "status": "ok"}
870 ro = {"state": "error", "items_returned": 0, "detail": "HTTP 500"}
871 self.assertEqual(
872 doctor.AUDIT_NOT_WORKING, doctor.audit_state("youtube", rec, ro)
873 )
874
875 def test_fresh_run_partial_is_unverified(self):
876 rec = {"tier": "ok", "status": "ok"}
877 ro = {"state": "partial", "items_returned": 8, "detail": "HTTP 400"}
878 self.assertEqual(
879 doctor.AUDIT_UNVERIFIED, doctor.audit_state("instagram", rec, ro)
880 )
881
882 def test_off_tier_is_could_be_on(self):
883 rec = {"tier": "off", "status": "opt-in"}
884 self.assertEqual(
885 doctor.AUDIT_COULD_BE_ON, doctor.audit_state("threads", rec)
886 )
887
888 def test_probe_result_decides_when_no_run(self):
889 rec = {"tier": "ok", "status": "ok"}
890 self.assertEqual(
891 doctor.AUDIT_WORKING, doctor.audit_state("tiktok", rec, None, {"ok": True})
892 )
893 self.assertEqual(
894 doctor.AUDIT_NOT_WORKING,
895 doctor.audit_state("tiktok", rec, None, {"ok": False}),
896 )
897
898 def test_transient_probe_failure_is_unverified_not_broken(self):
899 # A source that rate-limited the probe is unknown, not down. Calling it
900 # NOT WORKING sends people debugging a source that serves fine.
901 rec = {"tier": "ok", "status": "ok"}
902 self.assertEqual(
903 doctor.AUDIT_UNVERIFIED,
904 doctor.audit_state(
905 "reddit", rec, None, {"ok": False, "transient": True}
906 ),
907 )
908
909 def test_render_json_keeps_legacy_keys_and_adds_audit(self):
910 report = _build({})
911 for name, rec in report["sources"].items():
912 self.assertIn("tier", rec, name)
913 self.assertIn("status", rec, name)
914 self.assertIn("audit_state", rec, name)
915 self.assertEqual("config", report["mode"])
916 blob = json.loads(doctor.render_json(report))
917 self.assertIn("mode", blob)
918 self.assertIn("audit_state", blob["sources"]["github"])
919
920 def test_every_source_its_own_line(self):
921 text = doctor.render_text(_build({}))
922 self.assertRegex(text, r"[●◐✕○] github")
923
924 def test_working_line_shows_item_count(self):
925 tmp = tempfile.mkdtemp()
926 path = _write_last_report(
927 tmp, source_status={"reddit": {"state": "ok", "items_returned": 13}}
928 )
929 with _Hermetic(), mock.patch(
930 "lib.doctor._last_report_path", return_value=path
931 ):
932 text = doctor.render_text(doctor.build_report({}))
933 self.assertIn("13 items last run", text)
934
935
936 class JsonContract(unittest.TestCase):
937 """U9: doctor --json is additive; --cached serves the new audit shape."""
938
939 LEGACY_RECORD_KEYS = {
940 "tier", "status", "mode", "backends", "active_backend", "fix",
941 "requires", "note", "detail", "pin_var", "pin_flag", "pinned",
942 }
943
944 def test_legacy_record_keys_preserved(self):
945 blob = json.loads(doctor.render_json(_build({})))
946 rec = blob["sources"]["reddit"]
947 for key in self.LEGACY_RECORD_KEYS:
948 self.assertIn(key, rec, key)
949 self.assertIn("audit_state", rec) # additive
950 self.assertIn("mode", blob) # top-level additive
951
952 def test_new_keys_additive(self):
953 blob = json.loads(
954 doctor.render_json(
955 _build(
956 {"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"},
957 probe_map={"yt-dlp": health.OK},
958 )
959 )
960 )
961 yt = blob["sources"]["youtube"]
962 self.assertIn("cli", yt)
963 self.assertIn("backups", yt)
964 self.assertIn("comments", yt)
965
966 def test_cached_roundtrip_serves_audit_shape(self):
967 tmp = Path(tempfile.mkdtemp()) / "doctor-cache.json"
968 with _Hermetic(), mock.patch("lib.doctor.cache_path", return_value=tmp):
969 report = doctor.build_report({})
970 report["generated_at"] = datetime.datetime.now(
971 datetime.timezone.utc
972 ).isoformat()
973 report["from_cache"] = False
974 self.assertTrue(doctor._write_cache(report, {}))
975 served = doctor.read_cached_report({})
976 self.assertIsNotNone(served)
977 self.assertIn("audit_state", served["sources"]["reddit"])
978 self.assertIn("WORKING", doctor.render_text(served))
979
980
981 class LiveProbe(unittest.TestCase):
982 """U5: bounded live probe (--probe / no-fresh-run auto-fallback)."""
983
984 def test_probeable_excludes_credit_gated(self):
985 probeable = set(doctor._probeable_sources())
986 for gated in ("x", "tiktok", "instagram", "threads", "linkedin"):
987 self.assertNotIn(gated, probeable, gated)
988 # free HTTP + keyless CLI sources ARE probeable
989 for free in ("reddit", "hackernews", "polymarket", "github", "youtube"):
990 self.assertIn(free, probeable, free)
991
992 def test_probe_source_http_reachable(self):
993 with mock.patch("lib.doctor._http_ok", return_value=(True, "HTTP 200")):
994 res = doctor._probe_source("hackernews", {}, 5)
995 self.assertTrue(res["ok"])
996 self.assertTrue(res["probed"])
997
998 def test_probe_source_credit_gated_returns_none(self):
999 self.assertIsNone(doctor._probe_source("tiktok", {}, 5))
1000
1001 def test_reddit_probe_targets_the_endpoint_the_engine_uses(self):
1002 # /r/all/hot.json is permanently 403 keyless and no lane requests it;
1003 # probing it certified an endpoint the engine had abandoned (#899).
1004 url = doctor._HTTP_PROBE_URLS["reddit"]
1005 self.assertIn("search.rss", url)
1006 self.assertNotIn("hot.json", url)
1007
1008 def _probe_reddit_with_status(self, code):
1009 error = urllib.error.HTTPError(
1010 doctor._HTTP_PROBE_URLS["reddit"], code, "Blocked", {}, None
1011 )
1012 with (
1013 mock.patch("lib.doctor.urllib.request.urlopen", side_effect=error),
1014 # 429 buys a retry; don't pay the real backoff in the suite.
1015 mock.patch("lib.doctor.time.sleep"),
1016 ):
1017 return doctor._probe_source("reddit", {}, 5)
1018
1019 def test_reddit_probe_403_is_not_reachable(self):
1020 res = self._probe_reddit_with_status(403)
1021 self.assertFalse(res["ok"])
1022 self.assertIn("403", res["detail"])
1023
1024 def test_reddit_probe_429_is_not_reachable(self):
1025 res = self._probe_reddit_with_status(429)
1026 self.assertFalse(res["ok"])
1027 self.assertIn("429", res["detail"])
1028
1029 def test_reddit_probe_429_is_retried_once(self):
1030 # A single keyless probe draws a 429 during a burst while the lane —
1031 # which retries with backoff — serves the same query fine. Give the
1032 # probe that same second chance before it accuses a working source.
1033 with (
1034 mock.patch(
1035 "lib.doctor._http_ok", return_value=(False, "HTTP 429")
1036 ) as http_ok,
1037 mock.patch("lib.doctor.time.sleep") as sleep,
1038 ):
1039 res = doctor._probe_source("reddit", {}, 5)
1040 self.assertEqual(2, http_ok.call_count)
1041 sleep.assert_called_once_with(doctor._PROBE_RETRY_DELAY_SECONDS)
1042 self.assertTrue(res["transient"])
1043 self.assertIn("429", res["detail"])
1044
1045 def test_reddit_probe_429_then_ok_is_reachable(self):
1046 # The retry is the whole point: a probe that lands on the second
1047 # attempt reports plain success, with no transient residue.
1048 with (
1049 mock.patch(
1050 "lib.doctor._http_ok",
1051 side_effect=[(False, "HTTP 429"), (True, "HTTP 200")],
1052 ),
1053 mock.patch("lib.doctor.time.sleep"),
1054 ):
1055 res = doctor._probe_source("reddit", {}, 5)
1056 self.assertTrue(res["ok"])
1057 self.assertNotIn("transient", res)
1058
1059 def test_reddit_probe_403_is_not_retried(self):
1060 # 403 is the standing keyless block, not a burst. Retrying it only
1061 # doubles the wait before reporting a real outage.
1062 with (
1063 mock.patch(
1064 "lib.doctor._http_ok", return_value=(False, "HTTP 403")
1065 ) as http_ok,
1066 mock.patch("lib.doctor.time.sleep") as sleep,
1067 ):
1068 res = doctor._probe_source("reddit", {}, 5)
1069 self.assertEqual(1, http_ok.call_count)
1070 sleep.assert_not_called()
1071 self.assertFalse(res.get("transient", False))
1072
1073 def test_transient_retry_is_per_source(self):
1074 # Like the blocked-status carve-out above, the retry is scoped to the
1075 # source that actually rate-limits us.
1076 with (
1077 mock.patch(
1078 "lib.doctor._http_ok", return_value=(False, "HTTP 429")
1079 ) as http_ok,
1080 mock.patch("lib.doctor.time.sleep") as sleep,
1081 ):
1082 res = doctor._probe_source("hackernews", {}, 5)
1083 self.assertEqual(1, http_ok.call_count)
1084 sleep.assert_not_called()
1085 self.assertFalse(res.get("transient", False))
1086
1087 def test_non_reddit_probe_keeps_4xx_as_reachable(self):
1088 # The blocked-status carve-out is per-source: a 4xx elsewhere still
1089 # means the endpoint responded.
1090 error = urllib.error.HTTPError(
1091 doctor._HTTP_PROBE_URLS["github"], 403, "Forbidden", {}, None
1092 )
1093 with mock.patch("lib.doctor.urllib.request.urlopen", side_effect=error):
1094 res = doctor._probe_source("github", {}, 5)
1095 self.assertTrue(res["ok"])
1096
1097 def test_reddit_probe_sends_the_engine_user_agent(self):
1098 # Probing with a different UA measures the User-Agent, not the endpoint.
1099 seen = {}
1100
1101 def capture(req, timeout=None):
1102 seen["ua"] = req.get_header("User-agent")
1103 raise urllib.error.HTTPError(req.full_url, 500, "boom", {}, None)
1104
1105 with mock.patch("lib.doctor.urllib.request.urlopen", capture):
1106 doctor._probe_source("reddit", {}, 5)
1107 self.assertEqual(http.BROWSER_USER_AGENT, seen["ua"])
1108
1109 def test_probe_failure_is_isolated(self):
1110 def flaky(name, config, timeout):
1111 if name == "reddit":
1112 raise RuntimeError("boom")
1113 return {"ok": True, "probed": True}
1114
1115 with mock.patch("lib.doctor._probe_source", flaky):
1116 results = doctor._probe_sources({}, timeout=5)
1117 self.assertFalse(results["reddit"]["ok"]) # isolated failure
1118 self.assertIn("boom", results["reddit"]["detail"])
1119 self.assertTrue(results["hackernews"]["ok"]) # others unaffected
1120
1121 def test_probe_deadline_never_hangs(self):
1122 import time
1123
1124 def too_slow(name, config, timeout):
1125 time.sleep(1.3) # exceeds the timeout(0)+1s result deadline
1126 return {"ok": True, "probed": True}
1127
1128 with mock.patch("lib.doctor._probe_source", too_slow):
1129 results = doctor._probe_sources({}, timeout=0)
1130 self.assertTrue(results)
1131 self.assertTrue(
1132 any("deadline" in r.get("detail", "") for r in results.values())
1133 )
1134
1135 def test_probe_result_flips_unverified_to_working(self):
1136 rec = {"tier": "ok", "status": "ok", "audit_state": doctor.AUDIT_UNVERIFIED}
1137 report = {"sources": {"hackernews": rec}}
1138 doctor._apply_probe(report, {"hackernews": {"ok": True, "probed": True}})
1139 self.assertEqual(doctor.AUDIT_WORKING, rec["audit_state"])
1140 self.assertTrue(rec["probe"]["ok"])
1141
1142 def test_auto_probe_fires_when_no_fresh_run(self):
1143 canned = {"hackernews": {"ok": True, "detail": "HTTP 200", "probed": True}}
1144 with _Hermetic(), mock.patch(
1145 "lib.doctor._probe_sources", return_value=canned
1146 ) as probed:
1147 out = io.StringIO()
1148 err = io.StringIO()
1149 with redirect_stdout(out), redirect_stderr(err):
1150 rc = doctor.run({})
1151 self.assertEqual(0, rc)
1152 probed.assert_called() # auto-fired: no fresh run
1153 self.assertIn("live probe", err.getvalue())
1154
1155 def test_no_auto_probe_when_fresh_run(self):
1156 tmp = tempfile.mkdtemp()
1157 path = _write_last_report(
1158 tmp, source_status={"reddit": {"state": "ok", "items_returned": 5}}
1159 )
1160 with _Hermetic(), mock.patch(
1161 "lib.doctor._last_report_path", return_value=path
1162 ), mock.patch("lib.doctor._probe_sources") as probed:
1163 with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
1164 doctor.run({})
1165 probed.assert_not_called() # fresh run -> overlay, no probe
1166
1167
1168 class Postmortem(unittest.TestCase):
1169 """U4: --postmortem reads the last run's per-source outcomes."""
1170
1171 def _pm(self, source_status, fresh=True):
1172 tmp = tempfile.mkdtemp()
1173 path = _write_last_report(tmp, source_status=source_status, fresh=fresh)
1174 with _Hermetic(), mock.patch(
1175 "lib.doctor._last_report_path", return_value=path
1176 ):
1177 return doctor.build_postmortem({})
1178
1179 def test_failed_partial_succeeded_grouping(self):
1180 pm = self._pm(
1181 {
1182 "youtube": {
1183 "state": "error",
1184 "items_returned": 0,
1185 "detail": "HTTP 500",
1186 "fix_hint": "retry later",
1187 },
1188 "instagram": {
1189 "state": "partial",
1190 "items_returned": 8,
1191 "detail": "HTTP 400",
1192 },
1193 "reddit": {"state": "ok", "items_returned": 13},
1194 }
1195 )
1196 text = doctor.render_postmortem_text(pm)
1197 self.assertIn("Failed:", text)
1198 self.assertIn("HTTP 500", text)
1199 self.assertIn("retry later", text)
1200 self.assertIn("Partial:", text)
1201 self.assertIn("instagram", text)
1202 self.assertIn("Succeeded:", text)
1203 self.assertIn("reddit (13)", text)
1204
1205 def test_empty_state(self):
1206 with _Hermetic(): # _last_report_path -> None
1207 pm = doctor.build_postmortem({})
1208 self.assertFalse(pm["present"])
1209 self.assertIn("No saved run found", doctor.render_postmortem_text(pm))
1210
1211 def test_json_mode_shape(self):
1212 pm = self._pm({"youtube": {"state": "error", "items_returned": 0}})
1213 self.assertEqual("postmortem", pm["mode"])
1214 self.assertIn("youtube", pm["outcomes"])
1215
1216 def test_reads_stale_run_by_age(self):
1217 pm = self._pm(
1218 {"youtube": {"state": "timeout", "items_returned": 0}}, fresh=False
1219 )
1220 self.assertTrue(pm["present"])
1221 self.assertIn("youtube", pm["outcomes"])
1222
1223 def test_cli_dispatch_exits_zero(self):
1224 rc, out = _run_cli_doctor(["doctor", "--postmortem"], {})
1225 self.assertEqual(0, rc)
1226 self.assertIn("post-mortem", out)
1227
1228
1229 class BackupAndCommentLanes(unittest.TestCase):
1230 """U7: backup + comment sub-lanes render on their parent source."""
1231
1232 def test_backups_armed_with_sc_key(self):
1233 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1234 self.assertTrue(report["sources"]["reddit"]["backups"][0]["armed"])
1235 yt_backup = report["sources"]["youtube"]["backups"][0]
1236 self.assertTrue(yt_backup["armed"])
1237 self.assertIn("rate-limited", yt_backup["note"])
1238 text = doctor.render_text(report)
1239 self.assertIn("backup: ScrapeCreators transcript/search backstop — armed", text)
1240
1241 def test_backups_off_without_sc_key(self):
1242 report = _build({})
1243 self.assertFalse(report["sources"]["reddit"]["backups"][0]["armed"])
1244 self.assertFalse(report["sources"]["youtube"]["backups"][0]["armed"])
1245
1246 def test_youtube_comments_reflect_include_sources(self):
1247 on = _build(
1248 {
1249 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
1250 "INCLUDE_SOURCES": "tiktok,instagram,youtube_comments",
1251 }
1252 )
1253 self.assertTrue(on["sources"]["youtube"]["comments"]["enabled"])
1254 off = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1255 self.assertFalse(off["sources"]["youtube"]["comments"]["enabled"])
1256
1257 def test_x_dual_path_note(self):
1258 keyed = _build({"XAI_API_KEY": "dummy-xai-secret-000"})
1259 note = keyed["sources"]["x"]["backups"][0]["note"]
1260 self.assertIn("XAI_API_KEY", note)
1261 self.assertTrue(keyed["sources"]["x"]["backups"][0]["armed"])
1262
1263 def test_sub_lanes_in_json(self):
1264 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1265 blob = json.loads(doctor.render_json(report))
1266 self.assertIn("backups", blob["sources"]["youtube"])
1267 self.assertIn("comments", blob["sources"]["youtube"])
1268
1269
1270 class ThreadsOptIn(unittest.TestCase):
1271 """U6: Threads reports opt-in state honestly against INCLUDE_SOURCES."""
1272
1273 def test_key_without_optin_is_could_be_on(self):
1274 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1275 rec = report["sources"]["threads"]
1276 self.assertEqual("opt-in", rec["status"])
1277 self.assertEqual(doctor.AUDIT_COULD_BE_ON, rec["audit_state"])
1278 self.assertIn("INCLUDE_SOURCES", rec["fix"])
1279
1280 def test_key_with_optin_is_working(self):
1281 report = _build(
1282 {
1283 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
1284 "INCLUDE_SOURCES": "tiktok,instagram,threads",
1285 }
1286 )
1287 rec = report["sources"]["threads"]
1288 self.assertEqual("ok", rec["status"])
1289
1290 def test_no_key_is_could_be_on_with_sc_fix(self):
1291 rec = _build({})["sources"]["threads"]
1292 self.assertEqual("unconfigured", rec["status"])
1293 self.assertEqual(doctor.AUDIT_COULD_BE_ON, rec["audit_state"])
1294
1295 def test_tiktok_on_by_default_with_key_unchanged(self):
1296 # Regression: TikTok/Instagram stay on-by-default with a key (WORKING),
1297 # they are NOT opt-in-gated like Threads.
1298 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1299 self.assertEqual("ok", report["sources"]["tiktok"]["status"])
1300 self.assertEqual("ok", report["sources"]["instagram"]["status"])
1301
1302
1303 class CliHealth(unittest.TestCase):
1304 """U3: CLI-dependency health + techmeme/arxiv/trustpilot sources."""
1305
1306 def test_new_cli_sources_present(self):
1307 report = _build(
1308 {},
1309 probe_map={
1310 "techmeme-pp-cli": health.OK,
1311 "arxiv-pp-cli": health.OK,
1312 "trustpilot-pp-cli": health.OK,
1313 },
1314 )
1315 for src in ("techmeme", "arxiv", "trustpilot"):
1316 self.assertIn(src, report["sources"], src)
1317 self.assertEqual("ok", report["sources"][src]["cli"]["status"], src)
1318
1319 def test_cli_marker_and_block_for_ytdlp(self):
1320 report = _build({}, probe_map={"yt-dlp": health.OK})
1321 self.assertEqual("ok", report["sources"]["youtube"]["cli"]["status"])
1322 text = doctor.render_text(report)
1323 self.assertIn("CLI health", text)
1324 self.assertIn("[CLI: yt-dlp ✓]", text)
1325
1326 def test_keyless_source_has_no_cli(self):
1327 report = _build({})
1328 self.assertNotIn("cli", report["sources"]["polymarket"])
1329 self.assertIn("need no CLI", doctor.render_text(report))
1330
1331 def test_digg_off_path_is_not_working(self):
1332 def fake(name, timeout=health.PROBE_TIMEOUT):
1333 if name == "digg-pp-cli":
1334 return health.DependencyProbe(
1335 name=name,
1336 status=health.BROKEN,
1337 detail="installed off PATH",
1338 off_path=True,
1339 prescription="add ~/.local/bin to PATH",
1340 )
1341 return health.DependencyProbe(
1342 name=name, status=health.MISSING, detail="missing",
1343 prescription="install",
1344 )
1345
1346 with _Hermetic(), mock.patch("lib.health.probe_dependency", fake):
1347 report = doctor.build_report({})
1348 self.assertTrue(report["sources"]["digg"]["cli"]["off_path"])
1349 self.assertEqual(
1350 doctor.AUDIT_NOT_WORKING, report["sources"]["digg"]["audit_state"]
1351 )
1352
1353 def test_gh_absent_github_still_working(self):
1354 report = _build({}) # gh missing by default in _Hermetic
1355 gh = report["sources"]["github"]
1356 self.assertEqual(doctor.AUDIT_WORKING, gh["audit_state"])
1357 self.assertTrue(gh["cli"]["optional"])
1358
1359
1360 # ---------------------------------------------------------------------------
1361 # Grok Bot host (official-only X policy): doctor names only the official path
1362 # ---------------------------------------------------------------------------
1363
1364 # R4 vocabulary that must never appear in an X status or fix line on a Grok
1365 # Bot host (the pinned backend's own name is the one carve-out).
1366 GROK_BOT_FORBIDDEN = (
1367 "cookie", "cdp", "box-chrome", "bird", "auth_token", "ct0", "xquik",
1368 "grok login", "grok cli",
1369 )
1370 BEARER_CAVEAT = (
1371 "recent posts, about the last week, unless your X developer project has "
1372 "full-archive access"
1373 )
1374
1375
1376 def _grok_bot(**over):
1377 cfg = {"LAST30DAYS_HOST": "grok-bot"}
1378 cfg.update(over)
1379 return cfg
1380
1381
1382 def _x_text_lines(text):
1383 """The X source line plus its indented sub-lane lines from doctor text."""
1384 lines = text.splitlines()
1385 out = []
1386 for i, line in enumerate(lines):
1387 if not re.match(r"^\s*\S+\s+x(?:\s|$)", line):
1388 continue
1389 out.append(line)
1390 for follow in lines[i + 1:]:
1391 if follow.startswith(" "):
1392 out.append(follow)
1393 else:
1394 break
1395 break
1396 return out
1397
1398
1399 def _grok_signed_in():
1400 """A signed-in Grok CLI as the doctor probes see it (filesystem only)."""
1401 return [
1402 mock.patch("lib.backends.which", lambda n: "/usr/bin/grok" if n == "grok" else None),
1403 mock.patch("lib.grok_x.has_stored_auth", return_value=True),
1404 mock.patch(
1405 "lib.grok_x.stored_auth_status",
1406 return_value=(grok_x.AUTH_OK, "signed in", None),
1407 ),
1408 ]
1409
1410
1411 def _build_with(config, patches, **kwargs):
1412 with _Hermetic(**kwargs):
1413 for p in patches:
1414 p.start()
1415 try:
1416 return doctor.build_report(dict(config))
1417 finally:
1418 for p in reversed(patches):
1419 p.stop()
1420
1421
1422 class GrokBotHostDoctor(unittest.TestCase):
1423 """AE2, AE2a, AE3: on a Grok Bot host every X line names only the
1424 official path (connector lane, X_BEARER_TOKEN, XAI_API_KEY, xurl)."""
1425
1426 def _assert_official_vocabulary(self, report, allow=()):
1427 blob = json.dumps(report["sources"]["x"]).lower()
1428 text = "\n".join(_x_text_lines(doctor.render_text(report))).lower()
1429 self.assertTrue(text, "doctor text has no X line")
1430 for word in GROK_BOT_FORBIDDEN:
1431 if word in allow:
1432 continue
1433 self.assertNotIn(word, blob, f"{word!r} in X JSON record")
1434 self.assertNotIn(word, text, f"{word!r} in X text lines")
1435
1436 def test_nothing_configured_prescribes_bearer_and_names_no_cookie_path(self):
1437 config = _grok_bot(
1438 AUTH_TOKEN="dummy-auth-token-secret-000",
1439 CT0="dummy-ct0-secret-000",
1440 XQUIK_API_KEY="dummy-xquik-secret-000",
1441 FROM_BROWSER="firefox",
1442 AGENTCOOKIE="on",
1443 BROWSER_CDP_URL="http://127.0.0.1:9222",
1444 )
1445 report = _build(config)
1446 rec = report["sources"]["x"]
1447 self.assertEqual("off", rec["tier"])
1448 self.assertEqual("unconfigured", rec["status"])
1449 self.assertIsNone(rec["active_backend"])
1450 self.assertEqual(["xapi", "xai", "xurl"], [b["name"] for b in rec["backends"]])
1451 entry = prescriptions.for_x(config, "cookies_missing")
1452 self.assertEqual("bearer_missing", entry.failure)
1453 self.assertIn(entry.fix_nl, rec["fix"])
1454 self.assertIsNone(rec["pin_var"])
1455 self.assertFalse(rec["backups"][0]["armed"])
1456 self._assert_official_vocabulary(report)
1457 self.assertNotIn("LAST30DAYS_X_BACKEND", json.dumps(rec))
1458
1459 def test_signed_in_grok_cli_names_neither_cli_nor_pin_variable(self):
1460 report = _build_with(_grok_bot(), _grok_signed_in())
1461 rec = report["sources"]["x"]
1462 self.assertEqual("unconfigured", rec["status"])
1463 self.assertEqual("off", rec["tier"])
1464 self.assertNotIn("grok", [b["name"] for b in rec["backends"]])
1465 blob = json.dumps(rec)
1466 x_text = "\n".join(_x_text_lines(doctor.render_text(report)))
1467 for surface in (blob, x_text):
1468 # "Grok Bot settings" (the connector copy) and "xAI/Grok live
1469 # search" (the licensed xai product) are official vocabulary;
1470 # the CLI, its login, its store, and the pin knob must be absent.
1471 lowered = surface.lower()
1472 for word in ("grok cli", "grok login", "~/.grok", "grok binary", "grok --"):
1473 self.assertNotIn(word, lowered, word)
1474 self.assertNotIn("LAST30DAYS_X_BACKEND", surface)
1475 self._assert_official_vocabulary(report)
1476
1477 def test_bearer_predicts_xapi_with_week_caveat_and_no_network(self):
1478 secret = "dummy-x-bearer-secret-000"
1479 config = _grok_bot(X_BEARER_TOKEN=secret, AUTH_TOKEN="dummy-auth-token-secret-000",
1480 CT0="dummy-ct0-secret-000")
1481 patches = [
1482 mock.patch("lib.http.get", side_effect=AssertionError("doctor made a network call")),
1483 mock.patch("subprocess.run", side_effect=AssertionError("doctor spawned a subprocess")),
1484 mock.patch("subprocess.Popen", side_effect=AssertionError("doctor spawned a subprocess")),
1485 ]
1486 report = _build_with(config, patches)
1487 rec = report["sources"]["x"]
1488 self.assertEqual("ok", rec["status"])
1489 self.assertEqual("xapi", rec["active_backend"])
1490 self.assertEqual(f"will use: xapi ({BEARER_CAVEAT})", rec["note"])
1491 text = doctor.render_text(report)
1492 self.assertIn(f"will use: xapi ({BEARER_CAVEAT})", text)
1493 self.assertNotIn(secret, text)
1494 self.assertNotIn(secret, doctor.render_json(report))
1495 backup = rec["backups"][0]
1496 self.assertEqual("X auth path", backup["name"])
1497 self.assertTrue(backup["armed"])
1498 self.assertIn("X_BEARER_TOKEN", backup["note"])
1499 self.assertIn("about the last week", backup["note"])
1500 self.assertTrue(report["setup"]["keys_present"]["X_BEARER_TOKEN"])
1501 self._assert_official_vocabulary(report)
1502
1503 def test_xai_key_predicts_xai(self):
1504 report = _build(_grok_bot(XAI_API_KEY="dummy-xai-secret-000"))
1505 rec = report["sources"]["x"]
1506 self.assertEqual("xai", rec["active_backend"])
1507 self.assertTrue(rec["note"].startswith("will use: xai"))
1508 self.assertIn("XAI_API_KEY", rec["backups"][0]["note"])
1509 self._assert_official_vocabulary(report)
1510
1511 def test_lane_signal_predicts_connector(self):
1512 report = _build(_grok_bot(LAST30DAYS_X_HOST_LANE="1"))
1513 rec = report["sources"]["x"]
1514 self.assertEqual("ok", rec["status"])
1515 self.assertEqual("ok", rec["tier"])
1516 self.assertEqual("connector", rec["active_backend"])
1517 self.assertEqual("will use: X connector (host-fetched at run time)", rec["note"])
1518 self.assertEqual("", rec["fix"])
1519 backup = rec["backups"][0]
1520 self.assertTrue(backup["armed"])
1521 self.assertEqual("X connector lane armed", backup["note"])
1522 text = "\n".join(_x_text_lines(doctor.render_text(report)))
1523 self.assertIn("will use: X connector (host-fetched at run time)", text)
1524 self.assertIn("X connector lane armed", text)
1525 self._assert_official_vocabulary(report)
1526
1527 def test_lane_with_bearer_keeps_backend_prediction_and_lane_armed(self):
1528 report = _build(_grok_bot(LAST30DAYS_X_HOST_LANE="1", X_BEARER_TOKEN="dummy-x-bearer-secret-000"))
1529 rec = report["sources"]["x"]
1530 self.assertEqual("xapi", rec["active_backend"])
1531 self.assertEqual("X connector lane armed", rec["backups"][0]["note"])
1532
1533 def test_bird_pin_names_bird_once_as_pinned(self):
1534 config = _grok_bot(
1535 LAST30DAYS_X_BACKEND="bird",
1536 AUTH_TOKEN="dummy-auth-token-secret-000",
1537 CT0="dummy-ct0-secret-000",
1538 )
1539 report = _build_with(
1540 config,
1541 [mock.patch("lib.bird_x.is_bird_installed", return_value=True)],
1542 probe_map={"node": health.OK},
1543 )
1544 rec = report["sources"]["x"]
1545 self.assertEqual("bird", rec["active_backend"])
1546 self.assertEqual("will use: bird (pinned)", rec["note"])
1547 x_text = "\n".join(_x_text_lines(doctor.render_text(report))).lower()
1548 self.assertEqual(1, x_text.count("bird"), x_text)
1549 for word in GROK_BOT_FORBIDDEN:
1550 if word != "bird":
1551 self.assertNotIn(word, x_text, word)
1552 self.assertNotIn("LAST30DAYS_X_BACKEND", x_text.upper())
1553
1554 def test_grok_pin_names_grok_once_as_pinned(self):
1555 report = _build_with(_grok_bot(LAST30DAYS_X_BACKEND="grok"), _grok_signed_in())
1556 rec = report["sources"]["x"]
1557 self.assertEqual("grok", rec["active_backend"])
1558 self.assertEqual("will use: grok (pinned)", rec["note"])
1559 x_text = "\n".join(_x_text_lines(doctor.render_text(report))).lower()
1560 self.assertEqual(1, x_text.count("grok"), x_text)
1561 for word in GROK_BOT_FORBIDDEN:
1562 self.assertNotIn(word, x_text, word)
1563 self.assertNotIn("LAST30DAYS_X_BACKEND", x_text.upper())
1564
1565 def test_bird_pin_without_cookies_prescribes_only_the_official_path(self):
1566 report = _build(_grok_bot(LAST30DAYS_X_BACKEND="bird"))
1567 rec = report["sources"]["x"]
1568 self.assertIsNone(rec["active_backend"])
1569 self.assertIn("X_BEARER_TOKEN", rec["fix"])
1570 self.assertNotIn("cookie", rec["fix"].lower())
1571 self.assertNotIn("--allow-browser-cookies", rec["fix"])
1572
1573 def test_host_line_prints_resolved_host(self):
1574 text = doctor.render_text(_build(_grok_bot()))
1575 self.assertIn("host: grok-bot", text)
1576 default = _build({})
1577 self.assertIsNone(default["config"]["host"])
1578 self.assertIn("host: not set", doctor.render_text(default))
1579
1580 def test_lane_file_line_is_reported_as_ignored(self):
1581 report = _build(_grok_bot(_X_HOST_LANE_FILE_IGNORED=True))
1582 text = doctor.render_text(report)
1583 self.assertIn("LAST30DAYS_X_HOST_LANE", text)
1584 self.assertIn("ignored", text)
1585 self.assertEqual("unconfigured", report["sources"]["x"]["status"])
1586 self.assertNotIn("LAST30DAYS_X_HOST_LANE", doctor.render_text(_build(_grok_bot())))
1587
1588 def test_non_grok_host_prescription_unchanged(self):
1589 rec = _build({})["sources"]["x"]
1590 self.assertIs(prescriptions.get("x", "cookies_missing"), prescriptions.for_x({}, "cookies_missing"))
1591 self.assertIn("cookie", rec["fix"].lower())
1592 self.assertEqual("LAST30DAYS_X_BACKEND", rec["pin_var"])
1593 self.assertIn("no auth path armed", rec["backups"][0]["note"])
1594
1595 def test_bearer_without_pin_on_default_host_names_the_xapi_pin(self):
1596 """xapi is opt-in off Grok Bot: a bare bearer is an unconfigured X
1597 with a one-line enable, never a broken X with a cookie fix."""
1598 rec = _build({"X_BEARER_TOKEN": "dummy-x-bearer-secret-000"})["sources"]["x"]
1599 self.assertEqual("unconfigured", rec["status"])
1600 self.assertEqual("off", rec["tier"])
1601 self.assertIsNone(rec["active_backend"])
1602 self.assertIn("LAST30DAYS_X_BACKEND=xapi", rec["note"])
1603 self.assertEqual("", rec["fix"])
1604 self.assertNotIn("dummy-x-bearer", json.dumps(rec))
1605
1606 def test_xapi_pin_on_default_host_carries_the_caveat(self):
1607 rec = _build({"LAST30DAYS_X_BACKEND": "xapi", "X_BEARER_TOKEN": "dummy-x-bearer-secret-000"})["sources"]["x"]
1608 self.assertEqual("xapi", rec["active_backend"])
1609 self.assertIn("about the last week", rec["note"])
1610
1611
1612 class HostFingerprintAndCache(unittest.TestCase):
1613 """The host key, lane signal, and bearer presence all invalidate a cached
1614 report; the bearer value never lands in the cache file."""
1615
1616 def test_host_and_lane_keys_change_the_fingerprint(self):
1617 base = doctor._config_fingerprint({})
1618 self.assertNotEqual(base, doctor._config_fingerprint({"LAST30DAYS_HOST": "grok-bot"}))
1619 self.assertNotEqual(base, doctor._config_fingerprint({"LAST30DAYS_X_HOST_LANE": "1"}))
1620 self.assertNotEqual(base, doctor._config_fingerprint({"X_BEARER_TOKEN": "dummy-x-bearer-secret-000"}))
1621 self.assertIn("X_BEARER_TOKEN", doctor.KEY_PRESENCE_VARS)
1622 self.assertIn("X_BEARER_TOKEN", doctor._SECRET_CONFIG_VARS)
1623
1624 def test_cache_file_never_contains_the_bearer(self):
1625 secret = "dummy-x-bearer-secret-000"
1626 config = _grok_bot(X_BEARER_TOKEN=secret)
1627 tmp = Path(tempfile.mkdtemp()) / "doctor-cache.json"
1628 with _Hermetic(), mock.patch("lib.doctor.cache_path", return_value=tmp):
1629 report = doctor.build_report(dict(config))
1630 report["generated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
1631 report["from_cache"] = False
1632 self.assertTrue(doctor._write_cache(report, config))
1633 served = doctor.read_cached_report(config)
1634 self.assertNotIn(secret, tmp.read_text(encoding="utf-8"))
1635 self.assertIsNotNone(served)
1636 self.assertEqual("xapi", served["sources"]["x"]["active_backend"])
1637 # A bearer that was tampered into a fake report is refused.
1638 report["sources"]["x"]["detail"] = secret
1639 with mock.patch("lib.doctor.cache_path", return_value=tmp):
1640 self.assertFalse(doctor._write_cache(report, config))
1641
1642
1643 class GrokBotParityTable(unittest.TestCase):
1644 """host x bearer x lane x pin: doctor's prediction equals the pipeline's
1645 pre-failover selection, and Grok Bot output carries no R4 vocabulary."""
1646
1647 def test_doctor_prediction_matches_pipeline_selection(self):
1648 cases = itertools.product(
1649 ("", "grok-bot"), (False, True), (False, True), (None, "bird", "grok", "xapi")
1650 )
1651 for host, bearer, lane, pin in cases:
1652 config = {}
1653 if host:
1654 config["LAST30DAYS_HOST"] = host
1655 if bearer:
1656 config["X_BEARER_TOKEN"] = "dummy-x-bearer-secret-000"
1657 if lane:
1658 config["LAST30DAYS_X_HOST_LANE"] = "1"
1659 if pin:
1660 config["LAST30DAYS_X_BACKEND"] = pin
1661 with self.subTest(host=host or "default", bearer=bearer, lane=lane, pin=pin):
1662 patches = _grok_signed_in()
1663 with _Hermetic():
1664 for p in patches:
1665 p.start()
1666 try:
1667 chain = env.x_backend_chain(dict(config), local_only=True)
1668 report = doctor.build_report(dict(config))
1669 finally:
1670 for p in reversed(patches):
1671 p.stop()
1672 expected = chain[0] if chain else ("connector" if lane else None)
1673 rec = report["sources"]["x"]
1674 self.assertEqual(expected, rec["active_backend"])
1675 if host != "grok-bot":
1676 continue
1677 x_text = "\n".join(_x_text_lines(doctor.render_text(report))).lower()
1678 blob = json.dumps(rec).lower()
1679 allow = {pin} if pin in ("bird", "grok") else set()
1680 for word in GROK_BOT_FORBIDDEN:
1681 if word in allow:
1682 continue
1683 self.assertNotIn(word, x_text, word)
1684 if not allow:
1685 self.assertNotIn(word, blob, word)
1686 if allow and expected == pin:
1687 self.assertLessEqual(x_text.count(pin), 1, x_text)
1688 self.assertNotIn("last30days_x_backend", x_text)
1689
1690
1691 class UnsubstitutedTemplateReporting(unittest.TestCase):
1692 """Issue #1081 defect 2: a rejected `${user_config.*}` placeholder is named
1693 as unsubstituted instead of reading as "nothing configured"."""
1694
1695 def test_setup_block_carries_the_rejected_key_names(self):
1696 block = doctor._setup_block({env.TEMPLATE_CONFIG_KEYS: ["GEMINI_API_KEY"]})
1697
1698 self.assertEqual(["GEMINI_API_KEY"], block["unsubstituted_templates"])
1699 self.assertFalse(block["keys_present"]["GEMINI_API_KEY"])
1700
1701 def test_setup_block_defaults_to_an_empty_list(self):
1702 self.assertEqual([], doctor._setup_block({})["unsubstituted_templates"])
1703
1704 def test_render_names_the_template_without_listing_it_as_present(self):
1705 report = _build({env.TEMPLATE_CONFIG_KEYS: ["GEMINI_API_KEY"]})
1706
1707 text = doctor.render_text(report)
1708
1709 self.assertIn(
1710 "unsubstituted config template(s), counted as unset: GEMINI_API_KEY", text
1711 )
1712 self.assertNotIn("credentials present: GEMINI_API_KEY", text)
1713
1714 def test_render_is_unchanged_without_templates(self):
1715 self.assertNotIn(
1716 "unsubstituted config template(s)", doctor.render_text(_build({}))
1717 )
1718
1719
1720 if __name__ == "__main__":
1721 unittest.main()
1722
1722 lines PYTHON