返回 last30days-skill
test_prescriptions.py
根目录 / tests / test_prescriptions.py
1 """Tests for the fix-prescription registry (doctor plan U3, KTD 7).
2
3 One registry maps (source, failure mode) to its remediation in BOTH
4 natural-language and direct-CLI forms. Consumers: the doctor command (U4)
5 and lib/quality_nudge.py, which builds its fix text from the same entries
6 so the two surfaces cannot drift.
7 """
8
9 import re
10 from pathlib import Path
11 from unittest.mock import patch
12
13 import pytest
14
15 from lib import health, prescriptions
16
17
18 REPO_ROOT = Path(__file__).resolve().parent.parent
19 CONFIGURATION_MD = REPO_ROOT / "CONFIGURATION.md"
20
21 # A CLI fix must start with a runnable token: the engine invocation, an env
22 # assignment (optionally exported), or a documented binary name.
23 RUNNABLE = re.compile(
24 r"^(?:python3 \S*last30days\.py\b"
25 r"|[A-Z][A-Z0-9_]*="
26 r"|export [A-Z][A-Z0-9_]*="
27 r"|(?:brew|pipx|pip|scoop|npx|npm|xurl|yt-dlp|docker|grok) )"
28 )
29
30 # The seed failure inventory from the plan (U3 approach section).
31 SEED_INVENTORY = {
32 ("x", "cookies_missing"),
33 ("x", "cookies_expired"),
34 ("scrapecreators", "key_missing"),
35 ("bluesky", "app_password_missing"),
36 ("youtube", "transcription_key_missing"),
37 ("digg", "pp_cli_missing"),
38 ("digg", "pp_cli_off_path"),
39 ("digg", "pp_cli_broken"),
40 ("youtube", "ytdlp_missing"),
41 ("youtube", "ytdlp_stale"),
42 ("youtube", "ytdlp_broken"),
43 ("truthsocial", "token_missing"),
44 ("xiaohongshu", "service_unreachable"),
45 # Official X path (Grok Bot host): connector lane, bearer, credits.
46 ("x", "bearer_missing"),
47 ("x", "bearer_invalid"),
48 ("x", "payment_required"),
49 ("x", "connector_missing"),
50 }
51
52 OFFICIAL_X_FAILURES = (
53 "bearer_missing", "bearer_invalid", "payment_required", "connector_missing",
54 )
55 # R4 vocabulary the official entries must never carry.
56 GROK_BOT_FORBIDDEN = (
57 "cookie", "cdp", "box-chrome", "bird", "auth_token", "ct0", "xquik",
58 "grok login", "grok cli", "x.com",
59 )
60 GROK_BOT = {"LAST30DAYS_HOST": "grok-bot"}
61
62
63 def _configuration_md_slugs():
64 """GitHub-style anchor slugs for every CONFIGURATION.md heading."""
65 slugs = set()
66 for line in CONFIGURATION_MD.read_text(encoding="utf-8").splitlines():
67 m = re.match(r"#{1,6}\s+(.*)", line)
68 if not m:
69 continue
70 text = re.sub(r"[^\w\s-]", "", m.group(1).lower()).strip()
71 slugs.add(text.replace(" ", "-"))
72 return slugs
73
74
75 # ---------------------------------------------------------------------------
76 # Scenario 1: completeness lint over every registered entry
77 # ---------------------------------------------------------------------------
78
79 class TestCompletenessLint:
80 def test_seed_inventory_is_registered(self):
81 assert SEED_INVENTORY <= set(prescriptions.REGISTRY)
82
83 def test_every_entry_has_cause_and_both_fix_forms(self):
84 for key, entry in prescriptions.REGISTRY.items():
85 assert entry.cause.strip(), f"{key}: empty cause"
86 assert entry.fix_nl.strip(), f"{key}: empty natural-language fix"
87 assert entry.fix_cli.strip(), f"{key}: empty CLI fix"
88
89 def test_every_cli_form_starts_with_a_runnable_token(self):
90 for key, entry in prescriptions.REGISTRY.items():
91 for cli in (entry.fix_cli, *entry.alt_cli):
92 assert RUNNABLE.match(cli), f"{key}: not runnable: {cli!r}"
93
94 def test_nl_fix_never_duplicates_the_cli_string_verbatim(self):
95 for key, entry in prescriptions.REGISTRY.items():
96 assert entry.fix_nl.strip() != entry.fix_cli.strip(), key
97
98 def test_registry_keys_match_entry_fields(self):
99 for (source, failure), entry in prescriptions.REGISTRY.items():
100 assert entry.source == source
101 assert entry.failure == failure
102
103 def test_anchors_point_at_real_configuration_md_headings(self):
104 slugs = _configuration_md_slugs()
105 for key, entry in prescriptions.REGISTRY.items():
106 if entry.anchor:
107 assert entry.anchor in slugs, (
108 f"{key}: anchor #{entry.anchor} not a CONFIGURATION.md heading"
109 )
110
111 def test_no_secret_looking_values(self):
112 """Placeholders only - no copy-pasteable live credentials."""
113 secretish = re.compile(r"(sk-[A-Za-z0-9]{16,}|gsk_[A-Za-z0-9]{16,}|xox[bap]-)")
114 for key, entry in prescriptions.REGISTRY.items():
115 blob = " ".join((entry.cause, entry.fix_nl, entry.fix_cli, *entry.alt_cli))
116 assert not secretish.search(blob), key
117
118
119 # ---------------------------------------------------------------------------
120 # Documented CLI forms for the flagship entries
121 # ---------------------------------------------------------------------------
122
123 class TestDocumentedCliForms:
124 def test_x_cookie_fixes_use_setup_with_browser_cookie_consent(self):
125 expected = "python3 skills/last30days/scripts/last30days.py setup --allow-browser-cookies"
126 assert prescriptions.get("x", "cookies_missing").fix_cli == expected
127 assert prescriptions.get("x", "cookies_expired").fix_cli == expected
128
129 def test_scrapecreators_fix_is_the_github_device_flow(self):
130 entry = prescriptions.get("scrapecreators", "key_missing")
131 assert entry.fix_cli == "python3 skills/last30days/scripts/last30days.py setup --github"
132
133 def test_ytdlp_install_and_reinstall_reference_u1_health_strings(self):
134 """Binary-class fixes reference U1's tables instead of restating them."""
135 install, reinstall = health._MANAGER_PRESCRIPTIONS["yt-dlp"]["brew"]
136 assert prescriptions.get("youtube", "ytdlp_missing").fix_cli == install
137 assert prescriptions.get("youtube", "ytdlp_broken").fix_cli == reinstall
138
139 def test_digg_install_references_u1_printing_press_command(self):
140 entry = prescriptions.get("digg", "pp_cli_missing")
141 assert entry.fix_cli == health._pp_install_cmd("digg-pp-cli")
142
143
144 # ---------------------------------------------------------------------------
145 # Scenario 2: quality_nudge text derives from the same registry entries
146 # ---------------------------------------------------------------------------
147
148 def _nudge(config_overrides=None, result_overrides=None, ytdlp_installed=False):
149 from lib.quality_nudge import compute_quality_score
150 from lib import youtube_yt
151
152 config = {
153 "AUTH_TOKEN": None,
154 "CT0": None,
155 "XAI_API_KEY": None,
156 "XQUIK_API_KEY": None,
157 "SCRAPECREATORS_API_KEY": None,
158 }
159 config.update(config_overrides or {})
160 results = {"x_error": None, "youtube_error": None, "reddit_error": None}
161 results.update(result_overrides or {})
162 with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed):
163 return compute_quality_score(config, results)
164
165
166 class TestSharedWithQualityNudge:
167 def test_x_cookie_expired_nudge_is_built_from_the_registry_entry(self):
168 """Configured X that errored is a real outage, not an optional omission."""
169 entry = prescriptions.get("x", "cookies_expired")
170 q = _nudge(
171 config_overrides={"AUTH_TOKEN": "tok123"},
172 result_overrides={"x_error": "401 unauthorized"},
173 ytdlp_installed=True,
174 )
175 assert q["nudge_text"] is not None
176 assert entry.fix_nl in q["nudge_text"]
177 assert q["core_missing"] == ["x"]
178 assert q["core_errored"] == ["x"]
179
180 def test_x_cookie_missing_is_an_optional_omission_not_a_nudge(self):
181 q = _nudge(ytdlp_installed=True)
182 assert q["nudge_text"] is None
183 assert q["core_missing"] == []
184
185 def test_x_error_nudge_on_grok_bot_names_bearer_never_x_login(self):
186 """R4: the nudge after an X error on a Grok Bot host routes through
187 the policy-aware lookup and never says to log into x.com."""
188 config = dict(GROK_BOT, X_BEARER_TOKEN="dummy-bearer")
189 entry = prescriptions.for_x(config, "cookies_expired")
190 assert entry.failure == "bearer_invalid"
191 q = _nudge(
192 config_overrides=config,
193 result_overrides={"x_error": "401 unauthorized"},
194 ytdlp_installed=True,
195 )
196 assert q["core_errored"] == ["x"]
197 assert entry.fix_nl in q["nudge_text"]
198 lowered = q["nudge_text"].lower()
199 for word in GROK_BOT_FORBIDDEN:
200 assert word not in lowered, word
201
202 def test_x_credits_nudge_on_grok_bot_says_top_up(self):
203 config = dict(GROK_BOT, X_BEARER_TOKEN="dummy-bearer")
204 entry = prescriptions.for_x(config, "payment_required")
205 q = _nudge(
206 config_overrides=config,
207 result_overrides={"x_error": "xapi: payment required (X API credits exhausted)"},
208 ytdlp_installed=True,
209 )
210 assert entry.fix_nl in q["nudge_text"]
211 assert "top up" in q["nudge_text"].lower()
212 assert "x.com" not in q["nudge_text"].lower()
213
214 def test_x_error_nudge_off_grok_bot_is_unchanged(self):
215 entry = prescriptions.get("x", "cookies_expired")
216 q = _nudge(
217 config_overrides={"AUTH_TOKEN": "tok123"},
218 result_overrides={"x_error": "xquik: payment required (402)"},
219 ytdlp_installed=True,
220 )
221 assert entry.fix_nl in q["nudge_text"]
222
223 def test_ytdlp_missing_nudge_uses_registry_cli(self):
224 entry = prescriptions.get("youtube", "ytdlp_missing")
225 q = _nudge(config_overrides={"AUTH_TOKEN": "tok123"}, ytdlp_installed=False)
226 assert entry.fix_cli in q["nudge_text"]
227
228 def test_ytdlp_stale_degraded_nudge_uses_registry_cli_forms(self):
229 entry = prescriptions.get("youtube", "ytdlp_stale")
230 q = _nudge(
231 config_overrides={"AUTH_TOKEN": "tok123"},
232 ytdlp_installed=True,
233 result_overrides={
234 "youtube_videos_count": 6,
235 "youtube_transcripts_count": 0,
236 },
237 )
238 assert entry.fix_cli in q["nudge_text"]
239 for alt in entry.alt_cli:
240 assert alt in q["nudge_text"]
241
242 def test_quality_nudge_source_no_longer_hardcodes_fix_strings(self):
243 """The migrated fix strings must live in the registry only.
244
245 Trigger logic legitimately still reads credential names (e.g.
246 ``config.get("XAI_API_KEY")``); this guards the FIX text.
247 """
248 source = (
249 REPO_ROOT / "skills/last30days/scripts/lib/quality_nudge.py"
250 ).read_text(encoding="utf-8")
251 assert "brew " not in source
252 assert "api.x.ai" not in source
253 assert "log into x.com" not in source
254 assert "yt-dlp: brew" not in source
255
256
257 # ---------------------------------------------------------------------------
258 # Policy-aware X lookup (Grok Bot host names only the official path)
259 # ---------------------------------------------------------------------------
260
261 class TestPolicyAwareXLookup:
262 def test_default_host_returns_todays_entries(self):
263 assert prescriptions.for_x({}, "cookies_missing") is prescriptions.get("x", "cookies_missing")
264 assert prescriptions.for_x({}, "cookies_expired") is prescriptions.get("x", "cookies_expired")
265 assert prescriptions.for_x({"LAST30DAYS_HOST": "codex"}, "cookies_missing").failure == "cookies_missing"
266
267 def test_grok_bot_maps_cookie_and_grok_failures_to_official_entries(self):
268 assert prescriptions.for_x(GROK_BOT, "cookies_missing").failure == "bearer_missing"
269 assert prescriptions.for_x(GROK_BOT, "cookies_expired").failure == "bearer_invalid"
270 assert prescriptions.for_x(GROK_BOT, "grok_cli_missing").failure == "bearer_missing"
271 assert prescriptions.for_x(GROK_BOT, "grok_not_authenticated").failure == "bearer_missing"
272
273 def test_official_entries_pass_through_on_every_host(self):
274 for failure in OFFICIAL_X_FAILURES:
275 for config in ({}, GROK_BOT):
276 assert prescriptions.for_x(config, failure).failure == failure
277
278 def test_unknown_failure_falls_back_generically_on_grok_bot(self):
279 entry = prescriptions.for_x(GROK_BOT, "flux_capacitor_missing")
280 assert entry.fix_nl == prescriptions.GENERIC_FIX_NL
281
282 def test_official_entries_use_only_official_vocabulary(self):
283 for failure in OFFICIAL_X_FAILURES:
284 entry = prescriptions.get("x", failure)
285 blob = " ".join((entry.cause, entry.fix_nl, entry.fix_cli, *entry.alt_cli)).lower()
286 for word in GROK_BOT_FORBIDDEN:
287 assert word not in blob, (failure, word)
288
289 def test_bearer_copy_carries_the_week_caveat_not_parity(self):
290 for failure in ("bearer_missing", "bearer_invalid", "connector_missing"):
291 nl = prescriptions.get("x", failure).fix_nl
292 assert "X_BEARER_TOKEN" in nl, failure
293 assert "about the last week" in nl, failure
294 assert "full-archive" in nl, failure
295
296 def test_bearer_missing_offers_the_connector_first(self):
297 nl = prescriptions.get("x", "bearer_missing").fix_nl
298 assert nl.index("Grok Bot settings") < nl.index("X_BEARER_TOKEN")
299 assert "30-day" in nl
300 assert "XAI_API_KEY" in nl and "console.x.ai" in nl
301
302 def test_payment_required_says_top_up(self):
303 entry = prescriptions.get("x", "payment_required")
304 assert "top up" in entry.fix_nl.lower()
305 assert "credits" in entry.fix_nl.lower()
306
307
308 # ---------------------------------------------------------------------------
309 # Scenario 3: unregistered failure -> generic fallback, no crash
310 # ---------------------------------------------------------------------------
311
312 class TestFallback:
313 def test_lookup_returns_none_for_unregistered(self):
314 assert prescriptions.lookup("linkedin", "flux_capacitor_missing") is None
315
316 def test_get_returns_generic_configuration_md_pointer(self):
317 entry = prescriptions.get("linkedin", "flux_capacitor_missing")
318 assert "CONFIGURATION.md" in entry.fix_nl
319 assert RUNNABLE.match(entry.fix_cli)
320 assert entry.source == "linkedin"
321 assert entry.failure == "flux_capacitor_missing"
322
323 def test_get_returns_registered_entry_when_present(self):
324 assert prescriptions.get("x", "cookies_missing") is prescriptions.REGISTRY[
325 ("x", "cookies_missing")
326 ]
327
328
329 # ---------------------------------------------------------------------------
330 # Composition with U1 dependency probes (health.DependencyProbe)
331 # ---------------------------------------------------------------------------
332
333 class TestDependencyProbeComposition:
334 def test_ok_probe_needs_no_prescription(self):
335 probe = health.DependencyProbe(name="yt-dlp", status=health.OK, detail="2026.06.01")
336 assert prescriptions.for_dependency_probe(probe) is None
337
338 def test_probe_prescription_wins_the_cli_form(self):
339 """U1's machine-aware string (pipx owner here) overrides the static CLI."""
340 probe = health.DependencyProbe(
341 name="yt-dlp",
342 status=health.BROKEN,
343 detail="yt-dlp resolves to /x/yt-dlp but won't execute: stale shim",
344 prescription="pipx reinstall yt-dlp",
345 )
346 entry = prescriptions.for_dependency_probe(probe)
347 assert entry is not None
348 assert entry.fix_cli == "pipx reinstall yt-dlp"
349 # Registry vocabulary (NL form) is retained.
350 assert entry.fix_nl == prescriptions.get("youtube", "ytdlp_broken").fix_nl
351
352 def test_digg_off_path_probe_maps_to_the_path_entry(self):
353 probe = health.DependencyProbe(
354 name="digg-pp-cli",
355 status=health.MISSING,
356 detail=(
357 "digg-pp-cli is installed at /home/u/.local/bin/digg-pp-cli but "
358 "that directory is not on this process's PATH"
359 ),
360 prescription='add $HOME/.local/bin to PATH (e.g. export PATH="$HOME/.local/bin:$PATH") so digg-pp-cli resolves',
361 off_path=True,
362 )
363 entry = prescriptions.for_dependency_probe(probe)
364 assert entry is not None
365 assert entry.failure == "pp_cli_off_path"
366
367 def test_digg_broken_probe_maps_to_the_reinstall_entry(self):
368 """An installed-but-broken digg binary must get reinstall-framed
369 text, never the never-installed "install it" entry (F6); the
370 probe's own prescription wins the CLI form, mirroring
371 test_probe_prescription_wins_the_cli_form."""
372 reinstall = f"re-run the Printing Press install: {health.pp_install_cmd('digg')}"
373 probe = health.DependencyProbe(
374 name="digg-pp-cli",
375 status=health.BROKEN,
376 detail=(
377 "digg-pp-cli resolves to /home/u/.local/bin/digg-pp-cli "
378 "but won't execute"
379 ),
380 prescription=reinstall,
381 )
382 entry = prescriptions.for_dependency_probe(probe)
383 assert entry is not None
384 assert entry.failure == "pp_cli_broken"
385 assert entry.fix_cli == reinstall
386 # Registry vocabulary (NL form) is retained and reinstall-framed.
387 assert entry.fix_nl == prescriptions.get("digg", "pp_cli_broken").fix_nl
388 assert "reinstall" in entry.fix_nl
389 assert entry.fix_nl != prescriptions.get("digg", "pp_cli_missing").fix_nl
390
391 def test_digg_timeout_probe_also_maps_to_the_reinstall_entry(self):
392 probe = health.DependencyProbe(
393 name="digg-pp-cli",
394 status=health.TIMEOUT,
395 detail="digg-pp-cli --version timed out",
396 prescription="reinstall digg-pp-cli",
397 )
398 entry = prescriptions.for_dependency_probe(probe)
399 assert entry is not None
400 assert entry.failure == "pp_cli_broken"
401
402 def test_unregistered_dependency_wraps_the_probe(self):
403 probe = health.DependencyProbe(
404 name="ffmpeg",
405 status=health.MISSING,
406 detail="ffmpeg not found on PATH",
407 prescription="brew install ffmpeg",
408 )
409 entry = prescriptions.for_dependency_probe(probe)
410 assert entry is not None
411 assert entry.fix_cli == "brew install ffmpeg"
412 assert entry.fix_nl # still has a natural-language form
413
414
415 # ---------------------------------------------------------------------------
416 # Composition with U2 backend findings (lib/backends.py)
417 # ---------------------------------------------------------------------------
418
419 class TestBackendComposition:
420 def test_bird_cookie_prescription_embeds_the_registry_cli(self):
421 from lib import backends, bird_x
422
423 entry = prescriptions.get("x", "cookies_missing")
424 ok_node = health.DependencyProbe(name="node", status=health.OK, detail="v22.0.0")
425 with patch.object(bird_x, "is_bird_installed", return_value=True), \
426 patch.object(health, "probe_dependency", return_value=ok_node):
427 finding = backends._X_PROBES["bird"]({})
428 assert finding.status == health.MISSING
429 assert entry.fix_cli in finding.prescription
430
431 def test_scrapecreators_prescription_embeds_the_registry_cli(self):
432 from lib import backends
433
434 entry = prescriptions.get("scrapecreators", "key_missing")
435 finding = backends._SC_SPEC.probe({})
436 assert finding.status == health.MISSING
437 assert entry.fix_cli in finding.prescription
438 # test_backend_descriptors requires the key name to stay present.
439 assert "SCRAPECREATORS_API_KEY" in finding.prescription
440
441
442 class TestAltCliArityPin:
443 """Greptile PR review: quality_nudge composes YouTube nudges from the two
444 platform alternates on the ytdlp entries. The consumer is now tolerant of
445 any arity (degrades wording instead of crashing), and this pin keeps the
446 wording rich: both entries must keep at least the scoop + pip alternates."""
447
448 @pytest.mark.parametrize("failure", ["ytdlp_missing", "ytdlp_stale"])
449 def test_ytdlp_entries_keep_two_platform_alternates(self, failure):
450 entry = prescriptions.get("youtube", failure)
451 assert len(entry.alt_cli) >= 2, (
452 f"youtube/{failure} lost a platform alternate; quality_nudge "
453 "wording degrades (tolerant, but fix the entry or the prose)"
454 )
455
455 lines PYTHON