Files
peter bdf7c03b59 fix(r5): address the eighth /code-review high round — 7 findings
The worst one was, again, the previous round's own fix.

- Round 7 stopped awaiting the HLS sweep so startup could serve immediately — which
  also made it run CONCURRENTLY with playback, and `sweep_orphans` snapshotted the
  live-session set BEFORE listing the root and rmtree'd outside the lock. Since
  `start_session` reuses a directory path for the same (key, offset, tag), a stale
  name can become a live session at any moment. Two guards now: nothing whose mtime
  is newer than this process's start is ever deleted, and the live check + rmtree
  happen together under the lock, one directory at a time.
- The sweep task is awaited after cancel, so shutdown can't leave it pending.
- `_rate_limiter` used a truthiness test on its timestamp, so a `now()` of exactly
  0.0 read as "never ran" — unreachable in production, but `now` exists to be
  injected and a test clock starting at 0 is the obvious choice.
- The RSS total-outage error now carries the first underlying error: "all N channels
  failed" is also what ONE dead feed id looks like on a small instance, and a red
  card the user can't act on is its own kind of dishonest.
- The WebKit fullscreen handling moved into `lib/fullscreen` and PlexPlayer uses it
  too — it had the same unprefixed-only reads in three places, including an Escape
  branch that would have closed the whole player instead of leaving fullscreen.
- The `--max` CSS comment claimed `inset: 0` did the sizing; Tailwind's `w-full` is
  still on the element and wins. Comment now states the real mechanism.

Verified in real Chrome (the pane can't composite or do native fullscreen):
- F fills the 1920x889 viewport; the exit pill renders top-right on the letterbox
  band and overlaps NO YouTube control — the collision this round suspected does not
  reproduce in this embed (its own buttons sit bottom-left / bottom-right).
- Four real ArrowDown presses while maximised: persisted volume 100 -> 80, with the
  level flash visible on screen.
- A real click on the exit pill un-maximises and returns focus to the card.
- lib/fullscreen driven from a real click: enter (innerHeight 889 -> 1024), the
  change listener fires exactly once per transition, exit returns to 889.

Backend suite 150 -> 158; the new sweep tests are mutation-checked, and they read
their cutoff from the filesystem rather than a clock (the container VM and the
bind-mounted host disagree by enough to make a clock-based assertion flaky).
2026-07-24 04:44:20 +02:00

226 lines
9.0 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_, 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"""<?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"