- Store subscriber count from YouTube statistics and show it on channel page - Sync 50 videos per channel with playlistItems pagination support - Show per-channel and per-category new-videos counters (2-day window) - Replace hourly videos sync with activity trigger (2h idle) and incremental backfill (hard cap 200 per channel) - Clicking the sidebar new-videos count filters the category feed to recent videos only (new_only) - Update agent-team docs: deploy after green checks
159 lines
5.2 KiB
Python
159 lines
5.2 KiB
Python
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from types import SimpleNamespace
|
|
|
|
from app.services import sync_trigger
|
|
|
|
|
|
def _now():
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
def test_is_videos_sync_due_without_finished_at():
|
|
assert sync_trigger.is_videos_sync_due(None, _now(), 2) is True
|
|
|
|
|
|
def test_is_videos_sync_due_after_idle():
|
|
finished = (_now() - timedelta(hours=3)).isoformat()
|
|
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
|
|
|
|
|
|
def test_is_videos_sync_due_at_exact_threshold():
|
|
finished = (_now() - timedelta(hours=2)).isoformat()
|
|
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is True
|
|
|
|
|
|
def test_is_videos_sync_due_before_idle():
|
|
finished = (_now() - timedelta(hours=1)).isoformat()
|
|
assert sync_trigger.is_videos_sync_due(finished, _now(), 2) is False
|
|
|
|
|
|
def test_is_videos_sync_due_unparseable_finished_at():
|
|
assert sync_trigger.is_videos_sync_due("not-a-date", _now(), 2) is True
|
|
assert sync_trigger.is_videos_sync_due(12345, _now(), 2) is True # non-str garbage
|
|
|
|
|
|
def test_is_videos_sync_due_naive_finished_at():
|
|
naive = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=3)
|
|
assert sync_trigger.is_videos_sync_due(naive.isoformat(), _now(), 2) is True
|
|
|
|
|
|
def test_extract_finished_at():
|
|
assert sync_trigger._extract_finished_at(None) is None
|
|
assert sync_trigger._extract_finished_at("not json") is None
|
|
assert sync_trigger._extract_finished_at(json.dumps(["a"])) is None
|
|
assert sync_trigger._extract_finished_at(json.dumps({"status": "running", "finished_at": None})) is None
|
|
assert (
|
|
sync_trigger._extract_finished_at(json.dumps({"status": "completed", "finished_at": "2026-09-10T12:00:00+00:00"}))
|
|
== "2026-09-10T12:00:00+00:00"
|
|
)
|
|
|
|
|
|
class _FakeDB:
|
|
def __init__(self):
|
|
self.closed = False
|
|
|
|
def close(self):
|
|
self.closed = True
|
|
|
|
|
|
def _patch_threads(monkeypatch, started):
|
|
monkeypatch.setattr(
|
|
sync_trigger,
|
|
"threading",
|
|
SimpleNamespace(Thread=lambda target=None, daemon=None: started.append({"target": target, "daemon": daemon})),
|
|
)
|
|
|
|
|
|
def test_maybe_trigger_skips_when_sync_running(monkeypatch):
|
|
started = []
|
|
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: True)
|
|
monkeypatch.setattr(
|
|
sync_trigger,
|
|
"SessionLocal",
|
|
lambda: (_ for _ in ()).throw(AssertionError("DB must not be touched while running")),
|
|
)
|
|
_patch_threads(monkeypatch, started)
|
|
|
|
sync_trigger.maybe_trigger_videos_sync()
|
|
|
|
assert started == []
|
|
|
|
|
|
def test_maybe_trigger_skips_when_not_due(monkeypatch):
|
|
started = []
|
|
db = _FakeDB()
|
|
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=1)).isoformat()})
|
|
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
|
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
|
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
|
|
_patch_threads(monkeypatch, started)
|
|
|
|
sync_trigger.maybe_trigger_videos_sync()
|
|
|
|
assert started == []
|
|
assert db.closed is True
|
|
|
|
|
|
def test_maybe_trigger_starts_thread_when_due_never_synced(monkeypatch):
|
|
started = []
|
|
db = _FakeDB()
|
|
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
|
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
|
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: None)
|
|
_patch_threads(monkeypatch, started)
|
|
|
|
sync_trigger.maybe_trigger_videos_sync()
|
|
|
|
assert len(started) == 1
|
|
assert started[0]["target"] == sync_trigger._run_videos_sync
|
|
assert started[0]["daemon"] is True
|
|
assert db.closed is True
|
|
|
|
|
|
def test_maybe_trigger_starts_thread_when_due_after_idle(monkeypatch):
|
|
started = []
|
|
db = _FakeDB()
|
|
raw = json.dumps({"status": "completed", "finished_at": (_now() - timedelta(hours=5)).isoformat()})
|
|
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
|
monkeypatch.setattr(sync_trigger, "SessionLocal", lambda: db)
|
|
monkeypatch.setattr(sync_trigger, "get_setting", lambda session, key: raw)
|
|
_patch_threads(monkeypatch, started)
|
|
|
|
sync_trigger.maybe_trigger_videos_sync()
|
|
|
|
assert len(started) == 1
|
|
assert started[0]["daemon"] is True
|
|
|
|
|
|
def test_maybe_trigger_never_raises_when_db_unavailable(monkeypatch):
|
|
started = []
|
|
monkeypatch.setattr(sync_trigger.sync, "is_videos_sync_running", lambda: False)
|
|
monkeypatch.setattr(
|
|
sync_trigger,
|
|
"SessionLocal",
|
|
lambda: (_ for _ in ()).throw(RuntimeError("db down")),
|
|
)
|
|
_patch_threads(monkeypatch, started)
|
|
|
|
sync_trigger.maybe_trigger_videos_sync() # must not raise
|
|
|
|
assert started == []
|
|
|
|
|
|
def test_run_videos_sync_swallows_sync_in_progress(monkeypatch):
|
|
closed = []
|
|
monkeypatch.setattr(
|
|
sync_trigger,
|
|
"SessionLocal",
|
|
lambda: SimpleNamespace(close=lambda: closed.append(True)),
|
|
)
|
|
monkeypatch.setattr(
|
|
sync_trigger.sync,
|
|
"sync_videos",
|
|
lambda db: (_ for _ in ()).throw(sync_trigger.sync.SyncInProgress("busy")),
|
|
)
|
|
|
|
sync_trigger._run_videos_sync() # must not raise
|
|
|
|
assert closed == [True]
|