返回 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 json
21 import os
22 import sys
23 import tempfile
24 import unittest
25 import urllib.error
26 from contextlib import redirect_stderr, redirect_stdout
27 from pathlib import Path
28 from unittest import mock
29
30 import last30days as cli
31 from lib import backends, doctor, health, http, prescriptions
32
33 BIRD_STATUS_OFF = {
34 "installed": False,
35 "authenticated": False,
36 "username": None,
37 "can_install": True,
38 }
39
40 # Obvious dummies only (repo security hygiene).
41 FAKE_SECRETS = {
42 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
43 "XAI_API_KEY": "dummy-xai-secret-000",
44 "BRAVE_API_KEY": "dummy-brave-secret-000",
45 "GROQ_API_KEY": "dummy-groq-secret-000",
46 "AUTH_TOKEN": "dummy-auth-token-secret-000",
47 "CT0": "dummy-ct0-secret-000",
48 "BSKY_HANDLE": "dummy.example.social",
49 "BSKY_APP_PASSWORD": "dummy-bsky-secret-000",
50 "TRUTHSOCIAL_TOKEN": "dummy-truth-secret-000",
51 "GITHUB_TOKEN": "dummy-github-secret-000",
52 }
53
54 VALID_TIERS = {"ok", "warn", "off", "error"}
55 VALID_STATUSES = {
56 "ok", "degraded", "opt-in", "unconfigured", "missing", "broken", "timeout", "error",
57 }
58 # The R1 rollup table, row by row.
59 TIER_BY_STATUS = {
60 "ok": "ok",
61 "degraded": "warn",
62 "opt-in": "off",
63 "unconfigured": "off",
64 "missing": "error",
65 "broken": "error",
66 "timeout": "error",
67 "error": "error",
68 }
69
70
71 def _probe_dep(status_map=None, default_status=health.MISSING):
72 """Fake health.probe_dependency honoring a per-name status map."""
73 status_map = status_map or {}
74
75 def fake(name, timeout=health.PROBE_TIMEOUT):
76 status = status_map.get(name, default_status)
77 if status == health.OK:
78 return health.DependencyProbe(name=name, status=health.OK, detail=f"{name} 1.0.0")
79 return health.DependencyProbe(
80 name=name,
81 status=status,
82 detail=f"{name} probe simulated {status}",
83 prescription=(
84 f"install {name}" if status == health.MISSING else f"reinstall {name}"
85 ),
86 owner_pkg_manager="brew",
87 )
88
89 return fake
90
91
92 class _Hermetic:
93 """Context manager stack making doctor runs machine-independent."""
94
95 def __init__(self, probe_map=None, default_status=health.MISSING):
96 # yt-dlp now backs YouTube comments (free, keyless), so the comment
97 # gate reads env.is_ytdlp_available() -> shutil.which on the real host.
98 # Pin it to the same yt-dlp the probe_map declares, or doctor's comment
99 # branch would silently depend on whether the dev box has yt-dlp.
100 ytdlp_ok = (probe_map or {}).get("yt-dlp", default_status) == health.OK
101 self._patches = [
102 mock.patch("lib.health.probe_dependency", _probe_dep(probe_map, default_status)),
103 mock.patch("lib.env.is_ytdlp_available", return_value=ytdlp_ok),
104 mock.patch("lib.bird_x.is_bird_installed", return_value=False),
105 mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None),
106 mock.patch("lib.bird_x.get_bird_status", return_value=dict(BIRD_STATUS_OFF)),
107 # The doctor path is local-only for xurl: the live `xurl whoami`
108 # network check must never run (no-network guarantee).
109 mock.patch(
110 "lib.xurl_x.is_available",
111 side_effect=AssertionError(
112 "doctor path ran the live `xurl whoami` network check"
113 ),
114 ),
115 mock.patch("lib.xurl_x.has_stored_auth", return_value=False),
116 mock.patch(
117 "lib.xurl_x.stored_auth_status",
118 return_value=("missing", "no token store at ~/.xurl"),
119 ),
120 mock.patch("lib.backends.which", lambda name: None),
121 # Hermetic library: never glob the user's real saved-research dir.
122 # Tests that assert a specific brief count override this.
123 mock.patch("lib.doctor._count_saved_briefs", return_value=0),
124 # Hermetic run-evidence: never read the user's real last-report.json.
125 # Tests that inject run evidence override this with a temp file.
126 mock.patch("lib.doctor._last_report_path", return_value=None),
127 # Hermetic live probe: never make a real network call. Tests that
128 # exercise probing override this with canned results.
129 mock.patch("lib.doctor._probe_sources", return_value={}),
130 # FTS5 is present on CI/dev SQLite; pin it so the library record's
131 # branch is deterministic regardless of the host's SQLite build.
132 mock.patch("lib.library_index.fts5_available", return_value=True),
133 # Snapshot os.environ so the CLAUDECODE scrub below is restored on
134 # exit. The real test shell (Claude Code) sets CLAUDECODE=1, which
135 # would otherwise make doctor's host-native web detection fire in
136 # every test and mask the keyless-degraded path. Tests that want the
137 # host-native path pass CLAUDECODE explicitly in their config dict.
138 mock.patch.dict(os.environ, {}, clear=False),
139 ]
140
141 def __enter__(self):
142 for p in self._patches:
143 p.start()
144 os.environ.pop("CLAUDECODE", None)
145 return self
146
147 def __exit__(self, *exc):
148 for p in reversed(self._patches):
149 p.stop()
150 return False
151
152
153 def _build(config, **kwargs):
154 with _Hermetic(**kwargs):
155 return doctor.build_report(dict(config))
156
157
158 def _run_cli_doctor(argv, config):
159 with _Hermetic(), \
160 mock.patch.object(cli.env, "get_config", return_value=dict(config)), \
161 mock.patch.object(sys, "argv", ["last30days.py"] + argv):
162 stdout = io.StringIO()
163 stderr = io.StringIO()
164 with redirect_stdout(stdout), redirect_stderr(stderr):
165 rc = cli.main()
166 return rc, stdout.getvalue()
167
168
169 class KeylessEnvironment(unittest.TestCase):
170 """Scenario 1: fully keyless env."""
171
172 def setUp(self):
173 self.report = _build({})
174
175 def test_free_sources_tier_ok(self):
176 for name in ("reddit", "hackernews", "polymarket", "github"):
177 self.assertEqual("ok", self.report["sources"][name]["tier"], name)
178 self.assertEqual("ok", self.report["sources"][name]["status"], name)
179
180 def test_key_gated_sources_off_with_prescriptions(self):
181 for name in ("x", "tiktok", "instagram", "threads", "bluesky", "truthsocial"):
182 record = self.report["sources"][name]
183 self.assertEqual("off", record["tier"], name)
184 self.assertIn(record["status"], ("unconfigured", "opt-in"), name)
185 self.assertTrue(record["fix"], f"{name} must carry a fix prescription")
186
187 def test_youtube_off_when_ytdlp_missing_and_no_key(self):
188 record = self.report["sources"]["youtube"]
189 self.assertEqual("off", record["tier"])
190 self.assertEqual("unconfigured", record["status"])
191 self.assertTrue(record["fix"])
192
193 def test_web_keyless_floor_is_degraded_not_error(self):
194 record = self.report["sources"]["web"]
195 self.assertEqual("warn", record["tier"])
196 self.assertEqual("degraded", record["status"])
197 self.assertEqual("keyless", record["active_backend"])
198
199 def test_cli_exit_code_zero_even_with_problems(self):
200 rc, out = _run_cli_doctor(["doctor"], {})
201 self.assertEqual(0, rc)
202 self.assertIn("last30days doctor", out)
203
204
205 class GitHubAuthDetection(unittest.TestCase):
206 """GitHub doctor auth must mirror the real fetcher token source."""
207
208 def test_github_env_token_without_gh_reports_authenticated_tier(self):
209 with mock.patch.dict("os.environ", {"GITHUB_TOKEN": "dummy-github-secret-000"}), \
210 mock.patch("lib.doctor.shutil.which", return_value=None):
211 record = _build({})["sources"]["github"]
212
213 self.assertEqual("ok", record["tier"])
214 self.assertEqual("ok", record["status"])
215 self.assertEqual("authenticated tier (GITHUB_TOKEN or gh CLI)", record["detail"])
216
217 def test_github_without_env_token_or_gh_reports_unauthenticated_tier(self):
218 with mock.patch.dict("os.environ", {"GITHUB_TOKEN": ""}), \
219 mock.patch("lib.doctor.shutil.which", return_value=None):
220 record = _build({})["sources"]["github"]
221
222 self.assertEqual("ok", record["tier"])
223 self.assertEqual("ok", record["status"])
224 self.assertIn("unauthenticated REST tier", record["detail"])
225
226
227 class UnconfiguredXWithBrokenNode(unittest.TestCase):
228 """F9 repro: no X configuration + a broken node runtime must read as
229 off/unconfigured with the cookie fix on bird — never a configured-but-
230 broken error carrying a node prescription."""
231
232 def test_x_rolls_up_off_with_cookie_prescription(self):
233 report = _build({}, probe_map={"node": health.BROKEN})
234 record = report["sources"]["x"]
235 self.assertEqual("off", record["tier"])
236 self.assertEqual("unconfigured", record["status"])
237 bird = next(b for b in record["backends"] if b["name"] == "bird")
238 self.assertEqual("missing", bird["status"])
239 self.assertIn("cookie", (bird["detail"] + bird["fix"]).lower())
240 self.assertNotIn("node", bird["fix"].lower())
241
242
243 class CookieBackedXReadiness(unittest.TestCase):
244 """U2: when bird is installed and FROM_BROWSER will authenticate X at run
245 time, doctor reports X as Ready (not Off) with an honest, unverified note -
246 matching the real run behavior where browser cookies serve X fine even
247 though diagnose loads config in plan_only mode."""
248
249 def test_x_ready_when_bird_installed_and_from_browser(self):
250 with _Hermetic(), mock.patch("lib.bird_x.is_bird_installed", return_value=True):
251 report = doctor.build_report({"FROM_BROWSER": "auto"})
252 record = report["sources"]["x"]
253 self.assertEqual("ok", record["tier"])
254 self.assertEqual("ok", record["status"])
255 note = record["note"].lower()
256 self.assertIn("browser cookies", note)
257 self.assertIn("not verified", note)
258 self.assertIn("xai_api_key", note)
259
260 def test_x_stays_off_when_bird_installed_but_no_consent(self):
261 # bird installed but FROM_BROWSER=off -> no cookie path -> genuinely off.
262 with _Hermetic(), mock.patch("lib.bird_x.is_bird_installed", return_value=True):
263 report = doctor.build_report({"FROM_BROWSER": "off"})
264 record = report["sources"]["x"]
265 self.assertEqual("off", record["tier"])
266 self.assertEqual("unconfigured", record["status"])
267
268 def test_x_stays_off_when_consent_but_bird_missing(self):
269 # FROM_BROWSER set but bird not installed -> no runtime path -> off.
270 report = _build({"FROM_BROWSER": "auto"})
271 record = report["sources"]["x"]
272 self.assertEqual("off", record["tier"])
273 self.assertEqual("unconfigured", record["status"])
274
275
276 class LibraryDoctorLine(unittest.TestCase):
277 """U5: doctor reports the local research library so the report's
278 'From your library' block is explained on the health surface."""
279
280 def test_library_reports_indexed_brief_count(self):
281 with _Hermetic(), mock.patch("lib.doctor._count_saved_briefs", return_value=3):
282 record = doctor.build_report({})["sources"]["library"]
283 self.assertEqual("ok", record["status"])
284 self.assertIn("3 saved briefs", record["note"])
285
286 def test_library_empty_store_is_informational_ok(self):
287 record = _build({})["sources"]["library"] # count stubbed to 0
288 self.assertEqual("ok", record["status"])
289 self.assertIn("no saved briefs yet", record["note"])
290
291 def test_library_without_fts5_degrades_informationally(self):
292 # Inner patch overrides the _Hermetic FTS5 pin.
293 with _Hermetic(), mock.patch("lib.library_index.fts5_available", return_value=False):
294 record = doctor.build_report({})["sources"]["library"]
295 self.assertEqual("ok", record["status"])
296 self.assertIn("FTS5", record["note"])
297
298 def test_library_scan_failure_is_informational_ok(self):
299 # A glob/OS error must never fail the run - it degrades to an OK line.
300 with _Hermetic(), mock.patch(
301 "lib.doctor._count_saved_briefs", side_effect=OSError("permission denied")
302 ):
303 record = doctor.build_report({})["sources"]["library"]
304 self.assertEqual("ok", record["status"])
305 self.assertIn("local research library", record["note"])
306
307 def test_library_line_present_in_text_render(self):
308 text = doctor.render_text(_build({}))
309 self.assertTrue(
310 any("library" in l for l in text.splitlines()),
311 "doctor text output must carry a library line",
312 )
313
314
315 class JsonShape(unittest.TestCase):
316 """Scenario 2: documented per-source shape for every registered source."""
317
318 def setUp(self):
319 self.report = _build(dict(FAKE_SECRETS))
320
321 def test_every_registered_source_present(self):
322 self.assertEqual(set(doctor.SOURCE_ORDER), set(self.report["sources"].keys()))
323
324 def test_per_source_record_shape(self):
325 for name, record in self.report["sources"].items():
326 for key in ("tier", "status", "backends", "mode", "active_backend", "fix", "requires"):
327 self.assertIn(key, record, f"{name} missing {key}")
328 self.assertIn(record["tier"], VALID_TIERS, name)
329 self.assertIn(record["status"], VALID_STATUSES, name)
330
331 def test_tier_status_rollup_rows(self):
332 for name, record in self.report["sources"].items():
333 self.assertEqual(
334 TIER_BY_STATUS[record["status"]], record["tier"],
335 f"{name}: status {record['status']} must roll up to "
336 f"{TIER_BY_STATUS[record['status']]}",
337 )
338
339 def test_chained_sources_expose_backends_and_mode(self):
340 for name in ("x", "youtube", "web"):
341 record = self.report["sources"][name]
342 self.assertEqual("alternative", record["mode"], name)
343 self.assertIsInstance(record["backends"], list, name)
344 self.assertTrue(record["backends"], name)
345 self.assertEqual("conditional", self.report["sources"]["reddit"]["mode"])
346 self.assertIsInstance(self.report["sources"]["reddit"]["backends"], list)
347
348 def test_single_backend_sources_have_single_mode(self):
349 for name in ("hackernews", "polymarket", "github", "bluesky"):
350 record = self.report["sources"][name]
351 self.assertEqual("single", record["mode"], name)
352 self.assertIsNone(record["backends"], name)
353
354 def test_conditional_reddit_never_picks_a_winner(self):
355 record = self.report["sources"]["reddit"]
356 self.assertIsNone(record["active_backend"])
357 # Conditional wording is U2's, verbatim.
358 with _Hermetic():
359 expected = backends.resolve("reddit", dict(FAKE_SECRETS)).conditional
360 self.assertEqual(expected, record["note"])
361
362 def test_web_pin_is_flag_only_no_env_pin(self):
363 # Web search has NO env pin; only the --web-backend flag.
364 record = self.report["sources"]["web"]
365 self.assertIsNone(record["pin_var"])
366 self.assertEqual("--web-backend", record["pin_flag"])
367
368 def test_chained_ok_source_predicts_will_use(self):
369 record = self.report["sources"]["web"]
370 self.assertEqual("ok", record["tier"])
371 self.assertEqual("brave", record["active_backend"])
372 self.assertIn("will use: brave", record["note"])
373
374 def test_top_level_block(self):
375 for key in ("engine_version", "config", "setup", "permissions", "sources"):
376 self.assertIn(key, self.report)
377 self.assertIsInstance(self.report["engine_version"], str)
378 self.assertTrue(self.report["engine_version"])
379 setup = self.report["setup"]
380 self.assertIsInstance(setup["setup_complete"], bool)
381 for name, present in setup["keys_present"].items():
382 self.assertIsInstance(present, bool, name)
383 self.assertIn("status", self.report["permissions"])
384
385 def test_json_renderer_round_trips(self):
386 payload = json.loads(doctor.render_json(self.report))
387 self.assertEqual(set(doctor.SOURCE_ORDER), set(payload["sources"].keys()))
388
389
390 class ProbeFailureIsolation(unittest.TestCase):
391 """Scenario 3: one bad probe cannot blank the report."""
392
393 def test_timeout_probe_maps_to_timeout_status_error_tier(self):
394 report = _build({}, probe_map={"yt-dlp": health.TIMEOUT})
395 record = report["sources"]["youtube"]
396 self.assertEqual("timeout", record["status"])
397 self.assertEqual("error", record["tier"])
398 self.assertTrue(record["fix"])
399 # Everything else still renders.
400 self.assertEqual("ok", report["sources"]["reddit"]["tier"])
401 self.assertEqual("ok", report["sources"]["hackernews"]["tier"])
402
403 def test_broken_probe_maps_to_broken(self):
404 report = _build({}, probe_map={"yt-dlp": health.BROKEN})
405 record = report["sources"]["youtube"]
406 self.assertEqual("broken", record["status"])
407 self.assertEqual("error", record["tier"])
408
409 def test_chained_failure_requires_names_the_failed_backend(self):
410 """F4: chain[0] merely MISSING while a later backend is BROKEN ->
411 the record's requires is the BROKEN backend's (mirroring how the
412 OK/WARN branches use the active finding), never chain[0]'s."""
413 config = {
414 "AUTH_TOKEN": "dummy-auth-token-secret-000",
415 "CT0": "dummy-ct0-secret-000",
416 }
417 with _Hermetic(probe_map={"node": health.BROKEN}), \
418 mock.patch("lib.bird_x.is_bird_installed", return_value=True):
419 report = doctor.build_report(dict(config))
420 record = report["sources"]["x"]
421 self.assertEqual("broken", record["status"])
422 self.assertEqual("error", record["tier"])
423 by_name = {b["name"]: b for b in record["backends"]}
424 # chain[0] (xai) is merely unconfigured; bird is the broken one.
425 self.assertEqual("missing", by_name["xai"]["status"])
426 self.assertEqual("broken", by_name["bird"]["status"])
427 self.assertEqual(by_name["bird"]["requires"], record["requires"])
428 self.assertNotEqual(by_name["xai"]["requires"], record["requires"])
429
430 def test_source_exception_is_isolated(self):
431 real_resolve = backends.resolve
432
433 def exploding(source, config, pin=None):
434 if source == "x":
435 raise RuntimeError("probe blew up")
436 return real_resolve(source, config, pin)
437
438 with _Hermetic(), mock.patch("lib.backends.resolve", exploding):
439 report = doctor.build_report({})
440 record = report["sources"]["x"]
441 self.assertEqual("error", record["status"])
442 self.assertEqual("error", record["tier"])
443 self.assertIn("RuntimeError", record["detail"])
444 # The rest of the report survives.
445 self.assertEqual("ok", report["sources"]["reddit"]["tier"])
446 self.assertEqual(set(doctor.SOURCE_ORDER), set(report["sources"].keys()))
447 # And the whole report still renders as text and JSON.
448 self.assertTrue(doctor.render_text(report))
449 json.loads(doctor.render_json(report))
450
451
452 class NoSecretsInvariant(unittest.TestCase):
453 """Scenario 4: seeded fake credentials never appear in any output."""
454
455 def test_no_secret_values_in_text_or_json(self):
456 report = _build(dict(FAKE_SECRETS))
457 text = doctor.render_text(report)
458 raw_json = doctor.render_json(report)
459 for var, secret in FAKE_SECRETS.items():
460 if var == "BSKY_HANDLE":
461 continue # a handle is an identifier, not a credential
462 self.assertNotIn(secret, text, var)
463 self.assertNotIn(secret, raw_json, var)
464
465 def test_keys_present_are_booleans(self):
466 report = _build(dict(FAKE_SECRETS))
467 for name, value in report["setup"]["keys_present"].items():
468 self.assertIsInstance(value, bool, name)
469
470
471 class TopicWordDispatch(unittest.TestCase):
472 """Scenario 5: `doctor` dispatches exactly like `setup` (exact match only)."""
473
474 def test_doctor_topic_triggers_report(self):
475 with mock.patch("lib.doctor.run", return_value=0) as run, \
476 mock.patch.object(cli.env, "get_config", return_value={}), \
477 mock.patch.object(sys, "argv", ["last30days.py", "doctor"]):
478 stdout, stderr = io.StringIO(), io.StringIO()
479 with redirect_stdout(stdout), redirect_stderr(stderr):
480 rc = cli.main()
481 self.assertEqual(0, rc)
482 self.assertTrue(run.called)
483
484 def test_doctor_json_flag_passes_through(self):
485 with mock.patch("lib.doctor.run", return_value=0) as run, \
486 mock.patch.object(cli.env, "get_config", return_value={}), \
487 mock.patch.object(sys, "argv", ["last30days.py", "doctor", "--json"]):
488 stdout, stderr = io.StringIO(), io.StringIO()
489 with redirect_stdout(stdout), redirect_stderr(stderr):
490 rc = cli.main()
491 self.assertEqual(0, rc)
492 self.assertTrue(run.call_args.kwargs.get("emit_json"))
493
494 def test_doctor_emit_json_also_works(self):
495 rc, out = _run_cli_doctor(["doctor", "--emit=json"], {})
496 self.assertEqual(0, rc)
497 payload = json.loads(out)
498 self.assertIn("sources", payload)
499
500 def test_multiword_topic_containing_doctor_is_research_not_report(self):
501 # Same collision rule as setup: exact single-word match only. A real
502 # research topic goes down the research path (sentinel raised there).
503 with mock.patch("lib.doctor.run", side_effect=AssertionError("doctor must not run")), \
504 mock.patch.object(cli.env, "get_config", return_value={}), \
505 mock.patch.object(
506 cli.pipeline, "diagnose", side_effect=RuntimeError("research path reached")
507 ), \
508 mock.patch.object(sys, "argv", ["last30days.py", "doctor", "who", "reviews"]):
509 stdout, stderr = io.StringIO(), io.StringIO()
510 with redirect_stdout(stdout), redirect_stderr(stderr):
511 with self.assertRaises(RuntimeError):
512 cli.main()
513
514 def test_json_flag_rejected_for_research_topics(self):
515 with mock.patch.object(
516 cli.env, "get_config", side_effect=AssertionError("config should not load")
517 ), mock.patch.object(sys, "argv", ["last30days.py", "some", "topic", "--json"]):
518 stderr = io.StringIO()
519 with redirect_stderr(stderr), self.assertRaises(SystemExit) as exc:
520 cli.main()
521 self.assertEqual(2, exc.exception.code)
522 self.assertIn("--json", stderr.getvalue())
523
524
525 class IncludeSourcesTokenParsing(unittest.TestCase):
526 """Opt-in gates match whole INCLUDE_SOURCES tokens, never substrings."""
527
528 def test_substring_token_does_not_enable_linkedin(self):
529 report = _build({
530 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
531 "INCLUDE_SOURCES": "notlinkedincorp",
532 })
533 record = report["sources"]["linkedin"]
534 self.assertEqual("opt-in", record["status"])
535 self.assertEqual("off", record["tier"])
536
537 def test_exact_token_enables_linkedin(self):
538 report = _build({
539 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
540 "INCLUDE_SOURCES": "linkedin",
541 })
542 record = report["sources"]["linkedin"]
543 self.assertEqual("ok", record["status"])
544 self.assertEqual("ok", record["tier"])
545
546
547 class YoutubeTranscriptionNote(unittest.TestCase):
548 """F7: yt-dlp probes OK but no GROQ_API_KEY/OPENAI_API_KEY -> the ok
549 youtube record carries the caption-free note plus the
550 transcription_key_missing fix, and (F14) the text renderer surfaces
551 that fix even though the record's tier is ok."""
552
553 def setUp(self):
554 self.report = _build({}, probe_map={"yt-dlp": health.OK})
555 self.entry = prescriptions.get("youtube", "transcription_key_missing")
556
557 def test_ok_record_carries_note_and_fix(self):
558 record = self.report["sources"]["youtube"]
559 self.assertEqual("ok", record["tier"])
560 self.assertEqual("ok", record["status"])
561 note = record["note"].lower()
562 # Honest note: affirms the working path, scopes the key to caption-free.
563 self.assertIn("search + transcripts work", note)
564 self.assertIn("caption-free", note)
565 # Does not read as broken and does not attribute comment text to yt-dlp.
566 self.assertNotIn("no transcription key for caption-free videos", note)
567 self.assertIn(self.entry.fix_nl, record["fix"])
568 self.assertIn(self.entry.fix_cli, record["fix"])
569
570 def test_no_paid_comment_prescription_when_ytdlp_is_installed(self):
571 # yt-dlp fetches comment text free. With it installed, doctor must NOT
572 # tell the user to buy a ScrapeCreators key for comments — that would
573 # be selling a fix for a problem they do not have.
574 note = self.report["sources"]["youtube"]["note"].lower()
575 self.assertNotIn("comment text needs", note)
576 self.assertNotIn("scrapecreators", note)
577
578 def test_text_line_includes_the_fix_on_the_ok_line(self):
579 text = doctor.render_text(self.report)
580 # Located by source name, not glyph: the four-state audit sorts a
581 # no-run-evidence ok source to UNVERIFIED, but the transcription fix
582 # must still ride the youtube line.
583 line = next(
584 l
585 for l in text.splitlines()
586 if " youtube" in l and "search + transcripts work" in l
587 )
588 self.assertIn("search + transcripts work", line)
589 self.assertIn(f"fix: {self.entry.fix_nl}", line)
590 self.assertIn(self.entry.fix_cli, line)
591
592
593 class YoutubeCommentsFixLine(unittest.TestCase):
594 """Greptile P2: when only the comment-text caveat fires (transcription key
595 present), the record still carries an actionable fix line."""
596
597 def test_no_comment_caveat_when_ytdlp_present(self):
598 """yt-dlp installed -> comments are free -> nothing to prescribe."""
599 record = _build(
600 {"GROQ_API_KEY": "dummy-groq-secret-000"},
601 probe_map={"yt-dlp": health.OK},
602 )["sources"]["youtube"]
603 self.assertEqual("ok", record["status"])
604 note = record["note"].lower()
605 self.assertNotIn("comment text needs", note)
606
607 def test_comment_caveat_fires_when_ytdlp_absent_but_sc_backs_youtube(self):
608 """No yt-dlp, but an SC key keeps YouTube itself alive. Comments then
609 still need the youtube_comments opt-in, so the caveat must surface —
610 and must name yt-dlp as the free way out, not only the paid one.
611
612 (With neither yt-dlp nor a key, YouTube has no backend at all and the
613 record short-circuits to 'no backend configured' — no video, no
614 comments to caveat.)
615 """
616 record = _build(
617 {
618 "GROQ_API_KEY": "dummy-groq-secret-000",
619 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
620 },
621 probe_map={"yt-dlp": health.MISSING},
622 )["sources"]["youtube"]
623 note = record["note"].lower()
624 self.assertIn("comment text needs", note)
625 self.assertIn("yt-dlp (free)", note)
626 self.assertTrue(record["fix"], "comment-text caveat must carry a fix")
627
628 def test_transcription_fix_takes_precedence_when_both_fire(self):
629 record = _build({}, probe_map={"yt-dlp": health.OK})["sources"]["youtube"]
630 entry = prescriptions.get("youtube", "transcription_key_missing")
631 self.assertIn(entry.fix_nl, record["fix"])
632
633
634 class YoutubeHealthyWhenFullyConfigured(unittest.TestCase):
635 """U3: with a transcription key AND comment access, the YouTube note carries
636 no caveat - it is cleanly Ready."""
637
638 def test_no_caveats_when_transcription_and_comments_available(self):
639 report = _build(
640 {
641 "GROQ_API_KEY": "dummy-groq-secret-000",
642 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
643 "INCLUDE_SOURCES": "youtube_comments",
644 },
645 probe_map={"yt-dlp": health.OK},
646 )
647 record = report["sources"]["youtube"]
648 self.assertEqual("ok", record["status"])
649 note = record["note"].lower()
650 self.assertNotIn("caption-free", note)
651 self.assertNotIn("comment text needs", note)
652
653
654 class NativeSearchHost(unittest.TestCase):
655 """Scenario 6: native-search host with no web keys -> off, not error."""
656
657 def test_web_maps_to_off_with_host_native_note(self):
658 report = _build({"LAST30DAYS_NATIVE_SEARCH": "1"})
659 record = report["sources"]["web"]
660 self.assertEqual("off", record["tier"])
661 self.assertEqual("unconfigured", record["status"])
662 self.assertIn("host-native search", record["note"])
663
664 def test_web_on_claudecode_host_is_native_not_degraded(self):
665 # CLAUDECODE set but LAST30DAYS_NATIVE_SEARCH unset (the standalone
666 # `doctor` case) -> host-native note, not "degraded/keyless".
667 report = _build({"CLAUDECODE": "1"})
668 record = report["sources"]["web"]
669 self.assertEqual("off", record["tier"])
670 self.assertEqual("unconfigured", record["status"])
671 note = record["note"]
672 self.assertIn("Claude Code", note)
673 # Must NOT cite an env var the user never set.
674 self.assertNotIn("LAST30DAYS_NATIVE_SEARCH", note)
675
676 def test_web_native_via_real_env_var_not_just_config(self):
677 # Production path: env.get_config() never puts CLAUDECODE in the config
678 # dict, so the os.environ branch is the ONLY one a real Claude Code
679 # session hits. Set the process env var (config has no CLAUDECODE key).
680 with _Hermetic(), mock.patch.dict(os.environ, {"CLAUDECODE": "1"}):
681 record = doctor.build_report({})["sources"]["web"]
682 self.assertEqual("off", record["tier"])
683 self.assertIn("Claude Code", record["note"])
684 self.assertNotIn("LAST30DAYS_NATIVE_SEARCH", record["note"])
685
686 def test_web_stays_degraded_keyless_without_native_signal(self):
687 # No CLAUDECODE, no LAST30DAYS_NATIVE_SEARCH -> genuine keyless floor.
688 record = _build({})["sources"]["web"]
689 self.assertEqual("warn", record["tier"])
690 self.assertEqual("degraded", record["status"])
691 self.assertEqual("keyless", record["active_backend"])
692
693 def test_web_with_key_stays_ok_on_native_host(self):
694 report = _build({
695 "LAST30DAYS_NATIVE_SEARCH": "1",
696 "EXA_API_KEY": "dummy-exa-secret-000",
697 })
698 record = report["sources"]["web"]
699 self.assertEqual("ok", record["tier"])
700 self.assertEqual("exa", record["active_backend"])
701
702
703 class TextReport(unittest.TestCase):
704 """Grouped text rendering: four-state audit."""
705
706 def test_groups_and_lines(self):
707 report = _build({}, probe_map={"yt-dlp": health.BROKEN})
708 text = doctor.render_text(report)
709 self.assertIn("last30days doctor", text)
710 for header in (
711 "WORKING",
712 "TURNED ON - UNVERIFIED",
713 "NOT WORKING",
714 "COULD BE ON",
715 ):
716 self.assertIn(header, text)
717 # One line per source: glyph + source name; fix on non-ok lines.
718 self.assertIn("reddit", text)
719 self.assertIn("youtube", text)
720 self.assertIn("reinstall yt-dlp", text)
721 # Reddit renders U2's conditional wording verbatim, no single winner.
722 with _Hermetic():
723 conditional = backends.resolve("reddit", {}).conditional
724 self.assertIn(conditional, text)
725
726 def test_will_use_rendered_for_chained_ok_source(self):
727 report = _build({"BRAVE_API_KEY": "dummy-brave-secret-000"})
728 text = doctor.render_text(report)
729 self.assertIn("will use: brave", text)
730
731
732 def _write_last_report(dir_path, *, source_status, topic="wordpress", fresh=True):
733 """Write a minimal last-report.json the run-evidence loader can read."""
734 ts = datetime.datetime.now(datetime.timezone.utc)
735 if not fresh:
736 ts = ts - datetime.timedelta(
737 seconds=doctor.DEFAULT_REPORT_CACHE_TTL_SECONDS + 600
738 )
739 iso = ts.isoformat()
740 payload = {
741 "schema": doctor.REPORT_CACHE_SCHEMA_VERSION,
742 "timestamp": iso,
743 "topic": topic,
744 "reports": [
745 {
746 "entity": "",
747 "report": {
748 "generated_at": iso,
749 "source_status": {
750 src: {
751 "source": src,
752 "state": st.get("state"),
753 "items_returned": st.get("items_returned", 0),
754 "detail": st.get("detail"),
755 "at": iso,
756 "fix_hint": st.get("fix_hint"),
757 }
758 for src, st in source_status.items()
759 },
760 },
761 }
762 ],
763 }
764 path = Path(dir_path) / doctor.REPORT_CACHE_FILENAME
765 path.write_text(json.dumps(payload), encoding="utf-8")
766 return path
767
768
769 class RunEvidenceOverlay(unittest.TestCase):
770 """U1: build_report overlays last-report.json per-source outcomes."""
771
772 def _build_with_evidence(self, source_status, fresh=True):
773 tmp = tempfile.mkdtemp()
774 path = _write_last_report(tmp, source_status=source_status, fresh=fresh)
775 with _Hermetic(), mock.patch(
776 "lib.doctor._last_report_path", return_value=path
777 ):
778 return doctor.build_report({})
779
780 def test_failed_source_outcome_overlaid(self):
781 report = self._build_with_evidence(
782 {
783 "youtube": {"state": "error", "items_returned": 0, "detail": "HTTP 500"},
784 "reddit": {"state": "ok", "items_returned": 13},
785 }
786 )
787 yt = report["sources"]["youtube"]["run_outcome"]
788 self.assertIsNotNone(yt)
789 self.assertEqual("error", yt["state"])
790 self.assertIn("HTTP 500", yt["detail"])
791 self.assertEqual(
792 13, report["sources"]["reddit"]["run_outcome"]["items_returned"]
793 )
794 self.assertTrue(report["run_evidence"]["fresh"])
795 self.assertTrue(report["run_evidence"]["present"])
796
797 def test_no_cache_yields_no_outcomes(self):
798 with _Hermetic(): # _last_report_path -> None
799 report = doctor.build_report({})
800 self.assertIsNone(report["sources"]["reddit"]["run_outcome"])
801 self.assertFalse(report["run_evidence"]["present"])
802
803 def test_corrupt_cache_treated_as_absent(self):
804 tmp = tempfile.mkdtemp()
805 path = Path(tmp) / doctor.REPORT_CACHE_FILENAME
806 path.write_text("{not valid json", encoding="utf-8")
807 with _Hermetic(), mock.patch(
808 "lib.doctor._last_report_path", return_value=path
809 ):
810 report = doctor.build_report({})
811 self.assertIsNone(report["sources"]["reddit"]["run_outcome"])
812 self.assertFalse(report["run_evidence"]["present"])
813
814 def test_stale_cache_present_but_not_overlaid(self):
815 report = self._build_with_evidence(
816 {"youtube": {"state": "error", "items_returned": 0}}, fresh=False
817 )
818 # Present but not fresh: overlay withheld from plain doctor (R4),
819 # while --postmortem (U4) can still read it by age.
820 self.assertIsNone(report["sources"]["youtube"]["run_outcome"])
821 self.assertTrue(report["run_evidence"]["present"])
822 self.assertFalse(report["run_evidence"]["fresh"])
823
824
825 class FourStateAudit(unittest.TestCase):
826 """U2: audit_state derivation + grouped render."""
827
828 def test_keyless_ok_no_evidence_is_working(self):
829 rec = {"tier": "ok", "status": "ok"}
830 self.assertEqual(doctor.AUDIT_WORKING, doctor.audit_state("reddit", rec))
831
832 def test_configured_ok_no_evidence_is_unverified(self):
833 rec = {"tier": "ok", "status": "ok"}
834 self.assertEqual(doctor.AUDIT_UNVERIFIED, doctor.audit_state("tiktok", rec))
835
836 def test_fresh_run_items_is_working(self):
837 rec = {"tier": "ok", "status": "ok"}
838 ro = {"state": "ok", "items_returned": 13}
839 self.assertEqual(doctor.AUDIT_WORKING, doctor.audit_state("tiktok", rec, ro))
840
841 def test_fresh_run_error_is_not_working(self):
842 rec = {"tier": "ok", "status": "ok"}
843 ro = {"state": "error", "items_returned": 0, "detail": "HTTP 500"}
844 self.assertEqual(
845 doctor.AUDIT_NOT_WORKING, doctor.audit_state("youtube", rec, ro)
846 )
847
848 def test_fresh_run_partial_is_unverified(self):
849 rec = {"tier": "ok", "status": "ok"}
850 ro = {"state": "partial", "items_returned": 8, "detail": "HTTP 400"}
851 self.assertEqual(
852 doctor.AUDIT_UNVERIFIED, doctor.audit_state("instagram", rec, ro)
853 )
854
855 def test_off_tier_is_could_be_on(self):
856 rec = {"tier": "off", "status": "opt-in"}
857 self.assertEqual(
858 doctor.AUDIT_COULD_BE_ON, doctor.audit_state("threads", rec)
859 )
860
861 def test_probe_result_decides_when_no_run(self):
862 rec = {"tier": "ok", "status": "ok"}
863 self.assertEqual(
864 doctor.AUDIT_WORKING, doctor.audit_state("tiktok", rec, None, {"ok": True})
865 )
866 self.assertEqual(
867 doctor.AUDIT_NOT_WORKING,
868 doctor.audit_state("tiktok", rec, None, {"ok": False}),
869 )
870
871 def test_render_json_keeps_legacy_keys_and_adds_audit(self):
872 report = _build({})
873 for name, rec in report["sources"].items():
874 self.assertIn("tier", rec, name)
875 self.assertIn("status", rec, name)
876 self.assertIn("audit_state", rec, name)
877 self.assertEqual("config", report["mode"])
878 blob = json.loads(doctor.render_json(report))
879 self.assertIn("mode", blob)
880 self.assertIn("audit_state", blob["sources"]["github"])
881
882 def test_every_source_its_own_line(self):
883 text = doctor.render_text(_build({}))
884 self.assertRegex(text, r"[●◐✕○] github")
885
886 def test_working_line_shows_item_count(self):
887 tmp = tempfile.mkdtemp()
888 path = _write_last_report(
889 tmp, source_status={"reddit": {"state": "ok", "items_returned": 13}}
890 )
891 with _Hermetic(), mock.patch(
892 "lib.doctor._last_report_path", return_value=path
893 ):
894 text = doctor.render_text(doctor.build_report({}))
895 self.assertIn("13 items last run", text)
896
897
898 class JsonContract(unittest.TestCase):
899 """U9: doctor --json is additive; --cached serves the new audit shape."""
900
901 LEGACY_RECORD_KEYS = {
902 "tier", "status", "mode", "backends", "active_backend", "fix",
903 "requires", "note", "detail", "pin_var", "pin_flag", "pinned",
904 }
905
906 def test_legacy_record_keys_preserved(self):
907 blob = json.loads(doctor.render_json(_build({})))
908 rec = blob["sources"]["reddit"]
909 for key in self.LEGACY_RECORD_KEYS:
910 self.assertIn(key, rec, key)
911 self.assertIn("audit_state", rec) # additive
912 self.assertIn("mode", blob) # top-level additive
913
914 def test_new_keys_additive(self):
915 blob = json.loads(
916 doctor.render_json(
917 _build(
918 {"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"},
919 probe_map={"yt-dlp": health.OK},
920 )
921 )
922 )
923 yt = blob["sources"]["youtube"]
924 self.assertIn("cli", yt)
925 self.assertIn("backups", yt)
926 self.assertIn("comments", yt)
927
928 def test_cached_roundtrip_serves_audit_shape(self):
929 tmp = Path(tempfile.mkdtemp()) / "doctor-cache.json"
930 with _Hermetic(), mock.patch("lib.doctor.cache_path", return_value=tmp):
931 report = doctor.build_report({})
932 report["generated_at"] = datetime.datetime.now(
933 datetime.timezone.utc
934 ).isoformat()
935 report["from_cache"] = False
936 self.assertTrue(doctor._write_cache(report, {}))
937 served = doctor.read_cached_report({})
938 self.assertIsNotNone(served)
939 self.assertIn("audit_state", served["sources"]["reddit"])
940 self.assertIn("WORKING", doctor.render_text(served))
941
942
943 class LiveProbe(unittest.TestCase):
944 """U5: bounded live probe (--probe / no-fresh-run auto-fallback)."""
945
946 def test_probeable_excludes_credit_gated(self):
947 probeable = set(doctor._probeable_sources())
948 for gated in ("x", "tiktok", "instagram", "threads", "linkedin"):
949 self.assertNotIn(gated, probeable, gated)
950 # free HTTP + keyless CLI sources ARE probeable
951 for free in ("reddit", "hackernews", "polymarket", "github", "youtube"):
952 self.assertIn(free, probeable, free)
953
954 def test_probe_source_http_reachable(self):
955 with mock.patch("lib.doctor._http_ok", return_value=(True, "HTTP 200")):
956 res = doctor._probe_source("hackernews", {}, 5)
957 self.assertTrue(res["ok"])
958 self.assertTrue(res["probed"])
959
960 def test_probe_source_credit_gated_returns_none(self):
961 self.assertIsNone(doctor._probe_source("tiktok", {}, 5))
962
963 def test_reddit_probe_targets_the_endpoint_the_engine_uses(self):
964 # /r/all/hot.json is permanently 403 keyless and no lane requests it;
965 # probing it certified an endpoint the engine had abandoned (#899).
966 url = doctor._HTTP_PROBE_URLS["reddit"]
967 self.assertIn("search.rss", url)
968 self.assertNotIn("hot.json", url)
969
970 def _probe_reddit_with_status(self, code):
971 error = urllib.error.HTTPError(
972 doctor._HTTP_PROBE_URLS["reddit"], code, "Blocked", {}, None
973 )
974 with mock.patch(
975 "lib.doctor.urllib.request.urlopen", side_effect=error
976 ):
977 return doctor._probe_source("reddit", {}, 5)
978
979 def test_reddit_probe_403_is_not_reachable(self):
980 res = self._probe_reddit_with_status(403)
981 self.assertFalse(res["ok"])
982 self.assertIn("403", res["detail"])
983
984 def test_reddit_probe_429_is_not_reachable(self):
985 res = self._probe_reddit_with_status(429)
986 self.assertFalse(res["ok"])
987 self.assertIn("429", res["detail"])
988
989 def test_non_reddit_probe_keeps_4xx_as_reachable(self):
990 # The blocked-status carve-out is per-source: a 4xx elsewhere still
991 # means the endpoint responded.
992 error = urllib.error.HTTPError(
993 doctor._HTTP_PROBE_URLS["github"], 403, "Forbidden", {}, None
994 )
995 with mock.patch("lib.doctor.urllib.request.urlopen", side_effect=error):
996 res = doctor._probe_source("github", {}, 5)
997 self.assertTrue(res["ok"])
998
999 def test_reddit_probe_sends_the_engine_user_agent(self):
1000 # Probing with a different UA measures the User-Agent, not the endpoint.
1001 seen = {}
1002
1003 def capture(req, timeout=None):
1004 seen["ua"] = req.get_header("User-agent")
1005 raise urllib.error.HTTPError(req.full_url, 500, "boom", {}, None)
1006
1007 with mock.patch("lib.doctor.urllib.request.urlopen", capture):
1008 doctor._probe_source("reddit", {}, 5)
1009 self.assertEqual(http.BROWSER_USER_AGENT, seen["ua"])
1010
1011 def test_probe_failure_is_isolated(self):
1012 def flaky(name, config, timeout):
1013 if name == "reddit":
1014 raise RuntimeError("boom")
1015 return {"ok": True, "probed": True}
1016
1017 with mock.patch("lib.doctor._probe_source", flaky):
1018 results = doctor._probe_sources({}, timeout=5)
1019 self.assertFalse(results["reddit"]["ok"]) # isolated failure
1020 self.assertIn("boom", results["reddit"]["detail"])
1021 self.assertTrue(results["hackernews"]["ok"]) # others unaffected
1022
1023 def test_probe_deadline_never_hangs(self):
1024 import time
1025
1026 def too_slow(name, config, timeout):
1027 time.sleep(1.3) # exceeds the timeout(0)+1s result deadline
1028 return {"ok": True, "probed": True}
1029
1030 with mock.patch("lib.doctor._probe_source", too_slow):
1031 results = doctor._probe_sources({}, timeout=0)
1032 self.assertTrue(results)
1033 self.assertTrue(
1034 any("deadline" in r.get("detail", "") for r in results.values())
1035 )
1036
1037 def test_probe_result_flips_unverified_to_working(self):
1038 rec = {"tier": "ok", "status": "ok", "audit_state": doctor.AUDIT_UNVERIFIED}
1039 report = {"sources": {"hackernews": rec}}
1040 doctor._apply_probe(report, {"hackernews": {"ok": True, "probed": True}})
1041 self.assertEqual(doctor.AUDIT_WORKING, rec["audit_state"])
1042 self.assertTrue(rec["probe"]["ok"])
1043
1044 def test_auto_probe_fires_when_no_fresh_run(self):
1045 canned = {"hackernews": {"ok": True, "detail": "HTTP 200", "probed": True}}
1046 with _Hermetic(), mock.patch(
1047 "lib.doctor._probe_sources", return_value=canned
1048 ) as probed:
1049 out = io.StringIO()
1050 err = io.StringIO()
1051 with redirect_stdout(out), redirect_stderr(err):
1052 rc = doctor.run({})
1053 self.assertEqual(0, rc)
1054 probed.assert_called() # auto-fired: no fresh run
1055 self.assertIn("live probe", err.getvalue())
1056
1057 def test_no_auto_probe_when_fresh_run(self):
1058 tmp = tempfile.mkdtemp()
1059 path = _write_last_report(
1060 tmp, source_status={"reddit": {"state": "ok", "items_returned": 5}}
1061 )
1062 with _Hermetic(), mock.patch(
1063 "lib.doctor._last_report_path", return_value=path
1064 ), mock.patch("lib.doctor._probe_sources") as probed:
1065 with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
1066 doctor.run({})
1067 probed.assert_not_called() # fresh run -> overlay, no probe
1068
1069
1070 class Postmortem(unittest.TestCase):
1071 """U4: --postmortem reads the last run's per-source outcomes."""
1072
1073 def _pm(self, source_status, fresh=True):
1074 tmp = tempfile.mkdtemp()
1075 path = _write_last_report(tmp, source_status=source_status, fresh=fresh)
1076 with _Hermetic(), mock.patch(
1077 "lib.doctor._last_report_path", return_value=path
1078 ):
1079 return doctor.build_postmortem({})
1080
1081 def test_failed_partial_succeeded_grouping(self):
1082 pm = self._pm(
1083 {
1084 "youtube": {
1085 "state": "error",
1086 "items_returned": 0,
1087 "detail": "HTTP 500",
1088 "fix_hint": "retry later",
1089 },
1090 "instagram": {
1091 "state": "partial",
1092 "items_returned": 8,
1093 "detail": "HTTP 400",
1094 },
1095 "reddit": {"state": "ok", "items_returned": 13},
1096 }
1097 )
1098 text = doctor.render_postmortem_text(pm)
1099 self.assertIn("Failed:", text)
1100 self.assertIn("HTTP 500", text)
1101 self.assertIn("retry later", text)
1102 self.assertIn("Partial:", text)
1103 self.assertIn("instagram", text)
1104 self.assertIn("Succeeded:", text)
1105 self.assertIn("reddit (13)", text)
1106
1107 def test_empty_state(self):
1108 with _Hermetic(): # _last_report_path -> None
1109 pm = doctor.build_postmortem({})
1110 self.assertFalse(pm["present"])
1111 self.assertIn("No saved run found", doctor.render_postmortem_text(pm))
1112
1113 def test_json_mode_shape(self):
1114 pm = self._pm({"youtube": {"state": "error", "items_returned": 0}})
1115 self.assertEqual("postmortem", pm["mode"])
1116 self.assertIn("youtube", pm["outcomes"])
1117
1118 def test_reads_stale_run_by_age(self):
1119 pm = self._pm(
1120 {"youtube": {"state": "timeout", "items_returned": 0}}, fresh=False
1121 )
1122 self.assertTrue(pm["present"])
1123 self.assertIn("youtube", pm["outcomes"])
1124
1125 def test_cli_dispatch_exits_zero(self):
1126 rc, out = _run_cli_doctor(["doctor", "--postmortem"], {})
1127 self.assertEqual(0, rc)
1128 self.assertIn("post-mortem", out)
1129
1130
1131 class BackupAndCommentLanes(unittest.TestCase):
1132 """U7: backup + comment sub-lanes render on their parent source."""
1133
1134 def test_backups_armed_with_sc_key(self):
1135 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1136 self.assertTrue(report["sources"]["reddit"]["backups"][0]["armed"])
1137 yt_backup = report["sources"]["youtube"]["backups"][0]
1138 self.assertTrue(yt_backup["armed"])
1139 self.assertIn("rate-limited", yt_backup["note"])
1140 text = doctor.render_text(report)
1141 self.assertIn("backup: ScrapeCreators transcript/search backstop — armed", text)
1142
1143 def test_backups_off_without_sc_key(self):
1144 report = _build({})
1145 self.assertFalse(report["sources"]["reddit"]["backups"][0]["armed"])
1146 self.assertFalse(report["sources"]["youtube"]["backups"][0]["armed"])
1147
1148 def test_youtube_comments_reflect_include_sources(self):
1149 on = _build(
1150 {
1151 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
1152 "INCLUDE_SOURCES": "tiktok,instagram,youtube_comments",
1153 }
1154 )
1155 self.assertTrue(on["sources"]["youtube"]["comments"]["enabled"])
1156 off = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1157 self.assertFalse(off["sources"]["youtube"]["comments"]["enabled"])
1158
1159 def test_x_dual_path_note(self):
1160 keyed = _build({"XAI_API_KEY": "dummy-xai-secret-000"})
1161 note = keyed["sources"]["x"]["backups"][0]["note"]
1162 self.assertIn("XAI_API_KEY", note)
1163 self.assertTrue(keyed["sources"]["x"]["backups"][0]["armed"])
1164
1165 def test_sub_lanes_in_json(self):
1166 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1167 blob = json.loads(doctor.render_json(report))
1168 self.assertIn("backups", blob["sources"]["youtube"])
1169 self.assertIn("comments", blob["sources"]["youtube"])
1170
1171
1172 class ThreadsOptIn(unittest.TestCase):
1173 """U6: Threads reports opt-in state honestly against INCLUDE_SOURCES."""
1174
1175 def test_key_without_optin_is_could_be_on(self):
1176 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1177 rec = report["sources"]["threads"]
1178 self.assertEqual("opt-in", rec["status"])
1179 self.assertEqual(doctor.AUDIT_COULD_BE_ON, rec["audit_state"])
1180 self.assertIn("INCLUDE_SOURCES", rec["fix"])
1181
1182 def test_key_with_optin_is_working(self):
1183 report = _build(
1184 {
1185 "SCRAPECREATORS_API_KEY": "dummy-sc-secret-000",
1186 "INCLUDE_SOURCES": "tiktok,instagram,threads",
1187 }
1188 )
1189 rec = report["sources"]["threads"]
1190 self.assertEqual("ok", rec["status"])
1191
1192 def test_no_key_is_could_be_on_with_sc_fix(self):
1193 rec = _build({})["sources"]["threads"]
1194 self.assertEqual("unconfigured", rec["status"])
1195 self.assertEqual(doctor.AUDIT_COULD_BE_ON, rec["audit_state"])
1196
1197 def test_tiktok_on_by_default_with_key_unchanged(self):
1198 # Regression: TikTok/Instagram stay on-by-default with a key (WORKING),
1199 # they are NOT opt-in-gated like Threads.
1200 report = _build({"SCRAPECREATORS_API_KEY": "dummy-sc-secret-000"})
1201 self.assertEqual("ok", report["sources"]["tiktok"]["status"])
1202 self.assertEqual("ok", report["sources"]["instagram"]["status"])
1203
1204
1205 class CliHealth(unittest.TestCase):
1206 """U3: CLI-dependency health + techmeme/arxiv/trustpilot sources."""
1207
1208 def test_new_cli_sources_present(self):
1209 report = _build(
1210 {},
1211 probe_map={
1212 "techmeme-pp-cli": health.OK,
1213 "arxiv-pp-cli": health.OK,
1214 "trustpilot-pp-cli": health.OK,
1215 },
1216 )
1217 for src in ("techmeme", "arxiv", "trustpilot"):
1218 self.assertIn(src, report["sources"], src)
1219 self.assertEqual("ok", report["sources"][src]["cli"]["status"], src)
1220
1221 def test_cli_marker_and_block_for_ytdlp(self):
1222 report = _build({}, probe_map={"yt-dlp": health.OK})
1223 self.assertEqual("ok", report["sources"]["youtube"]["cli"]["status"])
1224 text = doctor.render_text(report)
1225 self.assertIn("CLI health", text)
1226 self.assertIn("[CLI: yt-dlp ✓]", text)
1227
1228 def test_keyless_source_has_no_cli(self):
1229 report = _build({})
1230 self.assertNotIn("cli", report["sources"]["polymarket"])
1231 self.assertIn("need no CLI", doctor.render_text(report))
1232
1233 def test_digg_off_path_is_not_working(self):
1234 def fake(name, timeout=health.PROBE_TIMEOUT):
1235 if name == "digg-pp-cli":
1236 return health.DependencyProbe(
1237 name=name,
1238 status=health.BROKEN,
1239 detail="installed off PATH",
1240 off_path=True,
1241 prescription="add ~/.local/bin to PATH",
1242 )
1243 return health.DependencyProbe(
1244 name=name, status=health.MISSING, detail="missing",
1245 prescription="install",
1246 )
1247
1248 with _Hermetic(), mock.patch("lib.health.probe_dependency", fake):
1249 report = doctor.build_report({})
1250 self.assertTrue(report["sources"]["digg"]["cli"]["off_path"])
1251 self.assertEqual(
1252 doctor.AUDIT_NOT_WORKING, report["sources"]["digg"]["audit_state"]
1253 )
1254
1255 def test_gh_absent_github_still_working(self):
1256 report = _build({}) # gh missing by default in _Hermetic
1257 gh = report["sources"]["github"]
1258 self.assertEqual(doctor.AUDIT_WORKING, gh["audit_state"])
1259 self.assertTrue(gh["cli"]["optional"])
1260
1261
1262 if __name__ == "__main__":
1263 unittest.main()
1264
1264 lines PYTHON