返回 last30days-skill
test_backend_descriptors.py
根目录 / tests / test_backend_descriptors.py
1 """U2: backend-chain descriptors with predicted selection (lib/backends.py).
2
3 Chained sources declare their routing once — imported from the definitions
4 lib/env.py already owns — and ``backends.resolve`` produces a truthful
5 "will use" prediction for alternative chains (X, YouTube, web search) plus
6 honest conditional wording for Reddit.
7
8 Covers the plan's U2 scenarios:
9 1. X with cookies present, bird healthy, no XAI key -> predicted ``bird``.
10 2. Pin var set to a later backend -> pin honored and marked pinned.
11 3. Preferred backend installed-but-unauthenticated does not shadow a
12 fully-usable fallback (collect-then-pick).
13 4. No backend usable -> tier error; prescription from the highest-priority
14 backend.
15 5. Paid lanes probe key presence ONLY — no network, no subprocess.
16 6. Reddit renders conditional wording (default + backfill), never a
17 computed winner; a scrapecreators pin renders as pinned.
18 7. Parity: descriptor prediction == pipeline's pre-failover X selection
19 (env.x_backend_chain()[0]) across three config permutations.
20 """
21
22 from pathlib import Path
23 from unittest import mock
24
25 import pytest
26
27 from lib import backends, env, grok_x, health, xurl_x
28
29
30 # ---------------------------------------------------------------------------
31 # Helpers
32 # ---------------------------------------------------------------------------
33
34 def _probe_dep(status_map=None, default_status=health.OK):
35 """Build a fake health.probe_dependency honoring a per-name status map."""
36 status_map = status_map or {}
37
38 def fake(name, timeout=health.PROBE_TIMEOUT):
39 status = status_map.get(name, default_status)
40 if status == health.OK:
41 return health.DependencyProbe(name=name, status=health.OK, detail=f"{name} 1.0.0")
42 return health.DependencyProbe(
43 name=name,
44 status=status,
45 detail=f"{name} probe simulated {status}",
46 prescription=f"reinstall {name}" if status != health.MISSING else f"install {name}",
47 owner_pkg_manager="brew",
48 )
49
50 return fake
51
52
53 def _x_env(
54 bird_installed=False,
55 xurl_installed=False,
56 xurl_authed=False,
57 node_status=health.OK,
58 grok_installed=False,
59 grok_authed=False,
60 grok_expired=False,
61 grok_off_path=None,
62 ):
63 """Context managers configuring the X-chain probe environment.
64
65 ``xurl_authed`` drives BOTH auth surfaces consistently: the research-time
66 network check (``is_available``) and the doctor-path local evidence
67 (``stored_auth_status``/``has_stored_auth``) — a real machine where the
68 user logged in has both.
69
70 ``grok_expired`` simulates an expired session: AUTH_EXPIRED status, but
71 has_stored_auth/is_available still return True (refresh may work).
72
73 ``grok_off_path`` drives the off-PATH branch of the grok probe. It must be
74 patched rather than left to the real filesystem: ``_off_path_binary``
75 scans installer dirs like ~/.local/bin, so a developer who has grok
76 installed there would otherwise take that branch on every
77 ``grok_installed=False`` case and see failures CI never reproduces.
78 """
79 from datetime import datetime, timezone, timedelta
80 stored = (
81 (xurl_x.AUTH_OK, "stored OAuth credentials found in ~/.xurl")
82 if xurl_authed
83 else (xurl_x.AUTH_MISSING, "no token store at ~/.xurl")
84 )
85 if grok_expired:
86 past = datetime.now(timezone.utc) - timedelta(hours=2)
87 grok_stored = (
88 grok_x.AUTH_EXPIRED,
89 f"Grok session expired at {past.isoformat()}",
90 past,
91 )
92 grok_available = grok_installed
93 elif grok_authed:
94 grok_stored = (
95 grok_x.AUTH_OK,
96 "stored Grok credentials found in ~/.grok/auth.json",
97 None,
98 )
99 grok_available = grok_installed
100 else:
101 grok_stored = (
102 grok_x.AUTH_MISSING,
103 "no Grok credential store at ~/.grok/auth.json",
104 None,
105 )
106 grok_available = False
107 return (
108 mock.patch("lib.bird_x.is_bird_installed", return_value=bird_installed),
109 mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None),
110 mock.patch("lib.xurl_x.is_available", return_value=xurl_authed),
111 mock.patch(
112 "lib.backends.which",
113 lambda name: (
114 "/usr/local/bin/xurl" if (name == "xurl" and xurl_installed)
115 else "/usr/local/bin/grok" if (name == "grok" and grok_installed)
116 else None
117 ),
118 ),
119 mock.patch("lib.grok_x.stored_auth_status", return_value=grok_stored),
120 mock.patch(
121 "lib.grok_x.has_stored_auth",
122 return_value=grok_installed and (grok_authed or grok_expired),
123 ),
124 mock.patch("lib.grok_x.is_available", return_value=grok_available),
125 mock.patch(
126 "lib.health._off_path_binary",
127 lambda name: (
128 Path(grok_off_path) if name == "grok" and grok_off_path else None
129 ),
130 ),
131 mock.patch("lib.health.probe_dependency", _probe_dep({"node": node_status})),
132 mock.patch("lib.xurl_x.stored_auth_status", return_value=stored),
133 mock.patch(
134 "lib.xurl_x.has_stored_auth",
135 return_value=xurl_installed and xurl_authed,
136 ),
137 )
138
139
140 def _resolve_x(config, **envkw):
141 with _stack(_x_env(**envkw)):
142 return backends.resolve("x", config)
143
144
145 class _stack:
146 """Enter/exit a tuple of context managers (contextlib.ExitStack, terse)."""
147
148 def __init__(self, ctxs):
149 self._ctxs = ctxs
150
151 def __enter__(self):
152 for c in self._ctxs:
153 c.__enter__()
154 return self
155
156 def __exit__(self, *exc):
157 for c in reversed(self._ctxs):
158 c.__exit__(*exc)
159 return False
160
161
162 # ---------------------------------------------------------------------------
163 # Descriptor registry: routing declared once, imported from env.py (KTD 6)
164 # ---------------------------------------------------------------------------
165
166 class TestDescriptorRegistry:
167 def test_x_chain_comes_from_env_definitions(self):
168 d = backends.get_descriptor("x")
169 assert d.mode == backends.MODE_ALTERNATIVE
170 # Auto chain order: bird first, grok excluded (opt-in only).
171 assert env.X_BACKEND_ORDER == ("bird", "xai", "xurl", "xquik")
172 # Grok and xapi are opt-in only off Grok Bot, not in the auto chain.
173 assert env.X_BACKEND_OPT_IN == ("grok", "xapi")
174 # All known backends (auto + opt-in) for pin validation.
175 assert env.X_BACKEND_KNOWN == ("bird", "xai", "xurl", "xquik", "grok", "xapi")
176 # Descriptor includes all backends (auto + opt-in) for doctor visibility.
177 assert tuple(s.name for s in d.backends) == env.X_BACKEND_ORDER + env.X_BACKEND_OPT_IN
178 # Grok and xapi are marked opt-in in the descriptor.
179 grok_spec = next(s for s in d.backends if s.name == "grok")
180 assert grok_spec.opt_in is True
181 xapi_spec = next(s for s in d.backends if s.name == "xapi")
182 assert xapi_spec.opt_in is True
183 assert xapi_spec.paid is True
184 assert xapi_spec.requires == "X_BEARER_TOKEN (X API v2)"
185 # Auto chain backends are NOT marked opt-in.
186 for name in env.X_BACKEND_ORDER:
187 spec = next(s for s in d.backends if s.name == name)
188 assert spec.opt_in is False
189 assert d.pin_var == env.X_BACKEND_PIN_VAR == "LAST30DAYS_X_BACKEND"
190
191 def test_env_exposes_reddit_pin_constants(self):
192 assert env.REDDIT_BACKEND_PIN_VAR == "LAST30DAYS_REDDIT_BACKEND"
193 assert env.REDDIT_SC_MIN_ITEMS_VAR == "LAST30DAYS_REDDIT_SC_MIN_ITEMS"
194
195 def test_youtube_and_web_chains_declared_in_order(self):
196 yt = backends.get_descriptor("youtube")
197 assert tuple(s.name for s in yt.backends) == ("yt-dlp", "scrapecreators")
198 web = backends.get_descriptor("web")
199 assert tuple(s.name for s in web.backends) == (
200 "brave", "exa", "serper", "parallel", "keyless",
201 )
202 assert web.pin_flag == "--web-backend"
203
204 def test_reddit_is_conditional_and_lanes_are_not_chain_entries(self):
205 d = backends.get_descriptor("reddit")
206 assert d.mode == backends.MODE_CONDITIONAL
207 names = [s.name for s in d.backends]
208 # Internal keyless lanes are sub-probe detail, never chain entries.
209 for lane in ("search", "listing", "arctic", "shreddit", "rss"):
210 assert lane not in names
211 assert names == ["public", "scrapecreators"]
212
213 def test_unknown_source_raises(self):
214 with pytest.raises(KeyError):
215 backends.get_descriptor("nope")
216 with pytest.raises(KeyError):
217 backends.resolve("nope", {})
218
219
220 # ---------------------------------------------------------------------------
221 # Scenario 1: cookies present, bird healthy, no XAI key -> bird predicted
222 # ---------------------------------------------------------------------------
223
224 class TestXPrediction:
225 def test_bird_predicted_with_cookies_and_no_xai_key(self):
226 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"}
227 res = _resolve_x(config, bird_installed=True)
228 assert res.active_backend == "bird"
229 assert res.tier == backends.TIER_OK
230 assert res.pinned is False
231 # Chain includes all backends (auto + opt-in) for doctor visibility.
232 expected_chain = list(env.X_BACKEND_ORDER + env.X_BACKEND_OPT_IN)
233 assert res.chain == expected_chain
234 assert [f.name for f in res.findings] == expected_chain
235 assert "will use: bird" in res.summary
236
237 def test_bird_predicted_even_when_xai_key_present(self):
238 """Cookies beat XAI_API_KEY when both are present (bird-first chain)."""
239 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0", "XAI_API_KEY": "dummy-key"}
240 res = _resolve_x(config, bird_installed=True)
241 assert res.active_backend == "bird"
242 assert res.tier == backends.TIER_OK
243 assert "will use: bird" in res.summary
244
245 def test_grok_is_never_auto_selected_unpinned(self):
246 """Grok is opt-in only: even if grok is the only configured backend, X is unconfigured unpinned."""
247 config = {}
248 res = _resolve_x(config, grok_installed=True, grok_authed=True)
249 # Grok is available but opt-in - should NOT be auto-selected.
250 grok = next(f for f in res.findings if f.name == "grok")
251 assert grok.status == health.OK
252 # But it should not be the active backend.
253 assert res.active_backend is None
254 assert res.tier == backends.TIER_ERROR
255
256 def test_grok_selected_when_pinned(self):
257 """Pin grok to enable it explicitly."""
258 config = {"LAST30DAYS_X_BACKEND": "grok"}
259 res = _resolve_x(config, grok_installed=True, grok_authed=True)
260 assert res.active_backend == "grok"
261 assert res.pinned is True
262 assert res.pin == "grok"
263 assert res.tier == backends.TIER_OK
264
265 def test_grok_pin_with_no_store_is_error(self):
266 """Pin grok without a valid store -> error with grok login prescription."""
267 config = {"LAST30DAYS_X_BACKEND": "grok"}
268 res = _resolve_x(config, grok_installed=True, grok_authed=False)
269 assert res.active_backend is None
270 assert res.pinned is True
271 assert res.tier == backends.TIER_ERROR
272 assert "grok login" in res.prescription.lower()
273
274 # Scenario 2: pin var set to a later backend -> honored + marked pinned.
275 def test_pin_to_later_backend_honored_and_marked(self):
276 config = {
277 "AUTH_TOKEN": "dummy-token",
278 "CT0": "dummy-ct0",
279 "XQUIK_API_KEY": "dummy-key",
280 "LAST30DAYS_X_BACKEND": "xquik",
281 }
282 res = _resolve_x(config, bird_installed=True)
283 assert res.active_backend == "xquik"
284 assert res.pinned is True
285 assert res.pin == "xquik"
286 assert res.tier == backends.TIER_OK
287 assert "pinned" in res.summary
288
289 # Scenario 3: installed-but-unauthenticated preferred backend must not
290 # shadow a fully usable fallback (collect-then-pick).
291 def test_unauthenticated_preferred_does_not_shadow_usable_fallback(self):
292 config = {"XQUIK_API_KEY": "dummy-key"}
293 res = _resolve_x(config, xurl_installed=True, xurl_authed=False)
294 assert res.active_backend == "xquik"
295 assert res.tier == backends.TIER_OK
296 xurl = next(f for f in res.findings if f.name == "xurl")
297 assert not xurl.usable
298 assert "auth" in (xurl.detail + xurl.prescription).lower()
299
300 # Scenario 4: nothing usable -> error tier, highest-priority prescription.
301 def test_no_backend_usable_is_error_with_top_priority_prescription(self):
302 res = _resolve_x({})
303 assert res.active_backend is None
304 assert res.tier == backends.TIER_ERROR
305 # bird (cookies) is first in the chain, so the prescription is about
306 # browser cookies, not XAI_API_KEY.
307 assert "browser-cookie" in res.prescription or "cookies" in res.prescription.lower()
308
309 def test_pinned_but_unusable_backend_is_error_with_its_prescription(self):
310 # Pin bird without cookies: env.x_backend_chain returns [] (pipeline
311 # raises); resolution mirrors that as an error carrying bird's fix.
312 config = {"LAST30DAYS_X_BACKEND": "bird"}
313 res = _resolve_x(config, bird_installed=True)
314 assert res.active_backend is None
315 assert res.pinned is True
316 assert res.tier == backends.TIER_ERROR
317 assert res.prescription # bird's cookie prescription, not xai's
318 assert "XAI_API_KEY" not in res.prescription
319
320 def test_broken_node_shim_makes_bird_unusable_and_falls_back(self):
321 # U1 integration: a stale node shim (BROKEN, not missing) must not
322 # let bird read as usable — the #692 class applied to chains.
323 config = {
324 "AUTH_TOKEN": "dummy-token",
325 "CT0": "dummy-ct0",
326 "XQUIK_API_KEY": "dummy-key",
327 }
328 res = _resolve_x(config, bird_installed=True, node_status=health.BROKEN)
329 assert res.active_backend == "xquik"
330 bird = next(f for f in res.findings if f.name == "bird")
331 assert bird.status == health.BROKEN
332 assert not bird.usable
333
334 def test_unconfigured_x_with_broken_node_is_unconfigured_not_node_error(self):
335 # F9: cookie presence is checked BEFORE the node runtime. With no X
336 # configuration at all, a broken node must not turn bird into a
337 # BROKEN finding carrying a node prescription — the honest state is
338 # "unconfigured, here is the cookie fix" (which doctor rolls up to
339 # tier off, since every finding is MISSING).
340 res = _resolve_x({}, node_status=health.BROKEN)
341 bird = next(f for f in res.findings if f.name == "bird")
342 assert bird.status == health.MISSING
343 assert "AUTH_TOKEN/CT0" in bird.detail
344 assert "cookie" in bird.prescription.lower()
345 assert "node" not in bird.prescription.lower()
346 # Doctor's off/unconfigured rollup keys on all-findings-MISSING.
347 assert all(f.status == health.MISSING for f in res.findings)
348
349 def test_cookies_present_broken_node_still_reads_broken(self):
350 # The inverse guard: once cookies ARE configured, a broken node is a
351 # real configured-but-broken state and must keep the node fix.
352 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"}
353 res = _resolve_x(config, bird_installed=True, node_status=health.BROKEN)
354 bird = next(f for f in res.findings if f.name == "bird")
355 assert bird.status == health.BROKEN
356 assert "node" in bird.prescription.lower()
357
358 def test_grok_expired_is_degraded_not_ok(self):
359 """Expired grok session -> DEGRADED tier (warn), not OK."""
360 # Grok is opt-in: needs explicit pin to be selected.
361 config = {"LAST30DAYS_X_BACKEND": "grok"}
362 res = _resolve_x(config, grok_installed=True, grok_expired=True)
363 grok = next(f for f in res.findings if f.name == "grok")
364 assert grok.status == health.DEGRADED
365 assert grok.usable # DEGRADED is still usable (refresh may work)
366 assert "expired" in grok.detail.lower()
367 assert "grok login" in grok.prescription.lower()
368 # With pin, grok is selected (degraded is usable).
369 assert res.active_backend == "grok"
370 assert res.tier == backends.TIER_WARN
371
372 def test_grok_expired_unpinned_not_selected(self):
373 """Expired grok without pin: X unconfigured, grok not auto-selected."""
374 res = _resolve_x({}, grok_installed=True, grok_expired=True)
375 grok = next(f for f in res.findings if f.name == "grok")
376 assert grok.status == health.DEGRADED
377 # Grok is opt-in, so even though it's usable (degraded), it's not selected.
378 assert res.active_backend is None
379 assert res.tier == backends.TIER_ERROR
380
381 def test_grok_expired_with_fallback_picks_fallback(self):
382 """When grok is expired AND a better auto-chain backend is OK, pick the OK one."""
383 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"}
384 res = _resolve_x(config, grok_installed=True, grok_expired=True, bird_installed=True)
385 # Bird is OK and in the auto chain. Grok is not considered (opt-in).
386 assert res.active_backend == "bird"
387 assert res.tier == backends.TIER_OK
388
389 def test_grok_future_expires_at_is_ok(self):
390 """Grok with future expires_at reports OK."""
391 res = _resolve_x({}, grok_installed=True, grok_authed=True)
392 grok = next(f for f in res.findings if f.name == "grok")
393 assert grok.status == health.OK
394 assert "not live-verified" in grok.detail
395
396
397 # ---------------------------------------------------------------------------
398 # Grok session expiry: three states (grok is opt-in only)
399 # ---------------------------------------------------------------------------
400
401 class TestGrokExpiryStates:
402 """Test the three grok auth states from the plan:
403 1. No grok CLI -> silent fallback (opt-in only)
404 2. CLI installed, never logged in -> silent fallback (opt-in only)
405 3. CLI installed, WAS logged in, session dead -> DEGRADED with expiry info (opt-in only)
406
407 Note: Grok is opt-in only. These tests verify the finding status, but grok
408 is never auto-selected unpinned.
409 """
410
411 def test_no_grok_cli_is_missing(self):
412 """No grok CLI -> MISSING status, no extra failure noise."""
413 res = _resolve_x({}, grok_installed=False)
414 grok = next(f for f in res.findings if f.name == "grok")
415 assert grok.status == health.MISSING
416 assert "not found on PATH" in grok.detail
417 # Grok is opt-in, so even MISSING doesn't affect the resolution.
418 # X is unconfigured (no auto-chain backends available).
419 assert res.active_backend is None
420
421 def test_grok_off_path_prescribes_a_path_edit_not_an_install(self):
422 """grok present in an installer dir but absent from PATH -> still
423 MISSING, but the prescription must be a PATH edit: installing again
424 would fix nothing. This branch had no coverage, so it only ever ran
425 on developer machines that happened to have grok in ~/.local/bin."""
426 res = _resolve_x({}, grok_installed=False,
427 grok_off_path="/home/dev/.local/bin/grok")
428 grok = next(f for f in res.findings if f.name == "grok")
429 assert grok.status == health.MISSING
430 assert "not on this process's PATH" in grok.detail
431 assert "/home/dev/.local/bin/grok" in grok.detail
432 assert "/home/dev/.local/bin" in grok.prescription
433 assert "install" not in grok.prescription.lower()
434 assert res.active_backend is None
435
436 def test_grok_installed_never_logged_in_is_missing(self):
437 """CLI installed but never logged in -> MISSING with login hint."""
438 res = _resolve_x({}, grok_installed=True, grok_authed=False)
439 grok = next(f for f in res.findings if f.name == "grok")
440 assert grok.status == health.MISSING
441 assert "not signed in" in grok.detail
442 assert "grok login" in grok.prescription
443 # Grok is opt-in, so X is unconfigured.
444 assert res.active_backend is None
445
446 def test_grok_session_expired_is_degraded_with_expiry(self):
447 """Session expired -> DEGRADED with timestamp and refresh hint."""
448 res = _resolve_x({}, grok_installed=True, grok_expired=True)
449 grok = next(f for f in res.findings if f.name == "grok")
450 assert grok.status == health.DEGRADED
451 assert "expired" in grok.detail.lower()
452 # The detail should include the expiry timestamp and hint
453 assert "refresh" in grok.detail.lower() or "login" in grok.prescription.lower()
454 # Grok is opt-in, so X is unconfigured even with degraded grok.
455 assert res.active_backend is None
456
457 def test_grok_healthy_session_is_ok(self):
458 """Non-expired credentials -> OK, but still opt-in only."""
459 res = _resolve_x({}, grok_installed=True, grok_authed=True)
460 grok = next(f for f in res.findings if f.name == "grok")
461 assert grok.status == health.OK
462 # Grok is opt-in, so X is unconfigured unpinned.
463 assert res.active_backend is None
464
465
466 # ---------------------------------------------------------------------------
467 # Scenario 5: paid lanes probe key presence only — never network/subprocess
468 # ---------------------------------------------------------------------------
469
470 def _forbid_io():
471 def boom(*a, **k):
472 raise AssertionError("paid-lane probe attempted I/O")
473
474 return (
475 mock.patch("socket.socket", boom),
476 mock.patch("socket.create_connection", boom),
477 mock.patch("urllib.request.urlopen", boom),
478 mock.patch("subprocess.run", boom),
479 mock.patch("subprocess.Popen", boom),
480 )
481
482
483 class TestPaidLaneProbes:
484 PAID = [
485 ("x", "xai", "XAI_API_KEY"),
486 ("x", "xquik", "XQUIK_API_KEY"),
487 ("web", "serper", "SERPER_API_KEY"),
488 ("youtube", "scrapecreators", "SCRAPECREATORS_API_KEY"),
489 ("reddit", "scrapecreators", "SCRAPECREATORS_API_KEY"),
490 ]
491
492 def test_paid_lanes_are_flagged_paid(self):
493 for source, name, _key in self.PAID:
494 spec = next(
495 s for s in backends.get_descriptor(source).backends if s.name == name
496 )
497 assert spec.paid is True, f"{source}/{name} must be a paid (key-only) lane"
498
499 def test_key_presence_probe_makes_no_network_or_subprocess_calls(self):
500 ctxs = _forbid_io()
501 with ctxs[0], ctxs[1], ctxs[2], ctxs[3], ctxs[4]:
502 for source, name, key in self.PAID:
503 spec = next(
504 s for s in backends.get_descriptor(source).backends if s.name == name
505 )
506 present = spec.probe({key: "dummy-key"})
507 assert present.status == health.OK
508 absent = spec.probe({})
509 assert absent.status == health.MISSING
510 assert key in absent.prescription
511
512
513 # ---------------------------------------------------------------------------
514 # F1 + F10: the doctor-path xurl probe is LOCAL-ONLY (stored-token evidence,
515 # never a live `xurl whoami` — doctor's no-network guarantee) and typed.
516 # ---------------------------------------------------------------------------
517
518 class TestXurlLocalProbe:
519 def _spec(self):
520 return next(
521 s for s in backends.get_descriptor("x").backends if s.name == "xurl"
522 )
523
524 def _finding(self, stored, installed=True):
525 """Run the xurl probe under the forbid-all-I/O harness."""
526 ctxs = _forbid_io()
527 with ctxs[0], ctxs[1], ctxs[2], ctxs[3], ctxs[4], \
528 mock.patch(
529 "lib.backends.which",
530 lambda n: "/usr/local/bin/xurl" if installed else None,
531 ), \
532 mock.patch("lib.xurl_x.stored_auth_status", return_value=stored):
533 return self._spec().probe({})
534
535 def test_token_store_present_is_ok_without_network(self):
536 finding = self._finding(
537 (xurl_x.AUTH_OK, "stored OAuth credentials found in ~/.xurl")
538 )
539 assert finding.status == health.OK
540 assert "not live-verified" in finding.detail
541
542 def test_no_token_store_is_missing_with_auth_prescription(self):
543 finding = self._finding((xurl_x.AUTH_MISSING, "no token store at ~/.xurl"))
544 assert finding.status == health.MISSING
545 assert "not authenticated" in finding.detail
546 assert "xurl auth oauth2 login" in finding.prescription
547
548 def test_unreadable_token_store_is_error_tier(self):
549 # F10: binary resolvable but the token-store read fails -> typed
550 # ERROR (doctor's error tier), never "unconfigured".
551 finding = self._finding(
552 (
553 xurl_x.AUTH_ERROR,
554 "token store ~/.xurl unreadable: PermissionError: denied",
555 )
556 )
557 assert finding.status == health.ERROR
558 assert not finding.usable
559 assert "unreadable" in finding.detail
560
561 def test_binary_absent_stays_not_installed(self):
562 finding = self._finding((xurl_x.AUTH_OK, "irrelevant"), installed=False)
563 assert finding.status == health.MISSING
564 assert "not found on PATH" in finding.detail
565
566 def test_whole_doctor_path_x_probe_makes_no_network_or_subprocess(self, tmp_path):
567 """The full X chain resolution plus the safe get_x_source_status —
568 the exact X probes doctor runs — under the forbid-everything
569 harness. The token store is a REAL file so the genuine
570 stored_auth_status code path (filesystem only) is exercised."""
571 store = tmp_path / ".xurl"
572 store.write_text(
573 "apps:\n app:\n oauth2_tokens:\n me:\n oauth2:\n"
574 " access_token: dummy-not-real\n",
575 encoding="utf-8",
576 )
577 config = {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"}
578 bird_status = {
579 "installed": True,
580 "authenticated": True,
581 "username": "env AUTH_TOKEN",
582 "can_install": True,
583 }
584 ctxs = _forbid_io()
585 with ctxs[0], ctxs[1], ctxs[2], ctxs[3], ctxs[4], \
586 mock.patch(
587 "lib.xurl_x.is_available",
588 side_effect=AssertionError(
589 "doctor path ran the live `xurl whoami` network check"
590 ),
591 ), \
592 mock.patch("lib.xurl_x.token_store_path", return_value=store), \
593 mock.patch("lib.backends.which", lambda n: f"/usr/local/bin/{n}"), \
594 mock.patch(
595 "lib.xurl_x.shutil.which", lambda n: f"/usr/local/bin/{n}"
596 ), \
597 mock.patch("lib.health.probe_dependency", _probe_dep()), \
598 mock.patch("lib.bird_x.is_bird_installed", return_value=True), \
599 mock.patch("lib.bird_x.set_credentials", lambda *a, **k: None), \
600 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status):
601 res = backends.resolve("x", config)
602 status = env.get_x_source_status(config, probe=False)
603 xurl_finding = next(f for f in res.findings if f.name == "xurl")
604 assert xurl_finding.status == health.OK
605 assert "not live-verified" in xurl_finding.detail
606 assert status["xurl_available"] is True
607
608
609 # ---------------------------------------------------------------------------
610 # Scenario 6: Reddit conditional wording, never a computed winner
611 # ---------------------------------------------------------------------------
612
613 class TestRedditConditional:
614 def test_sc_key_present_renders_default_plus_backfill_no_winner(self):
615 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
616 assert res.mode == backends.MODE_CONDITIONAL
617 assert res.active_backend is None # never a single computed winner
618 assert "will use" not in res.summary
619 low = res.conditional.lower()
620 assert "public keyless" in low
621 assert "default" in low
622 assert "scrapecreators backfill" in low
623 assert res.tier == backends.TIER_OK
624
625 def test_thinness_floor_appears_in_wording(self):
626 res = backends.resolve(
627 "reddit",
628 {"SCRAPECREATORS_API_KEY": "dummy-key", "LAST30DAYS_REDDIT_SC_MIN_ITEMS": "5"},
629 )
630 assert "5" in res.conditional
631 assert "floor" in res.conditional.lower()
632
633 def test_default_floor_names_five_item_floor(self):
634 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
635 low = res.conditional.lower()
636 assert "5-item floor" in low
637 assert "nothing" not in low
638
639 def test_empty_floor_treated_as_default(self):
640 res = backends.resolve(
641 "reddit",
642 {"SCRAPECREATORS_API_KEY": "dummy-key", "LAST30DAYS_REDDIT_SC_MIN_ITEMS": ""},
643 )
644 assert "5-item floor" in res.conditional.lower()
645
646 def test_explicit_zero_floor_means_empty_only_wording(self):
647 res = backends.resolve(
648 "reddit",
649 {"SCRAPECREATORS_API_KEY": "dummy-key", "LAST30DAYS_REDDIT_SC_MIN_ITEMS": "0"},
650 )
651 low = res.conditional.lower()
652 assert "nothing" in low
653 assert "floor" not in low
654
655 def test_malformed_floor_spends_nothing_extra(self):
656 # Malformed parses to 0 (empty-only), matching the pipeline.
657 res = backends.resolve(
658 "reddit",
659 {"SCRAPECREATORS_API_KEY": "dummy-key", "LAST30DAYS_REDDIT_SC_MIN_ITEMS": "lots"},
660 )
661 low = res.conditional.lower()
662 assert "nothing" in low
663 assert "floor" not in low
664
665 def test_pinned_scrapecreators_renders_pin(self):
666 res = backends.resolve(
667 "reddit",
668 {
669 "SCRAPECREATORS_API_KEY": "dummy-key",
670 "LAST30DAYS_REDDIT_BACKEND": "scrapecreators",
671 },
672 )
673 assert res.pinned is True
674 assert res.pin == "scrapecreators"
675 low = res.conditional.lower()
676 assert "pinned" in low
677 assert "primary" in low
678 assert res.active_backend is None # still conditional, not a winner
679
680 def test_no_key_means_no_backfill_wording(self):
681 res = backends.resolve("reddit", {})
682 low = res.conditional.lower()
683 assert "public keyless" in low
684 assert "backfill" not in low or "no scrapecreators" in low
685 assert res.tier == backends.TIER_OK # public composite always reachable
686
687 def test_pin_without_key_is_ignored_like_the_pipeline(self):
688 # pipeline gates sc_first on has_sc_key; the pin alone changes nothing.
689 res = backends.resolve(
690 "reddit", {"LAST30DAYS_REDDIT_BACKEND": "scrapecreators"},
691 )
692 assert res.pinned is False
693 assert "primary" not in res.conditional.lower().split("pin ignored")[0]
694
695 def test_keyless_lanes_are_sub_probe_detail(self):
696 res = backends.resolve("reddit", {})
697 public = next(f for f in res.findings if f.name == "public")
698 assert public.detail.endswith("(lanes: search, listing, arctic, shreddit)")
699 assert "rss" not in public.detail
700
701
702 # ---------------------------------------------------------------------------
703 # Scenario 7: parity with the pipeline's pre-failover X selection
704 # ---------------------------------------------------------------------------
705
706 class TestXParityWithPipeline:
707 """Descriptor prediction must equal env.x_backend_chain(config)[0] — the
708 exact expression pipeline._retrieve_stream uses as its pre-failover
709 primary (lib/pipeline.py, `chain = env.x_backend_chain(config)`)."""
710
711 def _assert_parity(self, config, **envkw):
712 with _stack(_x_env(**envkw)):
713 chain = env.x_backend_chain(config)
714 predicted = backends.resolve("x", config).active_backend
715 expected = chain[0] if chain else None
716 assert predicted == expected, (
717 f"prediction {predicted!r} != pipeline pre-failover {expected!r} "
718 f"for config keys {sorted(config)}"
719 )
720
721 def test_parity_xai_key_only(self):
722 self._assert_parity({"XAI_API_KEY": "dummy-key"})
723
724 def test_parity_cookies_and_bird_installed(self):
725 self._assert_parity(
726 {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
727 bird_installed=True,
728 )
729
730 def test_parity_pin_forces_xquik(self):
731 self._assert_parity(
732 {"XQUIK_API_KEY": "dummy-key", "LAST30DAYS_X_BACKEND": "xquik"},
733 )
734
735 def test_parity_nothing_configured(self):
736 self._assert_parity({})
737
738 def test_parity_grok_only_unpinned_is_unconfigured(self):
739 """Grok-only with no pin: X unconfigured (parity with env.x_backend_chain)."""
740 self._assert_parity({}, grok_installed=True, grok_authed=True)
741
742 def test_parity_grok_pinned(self):
743 """Grok pinned: grok is selected (parity with env.x_backend_chain)."""
744 self._assert_parity(
745 {"LAST30DAYS_X_BACKEND": "grok"},
746 grok_installed=True,
747 grok_authed=True,
748 )
749
750 def test_parity_cookies_beat_xai_key(self):
751 """Cookies beat XAI_API_KEY when both present (bird-first chain)."""
752 self._assert_parity(
753 {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0", "XAI_API_KEY": "dummy-key"},
754 bird_installed=True,
755 )
756
757 # Host rows (U1): the policy shapes both sides identically.
758
759 def test_parity_grok_bot_bearer(self):
760 self._assert_parity(
761 {"LAST30DAYS_HOST": "grok-bot", "X_BEARER_TOKEN": "dummy-bearer",
762 "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
763 bird_installed=True,
764 )
765
766 def test_parity_grok_bot_xai_key(self):
767 self._assert_parity(
768 {"LAST30DAYS_HOST": "grok-bot", "XAI_API_KEY": "dummy-key",
769 "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
770 bird_installed=True,
771 )
772
773 def test_parity_grok_bot_cookies_only_unpinned(self):
774 self._assert_parity(
775 {"LAST30DAYS_HOST": "grok-bot", "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0",
776 "XQUIK_API_KEY": "dummy-key"},
777 bird_installed=True,
778 )
779
780 def test_parity_grok_bot_bird_pin(self):
781 self._assert_parity(
782 {"LAST30DAYS_HOST": "grok-bot", "LAST30DAYS_X_BACKEND": "bird",
783 "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
784 bird_installed=True,
785 )
786
787 def test_parity_grok_bot_grok_pin(self):
788 self._assert_parity(
789 {"LAST30DAYS_HOST": "grok-bot", "LAST30DAYS_X_BACKEND": "grok"},
790 grok_installed=True,
791 grok_authed=True,
792 )
793
794 def test_parity_non_grok_linux_cookies_and_bearer(self):
795 with mock.patch("platform.system", return_value="Linux"):
796 self._assert_parity(
797 {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0",
798 "X_BEARER_TOKEN": "dummy-bearer"},
799 bird_installed=True,
800 )
801
802 def test_parity_macbook_xai_key(self):
803 with (
804 mock.patch("platform.system", return_value="Darwin"),
805 mock.patch.object(env, "_mac_model", return_value="MacBookPro18,2"),
806 ):
807 self._assert_parity({"XAI_API_KEY": "dummy-key"})
808
809
810 # ---------------------------------------------------------------------------
811 # get_x_source_status pin semantics (R4): grok pin forces grok source
812 # ---------------------------------------------------------------------------
813
814 class TestGetXSourceStatusGrokPin:
815 """get_x_source_status must respect LAST30DAYS_X_BACKEND=grok pin."""
816
817 def test_pin_grok_with_store_returns_grok_source(self):
818 """Pin grok + valid store -> get_x_source_status source is 'grok'."""
819 config = {"LAST30DAYS_X_BACKEND": "grok"}
820 bird_status = {
821 "installed": False,
822 "authenticated": False,
823 "username": "",
824 "can_install": False,
825 }
826 with (
827 mock.patch("lib.grok_x.has_stored_auth", return_value=True),
828 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
829 ):
830 status = env.get_x_source_status(config, probe=False)
831 assert status["source"] == "grok"
832 assert status["grok_available"] is True
833
834 def test_unpinned_with_store_does_not_return_grok_source(self):
835 """Unpinned + valid grok store -> source is NOT 'grok' (opt-in only)."""
836 config = {} # No pin
837 bird_status = {
838 "installed": False,
839 "authenticated": False,
840 "username": "",
841 "can_install": False,
842 }
843 with (
844 mock.patch("lib.grok_x.has_stored_auth", return_value=True),
845 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
846 ):
847 status = env.get_x_source_status(config, probe=False)
848 # Grok is available but NOT the source (opt-in only)
849 assert status["source"] != "grok"
850 assert status["source"] is None # No other backend configured
851 assert status["grok_available"] is True
852
853 def test_pin_grok_with_cookies_still_returns_grok(self):
854 """Pin grok with cookies present -> grok (pin forces single backend)."""
855 config = {
856 "LAST30DAYS_X_BACKEND": "grok",
857 "AUTH_TOKEN": "dummy-token",
858 "CT0": "dummy-ct0",
859 }
860 bird_status = {
861 "installed": True,
862 "authenticated": True,
863 "username": "test",
864 "can_install": True,
865 }
866 with (
867 mock.patch("lib.grok_x.has_stored_auth", return_value=True),
868 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
869 ):
870 status = env.get_x_source_status(config, probe=False)
871 # Pin forces grok even when bird is available
872 assert status["source"] == "grok"
873
874 def test_pin_grok_no_store_with_other_creds_returns_none(self):
875 """Pin grok + no store + other creds present -> source is None (exclusive pin)."""
876 config = {
877 "LAST30DAYS_X_BACKEND": "grok",
878 "AUTH_TOKEN": "dummy-token",
879 "CT0": "dummy-ct0",
880 "XAI_API_KEY": "dummy-key",
881 }
882 bird_status = {
883 "installed": True,
884 "authenticated": True,
885 "username": "test",
886 "can_install": True,
887 }
888 with (
889 mock.patch("lib.grok_x.has_stored_auth", return_value=False),
890 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
891 ):
892 status = env.get_x_source_status(config, probe=False)
893 # Pin is exclusive: grok unavailable -> None, NOT fallback to bird/xai
894 assert status["source"] is None
895 assert status["grok_available"] is False
896 # Other backends ARE available, but pin blocks fallback
897 assert status["bird_authenticated"] is True
898 assert status["xai_available"] is True
899
900 def test_pin_xai_with_cookies_returns_xai(self):
901 """Pin xai + cookies + XAI_API_KEY -> source is xai, not bird."""
902 config = {
903 "LAST30DAYS_X_BACKEND": "xai",
904 "AUTH_TOKEN": "dummy-token",
905 "CT0": "dummy-ct0",
906 "XAI_API_KEY": "dummy-key",
907 }
908 bird_status = {
909 "installed": True,
910 "authenticated": True,
911 "username": "test",
912 "can_install": True,
913 }
914 with (
915 mock.patch("lib.grok_x.has_stored_auth", return_value=False),
916 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
917 ):
918 status = env.get_x_source_status(config, probe=False)
919 # Pin is exclusive: xai pinned + available -> xai (not bird)
920 assert status["source"] == "xai"
921 # Bird is also available, but pin forces xai
922 assert status["bird_authenticated"] is True
923
924 def test_pin_xai_no_key_with_cookies_returns_none(self):
925 """Pin xai + no XAI_API_KEY + cookies -> source is None (exclusive pin)."""
926 config = {
927 "LAST30DAYS_X_BACKEND": "xai",
928 "AUTH_TOKEN": "dummy-token",
929 "CT0": "dummy-ct0",
930 # No XAI_API_KEY
931 }
932 bird_status = {
933 "installed": True,
934 "authenticated": True,
935 "username": "test",
936 "can_install": True,
937 }
938 with (
939 mock.patch("lib.grok_x.has_stored_auth", return_value=False),
940 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
941 ):
942 status = env.get_x_source_status(config, probe=False)
943 # Pin is exclusive: xai pinned but unavailable -> None (no fallback)
944 assert status["source"] is None
945 assert status["xai_available"] is False
946 # Bird is available but pin blocks fallback
947 assert status["bird_authenticated"] is True
948
949
950 # ---------------------------------------------------------------------------
951 # Runtime X backend pin (x_backend_chain / _resolve_x_backend)
952 # ---------------------------------------------------------------------------
953
954 class TestRuntimeXBackendPin:
955 """Runtime fetch path must honor any known pin exclusively, including grok."""
956
957 def test_pin_grok_with_store_and_cookies_returns_grok(self):
958 """Pin grok + grok store + cookies -> runtime returns grok, not bird."""
959 from lib import grok_x, providers
960
961 config = {
962 "LAST30DAYS_X_BACKEND": "grok",
963 "AUTH_TOKEN": "dummy-token",
964 "CT0": "dummy-ct0",
965 "XAI_API_KEY": "dummy-key",
966 }
967 with (
968 mock.patch.object(grok_x, "has_stored_auth", return_value=True),
969 mock.patch("lib.bird_x.is_bird_installed", return_value=True),
970 ):
971 # x_backend_chain is the authoritative runtime path
972 chain = env.x_backend_chain(config)
973 # _resolve_x_backend delegates to get_x_source (wraps x_backend_chain)
974 resolved = providers._resolve_x_backend(config)
975 # Pin grok + available -> grok (not bird/xai)
976 assert chain == ["grok"]
977 assert resolved == "grok"
978
979 def test_pin_grok_no_store_with_cookies_returns_none(self):
980 """Pin grok + no store + cookies -> runtime returns None (exclusive pin)."""
981 from lib import grok_x, providers
982
983 config = {
984 "LAST30DAYS_X_BACKEND": "grok",
985 "AUTH_TOKEN": "dummy-token",
986 "CT0": "dummy-ct0",
987 }
988 with (
989 mock.patch.object(grok_x, "has_stored_auth", return_value=False),
990 mock.patch("lib.bird_x.is_bird_installed", return_value=True),
991 ):
992 chain = env.x_backend_chain(config)
993 resolved = providers._resolve_x_backend(config)
994 # Pin grok + unavailable -> [] / None (no fallthrough to bird)
995 assert chain == []
996 assert resolved is None
997
998 def test_unpinned_with_grok_store_and_cookies_returns_bird(self):
999 """Unpinned + grok store + cookies -> runtime returns bird, never grok."""
1000 from lib import grok_x, providers
1001
1002 config = {
1003 "AUTH_TOKEN": "dummy-token",
1004 "CT0": "dummy-ct0",
1005 }
1006 with (
1007 mock.patch.object(grok_x, "has_stored_auth", return_value=True),
1008 mock.patch("lib.bird_x.is_bird_installed", return_value=True),
1009 ):
1010 chain = env.x_backend_chain(config)
1011 resolved = providers._resolve_x_backend(config)
1012 # Unpinned -> auto-chain (bird first), grok never auto-selected
1013 assert chain[0] == "bird"
1014 assert "grok" not in chain
1015 assert resolved == "bird"
1016
1017
1018 # ---------------------------------------------------------------------------
1019 # YouTube chain: yt-dlp -> ScrapeCreators
1020 # ---------------------------------------------------------------------------
1021
1022 class TestYouTubeChain:
1023 def test_ytdlp_healthy_wins(self):
1024 with mock.patch("lib.health.probe_dependency", _probe_dep()):
1025 res = backends.resolve("youtube", {"SCRAPECREATORS_API_KEY": "dummy-key"})
1026 assert res.active_backend == "yt-dlp"
1027 assert res.tier == backends.TIER_OK
1028
1029 def test_missing_ytdlp_falls_back_to_sc_key(self):
1030 with mock.patch(
1031 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
1032 ):
1033 res = backends.resolve("youtube", {"SCRAPECREATORS_API_KEY": "dummy-key"})
1034 assert res.active_backend == "scrapecreators"
1035 assert res.tier == backends.TIER_OK
1036
1037 def test_neither_available_error_carries_ytdlp_prescription(self):
1038 with mock.patch(
1039 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
1040 ):
1041 res = backends.resolve("youtube", {})
1042 assert res.active_backend is None
1043 assert res.tier == backends.TIER_ERROR
1044 assert "yt-dlp" in res.prescription
1045
1046
1047 # ---------------------------------------------------------------------------
1048 # Web search chain: brave -> exa -> serper -> parallel -> keyless floor
1049 # ---------------------------------------------------------------------------
1050
1051 class TestWebChain:
1052 def test_brave_key_predicted_first(self):
1053 res = backends.resolve(
1054 "web", {"BRAVE_API_KEY": "dummy-key", "EXA_API_KEY": "dummy-key"},
1055 )
1056 assert res.active_backend == "brave"
1057 assert res.tier == backends.TIER_OK
1058
1059 def test_keyless_floor_is_degraded_warn(self):
1060 res = backends.resolve("web", {})
1061 assert res.active_backend == "keyless"
1062 assert res.tier == backends.TIER_WARN
1063
1064 def test_native_search_suppresses_keyless_floor(self):
1065 res = backends.resolve("web", {"LAST30DAYS_NATIVE_SEARCH": "1"})
1066 keyless = next(f for f in res.findings if f.name == "keyless")
1067 assert not keyless.usable
1068 assert res.active_backend is None
1069
1070 def test_pin_via_web_backend_flag(self):
1071 res = backends.resolve(
1072 "web", {"BRAVE_API_KEY": "dummy-key", "EXA_API_KEY": "dummy-key"}, pin="exa",
1073 )
1074 assert res.active_backend == "exa"
1075 assert res.pinned is True
1076 assert "pinned" in res.summary
1077
1078 def test_parity_with_grounding_auto_dispatch(self):
1079 """resolve('web').active_backend must match the backend grounding's
1080 auto branch actually dispatches to, per config permutation."""
1081 from lib import grounding
1082
1083 def _auto_pick(config):
1084 picked = {}
1085
1086 def rec(label):
1087 def f(query, date_range, key, count=5):
1088 picked["backend"] = label
1089 return [], {"label": label}
1090 return f
1091
1092 with mock.patch.object(grounding, "brave_search", rec("brave")), \
1093 mock.patch.object(grounding, "exa_search", rec("exa")), \
1094 mock.patch.object(grounding, "serper_search", rec("serper")), \
1095 mock.patch.object(grounding, "parallel_search", rec("parallel")), \
1096 mock.patch(
1097 "lib.web_search_keyless.keyless_search",
1098 lambda q, dr, cfg: (picked.__setitem__("backend", "keyless") or ([], {})),
1099 ):
1100 grounding.web_search("q", ("2026-06-04", "2026-07-04"), config, backend="auto")
1101 return picked.get("backend")
1102
1103 for config in (
1104 {"BRAVE_API_KEY": "dummy-key"},
1105 {"SERPER_API_KEY": "dummy-key"},
1106 {},
1107 ):
1108 assert backends.resolve("web", config).active_backend == _auto_pick(config)
1109
1110
1111 # ---------------------------------------------------------------------------
1112 # Rendering: prediction reads as will-use, never as past observation
1113 # ---------------------------------------------------------------------------
1114
1115 class TestSummaryWording:
1116 def test_alternative_summary_is_will_use(self):
1117 res = backends.resolve("web", {"BRAVE_API_KEY": "dummy-key"})
1118 assert res.summary.startswith("will use: brave")
1119 assert "used" not in res.summary.split("will use")[1]
1120
1121 def test_error_summary_names_no_backend(self):
1122 with mock.patch(
1123 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
1124 ):
1125 res = backends.resolve("youtube", {})
1126 assert "will use" not in res.summary
1127 assert "no usable backend" in res.summary.lower()
1128
1129 def test_conditional_summary_is_the_conditional_wording(self):
1130 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
1131 assert res.summary == res.conditional
1132
1132 lines PYTHON