fischer-agentkit/tests/unit/calendar/test_sync_manager.py

198 lines
6.1 KiB
Python

"""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