198 lines
6.1 KiB
Python
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
|