| 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) )" |
| 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 | } |
| 46 | |
| 47 | |
| 48 | def _configuration_md_slugs(): |
| 49 | """GitHub-style anchor slugs for every CONFIGURATION.md heading.""" |
| 50 | slugs = set() |
| 51 | for line in CONFIGURATION_MD.read_text(encoding="utf-8").splitlines(): |
| 52 | m = re.match(r"#{1,6}\s+(.*)", line) |
| 53 | if not m: |
| 54 | continue |
| 55 | text = re.sub(r"[^\w\s-]", "", m.group(1).lower()).strip() |
| 56 | slugs.add(text.replace(" ", "-")) |
| 57 | return slugs |
| 58 | |
| 59 | |
| 60 | # --------------------------------------------------------------------------- |
| 61 | # Scenario 1: completeness lint over every registered entry |
| 62 | # --------------------------------------------------------------------------- |
| 63 | |
| 64 | class TestCompletenessLint: |
| 65 | def test_seed_inventory_is_registered(self): |
| 66 | assert SEED_INVENTORY <= set(prescriptions.REGISTRY) |
| 67 | |
| 68 | def test_every_entry_has_cause_and_both_fix_forms(self): |
| 69 | for key, entry in prescriptions.REGISTRY.items(): |
| 70 | assert entry.cause.strip(), f"{key}: empty cause" |
| 71 | assert entry.fix_nl.strip(), f"{key}: empty natural-language fix" |
| 72 | assert entry.fix_cli.strip(), f"{key}: empty CLI fix" |
| 73 | |
| 74 | def test_every_cli_form_starts_with_a_runnable_token(self): |
| 75 | for key, entry in prescriptions.REGISTRY.items(): |
| 76 | for cli in (entry.fix_cli, *entry.alt_cli): |
| 77 | assert RUNNABLE.match(cli), f"{key}: not runnable: {cli!r}" |
| 78 | |
| 79 | def test_nl_fix_never_duplicates_the_cli_string_verbatim(self): |
| 80 | for key, entry in prescriptions.REGISTRY.items(): |
| 81 | assert entry.fix_nl.strip() != entry.fix_cli.strip(), key |
| 82 | |
| 83 | def test_registry_keys_match_entry_fields(self): |
| 84 | for (source, failure), entry in prescriptions.REGISTRY.items(): |
| 85 | assert entry.source == source |
| 86 | assert entry.failure == failure |
| 87 | |
| 88 | def test_anchors_point_at_real_configuration_md_headings(self): |
| 89 | slugs = _configuration_md_slugs() |
| 90 | for key, entry in prescriptions.REGISTRY.items(): |
| 91 | if entry.anchor: |
| 92 | assert entry.anchor in slugs, ( |
| 93 | f"{key}: anchor #{entry.anchor} not a CONFIGURATION.md heading" |
| 94 | ) |
| 95 | |
| 96 | def test_no_secret_looking_values(self): |
| 97 | """Placeholders only - no copy-pasteable live credentials.""" |
| 98 | secretish = re.compile(r"(sk-[A-Za-z0-9]{16,}|gsk_[A-Za-z0-9]{16,}|xox[bap]-)") |
| 99 | for key, entry in prescriptions.REGISTRY.items(): |
| 100 | blob = " ".join((entry.cause, entry.fix_nl, entry.fix_cli, *entry.alt_cli)) |
| 101 | assert not secretish.search(blob), key |
| 102 | |
| 103 | |
| 104 | # --------------------------------------------------------------------------- |
| 105 | # Documented CLI forms for the flagship entries |
| 106 | # --------------------------------------------------------------------------- |
| 107 | |
| 108 | class TestDocumentedCliForms: |
| 109 | def test_x_cookie_fixes_use_setup_with_browser_cookie_consent(self): |
| 110 | expected = "python3 skills/last30days/scripts/last30days.py setup --allow-browser-cookies" |
| 111 | assert prescriptions.get("x", "cookies_missing").fix_cli == expected |
| 112 | assert prescriptions.get("x", "cookies_expired").fix_cli == expected |
| 113 | |
| 114 | def test_scrapecreators_fix_is_the_github_device_flow(self): |
| 115 | entry = prescriptions.get("scrapecreators", "key_missing") |
| 116 | assert entry.fix_cli == "python3 skills/last30days/scripts/last30days.py setup --github" |
| 117 | |
| 118 | def test_ytdlp_install_and_reinstall_reference_u1_health_strings(self): |
| 119 | """Binary-class fixes reference U1's tables instead of restating them.""" |
| 120 | install, reinstall = health._MANAGER_PRESCRIPTIONS["yt-dlp"]["brew"] |
| 121 | assert prescriptions.get("youtube", "ytdlp_missing").fix_cli == install |
| 122 | assert prescriptions.get("youtube", "ytdlp_broken").fix_cli == reinstall |
| 123 | |
| 124 | def test_digg_install_references_u1_printing_press_command(self): |
| 125 | entry = prescriptions.get("digg", "pp_cli_missing") |
| 126 | assert entry.fix_cli == health._pp_install_cmd("digg-pp-cli") |
| 127 | |
| 128 | |
| 129 | # --------------------------------------------------------------------------- |
| 130 | # Scenario 2: quality_nudge text derives from the same registry entries |
| 131 | # --------------------------------------------------------------------------- |
| 132 | |
| 133 | def _nudge(config_overrides=None, result_overrides=None, ytdlp_installed=False): |
| 134 | from lib.quality_nudge import compute_quality_score |
| 135 | from lib import youtube_yt |
| 136 | |
| 137 | config = { |
| 138 | "AUTH_TOKEN": None, |
| 139 | "CT0": None, |
| 140 | "XAI_API_KEY": None, |
| 141 | "XQUIK_API_KEY": None, |
| 142 | "SCRAPECREATORS_API_KEY": None, |
| 143 | } |
| 144 | config.update(config_overrides or {}) |
| 145 | results = {"x_error": None, "youtube_error": None, "reddit_error": None} |
| 146 | results.update(result_overrides or {}) |
| 147 | with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed): |
| 148 | return compute_quality_score(config, results) |
| 149 | |
| 150 | |
| 151 | class TestSharedWithQualityNudge: |
| 152 | def test_x_cookie_expired_nudge_is_built_from_the_registry_entry(self): |
| 153 | entry = prescriptions.get("x", "cookies_expired") |
| 154 | q = _nudge( |
| 155 | config_overrides={"AUTH_TOKEN": "tok123"}, |
| 156 | result_overrides={"x_error": "401 unauthorized"}, |
| 157 | ytdlp_installed=True, |
| 158 | ) |
| 159 | assert q["nudge_text"] is not None |
| 160 | assert entry.fix_nl in q["nudge_text"] |
| 161 | |
| 162 | def test_x_cookie_missing_nudge_is_built_from_the_registry_entry(self): |
| 163 | entry = prescriptions.get("x", "cookies_missing") |
| 164 | q = _nudge(ytdlp_installed=True) |
| 165 | assert entry.fix_nl in q["nudge_text"] |
| 166 | |
| 167 | def test_ytdlp_missing_nudge_uses_registry_cli(self): |
| 168 | entry = prescriptions.get("youtube", "ytdlp_missing") |
| 169 | q = _nudge(config_overrides={"AUTH_TOKEN": "tok123"}, ytdlp_installed=False) |
| 170 | assert entry.fix_cli in q["nudge_text"] |
| 171 | |
| 172 | def test_ytdlp_stale_degraded_nudge_uses_registry_cli_forms(self): |
| 173 | entry = prescriptions.get("youtube", "ytdlp_stale") |
| 174 | q = _nudge( |
| 175 | config_overrides={"AUTH_TOKEN": "tok123"}, |
| 176 | ytdlp_installed=True, |
| 177 | result_overrides={ |
| 178 | "youtube_videos_count": 6, |
| 179 | "youtube_transcripts_count": 0, |
| 180 | }, |
| 181 | ) |
| 182 | assert entry.fix_cli in q["nudge_text"] |
| 183 | for alt in entry.alt_cli: |
| 184 | assert alt in q["nudge_text"] |
| 185 | |
| 186 | def test_quality_nudge_source_no_longer_hardcodes_fix_strings(self): |
| 187 | """The migrated fix strings must live in the registry only. |
| 188 | |
| 189 | Trigger logic legitimately still reads credential names (e.g. |
| 190 | ``config.get("XAI_API_KEY")``); this guards the FIX text. |
| 191 | """ |
| 192 | source = ( |
| 193 | REPO_ROOT / "skills/last30days/scripts/lib/quality_nudge.py" |
| 194 | ).read_text(encoding="utf-8") |
| 195 | assert "brew " not in source |
| 196 | assert "api.x.ai" not in source |
| 197 | assert "log into x.com" not in source |
| 198 | assert "yt-dlp: brew" not in source |
| 199 | |
| 200 | |
| 201 | # --------------------------------------------------------------------------- |
| 202 | # Scenario 3: unregistered failure -> generic fallback, no crash |
| 203 | # --------------------------------------------------------------------------- |
| 204 | |
| 205 | class TestFallback: |
| 206 | def test_lookup_returns_none_for_unregistered(self): |
| 207 | assert prescriptions.lookup("linkedin", "flux_capacitor_missing") is None |
| 208 | |
| 209 | def test_get_returns_generic_configuration_md_pointer(self): |
| 210 | entry = prescriptions.get("linkedin", "flux_capacitor_missing") |
| 211 | assert "CONFIGURATION.md" in entry.fix_nl |
| 212 | assert RUNNABLE.match(entry.fix_cli) |
| 213 | assert entry.source == "linkedin" |
| 214 | assert entry.failure == "flux_capacitor_missing" |
| 215 | |
| 216 | def test_get_returns_registered_entry_when_present(self): |
| 217 | assert prescriptions.get("x", "cookies_missing") is prescriptions.REGISTRY[ |
| 218 | ("x", "cookies_missing") |
| 219 | ] |
| 220 | |
| 221 | |
| 222 | # --------------------------------------------------------------------------- |
| 223 | # Composition with U1 dependency probes (health.DependencyProbe) |
| 224 | # --------------------------------------------------------------------------- |
| 225 | |
| 226 | class TestDependencyProbeComposition: |
| 227 | def test_ok_probe_needs_no_prescription(self): |
| 228 | probe = health.DependencyProbe(name="yt-dlp", status=health.OK, detail="2026.06.01") |
| 229 | assert prescriptions.for_dependency_probe(probe) is None |
| 230 | |
| 231 | def test_probe_prescription_wins_the_cli_form(self): |
| 232 | """U1's machine-aware string (pipx owner here) overrides the static CLI.""" |
| 233 | probe = health.DependencyProbe( |
| 234 | name="yt-dlp", |
| 235 | status=health.BROKEN, |
| 236 | detail="yt-dlp resolves to /x/yt-dlp but won't execute: stale shim", |
| 237 | prescription="pipx reinstall yt-dlp", |
| 238 | ) |
| 239 | entry = prescriptions.for_dependency_probe(probe) |
| 240 | assert entry is not None |
| 241 | assert entry.fix_cli == "pipx reinstall yt-dlp" |
| 242 | # Registry vocabulary (NL form) is retained. |
| 243 | assert entry.fix_nl == prescriptions.get("youtube", "ytdlp_broken").fix_nl |
| 244 | |
| 245 | def test_digg_off_path_probe_maps_to_the_path_entry(self): |
| 246 | probe = health.DependencyProbe( |
| 247 | name="digg-pp-cli", |
| 248 | status=health.MISSING, |
| 249 | detail=( |
| 250 | "digg-pp-cli is installed at /home/u/.local/bin/digg-pp-cli but " |
| 251 | "that directory is not on this process's PATH" |
| 252 | ), |
| 253 | prescription='add $HOME/.local/bin to PATH (e.g. export PATH="$HOME/.local/bin:$PATH") so digg-pp-cli resolves', |
| 254 | off_path=True, |
| 255 | ) |
| 256 | entry = prescriptions.for_dependency_probe(probe) |
| 257 | assert entry is not None |
| 258 | assert entry.failure == "pp_cli_off_path" |
| 259 | |
| 260 | def test_digg_broken_probe_maps_to_the_reinstall_entry(self): |
| 261 | """An installed-but-broken digg binary must get reinstall-framed |
| 262 | text, never the never-installed "install it" entry (F6); the |
| 263 | probe's own prescription wins the CLI form, mirroring |
| 264 | test_probe_prescription_wins_the_cli_form.""" |
| 265 | reinstall = f"re-run the Printing Press install: {health.pp_install_cmd('digg')}" |
| 266 | probe = health.DependencyProbe( |
| 267 | name="digg-pp-cli", |
| 268 | status=health.BROKEN, |
| 269 | detail=( |
| 270 | "digg-pp-cli resolves to /home/u/.local/bin/digg-pp-cli " |
| 271 | "but won't execute" |
| 272 | ), |
| 273 | prescription=reinstall, |
| 274 | ) |
| 275 | entry = prescriptions.for_dependency_probe(probe) |
| 276 | assert entry is not None |
| 277 | assert entry.failure == "pp_cli_broken" |
| 278 | assert entry.fix_cli == reinstall |
| 279 | # Registry vocabulary (NL form) is retained and reinstall-framed. |
| 280 | assert entry.fix_nl == prescriptions.get("digg", "pp_cli_broken").fix_nl |
| 281 | assert "reinstall" in entry.fix_nl |
| 282 | assert entry.fix_nl != prescriptions.get("digg", "pp_cli_missing").fix_nl |
| 283 | |
| 284 | def test_digg_timeout_probe_also_maps_to_the_reinstall_entry(self): |
| 285 | probe = health.DependencyProbe( |
| 286 | name="digg-pp-cli", |
| 287 | status=health.TIMEOUT, |
| 288 | detail="digg-pp-cli --version timed out", |
| 289 | prescription="reinstall digg-pp-cli", |
| 290 | ) |
| 291 | entry = prescriptions.for_dependency_probe(probe) |
| 292 | assert entry is not None |
| 293 | assert entry.failure == "pp_cli_broken" |
| 294 | |
| 295 | def test_unregistered_dependency_wraps_the_probe(self): |
| 296 | probe = health.DependencyProbe( |
| 297 | name="ffmpeg", |
| 298 | status=health.MISSING, |
| 299 | detail="ffmpeg not found on PATH", |
| 300 | prescription="brew install ffmpeg", |
| 301 | ) |
| 302 | entry = prescriptions.for_dependency_probe(probe) |
| 303 | assert entry is not None |
| 304 | assert entry.fix_cli == "brew install ffmpeg" |
| 305 | assert entry.fix_nl # still has a natural-language form |
| 306 | |
| 307 | |
| 308 | # --------------------------------------------------------------------------- |
| 309 | # Composition with U2 backend findings (lib/backends.py) |
| 310 | # --------------------------------------------------------------------------- |
| 311 | |
| 312 | class TestBackendComposition: |
| 313 | def test_bird_cookie_prescription_embeds_the_registry_cli(self): |
| 314 | from lib import backends, bird_x |
| 315 | |
| 316 | entry = prescriptions.get("x", "cookies_missing") |
| 317 | ok_node = health.DependencyProbe(name="node", status=health.OK, detail="v22.0.0") |
| 318 | with patch.object(bird_x, "is_bird_installed", return_value=True), \ |
| 319 | patch.object(health, "probe_dependency", return_value=ok_node): |
| 320 | finding = backends._X_PROBES["bird"]({}) |
| 321 | assert finding.status == health.MISSING |
| 322 | assert entry.fix_cli in finding.prescription |
| 323 | |
| 324 | def test_scrapecreators_prescription_embeds_the_registry_cli(self): |
| 325 | from lib import backends |
| 326 | |
| 327 | entry = prescriptions.get("scrapecreators", "key_missing") |
| 328 | finding = backends._SC_SPEC.probe({}) |
| 329 | assert finding.status == health.MISSING |
| 330 | assert entry.fix_cli in finding.prescription |
| 331 | # test_backend_descriptors requires the key name to stay present. |
| 332 | assert "SCRAPECREATORS_API_KEY" in finding.prescription |
| 333 | |
| 334 | |
| 335 | class TestAltCliArityPin: |
| 336 | """Greptile PR review: quality_nudge composes YouTube nudges from the two |
| 337 | platform alternates on the ytdlp entries. The consumer is now tolerant of |
| 338 | any arity (degrades wording instead of crashing), and this pin keeps the |
| 339 | wording rich: both entries must keep at least the scoop + pip alternates.""" |
| 340 | |
| 341 | @pytest.mark.parametrize("failure", ["ytdlp_missing", "ytdlp_stale"]) |
| 342 | def test_ytdlp_entries_keep_two_platform_alternates(self, failure): |
| 343 | entry = prescriptions.get("youtube", failure) |
| 344 | assert len(entry.alt_cli) >= 2, ( |
| 345 | f"youtube/{failure} lost a platform alternate; quality_nudge " |
| 346 | "wording degrades (tolerant, but fix the entry or the prose)" |
| 347 | ) |
| 348 |