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

557 lines
18 KiB
Python

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