"""Guards that stop one bad YouTube answer from cascading: the subscription prune guard, the playlist-push circuit breaker, and RSS telling a failure apart from an empty feed.""" import httpx import pytest from app.sync import runner from app.sync.playlists import FAILED, _Breaker from app.sync.subscriptions import should_prune from app.youtube import rss from app.youtube.client import YouTubeError class TestShouldPrune: def test_a_normal_fetch_prunes(self): assert should_prune(fetched_count=12, existing_count=15) is True def test_an_empty_fetch_against_existing_rows_never_prunes(self): # The data-loss case: one anomalous empty-but-200 response must not wipe 15 rows. assert should_prune(fetched_count=0, existing_count=15) is False def test_an_empty_fetch_with_nothing_local_is_fine(self): # A brand-new account: nothing to lose, so the (no-op) prune may run. assert should_prune(fetched_count=0, existing_count=0) is True def test_a_single_remaining_subscription_still_prunes(self): assert should_prune(fetched_count=1, existing_count=200) is True class TestBreaker: def test_open_until_the_limit_is_reached(self): b = _Breaker(limit=3) for _ in range(2): b.hit(False) assert b.open() b.hit(False) assert not b.open() and b.tripped def test_a_success_re_arms_the_streak(self): b = _Breaker(limit=3) b.hit(False) b.hit(False) b.hit(True) # one write went through — this isn't an account-wide failure b.hit(False) b.hit(False) assert b.open() def test_stays_tripped_once_tripped(self): b = _Breaker(limit=2) b.hit(False) b.hit(False) b.hit(True) assert not b.open() def _quota_dead(): """A write op that fails the way a quota-exhausted / scope-revoked account fails.""" raise YouTubeError("quota exceeded") class TestBreakerRun: def test_returns_the_ops_result_and_leaves_failures_alone(self): b, failed = _Breaker(), [] assert b.run(lambda: "item-1", lambda: failed.append("v1")) == "item-1" assert failed == [] def test_a_youtube_error_records_the_failure_and_counts_towards_the_trip(self): b, failed = _Breaker(limit=2), [] assert b.run(_quota_dead, lambda: failed.append("v1")) is FAILED assert failed == ["v1"] and b.open() b.run(_quota_dead, lambda: failed.append("v2")) assert not b.open() def test_a_tripped_breaker_never_calls_the_op(self): b, failed, calls = _Breaker(limit=1), [], [] b.run(_quota_dead, lambda: failed.append("v1")) assert b.run(lambda: calls.append("called"), lambda: failed.append("v2")) is FAILED assert calls == [] # the whole point: no more quota spent assert failed == ["v1", "v2"] # …but the item is still reported def test_other_exceptions_are_not_swallowed(self): # Only YouTubeError is a "write failed" signal; a bug must still surface. def boom(): raise ValueError("bug") with pytest.raises(ValueError): _Breaker().run(boom, lambda: None) class _StubDb: """Just enough Session for run_rss_poll when the caller supplies the channel list.""" def __init__(self): self.rollbacks = 0 def rollback(self): self.rollbacks += 1 class _StubChannel: def __init__(self, cid): self.id = cid class TestRssPollResult: """`failed` must count every channel that did not get polled — feed-side AND apply-side. Counting only the fetch failures would move the "a broken run looks like a quiet hour" blind spot rather than remove it.""" def _run(self, monkeypatch, *, fetch, apply_, channels=("UC1", "UC2")): monkeypatch.setattr(runner, "fetch_channel_feed", fetch) monkeypatch.setattr(runner, "apply_rss_feed", apply_) return runner.run_rss_poll(_StubDb(), [_StubChannel(c) for c in channels]) def test_a_healthy_run_reports_no_failures(self, monkeypatch): out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=lambda db, ch, e: 1) assert out == {"new": 2, "failed": 0} def test_unreadable_feeds_are_counted(self, monkeypatch): # One dead channel among healthy ones: counted, but the run itself is still a run. def fetch(cid): if cid == "UC_dead": raise rss.RssError("404") return [{"id": "v"}] out = self._run( monkeypatch, fetch=fetch, apply_=lambda db, ch, e: 1, channels=("UC1", "UC_dead") ) assert out == {"new": 1, "failed": 1} def test_apply_failures_are_counted_too(self, monkeypatch): # A per-row write failure (a constraint clash on one channel) while the rest store fine. def apply_(db, ch, entries): if ch.id == "UC_bad": raise RuntimeError("constraint") return 1 out = self._run( monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=apply_, channels=("UC1", "UC_bad"), ) assert out == {"new": 1, "failed": 1}, "an unstored channel must not read as ok" def test_a_quiet_hour_is_distinguishable_from_an_outage(self, monkeypatch): quiet = self._run(monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0) assert quiet == {"new": 0, "failed": 0} # same "new", different "failed" — that's the point def test_a_total_outage_raises_instead_of_returning_ok(self, monkeypatch): # Every feed unreachable (the tinyproxy-lost-the-boot-race incident). Returning a result # here recorded the scheduler run as "ok", with the outage only visible inside the summary # text — a green pill for a run that did nothing at all. def boom(cid): raise rss.RssError("Connection refused") with pytest.raises(RuntimeError, match="all 2 channels"): self._run(monkeypatch, fetch=boom, apply_=lambda db, ch, e: 1) def test_the_outage_message_carries_the_underlying_error(self, monkeypatch): # "all N channels failed" is ALSO what one dead feed id looks like on a small instance. # The card has to say which: `HTTP 404` = fix the subscription, `Connection refused` = # the egress is down. def dead(cid): raise rss.RssError(f"RSS fetch for {cid} returned HTTP 404") with pytest.raises(RuntimeError, match="HTTP 404"): self._run(monkeypatch, fetch=dead, apply_=lambda db, ch, e: 1, channels=("UC_dead",)) def test_a_read_only_database_is_a_total_outage_too(self, monkeypatch): # Every feed reads fine, nothing can be stored: just as un-polled, just as red. def boom(db, ch, entries): raise RuntimeError("read-only transaction") with pytest.raises(RuntimeError, match="read-only transaction"): self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=boom) def test_no_channels_at_all_is_not_an_outage(self, monkeypatch): # A fresh instance with nothing subscribed must not report an error every poll. out = self._run( monkeypatch, fetch=lambda cid: [], apply_=lambda db, ch, e: 0, channels=() ) assert out == {"new": 0, "failed": 0} class TestRssErrorVsEmpty: """A failed poll must RAISE (so the caller leaves `last_rss_at` alone); only a genuinely empty feed may return [].""" def _patch(self, monkeypatch, **kwargs): def fake_get(url, **_): if "exc" in kwargs: raise kwargs["exc"] return httpx.Response( kwargs["status"], content=kwargs.get("body", b""), request=httpx.Request("GET", url), ) monkeypatch.setattr(rss.httpx, "get", fake_get) def test_non_200_raises(self, monkeypatch): self._patch(monkeypatch, status=404) with pytest.raises(rss.RssError): rss.fetch_channel_feed("UC_dead") def test_transport_error_raises(self, monkeypatch): self._patch(monkeypatch, exc=httpx.ConnectError("no route")) with pytest.raises(rss.RssError): rss.fetch_channel_feed("UC_offline") def test_an_empty_but_valid_feed_returns_empty(self, monkeypatch): feed = b"""""" self._patch(monkeypatch, status=200, body=feed) assert rss.fetch_channel_feed("UC_quiet") == [] def test_entries_are_parsed(self, monkeypatch): feed = b""" abc123Hello 2026-07-23T10:00:00+00:00 """ self._patch(monkeypatch, status=200, body=feed) out = rss.fetch_channel_feed("UC_live") assert [e["id"] for e in out] == ["abc123"] assert out[0]["channel_id"] == "UC_live"