| 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 |