- the sweep guard anchors the rating_key to digits: the round-2 widening had degenerated to "anything ending in _<digits>" (backup_2024, release_10) - RSS counts apply-side failures too, so a read-only database no longer reports a wholly failed poll as "ok — new=0, failed=0" - the player reads/writes its volume directly (readAccount/writeAccount): the persisted-object hook rendered nothing and cost a second write per settle - new tests: the sweep guard's accept/reject table, and run_ffmpeg's threading (drain, cancel while silent, stall watchdog, stderr tail on a nonzero exit) - the breaker sentinel is checked one way (isinstance) at all four sites - run_rss_poll is typed dict[str, int] All three new test groups were mutation-checked: reverting each fix turns them red (or, for the cancel/stall pair, hangs — which is the bug itself).
179 lines
6.8 KiB
Python
179 lines
6.8 KiB
Python
"""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_):
|
|
monkeypatch.setattr(runner, "fetch_channel_feed", fetch)
|
|
monkeypatch.setattr(runner, "apply_rss_feed", apply_)
|
|
return runner.run_rss_poll(_StubDb(), [_StubChannel("UC1"), _StubChannel("UC2")])
|
|
|
|
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):
|
|
def boom(cid):
|
|
raise rss.RssError("404")
|
|
|
|
out = self._run(monkeypatch, fetch=boom, apply_=lambda db, ch, e: 1)
|
|
assert out == {"new": 0, "failed": 2}
|
|
|
|
def test_apply_failures_are_counted_too(self, monkeypatch):
|
|
# A read-only database: every feed reads fine, nothing can be stored.
|
|
def boom(db, ch, entries):
|
|
raise RuntimeError("read-only transaction")
|
|
|
|
out = self._run(monkeypatch, fetch=lambda cid: [{"id": "v"}], apply_=boom)
|
|
assert out == {"new": 0, "failed": 2}, "a run where nothing was stored 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
|
|
|
|
|
|
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"""<?xml version="1.0"?><feed xmlns="http://www.w3.org/2005/Atom"></feed>"""
|
|
self._patch(monkeypatch, status=200, body=feed)
|
|
assert rss.fetch_channel_feed("UC_quiet") == []
|
|
|
|
def test_entries_are_parsed(self, monkeypatch):
|
|
feed = b"""<?xml version="1.0"?>
|
|
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:yt="http://www.youtube.com/xml/schemas/2015">
|
|
<entry><yt:videoId>abc123</yt:videoId><title>Hello</title>
|
|
<published>2026-07-23T10:00:00+00:00</published></entry>
|
|
</feed>"""
|
|
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"
|