From 8d4145ddf9ce2897f0a1bb764dc9f062e6553479 Mon Sep 17 00:00:00 2001 From: chiguyong Date: Tue, 23 Jun 2026 23:49:24 +0800 Subject: [PATCH] feat(calendar): U7 Outlook sync via Microsoft Graph API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutlookSyncProvider implementing AbstractSyncProvider for bidirectional Outlook Calendar sync. Uses Graph API delta query for incremental pull, auto-refreshes OAuth tokens on 401, and converts Outlook recurrence patterns to RRULE. Same conflict resolution as CalDAV (last-write-wins + WS notification). - src/agentkit/calendar/sync/outlook_provider.py — OutlookSyncProvider - tests/unit/calendar/test_sync_outlook.py — 8 tests --- .../calendar/sync/outlook_provider.py | 536 +++++++++++++++++ tests/unit/calendar/test_sync_outlook.py | 556 ++++++++++++++++++ 2 files changed, 1092 insertions(+) create mode 100644 src/agentkit/calendar/sync/outlook_provider.py create mode 100644 tests/unit/calendar/test_sync_outlook.py diff --git a/src/agentkit/calendar/sync/outlook_provider.py b/src/agentkit/calendar/sync/outlook_provider.py new file mode 100644 index 0000000..1c4974f --- /dev/null +++ b/src/agentkit/calendar/sync/outlook_provider.py @@ -0,0 +1,536 @@ +"""Outlook sync provider — bidirectional sync with Microsoft Graph API (U7). + +Uses ``httpx.AsyncClient`` for all Graph API calls. Conflict resolution is +last-write-wins based on ``last_modified``; conflicts emit a +``calendar_sync_conflict`` WS notification via the injectable ``notify_callback`` +(G4). + +ponytail: browser OAuth flow (auth-code grant + redirect) is deferred to U12 +settings UI. This provider assumes tokens are already stored in +``ExternalCalendarConfig.credentials``. Upgrade: add an ``OutlookOAuthFlow`` +helper that performs the device-code or auth-code flow and writes tokens. +""" + +from __future__ import annotations + +import json +import logging +import uuid +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx + +from agentkit.calendar.db import ( + DEFAULT_CALENDAR_DB_PATH, + get_event_by_external_id, + insert_event, + update_event, + update_external_config, +) +from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso +from agentkit.calendar.sync.base import AbstractSyncProvider + +logger = logging.getLogger(__name__) + +# Async callback signature: (event_type: str, payload: dict) -> None +NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]] + +GRAPH_BASE = "https://graph.microsoft.com/v1.0" +TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" +DEFAULT_SCOPE = "https://graph.microsoft.com/Calendars.ReadWrite offline_access" + +_DAY_MAP = { + "monday": "MO", + "tuesday": "TU", + "wednesday": "WE", + "thursday": "TH", + "friday": "FR", + "saturday": "SA", + "sunday": "SU", +} +_DAY_MAP_REVERSE = {v: k for k, v in _DAY_MAP.items()} +_FREQ_MAP = { + "daily": "DAILY", + "weekly": "WEEKLY", + "absoluteMonthly": "MONTHLY", + "absoluteYearly": "YEARLY", +} +_FREQ_MAP_REVERSE = {v: k for k, v in _FREQ_MAP.items()} + + +def _parse_iso(dt_str: str) -> datetime: + """Parse ISO 8601 string to UTC-aware datetime.""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def _outlook_dt_to_iso(dt_obj: dict[str, Any]) -> str: + """Convert Outlook dateTimeTimeZone to ISO 8601 UTC. + + ponytail: assumes Graph returns UTC (no ``Prefer: outlook.timezone`` header + is sent). If a non-UTC timezone is returned, it's treated as UTC. Upgrade: + use ``zoneinfo`` with a Windows→IANA timezone mapping for correct conversion. + """ + if not dt_obj: + return "" + dt_str = dt_obj.get("dateTime", "") + if not dt_str: + return "" + if "T" in dt_str: + dt = datetime.fromisoformat(dt_str) + else: + # Date only (all-day event) + dt = datetime.fromisoformat(dt_str + "T00:00:00") + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).isoformat() + + +def _iso_to_outlook_dt(iso_str: str, is_all_day: bool) -> dict[str, str]: + """Convert ISO 8601 UTC to Outlook dateTimeTimeZone.""" + if not iso_str: + return {"dateTime": "", "timeZone": "UTC"} + dt = _parse_iso(iso_str) + if is_all_day: + return {"dateTime": dt.strftime("%Y-%m-%d"), "timeZone": "UTC"} + return {"dateTime": dt.strftime("%Y-%m-%dT%H:%M:%S"), "timeZone": "UTC"} + + +def _outlook_recurrence_to_rrule(recurrence: dict[str, Any] | None) -> str | None: + """Convert Outlook recurrence pattern to RRULE string.""" + if not recurrence: + return None + pattern = recurrence.get("pattern", {}) or {} + range_obj = recurrence.get("range", {}) or {} + + parts: list[str] = [] + freq = _FREQ_MAP.get(pattern.get("type", "")) + if freq: + parts.append(f"FREQ={freq}") + + interval = pattern.get("interval") + if interval and interval > 1: + parts.append(f"INTERVAL={interval}") + + days = pattern.get("daysOfWeek", []) + if days: + bydays = [_DAY_MAP[d] for d in days if d in _DAY_MAP] + if bydays: + parts.append(f"BYDAY={','.join(bydays)}") + + count = pattern.get("numberOfOccurrences") + if count: + parts.append(f"COUNT={count}") + elif range_obj.get("type") == "endDate" and range_obj.get("endDate"): + end_date = range_obj["endDate"] # "2026-12-31" + parts.append(f"UNTIL={end_date.replace('-', '')}T235959Z") + + return ";".join(parts) if parts else None + + +def _rrule_to_outlook_recurrence(rrule: str, start_date: str) -> dict[str, Any] | None: + """Convert RRULE string to Outlook recurrence pattern. + + ``start_date`` is the event's start date in ``YYYY-MM-DD`` format (required + by Graph API for the ``range.startDate`` field). + """ + parts: dict[str, str] = {} + for part in rrule.split(";"): + if "=" in part: + k, v = part.split("=", 1) + parts[k.upper()] = v + + freq = parts.get("FREQ", "").upper() + pattern_type = _FREQ_MAP_REVERSE.get(freq) + if not pattern_type: + return None + + pattern: dict[str, Any] = {"type": pattern_type} + + interval = parts.get("INTERVAL") + pattern["interval"] = int(interval) if interval else 1 + + byday = parts.get("BYDAY") + if byday: + pattern["daysOfWeek"] = [ + _DAY_MAP_REVERSE[d] for d in byday.split(",") if d in _DAY_MAP_REVERSE + ] + + count = parts.get("COUNT") + until = parts.get("UNTIL") + + if count: + pattern["numberOfOccurrences"] = int(count) + range_obj: dict[str, Any] = { + "type": "numbered", + "startDate": start_date, + "numberOfOccurrences": int(count), + } + elif until: + # UNTIL=20261231T235959Z → "2026-12-31" + date_str = until[:8] + end_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}" + range_obj = {"type": "endDate", "startDate": start_date, "endDate": end_date} + else: + range_obj = {"type": "noEnd", "startDate": start_date} + + return {"pattern": pattern, "range": range_obj} + + +def _extract_delta_token(delta_link: str) -> str | None: + """Extract ``$deltaToken`` from a Graph delta link URL.""" + parsed = urlparse(delta_link) + params = parse_qs(parsed.query) + values = params.get("$deltaToken", []) + return values[0] if values else None + + +class OutlookSyncProvider(AbstractSyncProvider): + """Bidirectional Outlook sync provider via Microsoft Graph REST API. + + The ``client_factory`` parameter allows tests to inject a mock + ``httpx.AsyncClient`` without making real HTTP calls. When ``None``, a + real ``httpx.AsyncClient`` is constructed per-operation. + """ + + def __init__( + self, + db_path: str | Path | None = None, + client_factory: Callable[[], Any] | None = None, + notify_callback: NotifyCallback | None = None, + ) -> None: + self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH + self._client_factory = client_factory + self._notify = notify_callback + # ponytail: conflicts list is in-memory only; if the process restarts + # before a sync completes, conflict history is lost. Upgrade: persist + # to a calendar_sync_conflicts table. + + # ------------------------------------------------------------------ + # Client / auth + # ------------------------------------------------------------------ + + def _get_client(self) -> Any: + """Return an httpx.AsyncClient (real or mock from factory).""" + if self._client_factory is not None: + return self._client_factory() + return httpx.AsyncClient(timeout=30.0) + + def _load_creds(self, config: ExternalCalendarConfig) -> dict[str, Any]: + return json.loads(config.credentials) if config.credentials else {} + + def _save_creds(self, config: ExternalCalendarConfig, creds: dict[str, Any]) -> None: + config.credentials = json.dumps(creds) + + async def _refresh_token(self, client: Any, config: ExternalCalendarConfig) -> dict[str, Any]: + """Refresh the access token using the refresh_token grant. + + Posts to the Azure AD token endpoint, updates ``config.credentials`` + in-memory, and persists the new credentials to the DB. + """ + creds = self._load_creds(config) + resp = await client.request( + "POST", + TOKEN_URL, + data={ + "client_id": creds.get("client_id", ""), + "grant_type": "refresh_token", + "refresh_token": creds.get("refresh_token", ""), + "scope": DEFAULT_SCOPE, + }, + ) + resp.raise_for_status() + payload = resp.json() + creds["access_token"] = payload["access_token"] + if "refresh_token" in payload: + creds["refresh_token"] = payload["refresh_token"] + creds["expires_at"] = ( + datetime.now(timezone.utc) + timedelta(seconds=payload.get("expires_in", 3600)) + ).isoformat() + self._save_creds(config, creds) + await update_external_config(config.id, {"credentials": config.credentials}, self.db_path) + return creds + + async def _request( + self, + client: Any, + config: ExternalCalendarConfig, + method: str, + url: str, + *, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Make an authenticated Graph API request with 401 auto-refresh + retry.""" + creds = self._load_creds(config) + headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"} + resp = await client.request(method, url, headers=headers, json=json_body) + if resp.status_code == 401: + creds = await self._refresh_token(client, config) + headers = {"Authorization": f"Bearer {creds.get('access_token', '')}"} + resp = await client.request(method, url, headers=headers, json=json_body) + resp.raise_for_status() + return resp.json() if resp.text else {} + + # ------------------------------------------------------------------ + # Pull + # ------------------------------------------------------------------ + + async def pull_changes( + self, config: ExternalCalendarConfig, since: str | None = None + ) -> list[CalendarEvent]: + """Pull remote Outlook events via delta query. + + First call (no ``sync_token``) is a full sync within a date range. + Subsequent calls use the stored delta token for incremental sync. + Returns pulled/updated events. + """ + client = self._get_client() + try: + remote_events, delta_token = await self._pull_delta(client, config) + finally: + await client.aclose() + + # Persist delta token for next incremental sync + if delta_token: + config.sync_token = delta_token + await update_external_config(config.id, {"sync_token": delta_token}, self.db_path) + + result: list[CalendarEvent] = [] + for remote in remote_events: + local = await get_event_by_external_id( + remote.external_id, "outlook", config.user_id, self.db_path + ) + if local is None: + # New remote event → create local + await insert_event(remote, self.db_path) + result.append(remote) + else: + # Existing → check for conflict + resolved = await self._resolve_pull_conflict(local, remote) + if resolved is not None: + result.append(resolved) + return result + + async def _pull_delta( + self, client: Any, config: ExternalCalendarConfig + ) -> tuple[list[CalendarEvent], str | None]: + """Call /me/calendarView/delta. Returns (events, delta_token).""" + url = self._build_delta_url(config) + # ponytail: single-page fetch; pagination via @odata.nextLink is not + # followed. Upgrade: loop on nextLink until exhausted, then read + # deltaLink from the final page. + payload = await self._request(client, config, "GET", url) + events: list[CalendarEvent] = [] + for raw in payload.get("value", []): + parsed = self._parse_outlook_event(raw, config.user_id) + if parsed is not None: + events.append(parsed) + delta_link = payload.get("@odata.deltaLink") + delta_token = _extract_delta_token(delta_link) if delta_link else None + return events, delta_token + + def _build_delta_url(self, config: ExternalCalendarConfig) -> str: + """Build the delta query URL. Uses sync_token if present (incremental).""" + if config.sync_token: + return f"{GRAPH_BASE}/me/calendarView/delta?$deltaToken={config.sync_token}" + # Initial sync — use date range to scope the fetch + start = (datetime.now(timezone.utc) - timedelta(days=365)).strftime("%Y-%m-%dT%H:%M:%SZ") + end = (datetime.now(timezone.utc) + timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ") + return f"{GRAPH_BASE}/me/calendarView/delta?startDateTime={start}&endDateTime={end}" + + def _parse_outlook_event(self, raw: dict[str, Any], user_id: str) -> CalendarEvent | None: + """Convert a Graph event JSON to a CalendarEvent dataclass.""" + eid = raw.get("id") + if not eid: + return None + title = raw.get("subject") or "" + if not title: + return None + + start_str = _outlook_dt_to_iso(raw.get("start", {})) + end_str = _outlook_dt_to_iso(raw.get("end", {})) or start_str + is_all_day = bool(raw.get("isAllDay", False)) + + body = raw.get("body", {}) or {} + description = body.get("content", "") or "" + + location_obj = raw.get("location", {}) or {} + location = location_obj.get("displayName", "") or "" + + rrule = _outlook_recurrence_to_rrule(raw.get("recurrence")) + + last_modified = raw.get("lastModifiedDateTime", "") or _now_iso() + now = _now_iso() + return CalendarEvent( + id=uuid.uuid4().hex, + user_id=user_id, + title=title, + description=description, + start_time=start_str, + end_time=end_str, + is_all_day=is_all_day, + location=location, + rrule=rrule, + source="manual", + external_id=eid, + external_provider="outlook", + last_modified=last_modified, + created_at=now, + ) + + async def _resolve_pull_conflict( + self, local: CalendarEvent, remote: CalendarEvent + ) -> CalendarEvent | None: + """Resolve conflict when both local and remote exist (last-write-wins). + + If remote is newer → update local. If local is newer → conflict + (last-write-wins keeps local, but log + notify). If equal → no-op. + Returns the winning event (or None if local kept unchanged). + """ + local_lm = ( + _parse_iso(local.last_modified) + if local.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + remote_lm = ( + _parse_iso(remote.last_modified) + if remote.last_modified + else datetime.min.replace(tzinfo=timezone.utc) + ) + + if remote_lm > local_lm: + # Remote wins → update local + fields = { + "title": remote.title, + "description": remote.description, + "start_time": remote.start_time, + "end_time": remote.end_time, + "is_all_day": remote.is_all_day, + "location": remote.location, + "rrule": remote.rrule, + "last_modified": remote.last_modified, + } + await update_event(local.id, fields, self.db_path) + return remote + + if local_lm > remote_lm: + # Local wins → conflict, keep local, notify + await self._notify_conflict(local, remote, winner="local") + return None + + # Equal timestamps → no change needed + return None + + # ------------------------------------------------------------------ + # Push + # ------------------------------------------------------------------ + + async def push_changes( + self, config: ExternalCalendarConfig, events: list[CalendarEvent] + ) -> list[CalendarEvent]: + """Push local events to Outlook. Returns events with external_id set.""" + client = self._get_client() + try: + result: list[CalendarEvent] = [] + for event in events: + updated = await self._push_single(client, config, event) + result.append(updated) + finally: + await client.aclose() + return result + + async def _push_single( + self, client: Any, config: ExternalCalendarConfig, event: CalendarEvent + ) -> CalendarEvent: + """Push a single event to Outlook, return event with external_id set.""" + body = self._event_to_outlook(event) + if event.external_id: + # Update existing remote event + url = f"{GRAPH_BASE}/me/events/{event.external_id}" + await self._request(client, config, "PATCH", url, json_body=body) + fields = {"last_modified": _now_iso()} + await update_event(event.id, fields, self.db_path) + return event + # Create new remote event + url = f"{GRAPH_BASE}/me/events" + payload = await self._request(client, config, "POST", url, json_body=body) + new_id = payload.get("id") + if new_id: + fields = { + "external_id": new_id, + "external_provider": "outlook", + "last_modified": _now_iso(), + } + await update_event(event.id, fields, self.db_path) + event.external_id = new_id + event.external_provider = "outlook" + return event + + def _event_to_outlook(self, event: CalendarEvent) -> dict[str, Any]: + """Convert CalendarEvent to Outlook Graph event JSON.""" + body: dict[str, Any] = { + "subject": event.title, + "start": _iso_to_outlook_dt(event.start_time, event.is_all_day), + "end": _iso_to_outlook_dt(event.end_time, event.is_all_day), + "isAllDay": event.is_all_day, + } + if event.description: + body["body"] = {"contentType": "Text", "content": event.description} + if event.location: + body["location"] = {"displayName": event.location} + if event.rrule: + start_date = event.start_time[:10] if event.start_time else "2026-01-01" + rec = _rrule_to_outlook_recurrence(event.rrule, start_date) + if rec is not None: + body["recurrence"] = rec + return body + + # ------------------------------------------------------------------ + # Test connection + # ------------------------------------------------------------------ + + async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]: + """Test Outlook connectivity via GET /me. Returns (ok, error_msg).""" + client = self._get_client() + try: + try: + await self._request(client, config, "GET", f"{GRAPH_BASE}/me") + ok, msg = True, "" + except Exception as e: + ok, msg = False, str(e) + finally: + await client.aclose() + return ok, msg + + # ------------------------------------------------------------------ + # Conflict notification + # ------------------------------------------------------------------ + + async def _notify_conflict( + self, local: CalendarEvent, remote: CalendarEvent, winner: str + ) -> None: + """Log conflict and send WS notification via callback (G4).""" + logger.info( + "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s", + local.id, + local.external_id, + local.last_modified, + remote.last_modified, + winner, + ) + if self._notify is not None: + payload = { + "event_id": local.id, + "title": local.title, + "external_id": local.external_id, + "local_last_modified": local.last_modified, + "remote_last_modified": remote.last_modified, + "winner": winner, + } + await self._notify("calendar_sync_conflict", payload) diff --git a/tests/unit/calendar/test_sync_outlook.py b/tests/unit/calendar/test_sync_outlook.py new file mode 100644 index 0000000..87c450a --- /dev/null +++ b/tests/unit/calendar/test_sync_outlook.py @@ -0,0 +1,556 @@ +"""Tests for OutlookSyncProvider — bidirectional Outlook Calendar sync (U7). + +All Microsoft Graph API interactions are mocked via the ``client_factory`` +injection point. No real HTTP calls are made. +""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import pytest + +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.outlook_provider import OutlookSyncProvider + +USER_ID = "user-1" + + +# --------------------------------------------------------------------------- +# Mock helpers +# --------------------------------------------------------------------------- + + +class MockResponse: + """Minimal mock of an httpx.Response.""" + + def __init__(self, status_code: int = 200, json_data: Any = None) -> None: + self.status_code = status_code + self._json = json_data + self.text = json.dumps(json_data) if json_data is not None else "" + + def json(self) -> Any: + return self._json if self._json is not None else {} + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class MockOutlookClient: + """Mock httpx.AsyncClient — records requests, returns queued responses.""" + + def __init__(self) -> None: + self.requests: list[dict[str, Any]] = [] + self._responses: list[MockResponse] = [] + + def add_response(self, response: MockResponse) -> None: + self._responses.append(response) + + async def request( + self, + method: str, + url: str, + *, + headers: Any = None, + json: Any = None, + params: Any = None, + data: Any = None, + ) -> MockResponse: + self.requests.append( + { + "method": method, + "url": url, + "headers": headers, + "json": json, + "params": params, + "data": data, + } + ) + if self._responses: + return self._responses.pop(0) + return MockResponse(status_code=200, json_data={}) + + async def aclose(self) -> None: + pass + + +def make_outlook_event( + eid: str = "outlook-1", + subject: str = "Outlook Event", + start: str = "2026-07-01T10:00:00", + end: str = "2026-07-01T11:00:00", + is_all_day: bool = False, + description: str = "", + location: str = "", + recurrence: dict[str, Any] | None = None, + last_modified: str = "2026-06-01T00:00:00Z", +) -> dict[str, Any]: + """Build a minimal Graph event JSON dict.""" + event: dict[str, Any] = { + "id": eid, + "subject": subject, + "start": {"dateTime": start, "timeZone": "UTC"}, + "end": {"dateTime": end, "timeZone": "UTC"}, + "isAllDay": is_all_day, + "lastModifiedDateTime": last_modified, + } + if description: + event["body"] = {"contentType": "Text", "content": description} + if location: + event["location"] = {"displayName": location} + if recurrence: + event["recurrence"] = recurrence + return event + + +def make_config( + config_id: str = "config-1", + user_id: str = USER_ID, + provider: str = "outlook", + last_sync: str | None = None, + sync_token: str | None = None, + access_token: str = "test-token", + refresh_token: str = "test-refresh", +) -> ExternalCalendarConfig: + return ExternalCalendarConfig( + id=config_id, + user_id=user_id, + provider=provider, + credentials=json.dumps( + { + "access_token": access_token, + "refresh_token": refresh_token, + "client_id": "test-client-id", + "expires_at": "2026-12-31T00:00:00+00:00", + } + ), + last_sync=last_sync, + sync_token=sync_token, + ) + + +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="outlook" if external_id else None, + last_modified=last_modified, + created_at=last_modified, + rrule=rrule, + ) + + +def client_factory_from(client: MockOutlookClient): + """Wrap a mock client in a client_factory callable.""" + + def factory() -> MockOutlookClient: + return client + + return factory + + +# --------------------------------------------------------------------------- +# 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_outlook_provider_pull_delta_initial_sync( + calendar_db_path: Path, +) -> None: + """Mock returns 3 events → 3 local events created with external_id set.""" + events = [ + make_outlook_event(eid="outlook-1", subject="Event 1"), + make_outlook_event(eid="outlook-2", subject="Event 2"), + make_outlook_event(eid="outlook-3", subject="Event 3"), + ] + response = MockResponse( + 200, + { + "value": events, + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=initial-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 3 + db_events = await list_events(USER_ID, db_path=calendar_db_path) + assert len(db_events) == 3 + titles = {e.title for e in db_events} + assert titles == {"Event 1", "Event 2", "Event 3"} + for event in db_events: + assert event.external_id is not None + assert event.external_provider == "outlook" + # Delta token stored for next incremental sync + assert config.sync_token == "initial-token" + + +async def test_outlook_provider_pull_delta_incremental( + calendar_db_path: Path, +) -> None: + """Config has sync_token → incremental sync. 1 new + 1 updated processed.""" + # Pre-insert a local event that will be updated by the remote + local = make_local_event( + event_id="evt-1", + title="Old Title", + external_id="outlook-existing", + last_modified="2026-01-01T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + new_event = make_outlook_event(eid="outlook-new", subject="New Event") + updated_event = make_outlook_event( + eid="outlook-existing", + subject="Updated Title", + last_modified="2026-06-15T00:00:00Z", # newer than local + ) + response = MockResponse( + 200, + { + "value": [new_event, updated_event], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=new-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config(sync_token="previous-token") + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 2 # 1 new + 1 updated + + # Verify the request URL contains the delta token (incremental sync) + assert "$deltaToken=previous-token" in mock_client.requests[0]["url"] + + # New event was created + new_db = await get_event_by_external_id("outlook-new", "outlook", USER_ID, calendar_db_path) + assert new_db is not None + assert new_db.title == "New Event" + + # Existing event was updated (remote was newer) + updated_db = await get_event_by_external_id( + "outlook-existing", "outlook", USER_ID, calendar_db_path + ) + assert updated_db is not None + assert updated_db.title == "Updated Title" + + # Delta token updated + assert config.sync_token == "new-token" + + +# --------------------------------------------------------------------------- +# Push tests +# --------------------------------------------------------------------------- + + +async def test_outlook_provider_push_creates_remote_event( + calendar_db_path: Path, +) -> None: + """Local event with no external_id → POST creates remote, ID stored.""" + local = make_local_event(event_id="evt-1", title="New Local Event", external_id=None) + await insert_event(local, calendar_db_path) + + mock_client = MockOutlookClient() + mock_client.add_response( + MockResponse(201, {"id": "remote-uid-1", "subject": "New Local Event"}) + ) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_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 == "outlook" + + # Verify POST was called to /me/events + req = mock_client.requests[0] + assert req["method"] == "POST" + assert "/me/events" in req["url"] + assert req["json"]["subject"] == "New Local Event" + + # DB updated with external_id + db_event = await get_event_by_external_id("remote-uid-1", "outlook", USER_ID, calendar_db_path) + assert db_event is not None + + +async def test_outlook_provider_push_updates_remote_event( + calendar_db_path: Path, +) -> None: + """Local event with external_id → PATCH 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) + + mock_client = MockOutlookClient() + mock_client.add_response(MockResponse(200, {"id": "existing-uid"})) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_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 PATCH was called to /me/events/{id} + req = mock_client.requests[0] + assert req["method"] == "PATCH" + assert "/me/events/existing-uid" in req["url"] + assert req["json"]["subject"] == "Updated Local Event" + + +# --------------------------------------------------------------------------- +# Token refresh test +# --------------------------------------------------------------------------- + + +async def test_outlook_token_refresh_on_401(calendar_db_path: Path) -> None: + """Mock 401 → refresh token used → request retried with new token.""" + mock_client = MockOutlookClient() + # 1. First GET → 401 (token expired) + mock_client.add_response(MockResponse(401, {"error": "token expired"})) + # 2. Token refresh POST → 200 with new tokens + mock_client.add_response( + MockResponse( + 200, + { + "access_token": "new-token", + "refresh_token": "new-refresh", + "expires_in": 3600, + }, + ) + ) + # 3. Retry GET → 200 with events + mock_client.add_response( + MockResponse( + 200, + { + "value": [make_outlook_event(eid="outlook-1", subject="Refreshed Event")], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta" + "?$deltaToken=after-refresh" + ), + }, + ) + ) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config(access_token="expired-token") + + pulled = await provider.pull_changes(config) + + assert len(pulled) == 1 + assert pulled[0].title == "Refreshed Event" + + # 3 requests: GET (401), POST (refresh), GET (retry) + assert len(mock_client.requests) == 3 + assert mock_client.requests[0]["method"] == "GET" + assert mock_client.requests[1]["method"] == "POST" + assert mock_client.requests[2]["method"] == "GET" + + # Token refresh hit the Azure AD token endpoint + assert "login.microsoftonline.com" in mock_client.requests[1]["url"] + assert mock_client.requests[1]["data"]["grant_type"] == "refresh_token" + assert mock_client.requests[1]["data"]["refresh_token"] == "test-refresh" + + # Credentials updated in config + creds = json.loads(config.credentials) + assert creds["access_token"] == "new-token" + assert creds["refresh_token"] == "new-refresh" + + # Retry used the new access token + retry_headers = mock_client.requests[2]["headers"] + assert retry_headers["Authorization"] == "Bearer new-token" + + +# --------------------------------------------------------------------------- +# Conflict test +# --------------------------------------------------------------------------- + + +async def test_outlook_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="outlook-1", + last_modified="2026-06-15T00:00:00+00:00", + ) + await insert_event(local, calendar_db_path) + + remote = make_outlook_event( + eid="outlook-1", + subject="Remote Older", + last_modified="2026-06-01T00:00:00Z", # older than local + ) + response = MockResponse(200, {"value": [remote]}) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_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("outlook-1", "outlook", USER_ID, calendar_db_path) + assert db_event is not None + assert db_event.title == "Local Updated" + + +# --------------------------------------------------------------------------- +# RRULE roundtrip test +# --------------------------------------------------------------------------- + + +async def test_outlook_rrule_roundtrip(calendar_db_path: Path) -> None: + """Recurring event synced → RRULE preserved in both pull and push.""" + recurrence = { + "pattern": { + "type": "weekly", + "interval": 1, + "daysOfWeek": ["monday"], + "numberOfOccurrences": 4, + }, + "range": { + "type": "numbered", + "startDate": "2026-07-01", + "numberOfOccurrences": 4, + }, + } + remote = make_outlook_event( + eid="outlook-rrule", subject="Weekly Meeting", recurrence=recurrence + ) + response = MockResponse( + 200, + { + "value": [remote], + "@odata.deltaLink": ( + "https://graph.microsoft.com/v1.0/me/calendarView/delta?$deltaToken=rrule-token" + ), + }, + ) + mock_client = MockOutlookClient() + mock_client.add_response(response) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_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 recurrence pattern in request body sent to remote + events = await list_events(USER_ID, db_path=calendar_db_path) + mock_client2 = MockOutlookClient() + mock_client2.add_response( + MockResponse(201, {"id": "outlook-rrule", "subject": "Weekly Meeting"}) + ) + provider2 = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client2) + ) + await provider2.push_changes(config, events) + + req = mock_client2.requests[0] + assert req["method"] == "PATCH" + assert "/me/events/outlook-rrule" in req["url"] + body = req["json"] + assert "recurrence" in body + assert body["recurrence"]["pattern"]["type"] == "weekly" + assert body["recurrence"]["pattern"]["daysOfWeek"] == ["monday"] + assert body["recurrence"]["pattern"]["numberOfOccurrences"] == 4 + + +# --------------------------------------------------------------------------- +# test_connection tests +# --------------------------------------------------------------------------- + + +async def test_outlook_test_connection_success(calendar_db_path: Path) -> None: + """Mock Graph /me returns user profile → test_connection() returns (True, '').""" + mock_client = MockOutlookClient() + mock_client.add_response(MockResponse(200, {"id": "user-id", "displayName": "Test User"})) + + provider = OutlookSyncProvider( + db_path=calendar_db_path, client_factory=client_factory_from(mock_client) + ) + config = make_config() + + ok, msg = await provider.test_connection(config) + assert ok is True + assert msg == "" + + # Verify GET /me was called + req = mock_client.requests[0] + assert req["method"] == "GET" + assert "/me" in req["url"]