返回 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 ("rss", "listing", "arctic", "shreddit"):
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_zero_means_empty_only_wording(self):
634 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
635 assert "nothing" in res.conditional.lower()
636
637 def test_malformed_floor_treated_as_default(self):
638 res = backends.resolve(
639 "reddit",
640 {"SCRAPECREATORS_API_KEY": "dummy-key", "LAST30DAYS_REDDIT_SC_MIN_ITEMS": "lots"},
641 )
642 assert "nothing" in res.conditional.lower()
643
644 def test_pinned_scrapecreators_renders_pin(self):
645 res = backends.resolve(
646 "reddit",
647 {
648 "SCRAPECREATORS_API_KEY": "dummy-key",
649 "LAST30DAYS_REDDIT_BACKEND": "scrapecreators",
650 },
651 )
652 assert res.pinned is True
653 assert res.pin == "scrapecreators"
654 low = res.conditional.lower()
655 assert "pinned" in low
656 assert "primary" in low
657 assert res.active_backend is None # still conditional, not a winner
658
659 def test_no_key_means_no_backfill_wording(self):
660 res = backends.resolve("reddit", {})
661 low = res.conditional.lower()
662 assert "public keyless" in low
663 assert "backfill" not in low or "no scrapecreators" in low
664 assert res.tier == backends.TIER_OK # public composite always reachable
665
666 def test_pin_without_key_is_ignored_like_the_pipeline(self):
667 # pipeline gates sc_first on has_sc_key; the pin alone changes nothing.
668 res = backends.resolve(
669 "reddit", {"LAST30DAYS_REDDIT_BACKEND": "scrapecreators"},
670 )
671 assert res.pinned is False
672 assert "primary" not in res.conditional.lower().split("pin ignored")[0]
673
674 def test_keyless_lanes_are_sub_probe_detail(self):
675 res = backends.resolve("reddit", {})
676 public = next(f for f in res.findings if f.name == "public")
677 for lane in ("rss", "listing", "arctic", "shreddit"):
678 assert lane in public.detail
679
680
681 # ---------------------------------------------------------------------------
682 # Scenario 7: parity with the pipeline's pre-failover X selection
683 # ---------------------------------------------------------------------------
684
685 class TestXParityWithPipeline:
686 """Descriptor prediction must equal env.x_backend_chain(config)[0] — the
687 exact expression pipeline._retrieve_stream uses as its pre-failover
688 primary (lib/pipeline.py, `chain = env.x_backend_chain(config)`)."""
689
690 def _assert_parity(self, config, **envkw):
691 with _stack(_x_env(**envkw)):
692 chain = env.x_backend_chain(config)
693 predicted = backends.resolve("x", config).active_backend
694 expected = chain[0] if chain else None
695 assert predicted == expected, (
696 f"prediction {predicted!r} != pipeline pre-failover {expected!r} "
697 f"for config keys {sorted(config)}"
698 )
699
700 def test_parity_xai_key_only(self):
701 self._assert_parity({"XAI_API_KEY": "dummy-key"})
702
703 def test_parity_cookies_and_bird_installed(self):
704 self._assert_parity(
705 {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
706 bird_installed=True,
707 )
708
709 def test_parity_pin_forces_xquik(self):
710 self._assert_parity(
711 {"XQUIK_API_KEY": "dummy-key", "LAST30DAYS_X_BACKEND": "xquik"},
712 )
713
714 def test_parity_nothing_configured(self):
715 self._assert_parity({})
716
717 def test_parity_grok_only_unpinned_is_unconfigured(self):
718 """Grok-only with no pin: X unconfigured (parity with env.x_backend_chain)."""
719 self._assert_parity({}, grok_installed=True, grok_authed=True)
720
721 def test_parity_grok_pinned(self):
722 """Grok pinned: grok is selected (parity with env.x_backend_chain)."""
723 self._assert_parity(
724 {"LAST30DAYS_X_BACKEND": "grok"},
725 grok_installed=True,
726 grok_authed=True,
727 )
728
729 def test_parity_cookies_beat_xai_key(self):
730 """Cookies beat XAI_API_KEY when both present (bird-first chain)."""
731 self._assert_parity(
732 {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0", "XAI_API_KEY": "dummy-key"},
733 bird_installed=True,
734 )
735
736 # Host rows (U1): the policy shapes both sides identically.
737
738 def test_parity_grok_bot_bearer(self):
739 self._assert_parity(
740 {"LAST30DAYS_HOST": "grok-bot", "X_BEARER_TOKEN": "dummy-bearer",
741 "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
742 bird_installed=True,
743 )
744
745 def test_parity_grok_bot_xai_key(self):
746 self._assert_parity(
747 {"LAST30DAYS_HOST": "grok-bot", "XAI_API_KEY": "dummy-key",
748 "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
749 bird_installed=True,
750 )
751
752 def test_parity_grok_bot_cookies_only_unpinned(self):
753 self._assert_parity(
754 {"LAST30DAYS_HOST": "grok-bot", "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0",
755 "XQUIK_API_KEY": "dummy-key"},
756 bird_installed=True,
757 )
758
759 def test_parity_grok_bot_bird_pin(self):
760 self._assert_parity(
761 {"LAST30DAYS_HOST": "grok-bot", "LAST30DAYS_X_BACKEND": "bird",
762 "AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0"},
763 bird_installed=True,
764 )
765
766 def test_parity_grok_bot_grok_pin(self):
767 self._assert_parity(
768 {"LAST30DAYS_HOST": "grok-bot", "LAST30DAYS_X_BACKEND": "grok"},
769 grok_installed=True,
770 grok_authed=True,
771 )
772
773 def test_parity_non_grok_linux_cookies_and_bearer(self):
774 with mock.patch("platform.system", return_value="Linux"):
775 self._assert_parity(
776 {"AUTH_TOKEN": "dummy-token", "CT0": "dummy-ct0",
777 "X_BEARER_TOKEN": "dummy-bearer"},
778 bird_installed=True,
779 )
780
781 def test_parity_macbook_xai_key(self):
782 with (
783 mock.patch("platform.system", return_value="Darwin"),
784 mock.patch.object(env, "_mac_model", return_value="MacBookPro18,2"),
785 ):
786 self._assert_parity({"XAI_API_KEY": "dummy-key"})
787
788
789 # ---------------------------------------------------------------------------
790 # get_x_source_status pin semantics (R4): grok pin forces grok source
791 # ---------------------------------------------------------------------------
792
793 class TestGetXSourceStatusGrokPin:
794 """get_x_source_status must respect LAST30DAYS_X_BACKEND=grok pin."""
795
796 def test_pin_grok_with_store_returns_grok_source(self):
797 """Pin grok + valid store -> get_x_source_status source is 'grok'."""
798 config = {"LAST30DAYS_X_BACKEND": "grok"}
799 bird_status = {
800 "installed": False,
801 "authenticated": False,
802 "username": "",
803 "can_install": False,
804 }
805 with (
806 mock.patch("lib.grok_x.has_stored_auth", return_value=True),
807 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
808 ):
809 status = env.get_x_source_status(config, probe=False)
810 assert status["source"] == "grok"
811 assert status["grok_available"] is True
812
813 def test_unpinned_with_store_does_not_return_grok_source(self):
814 """Unpinned + valid grok store -> source is NOT 'grok' (opt-in only)."""
815 config = {} # No pin
816 bird_status = {
817 "installed": False,
818 "authenticated": False,
819 "username": "",
820 "can_install": False,
821 }
822 with (
823 mock.patch("lib.grok_x.has_stored_auth", return_value=True),
824 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
825 ):
826 status = env.get_x_source_status(config, probe=False)
827 # Grok is available but NOT the source (opt-in only)
828 assert status["source"] != "grok"
829 assert status["source"] is None # No other backend configured
830 assert status["grok_available"] is True
831
832 def test_pin_grok_with_cookies_still_returns_grok(self):
833 """Pin grok with cookies present -> grok (pin forces single backend)."""
834 config = {
835 "LAST30DAYS_X_BACKEND": "grok",
836 "AUTH_TOKEN": "dummy-token",
837 "CT0": "dummy-ct0",
838 }
839 bird_status = {
840 "installed": True,
841 "authenticated": True,
842 "username": "test",
843 "can_install": True,
844 }
845 with (
846 mock.patch("lib.grok_x.has_stored_auth", return_value=True),
847 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
848 ):
849 status = env.get_x_source_status(config, probe=False)
850 # Pin forces grok even when bird is available
851 assert status["source"] == "grok"
852
853 def test_pin_grok_no_store_with_other_creds_returns_none(self):
854 """Pin grok + no store + other creds present -> source is None (exclusive pin)."""
855 config = {
856 "LAST30DAYS_X_BACKEND": "grok",
857 "AUTH_TOKEN": "dummy-token",
858 "CT0": "dummy-ct0",
859 "XAI_API_KEY": "dummy-key",
860 }
861 bird_status = {
862 "installed": True,
863 "authenticated": True,
864 "username": "test",
865 "can_install": True,
866 }
867 with (
868 mock.patch("lib.grok_x.has_stored_auth", return_value=False),
869 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
870 ):
871 status = env.get_x_source_status(config, probe=False)
872 # Pin is exclusive: grok unavailable -> None, NOT fallback to bird/xai
873 assert status["source"] is None
874 assert status["grok_available"] is False
875 # Other backends ARE available, but pin blocks fallback
876 assert status["bird_authenticated"] is True
877 assert status["xai_available"] is True
878
879 def test_pin_xai_with_cookies_returns_xai(self):
880 """Pin xai + cookies + XAI_API_KEY -> source is xai, not bird."""
881 config = {
882 "LAST30DAYS_X_BACKEND": "xai",
883 "AUTH_TOKEN": "dummy-token",
884 "CT0": "dummy-ct0",
885 "XAI_API_KEY": "dummy-key",
886 }
887 bird_status = {
888 "installed": True,
889 "authenticated": True,
890 "username": "test",
891 "can_install": True,
892 }
893 with (
894 mock.patch("lib.grok_x.has_stored_auth", return_value=False),
895 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
896 ):
897 status = env.get_x_source_status(config, probe=False)
898 # Pin is exclusive: xai pinned + available -> xai (not bird)
899 assert status["source"] == "xai"
900 # Bird is also available, but pin forces xai
901 assert status["bird_authenticated"] is True
902
903 def test_pin_xai_no_key_with_cookies_returns_none(self):
904 """Pin xai + no XAI_API_KEY + cookies -> source is None (exclusive pin)."""
905 config = {
906 "LAST30DAYS_X_BACKEND": "xai",
907 "AUTH_TOKEN": "dummy-token",
908 "CT0": "dummy-ct0",
909 # No XAI_API_KEY
910 }
911 bird_status = {
912 "installed": True,
913 "authenticated": True,
914 "username": "test",
915 "can_install": True,
916 }
917 with (
918 mock.patch("lib.grok_x.has_stored_auth", return_value=False),
919 mock.patch("lib.bird_x.get_bird_status", return_value=bird_status),
920 ):
921 status = env.get_x_source_status(config, probe=False)
922 # Pin is exclusive: xai pinned but unavailable -> None (no fallback)
923 assert status["source"] is None
924 assert status["xai_available"] is False
925 # Bird is available but pin blocks fallback
926 assert status["bird_authenticated"] is True
927
928
929 # ---------------------------------------------------------------------------
930 # Runtime X backend pin (x_backend_chain / _resolve_x_backend)
931 # ---------------------------------------------------------------------------
932
933 class TestRuntimeXBackendPin:
934 """Runtime fetch path must honor any known pin exclusively, including grok."""
935
936 def test_pin_grok_with_store_and_cookies_returns_grok(self):
937 """Pin grok + grok store + cookies -> runtime returns grok, not bird."""
938 from lib import grok_x, providers
939
940 config = {
941 "LAST30DAYS_X_BACKEND": "grok",
942 "AUTH_TOKEN": "dummy-token",
943 "CT0": "dummy-ct0",
944 "XAI_API_KEY": "dummy-key",
945 }
946 with (
947 mock.patch.object(grok_x, "has_stored_auth", return_value=True),
948 mock.patch("lib.bird_x.is_bird_installed", return_value=True),
949 ):
950 # x_backend_chain is the authoritative runtime path
951 chain = env.x_backend_chain(config)
952 # _resolve_x_backend delegates to get_x_source (wraps x_backend_chain)
953 resolved = providers._resolve_x_backend(config)
954 # Pin grok + available -> grok (not bird/xai)
955 assert chain == ["grok"]
956 assert resolved == "grok"
957
958 def test_pin_grok_no_store_with_cookies_returns_none(self):
959 """Pin grok + no store + cookies -> runtime returns None (exclusive pin)."""
960 from lib import grok_x, providers
961
962 config = {
963 "LAST30DAYS_X_BACKEND": "grok",
964 "AUTH_TOKEN": "dummy-token",
965 "CT0": "dummy-ct0",
966 }
967 with (
968 mock.patch.object(grok_x, "has_stored_auth", return_value=False),
969 mock.patch("lib.bird_x.is_bird_installed", return_value=True),
970 ):
971 chain = env.x_backend_chain(config)
972 resolved = providers._resolve_x_backend(config)
973 # Pin grok + unavailable -> [] / None (no fallthrough to bird)
974 assert chain == []
975 assert resolved is None
976
977 def test_unpinned_with_grok_store_and_cookies_returns_bird(self):
978 """Unpinned + grok store + cookies -> runtime returns bird, never grok."""
979 from lib import grok_x, providers
980
981 config = {
982 "AUTH_TOKEN": "dummy-token",
983 "CT0": "dummy-ct0",
984 }
985 with (
986 mock.patch.object(grok_x, "has_stored_auth", return_value=True),
987 mock.patch("lib.bird_x.is_bird_installed", return_value=True),
988 ):
989 chain = env.x_backend_chain(config)
990 resolved = providers._resolve_x_backend(config)
991 # Unpinned -> auto-chain (bird first), grok never auto-selected
992 assert chain[0] == "bird"
993 assert "grok" not in chain
994 assert resolved == "bird"
995
996
997 # ---------------------------------------------------------------------------
998 # YouTube chain: yt-dlp -> ScrapeCreators
999 # ---------------------------------------------------------------------------
1000
1001 class TestYouTubeChain:
1002 def test_ytdlp_healthy_wins(self):
1003 with mock.patch("lib.health.probe_dependency", _probe_dep()):
1004 res = backends.resolve("youtube", {"SCRAPECREATORS_API_KEY": "dummy-key"})
1005 assert res.active_backend == "yt-dlp"
1006 assert res.tier == backends.TIER_OK
1007
1008 def test_missing_ytdlp_falls_back_to_sc_key(self):
1009 with mock.patch(
1010 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
1011 ):
1012 res = backends.resolve("youtube", {"SCRAPECREATORS_API_KEY": "dummy-key"})
1013 assert res.active_backend == "scrapecreators"
1014 assert res.tier == backends.TIER_OK
1015
1016 def test_neither_available_error_carries_ytdlp_prescription(self):
1017 with mock.patch(
1018 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
1019 ):
1020 res = backends.resolve("youtube", {})
1021 assert res.active_backend is None
1022 assert res.tier == backends.TIER_ERROR
1023 assert "yt-dlp" in res.prescription
1024
1025
1026 # ---------------------------------------------------------------------------
1027 # Web search chain: brave -> exa -> serper -> parallel -> keyless floor
1028 # ---------------------------------------------------------------------------
1029
1030 class TestWebChain:
1031 def test_brave_key_predicted_first(self):
1032 res = backends.resolve(
1033 "web", {"BRAVE_API_KEY": "dummy-key", "EXA_API_KEY": "dummy-key"},
1034 )
1035 assert res.active_backend == "brave"
1036 assert res.tier == backends.TIER_OK
1037
1038 def test_keyless_floor_is_degraded_warn(self):
1039 res = backends.resolve("web", {})
1040 assert res.active_backend == "keyless"
1041 assert res.tier == backends.TIER_WARN
1042
1043 def test_native_search_suppresses_keyless_floor(self):
1044 res = backends.resolve("web", {"LAST30DAYS_NATIVE_SEARCH": "1"})
1045 keyless = next(f for f in res.findings if f.name == "keyless")
1046 assert not keyless.usable
1047 assert res.active_backend is None
1048
1049 def test_pin_via_web_backend_flag(self):
1050 res = backends.resolve(
1051 "web", {"BRAVE_API_KEY": "dummy-key", "EXA_API_KEY": "dummy-key"}, pin="exa",
1052 )
1053 assert res.active_backend == "exa"
1054 assert res.pinned is True
1055 assert "pinned" in res.summary
1056
1057 def test_parity_with_grounding_auto_dispatch(self):
1058 """resolve('web').active_backend must match the backend grounding's
1059 auto branch actually dispatches to, per config permutation."""
1060 from lib import grounding
1061
1062 def _auto_pick(config):
1063 picked = {}
1064
1065 def rec(label):
1066 def f(query, date_range, key, count=5):
1067 picked["backend"] = label
1068 return [], {"label": label}
1069 return f
1070
1071 with mock.patch.object(grounding, "brave_search", rec("brave")), \
1072 mock.patch.object(grounding, "exa_search", rec("exa")), \
1073 mock.patch.object(grounding, "serper_search", rec("serper")), \
1074 mock.patch.object(grounding, "parallel_search", rec("parallel")), \
1075 mock.patch(
1076 "lib.web_search_keyless.keyless_search",
1077 lambda q, dr, cfg: (picked.__setitem__("backend", "keyless") or ([], {})),
1078 ):
1079 grounding.web_search("q", ("2026-06-04", "2026-07-04"), config, backend="auto")
1080 return picked.get("backend")
1081
1082 for config in (
1083 {"BRAVE_API_KEY": "dummy-key"},
1084 {"SERPER_API_KEY": "dummy-key"},
1085 {},
1086 ):
1087 assert backends.resolve("web", config).active_backend == _auto_pick(config)
1088
1089
1090 # ---------------------------------------------------------------------------
1091 # Rendering: prediction reads as will-use, never as past observation
1092 # ---------------------------------------------------------------------------
1093
1094 class TestSummaryWording:
1095 def test_alternative_summary_is_will_use(self):
1096 res = backends.resolve("web", {"BRAVE_API_KEY": "dummy-key"})
1097 assert res.summary.startswith("will use: brave")
1098 assert "used" not in res.summary.split("will use")[1]
1099
1100 def test_error_summary_names_no_backend(self):
1101 with mock.patch(
1102 "lib.health.probe_dependency", _probe_dep({"yt-dlp": health.MISSING}),
1103 ):
1104 res = backends.resolve("youtube", {})
1105 assert "will use" not in res.summary
1106 assert "no usable backend" in res.summary.lower()
1107
1108 def test_conditional_summary_is_the_conditional_wording(self):
1109 res = backends.resolve("reddit", {"SCRAPECREATORS_API_KEY": "dummy-key"})
1110 assert res.summary == res.conditional
1111
1111 lines PYTHON