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