557 lines
18 KiB
Python
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"]
|