feat(calendar): U7 Outlook sync via Microsoft Graph API
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
This commit is contained in:
parent
40bc27822f
commit
8d4145ddf9
|
|
@ -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)
|
||||
|
|
@ -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"]
|
||||
Loading…
Reference in New Issue