Files
noteflow/tests/application/test_calendar_service.py

504 lines
19 KiB
Python

"""Calendar service tests."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from noteflow.application.services.calendar import CalendarService, CalendarServiceError
from noteflow.config.settings import CalendarIntegrationSettings
from noteflow.domain.constants.fields import (
OAUTH_OVERRIDE_CLIENT_ID,
OAUTH_OVERRIDE_CLIENT_SECRET,
OAUTH_OVERRIDE_ENABLED,
OAUTH_OVERRIDE_REDIRECT_URI,
OAUTH_OVERRIDE_SCOPES,
PROVIDER,
)
from noteflow.domain.entities import Integration, IntegrationStatus, IntegrationType
from noteflow.domain.ports.calendar import CalendarEventInfo
from noteflow.domain.value_objects import OAuthClientConfig, OAuthTokens
@pytest.fixture
def mock_calendar_oauth_manager() -> MagicMock:
"""Create mock OAuth manager."""
manager = MagicMock()
manager.GOOGLE_SCOPES = ["calendar.read"]
manager.OUTLOOK_SCOPES = ["Calendars.Read"]
manager.initiate_auth.return_value = ("https://auth.example.com", "state-123")
manager.complete_auth = AsyncMock(
return_value=OAuthTokens(
access_token="access-token",
refresh_token="refresh-token",
token_type="Bearer",
expires_at=datetime.now(UTC) + timedelta(hours=1),
scope="email calendar",
)
)
manager.refresh_tokens = AsyncMock(
return_value=OAuthTokens(
access_token="new-access-token",
refresh_token="refresh-token",
token_type="Bearer",
expires_at=datetime.now(UTC) + timedelta(hours=1),
scope="email calendar",
)
)
manager.revoke_tokens = AsyncMock()
return manager
@pytest.fixture
def mock_google_adapter() -> MagicMock:
"""Create mock Google Calendar adapter."""
adapter = MagicMock()
adapter.list_events = AsyncMock(
return_value=[
CalendarEventInfo(
id="event-1",
title="Test Meeting",
start_time=datetime.now(UTC) + timedelta(hours=1),
end_time=datetime.now(UTC) + timedelta(hours=2),
attendees=("alice@example.com",),
provider="google",
)
]
)
adapter.get_user_email = AsyncMock(return_value="user@gmail.com")
return adapter
@pytest.fixture
def mock_outlook_adapter() -> MagicMock:
"""Create mock Outlook Calendar adapter."""
adapter = MagicMock()
adapter.list_events = AsyncMock(return_value=[])
adapter.get_user_email = AsyncMock(return_value="user@outlook.com")
return adapter
@pytest.fixture
def calendar_mock_uow(mock_uow: MagicMock) -> MagicMock:
"""Configure mock_uow with calendar service integrations behavior."""
mock_uow.integrations.get_by_type_and_provider = AsyncMock(return_value=None)
mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
mock_uow.integrations.add = AsyncMock()
mock_uow.integrations.get_secrets = AsyncMock(return_value=None)
mock_uow.integrations.set_secrets = AsyncMock()
return mock_uow
@pytest.fixture
def calendar_service(
calendar_settings: CalendarIntegrationSettings,
mock_calendar_oauth_manager: MagicMock,
mock_google_adapter: MagicMock,
mock_outlook_adapter: MagicMock,
calendar_mock_uow: MagicMock,
) -> CalendarService:
"""Create a CalendarService instance with all mock dependencies."""
return CalendarService(
uow_factory=lambda: calendar_mock_uow,
settings=calendar_settings,
oauth_manager=mock_calendar_oauth_manager,
google_adapter=mock_google_adapter,
outlook_adapter=mock_outlook_adapter,
)
@pytest.fixture
def connected_google_integration() -> Integration:
"""Create a connected Google Calendar integration."""
integration = Integration.create(
workspace_id=uuid4(),
name="Google Calendar",
integration_type=IntegrationType.CALENDAR,
config={"provider": "google"},
)
integration.connect(provider_email="user@gmail.com")
return integration
@pytest.fixture
def valid_token_secrets() -> dict[str, str]:
"""Create valid (non-expired) OAuth token secrets."""
return {
"access_token": "token",
"refresh_token": "refresh",
"token_type": "Bearer",
"expires_at": (datetime.now(UTC) + timedelta(hours=1)).isoformat(),
"scope": "calendar",
}
@pytest.fixture
def expired_token_secrets() -> dict[str, str]:
"""Create expired OAuth token secrets to test refresh."""
return {
"access_token": "expired-token",
"refresh_token": "refresh-token",
"token_type": "Bearer",
"expires_at": (datetime.now(UTC) - timedelta(hours=1)).isoformat(),
"scope": "calendar",
}
class TestCalendarServiceInitiateOAuth:
"""CalendarService.initiate_oauth tests."""
@pytest.mark.asyncio
async def test_initiate_oauth_returns_auth_url_and_state(
self,
calendar_service: CalendarService,
mock_calendar_oauth_manager: MagicMock,
) -> None:
"""initiate_oauth should return auth URL and state."""
auth_url, state = await calendar_service.initiate_oauth(
"google", "http://localhost/callback"
)
assert auth_url == "https://auth.example.com", "Auth URL should match expected value"
assert state == "state-123", "State token should match expected value"
mock_calendar_oauth_manager.initiate_auth.assert_called_once()
class TestCalendarServiceCompleteOAuth:
"""CalendarService.complete_oauth tests."""
@pytest.mark.asyncio
async def test_complete_oauth_stores_tokens(
self,
calendar_service: CalendarService,
mock_calendar_oauth_manager: MagicMock,
calendar_mock_uow: MagicMock,
) -> None:
"""complete_oauth should store tokens in integration secrets."""
from uuid import UUID as UUIDType
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
calendar_mock_uow.integrations.create = AsyncMock()
calendar_mock_uow.integrations.update = AsyncMock()
integration_id = await calendar_service.complete_oauth("google", "auth-code", "state-123")
assert isinstance(integration_id, UUIDType)
mock_calendar_oauth_manager.complete_auth.assert_called_once()
calendar_mock_uow.integrations.set_secrets.assert_called_once()
calendar_mock_uow.commit.assert_called()
@pytest.mark.asyncio
async def test_complete_oauth_creates_integration_when_none_exists(
self,
calendar_service: CalendarService,
calendar_mock_uow: MagicMock,
) -> None:
"""complete_oauth should create new integration when none exists."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
calendar_mock_uow.integrations.create = AsyncMock()
calendar_mock_uow.integrations.update = AsyncMock()
await calendar_service.complete_oauth("google", "auth-code", "state-123")
calendar_mock_uow.integrations.create.assert_called_once()
@pytest.mark.asyncio
async def test_complete_oauth_updates_existing_integration(
self,
calendar_service: CalendarService,
connected_google_integration: Integration,
calendar_mock_uow: MagicMock,
) -> None:
"""complete_oauth should update existing integration."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(
return_value=connected_google_integration
)
calendar_mock_uow.integrations.create = AsyncMock()
calendar_mock_uow.integrations.update = AsyncMock()
await calendar_service.complete_oauth("google", "auth-code", "state-123")
calendar_mock_uow.integrations.create.assert_not_called()
assert connected_google_integration.status == IntegrationStatus.CONNECTED
class TestCalendarServiceGetConnectionStatus:
"""CalendarService.get_connection_status tests."""
@pytest.mark.asyncio
async def test_get_connection_status_returns_connected_info(
self,
calendar_service: CalendarService,
connected_google_integration: Integration,
valid_token_secrets: dict[str, str],
calendar_mock_uow: MagicMock,
) -> None:
"""get_connection_status should return connection info on connected provider."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(
return_value=connected_google_integration
)
calendar_mock_uow.integrations.get_secrets = AsyncMock(return_value=valid_token_secrets)
status = await calendar_service.get_connection_status("google")
assert status.status == "connected", "Status should be connected"
assert status.provider == "google", "Provider should be google"
@pytest.mark.asyncio
async def test_get_connection_status_returns_disconnected_when_no_integration(
self,
calendar_service: CalendarService,
calendar_mock_uow: MagicMock,
) -> None:
"""get_connection_status should return disconnected when no integration."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
status = await calendar_service.get_connection_status("google")
assert status.status == "disconnected"
class TestCalendarServiceDisconnect:
"""CalendarService.disconnect tests."""
@pytest.mark.asyncio
async def test_disconnect_revokes_tokens_and_deletes_integration(
self,
calendar_service: CalendarService,
connected_google_integration: Integration,
calendar_mock_uow: MagicMock,
mock_calendar_oauth_manager: MagicMock,
) -> None:
"""disconnect should revoke tokens and delete integration."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(
return_value=connected_google_integration
)
calendar_mock_uow.integrations.get_secrets = AsyncMock(
return_value={"access_token": "token"}
)
calendar_mock_uow.integrations.delete = AsyncMock()
result = await calendar_service.disconnect("google")
assert result is True
mock_calendar_oauth_manager.revoke_tokens.assert_called_once()
calendar_mock_uow.integrations.delete.assert_called_once()
calendar_mock_uow.commit.assert_called()
class TestCalendarServiceListEvents:
"""CalendarService.list_calendar_events tests."""
@pytest.mark.asyncio
async def test_list_events_fetches_from_connected_provider(
self,
calendar_service: CalendarService,
connected_google_integration: Integration,
valid_token_secrets: dict[str, str],
calendar_mock_uow: MagicMock,
mock_google_adapter: MagicMock,
) -> None:
"""list_calendar_events should fetch events from connected provider."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(
return_value=connected_google_integration
)
calendar_mock_uow.integrations.get_secrets = AsyncMock(return_value=valid_token_secrets)
calendar_mock_uow.integrations.update = AsyncMock()
events = await calendar_service.list_calendar_events(provider="google")
assert len(events) == 1, "Should return exactly one event"
assert events[0].title == "Test Meeting", "Event title should match expected value"
mock_google_adapter.list_events.assert_called_once()
@pytest.mark.asyncio
async def test_list_events_refreshes_expired_token(
self,
calendar_service: CalendarService,
connected_google_integration: Integration,
expired_token_secrets: dict[str, str],
calendar_mock_uow: MagicMock,
mock_calendar_oauth_manager: MagicMock,
) -> None:
"""list_calendar_events should refresh expired token prior to fetching."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(
return_value=connected_google_integration
)
calendar_mock_uow.integrations.get_secrets = AsyncMock(return_value=expired_token_secrets)
calendar_mock_uow.integrations.update = AsyncMock()
await calendar_service.list_calendar_events(provider="google")
mock_calendar_oauth_manager.refresh_tokens.assert_called_once()
calendar_mock_uow.integrations.set_secrets.assert_called()
@pytest.mark.asyncio
async def test_list_events_raises_when_not_connected(
self,
calendar_service: CalendarService,
calendar_mock_uow: MagicMock,
) -> None:
"""list_calendar_events should raise error when provider not connected."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
with pytest.raises(CalendarServiceError, match="not connected"):
await calendar_service.list_calendar_events(provider="google")
class TestCalendarServiceOAuthOverrideConfig:
"""CalendarService OAuth override config tests."""
@pytest.mark.asyncio
async def test_get_oauth_client_config_defaults_when_missing(
self,
calendar_service: CalendarService,
calendar_settings: CalendarIntegrationSettings,
mock_calendar_oauth_manager: MagicMock,
) -> None:
"""get_oauth_client_config should return defaults when no integration exists."""
config, override_enabled, has_secret = await calendar_service.get_oauth_client_config(
"google"
)
assert config.client_id == "", "Client ID should default to empty string"
assert (
config.redirect_uri == calendar_settings.redirect_uri
), "Redirect URI should use settings default"
assert config.scopes == tuple(
mock_calendar_oauth_manager.GOOGLE_SCOPES
), "Scopes should fall back to default provider scopes"
assert override_enabled is False, "Override should be disabled by default"
assert has_secret is False, "Stored secret flag should be false by default"
@pytest.mark.asyncio
async def test_get_oauth_client_config_returns_override_values(
self,
calendar_service: CalendarService,
calendar_mock_uow: MagicMock,
) -> None:
"""get_oauth_client_config should return stored override values."""
integration = Integration.create(
workspace_id=uuid4(),
name="Google Calendar",
integration_type=IntegrationType.CALENDAR,
config={
PROVIDER: "google",
OAUTH_OVERRIDE_ENABLED: True,
OAUTH_OVERRIDE_CLIENT_ID: "client-123",
OAUTH_OVERRIDE_REDIRECT_URI: "http://localhost/callback",
OAUTH_OVERRIDE_SCOPES: ["scope-a"],
},
)
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=integration)
calendar_mock_uow.integrations.get_secrets = AsyncMock(
return_value={OAUTH_OVERRIDE_CLIENT_SECRET: "secret-xyz"}
)
config, override_enabled, has_secret = await calendar_service.get_oauth_client_config(
"google"
)
assert config.client_id == "client-123", "Client ID should match stored override"
assert (
config.redirect_uri == "http://localhost/callback"
), "Redirect URI should match stored override"
assert config.scopes == ("scope-a",), "Scopes should match stored override"
assert override_enabled is True, "Override should be enabled when stored"
assert has_secret is True, "Stored secret flag should reflect stored secret"
@pytest.mark.asyncio
async def test_set_oauth_client_config_persists_config(
self,
calendar_service: CalendarService,
calendar_mock_uow: MagicMock,
) -> None:
"""set_oauth_client_config should persist config."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
calendar_mock_uow.integrations.create = AsyncMock()
calendar_mock_uow.integrations.update = AsyncMock()
calendar_mock_uow.integrations.get_secrets = AsyncMock(return_value={})
client_config = OAuthClientConfig(
client_id="client-456",
client_secret="secret-abc",
redirect_uri="http://localhost/custom",
scopes=("scope-b",),
)
await calendar_service.set_oauth_client_config(
provider="google",
client_config=client_config,
override_enabled=True,
)
calendar_mock_uow.integrations.update.assert_called_once()
update_call = calendar_mock_uow.integrations.update.call_args[0][0]
assert update_call.config[OAUTH_OVERRIDE_ENABLED] is True, (
"Override enabled flag should be stored"
)
assert update_call.config[OAUTH_OVERRIDE_CLIENT_ID] == "client-456", (
"Client ID should be stored in config"
)
assert update_call.config[OAUTH_OVERRIDE_REDIRECT_URI] == "http://localhost/custom", (
"Redirect URI should be stored in config"
)
assert update_call.config[OAUTH_OVERRIDE_SCOPES] == ["scope-b"], (
"Scopes should be stored in config"
)
@pytest.mark.asyncio
async def test_set_oauth_client_config_persists_secret(
self,
calendar_service: CalendarService,
calendar_mock_uow: MagicMock,
) -> None:
"""set_oauth_client_config should persist secret."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
calendar_mock_uow.integrations.create = AsyncMock()
calendar_mock_uow.integrations.update = AsyncMock()
calendar_mock_uow.integrations.get_secrets = AsyncMock(return_value={})
client_config = OAuthClientConfig(
client_id="client-456",
client_secret="secret-abc",
redirect_uri="http://localhost/custom",
scopes=("scope-b",),
)
await calendar_service.set_oauth_client_config(
provider="google",
client_config=client_config,
override_enabled=True,
)
secrets_call = calendar_mock_uow.integrations.set_secrets.call_args.kwargs
assert (
secrets_call["secrets"][OAUTH_OVERRIDE_CLIENT_SECRET] == "secret-abc"
), "Client secret should be persisted in secrets store"
@pytest.mark.asyncio
async def test_set_oauth_client_config_requires_secret_when_enabled(
self,
calendar_service: CalendarService,
calendar_mock_uow: MagicMock,
) -> None:
"""set_oauth_client_config should require secret when override enabled."""
calendar_mock_uow.integrations.get_by_provider = AsyncMock(return_value=None)
calendar_mock_uow.integrations.create = AsyncMock()
calendar_mock_uow.integrations.update = AsyncMock()
calendar_mock_uow.integrations.get_secrets = AsyncMock(return_value={})
with pytest.raises(
CalendarServiceError,
match="client secret is missing",
):
await calendar_service.set_oauth_client_config(
provider="google",
client_config=OAuthClientConfig(
client_id="client-789",
client_secret="",
redirect_uri="http://localhost/override",
scopes=("scope-c",),
),
override_enabled=True,
)