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