import asyncio from datetime import datetime import pytest from sqlalchemy import create_engine from sqlalchemy.pool import StaticPool from app.db.models import Base from app.db.repositories import SchedulerSettingsRepository from app.db.session import create_session_factory from app.sync.scheduler import DayNightIntervalProvider, SyncScheduler, is_daytime class FakeSyncManager: def __init__(self, results=None) -> None: self.results = results if results is not None else [] self.calls = 0 async def sync_all_enabled(self): self.calls += 1 if self.results and isinstance(self.results[0], Exception): raise self.results[0] return list(self.results) @pytest.mark.asyncio async def test_scheduler_calls_sync_all_and_survives_failure() -> None: fake = FakeSyncManager(results=[RuntimeError("one user failed")]) scheduler = SyncScheduler(fake, interval_seconds=0.01) await scheduler.start() await asyncio.sleep(0.035) await scheduler.stop() assert fake.calls >= 2 assert scheduler.last_tick is not None @pytest.mark.asyncio async def test_scheduler_stop_cancels_the_loop() -> None: fake = FakeSyncManager() scheduler = SyncScheduler(fake, interval_seconds=0.01) await scheduler.start() await asyncio.sleep(0.035) await scheduler.stop() assert scheduler._task is None calls_after_stop = fake.calls await asyncio.sleep(0.05) assert fake.calls == calls_after_stop @pytest.mark.parametrize( ("hour", "day_start", "night_start", "expected"), [ (6, 6, 22, True), (21, 6, 22, True), (5, 6, 22, False), (22, 6, 22, False), (0, 6, 22, False), # wraps past midnight: day period is [20, 6) (23, 20, 6, True), (2, 20, 6, True), (10, 20, 6, False), (20, 20, 6, True), (6, 20, 6, False), # degenerate: identical start hours means always day (13, 9, 9, True), ], ) def test_is_daytime(hour, day_start, night_start, expected) -> None: assert is_daytime(hour, day_start, night_start) is expected @pytest.fixture def db_session_factory(): engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) Base.metadata.create_all(engine) factory = create_session_factory(engine) try: yield factory finally: engine.dispose() @pytest.mark.asyncio async def test_interval_provider_uses_day_interval_during_the_day(db_session_factory) -> None: provider = DayNightIntervalProvider( db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0) ) assert provider() == 5 * 60 @pytest.mark.asyncio async def test_interval_provider_uses_night_interval_at_night(db_session_factory) -> None: with db_session_factory() as session: repo = SchedulerSettingsRepository(session) row = repo.get_or_create(default_minutes=5) repo.update(row, night_interval_minutes=30) provider = DayNightIntervalProvider( db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 23, 0) ) assert provider() == 30 * 60 @pytest.mark.asyncio async def test_scheduler_uses_interval_provider_for_next_tick(db_session_factory) -> None: with db_session_factory() as session: repo = SchedulerSettingsRepository(session) row = repo.get_or_create(default_minutes=5) repo.update(row, day_interval_minutes=1, night_interval_minutes=1) provider = DayNightIntervalProvider( db_session_factory, default_minutes=5, now=lambda: datetime(2026, 1, 1, 12, 0) ) fake = FakeSyncManager() scheduler = SyncScheduler(fake, interval_seconds=999, interval_provider=provider) await scheduler.run_once() expected_seconds = 60 # day_interval_minutes=1 -> 60s, not the unrelated interval_seconds=999 fallback delta = (scheduler.next_tick - scheduler.last_tick).total_seconds() assert abs(delta - expected_seconds) < 1 @pytest.mark.asyncio async def test_scheduler_falls_back_when_interval_provider_raises() -> None: def broken_provider(): raise RuntimeError("db unavailable") fake = FakeSyncManager() scheduler = SyncScheduler(fake, interval_seconds=42, interval_provider=broken_provider) await scheduler.run_once() delta = (scheduler.next_tick - scheduler.last_tick).total_seconds() assert abs(delta - 42) < 1