- list_channels no longer computes the Normal/Shorts/Live aggregation the frontend stopped reading when R14 dropped the N/S/L table column; the dead ManagedChannel.count_* fields are gone too. - Immediate "get full history" backfill now coordinates with the paced scheduler via a small in-flight set: capped at 2 concurrent + per-channel dedup, and run_deep_backfill skips a channel an immediate backfill owns — killing the cursor race and bounding the quota-drain burst, without losing the speed win. - Add test_channel_backfill.py: the channelDetail N/S/L counts + the guard's no-op / cap / scheduler-skip behaviour. - Inline the syncedChannels useMemo (its dep is a fresh array each render, so it never cached); fix the stale "~2-min ticks" comment (it's 10 minutes).
129 lines
5.2 KiB
Python
129 lines
5.2 KiB
Python
"""R14 backend coverage: the channel-detail Normal/Shorts/Live counts (moved off the manager table),
|
||
and the interactive "get full history" immediate-backfill guard that keeps one burst from racing the
|
||
scheduler's cursor or draining the shared backfill budget."""
|
||
from datetime import datetime, timezone
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
import app.state as state
|
||
from app.auth import current_user
|
||
from app.db import get_db
|
||
from app.main import app
|
||
from app.models import Channel, User, Video
|
||
from app.sync import runner
|
||
|
||
|
||
def test_channel_detail_returns_normal_shorts_live_counts(db, monkeypatch):
|
||
# published_at set so channel_detail skips the YouTube-enrich path (no quota / network in tests).
|
||
ch = Channel(
|
||
id="UC_nsl",
|
||
title="N/S/L Channel",
|
||
published_at=datetime(2020, 1, 1, tzinfo=timezone.utc),
|
||
)
|
||
db.add(ch)
|
||
me = User(email="nsl@example.com")
|
||
db.add(me)
|
||
db.flush()
|
||
# 2 normal, 1 short, 1 live → stored 4, normal = 4 − 1 (short) − 1 (live) = 2.
|
||
db.add_all(
|
||
[
|
||
Video(id="v_norm1", channel_id=ch.id),
|
||
Video(id="v_norm2", channel_id=ch.id),
|
||
Video(id="v_short", channel_id=ch.id, is_short=True),
|
||
Video(id="v_live", channel_id=ch.id, live_status="live"),
|
||
]
|
||
)
|
||
db.commit()
|
||
|
||
# The request gate 503s an unconfigured instance; flip the process-level flag (auto-restored).
|
||
monkeypatch.setattr(state, "_configured_cache", True)
|
||
app.dependency_overrides[get_db] = lambda: db
|
||
app.dependency_overrides[current_user] = lambda: me
|
||
try:
|
||
resp = TestClient(app).get(f"/api/channels/{ch.id}")
|
||
finally:
|
||
app.dependency_overrides.clear()
|
||
|
||
assert resp.status_code == 200, resp.text
|
||
body = resp.json()
|
||
assert body["stored_videos"] == 4
|
||
assert body["count_short"] == 1
|
||
assert body["count_live"] == 1
|
||
assert body["count_normal"] == 2
|
||
|
||
|
||
class TestImmediateBackfillGuard:
|
||
"""backfill_channel_now coordinates with the paced scheduler through a small in-flight set: a
|
||
second call for a channel already in flight is a no-op, the set is capped so a burst can't drain
|
||
the shared budget, and run_deep_backfill skips a channel an immediate backfill is handling."""
|
||
|
||
def setup_method(self):
|
||
runner._immediate_backfill.clear()
|
||
|
||
def teardown_method(self):
|
||
runner._immediate_backfill.clear()
|
||
|
||
@staticmethod
|
||
def _boom_session():
|
||
# SessionLocal is only reached AFTER the guard admits the channel; if the guard short-circuits
|
||
# (as it must here) this is never called, so raising proves the short-circuit.
|
||
raise AssertionError("guard must short-circuit before opening a session")
|
||
|
||
def test_a_second_call_for_an_in_flight_channel_is_a_noop(self, monkeypatch):
|
||
runner._immediate_backfill.add("UC_busy")
|
||
monkeypatch.setattr(runner, "SessionLocal", self._boom_session)
|
||
runner.backfill_channel_now("UC_busy") # returns immediately; _boom_session never runs
|
||
assert runner._immediate_backfill == {"UC_busy"} # the in-flight entry is untouched
|
||
|
||
def test_over_the_concurrency_cap_falls_back_to_the_scheduler(self, monkeypatch):
|
||
for i in range(runner._MAX_CONCURRENT_IMMEDIATE):
|
||
runner._immediate_backfill.add(f"UC_full_{i}")
|
||
monkeypatch.setattr(runner, "SessionLocal", self._boom_session)
|
||
runner.backfill_channel_now("UC_new") # over the cap → no-op, scheduler will handle it
|
||
assert "UC_new" not in runner._immediate_backfill
|
||
|
||
def test_scheduler_deep_pass_skips_an_in_flight_channel(self, db, monkeypatch):
|
||
# A channel the scheduler's query WOULD pick (deep-requested, recent-synced, not done)…
|
||
ch = Channel(
|
||
id="UC_inflight",
|
||
title="In-flight",
|
||
recent_synced_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||
uploads_playlist_id="UU_inflight",
|
||
backfill_done=False,
|
||
)
|
||
db.add(ch)
|
||
me = User(email="svc@example.com")
|
||
db.add(me)
|
||
db.flush()
|
||
from app.models import Subscription
|
||
|
||
db.add(Subscription(user_id=me.id, channel_id=ch.id, deep_requested=True))
|
||
db.commit()
|
||
|
||
paged: list[str] = []
|
||
monkeypatch.setattr(runner, "get_service_user", lambda _db: me)
|
||
monkeypatch.setattr(runner.quota, "remaining_today", lambda _db: 10_000)
|
||
monkeypatch.setattr(runner, "YouTubeClient", lambda *a, **k: _NullYt())
|
||
monkeypatch.setattr(
|
||
runner, "backfill_channel_deep", lambda _db, _yt, channel, _mp: paged.append(channel.id) or 0
|
||
)
|
||
monkeypatch.setattr(runner, "reconcile_full_history", lambda _db: 0)
|
||
|
||
# …is skipped by run_deep_backfill while an immediate backfill owns it (no cursor race).
|
||
runner._immediate_backfill.add(ch.id)
|
||
runner.run_deep_backfill(db)
|
||
assert paged == [], "the scheduler must not page a channel an immediate backfill is handling"
|
||
|
||
# …and IS paged once the immediate backfill releases it.
|
||
runner._immediate_backfill.discard(ch.id)
|
||
runner.run_deep_backfill(db)
|
||
assert paged == [ch.id]
|
||
|
||
|
||
class _NullYt:
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *a):
|
||
return False
|