"""Tests for SyncManager — orchestrates external calendar sync (U6, G8). Uses a mock ``AbstractSyncProvider`` to test SyncManager in isolation, verifying provider iteration, last_sync updates, and conflict WS push. """ from __future__ import annotations import asyncio import json import sys from pathlib import Path from typing import Any from unittest.mock import MagicMock import pytest # caldav is not installed — inject mock so SyncManager's import of # CalDAVSyncProvider (which imports caldav) does not fail. if "caldav" not in sys.modules: # pragma: no cover sys.modules["caldav"] = MagicMock() from agentkit.calendar.db import ( init_calendar_db, insert_external_config, list_external_configs, ) from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig from agentkit.calendar.sync.base import AbstractSyncProvider from agentkit.calendar.sync.manager import SyncManager USER_ID = "user-1" # --------------------------------------------------------------------------- # Mock provider + helpers # --------------------------------------------------------------------------- class MockSyncProvider(AbstractSyncProvider): """In-memory mock provider that records calls for assertion.""" def __init__(self) -> None: self.pull_calls: int = 0 self.push_calls: int = 0 self.pull_configs: list[ExternalCalendarConfig] = [] self.push_configs: list[ExternalCalendarConfig] = [] async def pull_changes( self, config: ExternalCalendarConfig, since: str | None = None ) -> list[CalendarEvent]: self.pull_calls += 1 self.pull_configs.append(config) return [] async def push_changes( self, config: ExternalCalendarConfig, events: list[CalendarEvent] ) -> list[CalendarEvent]: self.push_calls += 1 self.push_configs.append(config) return events async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: return True, "" def make_config( config_id: str = "config-1", user_id: str = USER_ID, provider: str = "caldav", last_sync: str | None = None, ) -> ExternalCalendarConfig: return ExternalCalendarConfig( id=config_id, user_id=user_id, provider=provider, credentials=json.dumps( {"url": "https://caldav.example.com", "username": "user", "password": "pass"} ), last_sync=last_sync, ) def make_event( event_id: str = "evt-1", title: str = "Test Event", last_modified: str = "2026-06-01T00:00:00+00:00", external_id: str | None = None, ) -> CalendarEvent: return CalendarEvent( id=event_id, user_id=USER_ID, title=title, start_time="2026-07-01T10:00:00+00:00", end_time="2026-07-01T11:00:00+00:00", external_id=external_id, external_provider="caldav" if external_id else None, last_modified=last_modified, created_at=last_modified, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def calendar_db_path(tmp_path: Path) -> Path: path = tmp_path / "test_calendar.db" asyncio.run(init_calendar_db(path)) return path # --------------------------------------------------------------------------- # sync_all tests # --------------------------------------------------------------------------- async def test_sync_manager_sync_all_iterates_providers( calendar_db_path: Path, ) -> None: """2 configs → both providers called (G8).""" config1 = make_config(config_id="config-1") config2 = make_config(config_id="config-2") await insert_external_config(config1, calendar_db_path) await insert_external_config(config2, calendar_db_path) mock_provider = MockSyncProvider() manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider}) result = await manager.sync_all(USER_ID) assert result["synced"] == 2 assert result["errors"] == [] assert mock_provider.pull_calls == 2 assert mock_provider.push_calls == 2 # --------------------------------------------------------------------------- # sync_provider tests # --------------------------------------------------------------------------- async def test_sync_manager_sync_provider_updates_last_sync( calendar_db_path: Path, ) -> None: """Sync completes → config.last_sync updated (G8).""" config = make_config(config_id="config-1", last_sync=None) await insert_external_config(config, calendar_db_path) mock_provider = MockSyncProvider() manager = SyncManager(db_path=calendar_db_path, providers={"caldav": mock_provider}) await manager.sync_provider("config-1") configs = await list_external_configs(USER_ID, calendar_db_path) assert len(configs) == 1 assert configs[0].last_sync is not None # --------------------------------------------------------------------------- # resolve_conflict tests # --------------------------------------------------------------------------- async def test_sync_manager_resolve_conflict_notifies_user( calendar_db_path: Path, ) -> None: """Conflict → WS callback called with calendar_sync_conflict type (G8/G4).""" notifications: list[tuple[str, dict[str, Any]]] = [] async def notify(event_type: str, payload: dict[str, Any]) -> None: notifications.append((event_type, payload)) manager = SyncManager(db_path=calendar_db_path, notify_callback=notify) local = make_event( event_id="evt-1", title="Local Version", last_modified="2026-06-15T00:00:00+00:00", external_id="uid-1", ) remote = make_event( event_id="evt-remote", title="Remote Version", last_modified="2026-06-01T00:00:00+00:00", external_id="uid-1", ) winner = await manager.resolve_conflict(local, remote) assert len(notifications) == 1 event_type, payload = notifications[0] assert event_type == "calendar_sync_conflict" assert payload["winner"] == "local" assert winner is local # Local is newer → local wins