504 lines
19 KiB
Python
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,
|
|
)
|