返回 last30days-skill
test_quality_nudge.py
根目录 / tests / test_quality_nudge.py
1 """Tests for post-research quality score and upgrade nudge.
2
3 Reddit is always a core source (free public JSON). X remains supported when
4 active, but its absence is optional and must not lower the quality grade or
5 trigger an authentication nudge.
6 ScrapeCreators adds TikTok + Instagram as bonus sources, not core.
7 """
8
9 import pytest
10 from unittest.mock import patch
11
12 # ---------------------------------------------------------------------------
13 # Helpers
14 # ---------------------------------------------------------------------------
15
16
17 def _base_config(**overrides):
18 """Return a minimal config dict."""
19 config = {
20 "AUTH_TOKEN": None,
21 "CT0": None,
22 "XAI_API_KEY": None,
23 "XQUIK_API_KEY": None,
24 "SCRAPECREATORS_API_KEY": None,
25 }
26 config.update(overrides)
27 return config
28
29
30 def _base_results(**overrides):
31 """Return a minimal research_results dict with no errors."""
32 results = {
33 "x_error": None,
34 "youtube_error": None,
35 "reddit_error": None,
36 }
37 results.update(overrides)
38 return results
39
40
41 def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False):
42 """Helper to call compute_quality_score with mocked yt-dlp check."""
43 from lib.quality_nudge import compute_quality_score
44 from lib import youtube_yt
45
46 config = _base_config(**(config_overrides or {}))
47 results = _base_results(**(result_overrides or {}))
48
49 with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed):
50 return compute_quality_score(config, results)
51
52 # ---------------------------------------------------------------------------
53 # Tests
54 # ---------------------------------------------------------------------------
55
56
57 class TestBaseline:
58 """HN + Polymarket + Reddit active; X omitted and YouTube missing."""
59
60 def test_score_75(self):
61 q = _compute()
62 assert q["score_pct"] == 75
63
64 def test_active_sources(self):
65 q = _compute()
66 assert "hn" in q["core_active"]
67 assert "polymarket" in q["core_active"]
68 assert "reddit" in q["core_active"]
69 assert len(q["core_active"]) == 3
70
71 def test_only_youtube_is_missing(self):
72 q = _compute()
73 assert q["core_missing"] == ["youtube"]
74 assert "x" not in q["core_active"]
75
76 def test_reddit_not_in_missing(self):
77 """Reddit is always active - never appears in missing."""
78 q = _compute()
79 assert "reddit" not in q["core_missing"]
80 assert "reddit_comments" not in q["core_missing"]
81
82 def test_nudge_mentions_youtube_not_x(self):
83 q = _compute()
84 assert q["nudge_text"] is not None
85 assert "YouTube" in q["nudge_text"]
86 assert "X/Twitter" not in q["nudge_text"]
87
88 def test_nudge_does_not_mention_reddit(self):
89 """Reddit is free - nudge should not tell user to get SC for it."""
90 q = _compute()
91 assert "Reddit with comments" not in q["nudge_text"]
92
93
94 class TestXCookies:
95 """+X cookies -> 80%."""
96
97 def test_score_80(self):
98 q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
99 assert q["score_pct"] == 80
100
101 def test_nudge_mentions_yt_only(self):
102 q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
103 assert "YouTube" in q["nudge_text"]
104 assert "X/Twitter" not in q["nudge_text"]
105
106 def test_x_remains_active_when_configured(self):
107 q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
108 assert "x" in q["core_active"]
109
110
111 class TestXquikKey:
112 """+Xquik key -> 80% without browser-cookie or xAI credentials."""
113
114 def test_score_80(self):
115 q = _compute(config_overrides={"XQUIK_API_KEY": "xq_test"})
116 assert q["score_pct"] == 80
117 assert "x" in q["core_active"]
118
119 def test_nudge_mentions_yt_only(self):
120 q = _compute(config_overrides={"XQUIK_API_KEY": "xq_test"})
121 assert "YouTube" in q["nudge_text"]
122 assert "X/Twitter" not in q["nudge_text"]
123
124
125 class TestActiveSourceX:
126 """The runtime active-source list preserves X without legacy credentials."""
127
128 def test_active_x_is_counted(self):
129 q = _compute(result_overrides={"active_sources": ["reddit", "x", "youtube"]})
130 assert "x" in q["core_active"]
131
132
133 class TestConfiguredXErrored:
134 """A configured X that errored is a real outage: docked and surfaced,
135 never disguised as an optional omission."""
136
137 def test_errored_x_docks_the_score(self):
138 q = _compute(
139 config_overrides={"AUTH_TOKEN": "tok123"},
140 result_overrides={"x_error": "401 unauthorized"},
141 ytdlp_installed=True,
142 )
143 assert q["score_pct"] == 80 # 4/5 - X stays in the denominator
144 assert q["core_missing"] == ["x"]
145 assert q["core_errored"] == ["x"]
146
147 def test_errored_x_nudge_surfaces_the_repair(self):
148 q = _compute(
149 config_overrides={"AUTH_TOKEN": "tok123"},
150 result_overrides={"x_error": "401 unauthorized"},
151 ytdlp_installed=True,
152 )
153 assert q["nudge_text"] is not None
154 assert "X/Twitter (errored this run)" in q["nudge_text"]
155
156 def test_runtime_active_x_that_errored_is_also_docked(self):
157 q = _compute(
158 result_overrides={
159 "active_sources": ["reddit", "x", "youtube"],
160 "x_error": "429 rate limited",
161 },
162 ytdlp_installed=True,
163 )
164 assert q["core_errored"] == ["x"]
165 assert q["score_pct"] == 80
166
167
168 class TestXPlusYtdlp:
169 """+X + yt-dlp -> 100%. No SC needed for full core coverage."""
170
171 def test_score_100(self):
172 q = _compute(
173 config_overrides={"AUTH_TOKEN": "tok123"},
174 ytdlp_installed=True,
175 )
176 assert q["score_pct"] == 100
177
178 def test_nudge_is_none(self):
179 """Full core coverage with zero paid keys."""
180 q = _compute(
181 config_overrides={"AUTH_TOKEN": "tok123"},
182 ytdlp_installed=True,
183 )
184 assert q["nudge_text"] is None
185
186
187 class TestFullCoverageWithSC:
188 """+X + yt-dlp + SC -> still 100%, SC adds bonus sources."""
189
190 def test_score_100(self):
191 q = _compute(
192 config_overrides={
193 "AUTH_TOKEN": "tok123",
194 "SCRAPECREATORS_API_KEY": "sc_key",
195 },
196 ytdlp_installed=True,
197 )
198 assert q["score_pct"] == 100
199
200 def test_nudge_is_none(self):
201 q = _compute(
202 config_overrides={
203 "AUTH_TOKEN": "tok123",
204 "SCRAPECREATORS_API_KEY": "sc_key",
205 },
206 ytdlp_installed=True,
207 )
208 assert q["nudge_text"] is None
209
210
211 class TestSCDoesNotAffectCoreScore:
212 """SC key should not change core score - it only adds bonus sources."""
213
214 def test_sc_alone_still_75(self):
215 """SC key without yt-dlp is still 75% of non-optional core."""
216 q = _compute(config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"})
217 assert q["score_pct"] == 75
218
219 def test_sc_plus_ytdlp_is_100(self):
220 q = _compute(
221 config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
222 ytdlp_installed=True,
223 )
224 assert q["score_pct"] == 100
225
226 def test_no_x_cookie_nudge_after_complete_available_source_run(self):
227 q = _compute(
228 config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
229 ytdlp_installed=True,
230 )
231 assert q["nudge_text"] is None
232
233
234 class TestYouTubeFallbackProvider:
235 """YouTube data from fallback/provider paths is degraded, not missing."""
236
237 def test_fallback_youtube_data_without_ytdlp_counts_active_not_missing(self):
238 q = _compute(
239 config_overrides={
240 "AUTH_TOKEN": "tok123",
241 "SCRAPECREATORS_API_KEY": "sc_key",
242 },
243 ytdlp_installed=False,
244 result_overrides={
245 "youtube_videos_count": 5,
246 "youtube_transcripts_count": 4,
247 },
248 )
249 assert q["score_pct"] == 100
250 assert "youtube" in q["core_active"]
251 assert "youtube" not in q["core_missing"]
252 assert "youtube" in q["core_degraded"]
253 assert q["nudge_text"] is not None
254 assert "Missing: YouTube" not in q["nudge_text"]
255 assert "Degraded: YouTube" in q["nudge_text"]
256 assert "fallback/provider" in q["nudge_text"]
257 assert "local yt-dlp is not installed" in q["nudge_text"]
258 assert "stale yt-dlp" not in q["nudge_text"].lower()
259
260 def test_ytdlp_install_check_runs_once_per_score(self):
261 from lib.quality_nudge import compute_quality_score
262 from lib import youtube_yt
263
264 with patch.object(youtube_yt, "is_ytdlp_installed", return_value=False) as ytdlp_check:
265 compute_quality_score(
266 _base_config(AUTH_TOKEN="tok123"),
267 _base_results(
268 youtube_videos_count=5,
269 youtube_transcripts_count=4,
270 ),
271 )
272
273 ytdlp_check.assert_called_once()
274
275 def test_no_fallback_data_without_ytdlp_still_missing(self):
276 q = _compute(
277 config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
278 ytdlp_installed=False,
279 result_overrides={
280 "youtube_videos_count": 0,
281 "youtube_transcripts_count": 0,
282 },
283 )
284 assert "youtube" in q["core_missing"]
285 assert "youtube" not in q["core_active"]
286 assert "youtube" not in q["core_degraded"]
287 assert "Missing: YouTube" in q["nudge_text"]
288 assert "X/Twitter" not in q["nudge_text"]
289
290
291 class TestDisclaimerAlwaysPresent:
292 """Nudge always includes no-affiliate disclaimer when present."""
293
294 def test_disclaimer_baseline(self):
295 q = _compute()
296 assert "no affiliation" in q["nudge_text"]
297
298 def test_disclaimer_partial(self):
299 q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
300 assert "no affiliation" in q["nudge_text"]
301
302 def test_disclaimer_not_present_at_100(self):
303 q = _compute(
304 config_overrides={"AUTH_TOKEN": "tok123"},
305 ytdlp_installed=True,
306 )
307 assert q["nudge_text"] is None
308
309
310 class TestRedditNeverInCoreErrored:
311 """Reddit errors don't affect core score since it's always-active via public path."""
312
313 def test_reddit_error_does_not_affect_score(self):
314 q = _compute(
315 config_overrides={"AUTH_TOKEN": "tok123"},
316 result_overrides={"reddit_error": "429 Too Many Requests"},
317 ytdlp_installed=True,
318 )
319 # Reddit is always-active in core (public path), error doesn't demote it
320 assert "reddit" in q["core_active"]
321 assert q["score_pct"] == 100
322
323
324 class TestYouTubeDegraded:
325 """YouTube is `degraded` when videos returned but transcripts below threshold.
326
327 Canonical failure mode: a stale yt-dlp binary still finds videos via search
328 but silently fails every transcript fetch because YouTube's caption format
329 has moved on. Pre-fix the user got no signal of this; the footer hid zero,
330 and quality_nudge only checked top-level errors.
331 """
332
333 def test_zero_of_six_transcripts_flags_degraded(self):
334 q = _compute(
335 ytdlp_installed=True,
336 result_overrides={
337 "youtube_videos_count": 6,
338 "youtube_transcripts_count": 0,
339 },
340 )
341 assert "youtube" in q["core_degraded"]
342 assert q["nudge_text"] is not None
343 # Counts surface in the message so the user sees the actual ratio
344 assert "6 videos" in q["nudge_text"]
345 assert "0 transcripts" in q["nudge_text"]
346 assert "stale yt-dlp" in q["nudge_text"].lower()
347 # Updates path mentions all three common package managers
348 assert "scoop" in q["nudge_text"].lower()
349 assert "brew" in q["nudge_text"].lower()
350 assert "pip install" in q["nudge_text"].lower()
351
352 def test_five_of_six_transcripts_does_not_flag_degraded(self):
353 # 83% transcript success - well above the 50% threshold
354 # X is also enabled so all 5 cores are active and no nudge should fire
355 q = _compute(
356 config_overrides={"AUTH_TOKEN": "tok123"},
357 ytdlp_installed=True,
358 result_overrides={
359 "youtube_videos_count": 6,
360 "youtube_transcripts_count": 5,
361 },
362 )
363 assert "youtube" not in q["core_degraded"]
364 assert q["nudge_text"] is None # All 5 core sources active, no degradation
365
366 def test_zero_videos_does_not_flag_degraded(self):
367 # No videos returned -> degraded check is meaningless and must not fire
368 q = _compute(
369 ytdlp_installed=True,
370 result_overrides={
371 "youtube_videos_count": 0,
372 "youtube_transcripts_count": 0,
373 },
374 )
375 assert "youtube" not in q["core_degraded"]
376
377 def test_one_of_three_transcripts_flags_degraded(self):
378 # 33% - below 50% threshold; the canonical "yt-dlp partially working" case
379 q = _compute(
380 ytdlp_installed=True,
381 result_overrides={
382 "youtube_videos_count": 3,
383 "youtube_transcripts_count": 1,
384 },
385 )
386 assert "youtube" in q["core_degraded"]
387 assert "Degraded: YouTube" in q["nudge_text"]
388
389 def test_threshold_tunable_via_config(self):
390 # Operator overrides threshold via env-style config to be more permissive
391 q = _compute(
392 config_overrides={"DEGRADED_TRANSCRIPT_THRESHOLD": "0.1"},
393 ytdlp_installed=True,
394 result_overrides={
395 "youtube_videos_count": 10,
396 "youtube_transcripts_count": 2, # 20%, below default 50% but above override 10%
397 },
398 )
399 assert "youtube" not in q["core_degraded"]
400
401 def test_degraded_does_not_affect_score(self):
402 # Degradation is informational, not score-affecting; YouTube still counts as active
403 q = _compute(
404 config_overrides={"AUTH_TOKEN": "tok123"},
405 ytdlp_installed=True,
406 result_overrides={
407 "youtube_videos_count": 6,
408 "youtube_transcripts_count": 0,
409 },
410 )
411 assert "youtube" in q["core_active"]
412 assert q["score_pct"] == 100 # Full active count regardless of degradation
413 # But nudge still fires
414 assert q["nudge_text"] is not None
415 assert "Degraded: YouTube" in q["nudge_text"]
416
417
418 class TestYouTubeCaptionsDisabledDoesNotFalseFlag:
419 """Captions-disabled videos must not lower the transcript-fetch ratio.
420
421 A video where the uploader disabled captions can never produce a transcript,
422 no matter how fresh yt-dlp is. Counting it in the denominator of the
423 degraded-ratio check produces false positives - one captions-disabled video
424 in a small result set was triggering a "stale yt-dlp binary" nudge that was
425 wrong. Fix: subtract captions_disabled from the denominator.
426 """
427
428 def test_zero_captions_disabled_preserves_existing_behavior(self):
429 # Pre-existing case: 0 of 6 transcripts is still degraded (no captions
430 # disabled to discount). Behavior is unchanged from TestYouTubeDegraded.
431 q = _compute(
432 ytdlp_installed=True,
433 result_overrides={
434 "youtube_videos_count": 6,
435 "youtube_transcripts_count": 0,
436 "youtube_captions_disabled_count": 0,
437 },
438 )
439 assert "youtube" in q["core_degraded"]
440
441 def test_all_videos_captions_disabled_does_not_flag(self):
442 # Every returned video had captions disabled by the uploader.
443 # That's not a yt-dlp problem - it's an upstream content fact. Must not
444 # flag degraded.
445 q = _compute(
446 ytdlp_installed=True,
447 result_overrides={
448 "youtube_videos_count": 3,
449 "youtube_transcripts_count": 0,
450 "youtube_captions_disabled_count": 3,
451 },
452 )
453 assert "youtube" not in q["core_degraded"]
454
455 def test_mixed_uses_corrected_denominator(self):
456 # 6 videos, 3 captions_disabled, 2 transcripts.
457 # Naive (buggy) ratio: 2/6 = 33% (would flag).
458 # Corrected ratio: 2/(6-3) = 67% (does NOT flag).
459 # This case demonstrates the fix changes the verdict.
460 q = _compute(
461 ytdlp_installed=True,
462 result_overrides={
463 "youtube_videos_count": 6,
464 "youtube_transcripts_count": 2,
465 "youtube_captions_disabled_count": 3,
466 },
467 )
468 assert "youtube" not in q["core_degraded"]
469
470 def test_mixed_still_flags_when_truly_degraded(self):
471 # Even after discounting captions-disabled, the ratio is still bad.
472 # 8 videos, 1 captions_disabled, 1 transcript -> 1/(8-1) = 14% (flags).
473 q = _compute(
474 ytdlp_installed=True,
475 result_overrides={
476 "youtube_videos_count": 8,
477 "youtube_transcripts_count": 1,
478 "youtube_captions_disabled_count": 1,
479 },
480 )
481 assert "youtube" in q["core_degraded"]
482 # Nudge should still mention the stale yt-dlp possibility but also
483 # acknowledge that captions-disabled is a separate cause.
484 assert q["nudge_text"] is not None
485 assert "captions disabled" in q["nudge_text"].lower()
486
487 def test_missing_count_defaults_to_zero(self):
488 # Older callers that don't pass the new key still work (default 0).
489 q = _compute(
490 ytdlp_installed=True,
491 result_overrides={
492 "youtube_videos_count": 6,
493 "youtube_transcripts_count": 0,
494 # youtube_captions_disabled_count intentionally omitted
495 },
496 )
497 assert "youtube" in q["core_degraded"]
498
499
500 class TestStaleNudgeRequiresActualFetchFailures:
501 """Zero failed fetches must suppress the stale-yt-dlp nudge (#531).
502
503 The report counts (youtube_videos_count / youtube_transcripts_count) are
504 computed from post-pruning items. A run where every transcript fetch
505 succeeded but the fetched videos were later pruned by freshness scoring
506 looks identical to a stale-binary run from those counts alone, producing
507 a false "stale yt-dlp binary" nudge. When actual fetch outcomes are
508 available and show zero failures, the binary demonstrably works.
509 """
510
511 def test_zero_failures_does_not_flag(self):
512 # The #531 repro: 2 in-report videos, 0 transcripts among them, but
513 # all 6 attempted fetches succeeded (on videos pruned later).
514 q = _compute(
515 ytdlp_installed=True,
516 result_overrides={
517 "youtube_videos_count": 2,
518 "youtube_transcripts_count": 0,
519 "youtube_captions_disabled_count": 0,
520 "youtube_transcript_fetch_attempts": 6,
521 "youtube_transcript_fetch_failures": 0,
522 },
523 )
524 assert "youtube" not in q["core_degraded"]
525
526 def test_actual_failures_still_flag(self):
527 q = _compute(
528 ytdlp_installed=True,
529 result_overrides={
530 "youtube_videos_count": 6,
531 "youtube_transcripts_count": 0,
532 "youtube_captions_disabled_count": 0,
533 "youtube_transcript_fetch_attempts": 6,
534 "youtube_transcript_fetch_failures": 6,
535 },
536 )
537 assert "youtube" in q["core_degraded"]
538
539 def test_missing_fetch_stats_preserves_existing_behavior(self):
540 # Callers that don't pass fetch stats (or the SC path, which doesn't
541 # use the local yt-dlp binary) fall back to the ratio heuristic.
542 q = _compute(
543 ytdlp_installed=True,
544 result_overrides={
545 "youtube_videos_count": 6,
546 "youtube_transcripts_count": 0,
547 "youtube_captions_disabled_count": 0,
548 },
549 )
550 assert "youtube" in q["core_degraded"]
551
552 def test_zero_attempts_preserves_existing_behavior(self):
553 q = _compute(
554 ytdlp_installed=True,
555 result_overrides={
556 "youtube_videos_count": 6,
557 "youtube_transcripts_count": 0,
558 "youtube_captions_disabled_count": 0,
559 "youtube_transcript_fetch_attempts": 0,
560 "youtube_transcript_fetch_failures": 0,
561 },
562 )
563 assert "youtube" in q["core_degraded"]
564
565
566 class TestInstagramSilentFailure:
567 """Instagram is a `bonus` source via SC. Silent-failure detection: if SC
568 is configured but the source returned zero items, surface a nudge so the
569 user understands why the brief lacks an Instagram section.
570
571 Pre-fix the user got no signal - SC's /v2/instagram/reels/search 500s
572 frequently on multi-token queries and the pipeline silently returned
573 empty without any indication.
574 """
575
576 def test_zero_items_with_sc_flags_bonus_errored(self):
577 q = _compute(
578 config_overrides={
579 "AUTH_TOKEN": "tok123",
580 "SCRAPECREATORS_API_KEY": "sc_key",
581 },
582 ytdlp_installed=True,
583 result_overrides={"instagram_items_count": 0},
584 )
585 assert "instagram" in q["bonus_errored"]
586 assert q["nudge_text"] is not None
587 assert "Instagram" in q["nudge_text"]
588
589 def test_zero_items_without_sc_does_not_flag(self):
590 q = _compute(
591 config_overrides={"AUTH_TOKEN": "tok123"},
592 ytdlp_installed=True,
593 result_overrides={"instagram_items_count": 0},
594 )
595 assert "instagram" not in q.get("bonus_errored", [])
596
597 def test_nonzero_items_does_not_flag(self):
598 q = _compute(
599 config_overrides={
600 "AUTH_TOKEN": "tok123",
601 "SCRAPECREATORS_API_KEY": "sc_key",
602 },
603 ytdlp_installed=True,
604 result_overrides={"instagram_items_count": 5},
605 )
606 assert "instagram" not in q["bonus_errored"]
607 assert q["nudge_text"] is None
608
609 def test_missing_key_means_source_did_not_run(self):
610 q = _compute(
611 config_overrides={
612 "AUTH_TOKEN": "tok123",
613 "SCRAPECREATORS_API_KEY": "sc_key",
614 },
615 ytdlp_installed=True,
616 )
617 assert "instagram" not in q["bonus_errored"]
618 assert q["nudge_text"] is None
619
620 def test_nudge_text_explains_workaround(self):
621 q = _compute(
622 config_overrides={
623 "AUTH_TOKEN": "tok123",
624 "SCRAPECREATORS_API_KEY": "sc_key",
625 },
626 ytdlp_installed=True,
627 result_overrides={"instagram_items_count": 0},
628 )
629 assert q["nudge_text"] is not None
630 text_lower = q["nudge_text"].lower()
631 assert "instagram" in text_lower
632 assert ("0 reels" in text_lower or "silent" in text_lower
633 or "hashtag" in text_lower)
634
635 def test_bonus_errored_does_not_affect_core_score(self):
636 q = _compute(
637 config_overrides={
638 "AUTH_TOKEN": "tok123",
639 "SCRAPECREATORS_API_KEY": "sc_key",
640 },
641 ytdlp_installed=True,
642 result_overrides={"instagram_items_count": 0},
643 )
644 assert q["score_pct"] == 100
645 assert "instagram" in q["bonus_errored"]
646 assert q["nudge_text"] is not None
647 assert "Bonus source silent" in q["nudge_text"]
648
649 def test_bonus_errored_field_always_present(self):
650 q = _compute()
651 assert q.get("bonus_errored") == []
652
653 def test_exclude_sources_instagram_suppresses_silent_failure(self):
654 """User set EXCLUDE_SOURCES=instagram - the source intentionally did
655 not run, so the zero-count instagram_items_count written by
656 last30days.py is a non-event, not a silent failure. Pre-fix: the
657 nudge fired anyway because the gate only checked SC-key + count.
658 """
659 q = _compute(
660 config_overrides={
661 "AUTH_TOKEN": "tok123",
662 "SCRAPECREATORS_API_KEY": "sc_key",
663 "EXCLUDE_SOURCES": "instagram",
664 },
665 ytdlp_installed=True,
666 result_overrides={"instagram_items_count": 0},
667 )
668 assert "instagram" not in q["bonus_errored"]
669 assert q["nudge_text"] is None
670
671 def test_exclude_sources_multi_value_with_instagram(self):
672 """Canonical parsing pattern is comma-separated; case-insensitive."""
673 q = _compute(
674 config_overrides={
675 "AUTH_TOKEN": "tok123",
676 "SCRAPECREATORS_API_KEY": "sc_key",
677 "EXCLUDE_SOURCES": "threads, Instagram , pinterest",
678 },
679 ytdlp_installed=True,
680 result_overrides={"instagram_items_count": 0},
681 )
682 assert "instagram" not in q["bonus_errored"]
683
684 def test_exclude_sources_other_value_still_flags(self):
685 """EXCLUDE_SOURCES that does not mention instagram must not suppress
686 the silent-failure nudge for instagram.
687 """
688 q = _compute(
689 config_overrides={
690 "AUTH_TOKEN": "tok123",
691 "SCRAPECREATORS_API_KEY": "sc_key",
692 "EXCLUDE_SOURCES": "threads",
693 },
694 ytdlp_installed=True,
695 result_overrides={"instagram_items_count": 0},
696 )
697 assert "instagram" in q["bonus_errored"]
698
699 def test_include_sources_without_instagram_suppresses_silent_failure(self):
700 """User set INCLUDE_SOURCES to an opt-in allowlist that omits
701 instagram — the pipeline skips the source by allowlist filter, so
702 the zero-count instagram_items_count is intentional, not a silent
703 failure. Symmetric to the EXCLUDE_SOURCES=instagram guard.
704 """
705 q = _compute(
706 config_overrides={
707 "AUTH_TOKEN": "tok123",
708 "SCRAPECREATORS_API_KEY": "sc_key",
709 "INCLUDE_SOURCES": "reddit,hn,x,youtube",
710 },
711 ytdlp_installed=True,
712 result_overrides={"instagram_items_count": 0},
713 )
714 assert "instagram" not in q["bonus_errored"]
715 assert q["nudge_text"] is None
716
717 def test_include_sources_multi_value_without_instagram(self):
718 """Canonical parsing pattern is comma-separated; case-insensitive."""
719 q = _compute(
720 config_overrides={
721 "AUTH_TOKEN": "tok123",
722 "SCRAPECREATORS_API_KEY": "sc_key",
723 "INCLUDE_SOURCES": " Reddit, HN , YouTube ",
724 },
725 ytdlp_installed=True,
726 result_overrides={"instagram_items_count": 0},
727 )
728 assert "instagram" not in q["bonus_errored"]
729
730 def test_include_sources_with_instagram_still_flags(self):
731 """INCLUDE_SOURCES that explicitly names instagram must not suppress
732 the silent-failure nudge — the source was opted in, so a zero count
733 is a real silent failure.
734 """
735 q = _compute(
736 config_overrides={
737 "AUTH_TOKEN": "tok123",
738 "SCRAPECREATORS_API_KEY": "sc_key",
739 "INCLUDE_SOURCES": "reddit,instagram",
740 },
741 ytdlp_installed=True,
742 result_overrides={"instagram_items_count": 0},
743 )
744 assert "instagram" in q["bonus_errored"]
745
746 def test_include_sources_empty_does_not_suppress(self):
747 """Empty/unset INCLUDE_SOURCES means no allowlist filter, so the
748 silent-failure gate should still fire when instagram is zero.
749 """
750 q = _compute(
751 config_overrides={
752 "AUTH_TOKEN": "tok123",
753 "SCRAPECREATORS_API_KEY": "sc_key",
754 "INCLUDE_SOURCES": "",
755 },
756 ytdlp_installed=True,
757 result_overrides={"instagram_items_count": 0},
758 )
759 assert "instagram" in q["bonus_errored"]
760
761
762 class TestBearerCredential:
763 """X_BEARER_TOKEN counts as an X credential where the xapi backend can
764 run (a Grok Bot host, or an explicit xapi pin); an ambient bearer on a
765 plain host stays what it is today: not a configured X source."""
766
767 def test_bearer_counts_on_grok_bot(self):
768 q = _compute(
769 config_overrides={"LAST30DAYS_HOST": "grok-bot", "X_BEARER_TOKEN": "dummy-bearer"},
770 ytdlp_installed=True,
771 )
772 assert "x" in q["core_active"]
773 assert q["score_pct"] == 100
774
775 def test_bearer_counts_when_xapi_is_pinned(self):
776 q = _compute(
777 config_overrides={"X_BEARER_TOKEN": "dummy-bearer", "LAST30DAYS_X_BACKEND": "xapi"},
778 ytdlp_installed=True,
779 )
780 assert "x" in q["core_active"]
781
782 def test_ambient_bearer_on_plain_host_is_an_optional_omission(self):
783 q = _compute(config_overrides={"X_BEARER_TOKEN": "dummy-bearer"}, ytdlp_installed=True)
784 assert "x" not in q["core_active"]
785 assert q["core_missing"] == []
786 assert q["nudge_text"] is None
787
788 def test_grok_bot_x_error_nudge_names_bearer_never_x_login(self):
789 q = _compute(
790 config_overrides={"LAST30DAYS_HOST": "grok-bot", "X_BEARER_TOKEN": "dummy-bearer"},
791 result_overrides={"x_error": "401 unauthorized"},
792 ytdlp_installed=True,
793 )
794 assert q["core_errored"] == ["x"]
795 text = q["nudge_text"].lower()
796 assert "x_bearer_token" in text
797 for word in ("x.com", "cookie", "bird", "auth_token", "ct0", "xquik", "grok cli", "grok login"):
798 assert word not in text, word
799
800 def test_declared_lane_without_envelope_prescribes_the_connector(self):
801 from lib import x_envelope
802 q = _compute(
803 config_overrides={"LAST30DAYS_HOST": "grok-bot"},
804 result_overrides={"x_error": x_envelope.DETAIL_NOT_PASSED, "active_sources": ["x"]},
805 ytdlp_installed=True,
806 )
807 assert q["core_errored"] == ["x"]
808 text = q["nudge_text"].lower()
809 assert "connector" in text
810 assert "--x-posts" in text
811 for word in ("x.com", "cookie", "bird", "auth_token", "ct0"):
812 assert word not in text, word
813
814 def test_grok_bot_credit_error_nudge_says_top_up(self):
815 q = _compute(
816 config_overrides={"LAST30DAYS_HOST": "grok-bot", "X_BEARER_TOKEN": "dummy-bearer"},
817 result_overrides={"x_error": "xapi: payment required (X API credits exhausted)"},
818 ytdlp_installed=True,
819 )
820 assert "top up" in q["nudge_text"].lower()
821 assert "x.com" not in q["nudge_text"].lower()
822
822 lines PYTHON