2860fb5d18
Security / Dependency review (push) Has been skipped
Scorecard / Scorecard analysis (push) Failing after 0s
Validate / eval (push) Failing after 0s
Security / Dependency audit (push) Failing after 1s
Security / Secret scan (push) Failing after 1s
Validate / tests (push) Failing after 0s
Validate / mcp-tests (push) Failing after 1s
GitHub Actions Security Analysis with zizmor 🌈 / zizmor (push) Failing after 1s
Security / SAST scan (push) Failing after 13m52s
712 lines
26 KiB
Python
712 lines
26 KiB
Python
"""Tests for post-research quality score and upgrade nudge.
|
|
|
|
Reddit is always a core source (free public JSON). The 5 core sources are:
|
|
HN, Polymarket, Reddit (always active), X, YouTube.
|
|
ScrapeCreators adds TikTok + Instagram as bonus sources, not core.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _base_config(**overrides):
|
|
"""Return a minimal config dict."""
|
|
config = {
|
|
"AUTH_TOKEN": None,
|
|
"CT0": None,
|
|
"XAI_API_KEY": None,
|
|
"XQUIK_API_KEY": None,
|
|
"SCRAPECREATORS_API_KEY": None,
|
|
}
|
|
config.update(overrides)
|
|
return config
|
|
|
|
|
|
def _base_results(**overrides):
|
|
"""Return a minimal research_results dict with no errors."""
|
|
results = {
|
|
"x_error": None,
|
|
"youtube_error": None,
|
|
"reddit_error": None,
|
|
}
|
|
results.update(overrides)
|
|
return results
|
|
|
|
|
|
def _compute(config_overrides=None, result_overrides=None, ytdlp_installed=False):
|
|
"""Helper to call compute_quality_score with mocked yt-dlp check."""
|
|
from lib.quality_nudge import compute_quality_score
|
|
from lib import youtube_yt
|
|
|
|
config = _base_config(**(config_overrides or {}))
|
|
results = _base_results(**(result_overrides or {}))
|
|
|
|
with patch.object(youtube_yt, "is_ytdlp_installed", return_value=ytdlp_installed):
|
|
return compute_quality_score(config, results)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBaseline:
|
|
"""HN + Polymarket + Reddit always active (no X, no YT) -> 60%."""
|
|
|
|
def test_score_60(self):
|
|
q = _compute()
|
|
assert q["score_pct"] == 60
|
|
|
|
def test_active_sources(self):
|
|
q = _compute()
|
|
assert "hn" in q["core_active"]
|
|
assert "polymarket" in q["core_active"]
|
|
assert "reddit" in q["core_active"]
|
|
assert len(q["core_active"]) == 3
|
|
|
|
def test_missing_x_and_youtube(self):
|
|
q = _compute()
|
|
assert set(q["core_missing"]) == {"x", "youtube"}
|
|
|
|
def test_reddit_not_in_missing(self):
|
|
"""Reddit is always active - never appears in missing."""
|
|
q = _compute()
|
|
assert "reddit" not in q["core_missing"]
|
|
assert "reddit_comments" not in q["core_missing"]
|
|
|
|
def test_nudge_mentions_x_and_youtube(self):
|
|
q = _compute()
|
|
assert q["nudge_text"] is not None
|
|
assert "X/Twitter" in q["nudge_text"]
|
|
assert "YouTube" in q["nudge_text"]
|
|
|
|
def test_nudge_does_not_mention_reddit(self):
|
|
"""Reddit is free - nudge should not tell user to get SC for it."""
|
|
q = _compute()
|
|
assert "Reddit with comments" not in q["nudge_text"]
|
|
|
|
|
|
class TestXCookies:
|
|
"""+X cookies -> 80%."""
|
|
|
|
def test_score_80(self):
|
|
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
|
|
assert q["score_pct"] == 80
|
|
|
|
def test_nudge_mentions_yt_only(self):
|
|
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
|
|
assert "YouTube" in q["nudge_text"]
|
|
assert "X/Twitter" not in q["nudge_text"]
|
|
|
|
|
|
class TestXquikKey:
|
|
"""+Xquik key -> 80% without browser-cookie or xAI credentials."""
|
|
|
|
def test_score_80(self):
|
|
q = _compute(config_overrides={"XQUIK_API_KEY": "xq_test"})
|
|
assert q["score_pct"] == 80
|
|
assert "x" in q["core_active"]
|
|
|
|
def test_nudge_mentions_yt_only(self):
|
|
q = _compute(config_overrides={"XQUIK_API_KEY": "xq_test"})
|
|
assert "YouTube" in q["nudge_text"]
|
|
assert "X/Twitter" not in q["nudge_text"]
|
|
|
|
|
|
class TestXPlusYtdlp:
|
|
"""+X + yt-dlp -> 100%. No SC needed for full core coverage."""
|
|
|
|
def test_score_100(self):
|
|
q = _compute(
|
|
config_overrides={"AUTH_TOKEN": "tok123"},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert q["score_pct"] == 100
|
|
|
|
def test_nudge_is_none(self):
|
|
"""Full core coverage with zero paid keys."""
|
|
q = _compute(
|
|
config_overrides={"AUTH_TOKEN": "tok123"},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert q["nudge_text"] is None
|
|
|
|
|
|
class TestFullCoverageWithSC:
|
|
"""+X + yt-dlp + SC -> still 100%, SC adds bonus sources."""
|
|
|
|
def test_score_100(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert q["score_pct"] == 100
|
|
|
|
def test_nudge_is_none(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert q["nudge_text"] is None
|
|
|
|
|
|
class TestSCDoesNotAffectCoreScore:
|
|
"""SC key should not change core score - it only adds bonus sources."""
|
|
|
|
def test_sc_alone_still_60(self):
|
|
"""SC key without X or yt-dlp is still 60% (3/5 core)."""
|
|
q = _compute(config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"})
|
|
assert q["score_pct"] == 60
|
|
|
|
def test_sc_plus_ytdlp_is_80(self):
|
|
q = _compute(
|
|
config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert q["score_pct"] == 80
|
|
|
|
def test_nudge_suggests_browser_cookies(self):
|
|
q = _compute(
|
|
config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert q["nudge_text"] is not None
|
|
assert "x.com" in q["nudge_text"].lower()
|
|
assert "XQUIK_API_KEY" in q["nudge_text"]
|
|
|
|
|
|
class TestYouTubeFallbackProvider:
|
|
"""YouTube data from fallback/provider paths is degraded, not missing."""
|
|
|
|
def test_fallback_youtube_data_without_ytdlp_counts_active_not_missing(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=False,
|
|
result_overrides={
|
|
"youtube_videos_count": 5,
|
|
"youtube_transcripts_count": 4,
|
|
},
|
|
)
|
|
assert q["score_pct"] == 100
|
|
assert "youtube" in q["core_active"]
|
|
assert "youtube" not in q["core_missing"]
|
|
assert "youtube" in q["core_degraded"]
|
|
assert q["nudge_text"] is not None
|
|
assert "Missing: YouTube" not in q["nudge_text"]
|
|
assert "Degraded: YouTube" in q["nudge_text"]
|
|
assert "fallback/provider" in q["nudge_text"]
|
|
assert "local yt-dlp is not installed" in q["nudge_text"]
|
|
assert "stale yt-dlp" not in q["nudge_text"].lower()
|
|
|
|
def test_ytdlp_install_check_runs_once_per_score(self):
|
|
from lib.quality_nudge import compute_quality_score
|
|
from lib import youtube_yt
|
|
|
|
with patch.object(youtube_yt, "is_ytdlp_installed", return_value=False) as ytdlp_check:
|
|
compute_quality_score(
|
|
_base_config(AUTH_TOKEN="tok123"),
|
|
_base_results(
|
|
youtube_videos_count=5,
|
|
youtube_transcripts_count=4,
|
|
),
|
|
)
|
|
|
|
ytdlp_check.assert_called_once()
|
|
|
|
def test_no_fallback_data_without_ytdlp_still_missing(self):
|
|
q = _compute(
|
|
config_overrides={"SCRAPECREATORS_API_KEY": "sc_key"},
|
|
ytdlp_installed=False,
|
|
result_overrides={
|
|
"youtube_videos_count": 0,
|
|
"youtube_transcripts_count": 0,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_missing"]
|
|
assert "youtube" not in q["core_active"]
|
|
assert "youtube" not in q["core_degraded"]
|
|
assert "Missing: X/Twitter, YouTube" in q["nudge_text"]
|
|
|
|
|
|
class TestDisclaimerAlwaysPresent:
|
|
"""Nudge always includes no-affiliate disclaimer when present."""
|
|
|
|
def test_disclaimer_baseline(self):
|
|
q = _compute()
|
|
assert "no affiliation" in q["nudge_text"]
|
|
|
|
def test_disclaimer_partial(self):
|
|
q = _compute(config_overrides={"AUTH_TOKEN": "tok123"})
|
|
assert "no affiliation" in q["nudge_text"]
|
|
|
|
def test_disclaimer_not_present_at_100(self):
|
|
q = _compute(
|
|
config_overrides={"AUTH_TOKEN": "tok123"},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert q["nudge_text"] is None
|
|
|
|
|
|
class TestRedditNeverInCoreErrored:
|
|
"""Reddit errors don't affect core score since it's always-active via public path."""
|
|
|
|
def test_reddit_error_does_not_affect_score(self):
|
|
q = _compute(
|
|
config_overrides={"AUTH_TOKEN": "tok123"},
|
|
result_overrides={"reddit_error": "429 Too Many Requests"},
|
|
ytdlp_installed=True,
|
|
)
|
|
# Reddit is always-active in core (public path), error doesn't demote it
|
|
assert "reddit" in q["core_active"]
|
|
assert q["score_pct"] == 100
|
|
|
|
|
|
class TestYouTubeDegraded:
|
|
"""YouTube is `degraded` when videos returned but transcripts below threshold.
|
|
|
|
Canonical failure mode: a stale yt-dlp binary still finds videos via search
|
|
but silently fails every transcript fetch because YouTube's caption format
|
|
has moved on. Pre-fix the user got no signal of this; the footer hid zero,
|
|
and quality_nudge only checked top-level errors.
|
|
"""
|
|
|
|
def test_zero_of_six_transcripts_flags_degraded(self):
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 0,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
assert q["nudge_text"] is not None
|
|
# Counts surface in the message so the user sees the actual ratio
|
|
assert "6 videos" in q["nudge_text"]
|
|
assert "0 transcripts" in q["nudge_text"]
|
|
assert "stale yt-dlp" in q["nudge_text"].lower()
|
|
# Updates path mentions all three common package managers
|
|
assert "scoop" in q["nudge_text"].lower()
|
|
assert "brew" in q["nudge_text"].lower()
|
|
assert "pip install" in q["nudge_text"].lower()
|
|
|
|
def test_five_of_six_transcripts_does_not_flag_degraded(self):
|
|
# 83% transcript success - well above the 50% threshold
|
|
# X is also enabled so all 5 cores are active and no nudge should fire
|
|
q = _compute(
|
|
config_overrides={"AUTH_TOKEN": "tok123"},
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 5,
|
|
},
|
|
)
|
|
assert "youtube" not in q["core_degraded"]
|
|
assert q["nudge_text"] is None # All 5 core sources active, no degradation
|
|
|
|
def test_zero_videos_does_not_flag_degraded(self):
|
|
# No videos returned -> degraded check is meaningless and must not fire
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 0,
|
|
"youtube_transcripts_count": 0,
|
|
},
|
|
)
|
|
assert "youtube" not in q["core_degraded"]
|
|
|
|
def test_one_of_three_transcripts_flags_degraded(self):
|
|
# 33% - below 50% threshold; the canonical "yt-dlp partially working" case
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 3,
|
|
"youtube_transcripts_count": 1,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
assert "Degraded: YouTube" in q["nudge_text"]
|
|
|
|
def test_threshold_tunable_via_config(self):
|
|
# Operator overrides threshold via env-style config to be more permissive
|
|
q = _compute(
|
|
config_overrides={"DEGRADED_TRANSCRIPT_THRESHOLD": "0.1"},
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 10,
|
|
"youtube_transcripts_count": 2, # 20%, below default 50% but above override 10%
|
|
},
|
|
)
|
|
assert "youtube" not in q["core_degraded"]
|
|
|
|
def test_degraded_does_not_affect_score(self):
|
|
# Degradation is informational, not score-affecting; YouTube still counts as active
|
|
q = _compute(
|
|
config_overrides={"AUTH_TOKEN": "tok123"},
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 0,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_active"]
|
|
assert q["score_pct"] == 100 # Full active count regardless of degradation
|
|
# But nudge still fires
|
|
assert q["nudge_text"] is not None
|
|
assert "Degraded: YouTube" in q["nudge_text"]
|
|
|
|
|
|
class TestYouTubeCaptionsDisabledDoesNotFalseFlag:
|
|
"""Captions-disabled videos must not lower the transcript-fetch ratio.
|
|
|
|
A video where the uploader disabled captions can never produce a transcript,
|
|
no matter how fresh yt-dlp is. Counting it in the denominator of the
|
|
degraded-ratio check produces false positives - one captions-disabled video
|
|
in a small result set was triggering a "stale yt-dlp binary" nudge that was
|
|
wrong. Fix: subtract captions_disabled from the denominator.
|
|
"""
|
|
|
|
def test_zero_captions_disabled_preserves_existing_behavior(self):
|
|
# Pre-existing case: 0 of 6 transcripts is still degraded (no captions
|
|
# disabled to discount). Behavior is unchanged from TestYouTubeDegraded.
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 0,
|
|
"youtube_captions_disabled_count": 0,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
|
|
def test_all_videos_captions_disabled_does_not_flag(self):
|
|
# Every returned video had captions disabled by the uploader.
|
|
# That's not a yt-dlp problem - it's an upstream content fact. Must not
|
|
# flag degraded.
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 3,
|
|
"youtube_transcripts_count": 0,
|
|
"youtube_captions_disabled_count": 3,
|
|
},
|
|
)
|
|
assert "youtube" not in q["core_degraded"]
|
|
|
|
def test_mixed_uses_corrected_denominator(self):
|
|
# 6 videos, 3 captions_disabled, 2 transcripts.
|
|
# Naive (buggy) ratio: 2/6 = 33% (would flag).
|
|
# Corrected ratio: 2/(6-3) = 67% (does NOT flag).
|
|
# This case demonstrates the fix changes the verdict.
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 2,
|
|
"youtube_captions_disabled_count": 3,
|
|
},
|
|
)
|
|
assert "youtube" not in q["core_degraded"]
|
|
|
|
def test_mixed_still_flags_when_truly_degraded(self):
|
|
# Even after discounting captions-disabled, the ratio is still bad.
|
|
# 8 videos, 1 captions_disabled, 1 transcript -> 1/(8-1) = 14% (flags).
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 8,
|
|
"youtube_transcripts_count": 1,
|
|
"youtube_captions_disabled_count": 1,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
# Nudge should still mention the stale yt-dlp possibility but also
|
|
# acknowledge that captions-disabled is a separate cause.
|
|
assert q["nudge_text"] is not None
|
|
assert "captions disabled" in q["nudge_text"].lower()
|
|
|
|
def test_missing_count_defaults_to_zero(self):
|
|
# Older callers that don't pass the new key still work (default 0).
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 0,
|
|
# youtube_captions_disabled_count intentionally omitted
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
|
|
|
|
class TestStaleNudgeRequiresActualFetchFailures:
|
|
"""Zero failed fetches must suppress the stale-yt-dlp nudge (#531).
|
|
|
|
The report counts (youtube_videos_count / youtube_transcripts_count) are
|
|
computed from post-pruning items. A run where every transcript fetch
|
|
succeeded but the fetched videos were later pruned by freshness scoring
|
|
looks identical to a stale-binary run from those counts alone, producing
|
|
a false "stale yt-dlp binary" nudge. When actual fetch outcomes are
|
|
available and show zero failures, the binary demonstrably works.
|
|
"""
|
|
|
|
def test_zero_failures_does_not_flag(self):
|
|
# The #531 repro: 2 in-report videos, 0 transcripts among them, but
|
|
# all 6 attempted fetches succeeded (on videos pruned later).
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 2,
|
|
"youtube_transcripts_count": 0,
|
|
"youtube_captions_disabled_count": 0,
|
|
"youtube_transcript_fetch_attempts": 6,
|
|
"youtube_transcript_fetch_failures": 0,
|
|
},
|
|
)
|
|
assert "youtube" not in q["core_degraded"]
|
|
|
|
def test_actual_failures_still_flag(self):
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 0,
|
|
"youtube_captions_disabled_count": 0,
|
|
"youtube_transcript_fetch_attempts": 6,
|
|
"youtube_transcript_fetch_failures": 6,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
|
|
def test_missing_fetch_stats_preserves_existing_behavior(self):
|
|
# Callers that don't pass fetch stats (or the SC path, which doesn't
|
|
# use the local yt-dlp binary) fall back to the ratio heuristic.
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 0,
|
|
"youtube_captions_disabled_count": 0,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
|
|
def test_zero_attempts_preserves_existing_behavior(self):
|
|
q = _compute(
|
|
ytdlp_installed=True,
|
|
result_overrides={
|
|
"youtube_videos_count": 6,
|
|
"youtube_transcripts_count": 0,
|
|
"youtube_captions_disabled_count": 0,
|
|
"youtube_transcript_fetch_attempts": 0,
|
|
"youtube_transcript_fetch_failures": 0,
|
|
},
|
|
)
|
|
assert "youtube" in q["core_degraded"]
|
|
|
|
|
|
class TestInstagramSilentFailure:
|
|
"""Instagram is a `bonus` source via SC. Silent-failure detection: if SC
|
|
is configured but the source returned zero items, surface a nudge so the
|
|
user understands why the brief lacks an Instagram section.
|
|
|
|
Pre-fix the user got no signal - SC's /v2/instagram/reels/search 500s
|
|
frequently on multi-token queries and the pipeline silently returned
|
|
empty without any indication.
|
|
"""
|
|
|
|
def test_zero_items_with_sc_flags_bonus_errored(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" in q["bonus_errored"]
|
|
assert q["nudge_text"] is not None
|
|
assert "Instagram" in q["nudge_text"]
|
|
|
|
def test_zero_items_without_sc_does_not_flag(self):
|
|
q = _compute(
|
|
config_overrides={"AUTH_TOKEN": "tok123"},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" not in q.get("bonus_errored", [])
|
|
|
|
def test_nonzero_items_does_not_flag(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 5},
|
|
)
|
|
assert "instagram" not in q["bonus_errored"]
|
|
assert q["nudge_text"] is None
|
|
|
|
def test_missing_key_means_source_did_not_run(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=True,
|
|
)
|
|
assert "instagram" not in q["bonus_errored"]
|
|
assert q["nudge_text"] is None
|
|
|
|
def test_nudge_text_explains_workaround(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert q["nudge_text"] is not None
|
|
text_lower = q["nudge_text"].lower()
|
|
assert "instagram" in text_lower
|
|
assert ("0 reels" in text_lower or "silent" in text_lower
|
|
or "hashtag" in text_lower)
|
|
|
|
def test_bonus_errored_does_not_affect_core_score(self):
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert q["score_pct"] == 100
|
|
assert "instagram" in q["bonus_errored"]
|
|
assert q["nudge_text"] is not None
|
|
assert "Bonus source silent" in q["nudge_text"]
|
|
|
|
def test_bonus_errored_field_always_present(self):
|
|
q = _compute()
|
|
assert q.get("bonus_errored") == []
|
|
|
|
def test_exclude_sources_instagram_suppresses_silent_failure(self):
|
|
"""User set EXCLUDE_SOURCES=instagram - the source intentionally did
|
|
not run, so the zero-count instagram_items_count written by
|
|
last30days.py is a non-event, not a silent failure. Pre-fix: the
|
|
nudge fired anyway because the gate only checked SC-key + count.
|
|
"""
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
"EXCLUDE_SOURCES": "instagram",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" not in q["bonus_errored"]
|
|
assert q["nudge_text"] is None
|
|
|
|
def test_exclude_sources_multi_value_with_instagram(self):
|
|
"""Canonical parsing pattern is comma-separated; case-insensitive."""
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
"EXCLUDE_SOURCES": "threads, Instagram , pinterest",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" not in q["bonus_errored"]
|
|
|
|
def test_exclude_sources_other_value_still_flags(self):
|
|
"""EXCLUDE_SOURCES that does not mention instagram must not suppress
|
|
the silent-failure nudge for instagram.
|
|
"""
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
"EXCLUDE_SOURCES": "threads",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" in q["bonus_errored"]
|
|
|
|
def test_include_sources_without_instagram_suppresses_silent_failure(self):
|
|
"""User set INCLUDE_SOURCES to an opt-in allowlist that omits
|
|
instagram — the pipeline skips the source by allowlist filter, so
|
|
the zero-count instagram_items_count is intentional, not a silent
|
|
failure. Symmetric to the EXCLUDE_SOURCES=instagram guard.
|
|
"""
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
"INCLUDE_SOURCES": "reddit,hn,x,youtube",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" not in q["bonus_errored"]
|
|
assert q["nudge_text"] is None
|
|
|
|
def test_include_sources_multi_value_without_instagram(self):
|
|
"""Canonical parsing pattern is comma-separated; case-insensitive."""
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
"INCLUDE_SOURCES": " Reddit, HN , YouTube ",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" not in q["bonus_errored"]
|
|
|
|
def test_include_sources_with_instagram_still_flags(self):
|
|
"""INCLUDE_SOURCES that explicitly names instagram must not suppress
|
|
the silent-failure nudge — the source was opted in, so a zero count
|
|
is a real silent failure.
|
|
"""
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
"INCLUDE_SOURCES": "reddit,instagram",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" in q["bonus_errored"]
|
|
|
|
def test_include_sources_empty_does_not_suppress(self):
|
|
"""Empty/unset INCLUDE_SOURCES means no allowlist filter, so the
|
|
silent-failure gate should still fire when instagram is zero.
|
|
"""
|
|
q = _compute(
|
|
config_overrides={
|
|
"AUTH_TOKEN": "tok123",
|
|
"SCRAPECREATORS_API_KEY": "sc_key",
|
|
"INCLUDE_SOURCES": "",
|
|
},
|
|
ytdlp_installed=True,
|
|
result_overrides={"instagram_items_count": 0},
|
|
)
|
|
assert "instagram" in q["bonus_errored"]
|