"""Tests for CalDAVSyncProvider — bidirectional Apple Calendar sync (U6). The ``caldav`` library is not installed in the test environment, so we inject a mock module into ``sys.modules`` before importing the provider. All CalDAV interactions are mocked via the ``client_factory`` injection point. """ 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 in the test env — inject a mock module so that # `import caldav` in caldav_provider.py succeeds at import time. if "caldav" not in sys.modules: # pragma: no cover sys.modules["caldav"] = MagicMock() from agentkit.calendar.db import ( get_event_by_external_id, init_calendar_db, insert_event, list_events, ) from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig from agentkit.calendar.sync.caldav_provider import CalDAVSyncProvider USER_ID = "user-1" # --------------------------------------------------------------------------- # ICS + Mock helpers # --------------------------------------------------------------------------- def make_ics( uid: str, summary: str, start: str = "20260701T100000Z", end: str = "20260701T110000Z", description: str = "", location: str = "", rrule: str | None = None, last_modified: str = "20260601T000000Z", ) -> bytes: """Build a minimal valid ICS bytes payload for a single VEVENT.""" lines = [ b"BEGIN:VCALENDAR", b"VERSION:2.0", b"PRODID:-//Test//Test//EN", b"BEGIN:VEVENT", f"UID:{uid}".encode(), f"SUMMARY:{summary}".encode(), f"DTSTART:{start}".encode(), f"DTEND:{end}".encode(), ] if description: lines.append(f"DESCRIPTION:{description}".encode()) if location: lines.append(f"LOCATION:{location}".encode()) if rrule: lines.append(f"RRULE:{rrule}".encode()) if last_modified: lines.append(f"LAST-MODIFIED:{last_modified}".encode()) lines.extend([b"END:VEVENT", b"END:VCALENDAR"]) return b"\r\n".join(lines) class MockCaldavEvent: """Mock caldav.Event — exposes .data returning ICS bytes.""" def __init__(self, ics_data: bytes) -> None: self.data = ics_data def make_mock_client( remote_events: list[bytes], saved_uid: str | None = None, raise_on_connect: Exception | None = None, ) -> tuple[Any, Any]: """Create a mock CalDAV client and its mock calendar. Returns ``(client, mock_calendar)``. ``mock_calendar`` is None when ``raise_on_connect`` is set (connection fails before calendar is reached). """ client = MagicMock() if raise_on_connect is not None: client.principal.side_effect = raise_on_connect return client, None principal = MagicMock() calendar = MagicMock() calendar.date_search.return_value = [MockCaldavEvent(d) for d in remote_events] saved_ics = make_ics(saved_uid or "saved-uid", "Saved Event") calendar.save_event.return_value = MockCaldavEvent(saved_ics) principal.calendars.return_value = [calendar] client.principal.return_value = principal return client, calendar def client_factory_from(client: Any) -> Any: """Wrap a mock client in a client_factory callable.""" def factory(config: ExternalCalendarConfig) -> Any: return client return factory 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_local_event( event_id: str = "evt-1", title: str = "Local Event", external_id: str | None = None, last_modified: str = "2026-01-01T00:00:00+00:00", rrule: 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, rrule=rrule, ) # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # Pull tests # --------------------------------------------------------------------------- async def test_caldav_provider_pull_creates_local_events(calendar_db_path: Path) -> None: """Mock returns 2 events → 2 local events created with external_id set.""" ics1 = make_ics("uid-1", "Remote Event 1") ics2 = make_ics("uid-2", "Remote Event 2") client, _ = make_mock_client([ics1, ics2]) provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() pulled = await provider.pull_changes(config) assert len(pulled) == 2 events = await list_events(USER_ID, db_path=calendar_db_path) assert len(events) == 2 titles = {e.title for e in events} assert titles == {"Remote Event 1", "Remote Event 2"} for event in events: assert event.external_id is not None assert event.external_provider == "caldav" async def test_caldav_provider_pull_updates_existing_event(calendar_db_path: Path) -> None: """Local event exists (matched by external_id), remote is newer → local updated.""" local = make_local_event( event_id="evt-1", title="Old Title", external_id="uid-1", last_modified="2026-01-01T00:00:00+00:00", ) await insert_event(local, calendar_db_path) ics_remote = make_ics("uid-1", "Updated Title", last_modified="20260601T120000Z") client, _ = make_mock_client([ics_remote]) provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() pulled = await provider.pull_changes(config) assert len(pulled) == 1 updated = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path) assert updated is not None assert updated.title == "Updated Title" # --------------------------------------------------------------------------- # Push tests # --------------------------------------------------------------------------- async def test_caldav_provider_push_creates_remote_event(calendar_db_path: Path) -> None: """Local event with no external_id → push creates remote, external_id stored.""" local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None) await insert_event(local, calendar_db_path) client, _ = make_mock_client([], saved_uid="remote-uid-1") provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() events = await list_events(USER_ID, db_path=calendar_db_path) pushed = await provider.push_changes(config, events) assert len(pushed) == 1 assert pushed[0].external_id == "remote-uid-1" assert pushed[0].external_provider == "caldav" db_event = await get_event_by_external_id("remote-uid-1", "caldav", USER_ID, calendar_db_path) assert db_event is not None async def test_caldav_provider_push_updates_remote_event(calendar_db_path: Path) -> None: """Local event modified, has external_id → push updates remote.""" local = make_local_event( event_id="evt-1", title="Updated Local Event", external_id="existing-uid", last_modified="2026-06-01T00:00:00+00:00", ) await insert_event(local, calendar_db_path) client, mock_calendar = make_mock_client([], saved_uid="existing-uid") provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() events = await list_events(USER_ID, db_path=calendar_db_path) pushed = await provider.push_changes(config, events) assert len(pushed) == 1 assert pushed[0].external_id == "existing-uid" # Verify save_event was called with ICS containing the existing UID saved_ics = mock_calendar.save_event.call_args[0][0] assert b"UID:existing-uid" in saved_ics # --------------------------------------------------------------------------- # Conflict tests # --------------------------------------------------------------------------- async def test_caldav_conflict_last_write_wins(calendar_db_path: Path) -> None: """Both sides modified, local is newer → local wins, local not updated.""" local = make_local_event( event_id="evt-1", title="Local Updated", external_id="uid-1", last_modified="2026-06-15T00:00:00+00:00", ) await insert_event(local, calendar_db_path) ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z") client, _ = make_mock_client([ics_remote]) provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() pulled = await provider.pull_changes(config) # Local wins → no update, pulled is empty assert len(pulled) == 0 db_event = await get_event_by_external_id("uid-1", "caldav", USER_ID, calendar_db_path) assert db_event is not None assert db_event.title == "Local Updated" async def test_caldav_conflict_sends_ws_notification(calendar_db_path: Path) -> None: """Conflict detected → WS callback called with calendar_sync_conflict type (G4).""" notifications: list[tuple[str, dict[str, Any]]] = [] async def notify(event_type: str, payload: dict[str, Any]) -> None: notifications.append((event_type, payload)) local = make_local_event( event_id="evt-1", title="Local Updated", external_id="uid-1", last_modified="2026-06-15T00:00:00+00:00", ) await insert_event(local, calendar_db_path) ics_remote = make_ics("uid-1", "Remote Older", last_modified="20260601T000000Z") client, _ = make_mock_client([ics_remote]) provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client), notify_callback=notify, ) config = make_config() await provider.pull_changes(config) assert len(notifications) == 1 event_type, payload = notifications[0] assert event_type == "calendar_sync_conflict" assert payload["event_id"] == "evt-1" assert payload["winner"] == "local" # --------------------------------------------------------------------------- # RRULE roundtrip test # --------------------------------------------------------------------------- async def test_caldav_rrule_roundtrip(calendar_db_path: Path) -> None: """Event with RRULE synced → RRULE preserved in both pull and push.""" rrule = "FREQ=WEEKLY;BYDAY=MO;COUNT=4" ics_remote = make_ics("uid-rrule", "Weekly Meeting", rrule=rrule) client, _ = make_mock_client([ics_remote]) provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() # Pull: verify RRULE preserved pulled = await provider.pull_changes(config) assert len(pulled) == 1 assert pulled[0].rrule is not None assert "FREQ=WEEKLY" in pulled[0].rrule assert "BYDAY=MO" in pulled[0].rrule assert "COUNT=4" in pulled[0].rrule # Push: verify RRULE in ICS sent to remote events = await list_events(USER_ID, db_path=calendar_db_path) client2, mock_calendar2 = make_mock_client([], saved_uid="uid-rrule") provider2 = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client2) ) await provider2.push_changes(config, events) saved_ics = mock_calendar2.save_event.call_args[0][0] assert b"RRULE:FREQ=WEEKLY" in saved_ics assert b"BYDAY=MO" in saved_ics assert b"COUNT=4" in saved_ics # --------------------------------------------------------------------------- # test_connection tests # --------------------------------------------------------------------------- async def test_caldav_test_connection_success(calendar_db_path: Path) -> None: """Mock returns calendars → test_connection() returns (True, "").""" client, _ = make_mock_client([]) provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() ok, msg = await provider.test_connection(config) assert ok is True assert msg == "" async def test_caldav_test_connection_failure(calendar_db_path: Path) -> None: """Mock raises → test_connection() returns (False, error_msg).""" client, _ = make_mock_client([], raise_on_connect=ConnectionError("Auth failed")) provider = CalDAVSyncProvider( db_path=calendar_db_path, client_factory=client_factory_from(client) ) config = make_config() ok, msg = await provider.test_connection(config) assert ok is False assert "Auth failed" in msg