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]