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