Files
konstruct/tests/unit/test_calendar_lookup.py
Adolfo Delorenzo 08572fcc40 feat(10-02): Google Calendar OAuth endpoints and per-tenant calendar tool
- Add calendar_auth.py: OAuth install/callback/status endpoints with HMAC-signed state
- Replace calendar_lookup.py service account stub with per-tenant OAuth token lookup
- Support list, check_availability, and create actions with natural language responses
- Token auto-refresh: write updated credentials back to channel_connections on refresh
- Add migration 013: add google_calendar to channel_type CHECK constraint
- Add unit tests: 16 tests covering all actions, not-connected path, token refresh write-back
2026-03-26 09:07:37 -06:00

424 lines
14 KiB
Python

"""
Unit tests for the per-tenant OAuth calendar_lookup tool.
Tests:
- Returns "not configured" message when no tenant_id provided
- Returns "not connected" message when no ChannelConnection exists for tenant
- action="list" calls Google Calendar API and returns formatted event list
- action="check_availability" returns free/busy summary
- action="create" creates an event and returns confirmation
- Token refresh write-back: updated credentials written to DB
- All responses are natural language strings (no raw JSON)
- API errors return human-readable messages
"""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_TENANT_ID = str(uuid.uuid4())
_DATE = "2026-03-26"
# Fake encrypted token JSON stored in channel_connections.config
_FAKE_ENCRYPTED_TOKEN = "gAAAAAB..."
# Decrypted token dict (as would come from Google OAuth)
_FAKE_TOKEN_DICT = {
"token": "ya29.test_access_token",
"refresh_token": "1//test_refresh_token",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "test-client-id",
"client_secret": "test-client-secret",
"scopes": ["https://www.googleapis.com/auth/calendar"],
}
# Sample Google Calendar events response
_FAKE_EVENTS = {
"items": [
{
"summary": "Team Standup",
"start": {"dateTime": "2026-03-26T09:00:00+00:00"},
"end": {"dateTime": "2026-03-26T09:30:00+00:00"},
},
{
"summary": "Sprint Planning",
"start": {"dateTime": "2026-03-26T14:00:00+00:00"},
"end": {"dateTime": "2026-03-26T15:00:00+00:00"},
},
]
}
# Common patch targets
_PATCH_ENC = "orchestrator.tools.builtins.calendar_lookup.KeyEncryptionService"
_PATCH_CREDS = "orchestrator.tools.builtins.calendar_lookup.google_credentials_from_token"
_PATCH_BUILD = "orchestrator.tools.builtins.calendar_lookup.build"
_PATCH_SETTINGS = "orchestrator.tools.builtins.calendar_lookup.settings"
def _make_mock_session(conn_config: dict | None = None):
"""Build a mock AsyncSession that returns a ChannelConnection or None."""
session = AsyncMock()
mock_result = MagicMock()
if conn_config is not None:
mock_conn = MagicMock()
mock_conn.id = uuid.uuid4()
mock_conn.config = conn_config
mock_result.scalar_one_or_none.return_value = mock_conn
else:
mock_result.scalar_one_or_none.return_value = None
session.execute.return_value = mock_result
session.commit = AsyncMock()
return session
def _make_enc_mock():
"""Create a mock KeyEncryptionService with decrypt returning the fake token JSON."""
import json
mock_enc = MagicMock()
mock_enc.decrypt.return_value = json.dumps(_FAKE_TOKEN_DICT)
mock_enc.encrypt.return_value = "new_encrypted_token"
return mock_enc
def _make_mock_settings():
"""Create mock settings with encryption key configured."""
mock_settings = MagicMock()
mock_settings.platform_encryption_key = "test-key"
mock_settings.platform_encryption_key_previous = ""
return mock_settings
# ---------------------------------------------------------------------------
# No tenant_id
# ---------------------------------------------------------------------------
async def test_calendar_lookup_no_tenant_id_returns_message():
"""calendar_lookup without tenant_id returns a helpful error message."""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
result = await calendar_lookup(date=_DATE)
assert "tenant" in result.lower() or "not available" in result.lower()
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# Not connected
# ---------------------------------------------------------------------------
async def test_calendar_lookup_not_connected_returns_message():
"""calendar_lookup with no ChannelConnection returns 'not connected' message."""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
mock_session = _make_mock_session(conn_config=None)
# Pass _session directly to bypass DB session creation
result = await calendar_lookup(date=_DATE, tenant_id=_TENANT_ID, _session=mock_session)
assert "not connected" in result.lower() or "connect" in result.lower()
assert isinstance(result, str)
# ---------------------------------------------------------------------------
# action="list"
# ---------------------------------------------------------------------------
async def test_calendar_lookup_list_returns_formatted_events():
"""
action="list" returns a natural-language event list.
No raw JSON — results are human-readable strings.
"""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
mock_creds = MagicMock()
mock_creds.token = "ya29.test_access_token"
mock_creds.expired = False
mock_creds.valid = True
mock_service = MagicMock()
mock_events_list = MagicMock()
mock_events_list.execute.return_value = _FAKE_EVENTS
mock_service.events.return_value.list.return_value = mock_events_list
with (
patch(_PATCH_ENC) as mock_enc_cls,
patch(_PATCH_CREDS) as mock_creds_fn,
patch(_PATCH_BUILD) as mock_build,
patch(_PATCH_SETTINGS, _make_mock_settings()),
):
mock_enc_cls.return_value = _make_enc_mock()
mock_creds_fn.return_value = mock_creds
mock_build.return_value = mock_service
result = await calendar_lookup(
date=_DATE,
action="list",
tenant_id=_TENANT_ID,
_session=mock_session,
)
assert isinstance(result, str)
assert "Team Standup" in result
assert "Sprint Planning" in result
# No raw JSON
assert "{" not in result or "items" not in result
async def test_calendar_lookup_list_no_events():
"""action="list" with no events returns a 'no events' message."""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
mock_creds = MagicMock()
mock_creds.expired = False
mock_creds.valid = True
mock_service = MagicMock()
mock_service.events.return_value.list.return_value.execute.return_value = {"items": []}
with (
patch(_PATCH_ENC) as mock_enc_cls,
patch(_PATCH_CREDS) as mock_creds_fn,
patch(_PATCH_BUILD) as mock_build,
patch(_PATCH_SETTINGS, _make_mock_settings()),
):
mock_enc_cls.return_value = _make_enc_mock()
mock_creds_fn.return_value = mock_creds
mock_build.return_value = mock_service
result = await calendar_lookup(
date=_DATE,
action="list",
tenant_id=_TENANT_ID,
_session=mock_session,
)
assert "no event" in result.lower() or "free" in result.lower()
# ---------------------------------------------------------------------------
# action="check_availability"
# ---------------------------------------------------------------------------
async def test_calendar_lookup_check_availability_with_events():
"""action="check_availability" returns busy slot summary when events exist."""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
mock_creds = MagicMock()
mock_creds.expired = False
mock_creds.valid = True
mock_service = MagicMock()
mock_service.events.return_value.list.return_value.execute.return_value = _FAKE_EVENTS
with (
patch(_PATCH_ENC) as mock_enc_cls,
patch(_PATCH_CREDS) as mock_creds_fn,
patch(_PATCH_BUILD) as mock_build,
patch(_PATCH_SETTINGS, _make_mock_settings()),
):
mock_enc_cls.return_value = _make_enc_mock()
mock_creds_fn.return_value = mock_creds
mock_build.return_value = mock_service
result = await calendar_lookup(
date=_DATE,
action="check_availability",
tenant_id=_TENANT_ID,
_session=mock_session,
)
assert isinstance(result, str)
assert "busy" in result.lower() or "slot" in result.lower() or "standup" in result.lower()
async def test_calendar_lookup_check_availability_free_day():
"""action="check_availability" with no events returns 'entire day is free'."""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
mock_creds = MagicMock()
mock_creds.expired = False
mock_creds.valid = True
mock_service = MagicMock()
mock_service.events.return_value.list.return_value.execute.return_value = {"items": []}
with (
patch(_PATCH_ENC) as mock_enc_cls,
patch(_PATCH_CREDS) as mock_creds_fn,
patch(_PATCH_BUILD) as mock_build,
patch(_PATCH_SETTINGS, _make_mock_settings()),
):
mock_enc_cls.return_value = _make_enc_mock()
mock_creds_fn.return_value = mock_creds
mock_build.return_value = mock_service
result = await calendar_lookup(
date=_DATE,
action="check_availability",
tenant_id=_TENANT_ID,
_session=mock_session,
)
assert "free" in result.lower()
# ---------------------------------------------------------------------------
# action="create"
# ---------------------------------------------------------------------------
async def test_calendar_lookup_create_event():
"""action="create" inserts an event and returns confirmation."""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
mock_creds = MagicMock()
mock_creds.expired = False
mock_creds.valid = True
created_event = {
"id": "abc123",
"summary": "Product Demo",
"start": {"dateTime": "2026-03-26T10:00:00+00:00"},
"end": {"dateTime": "2026-03-26T11:00:00+00:00"},
}
mock_service = MagicMock()
mock_service.events.return_value.insert.return_value.execute.return_value = created_event
with (
patch(_PATCH_ENC) as mock_enc_cls,
patch(_PATCH_CREDS) as mock_creds_fn,
patch(_PATCH_BUILD) as mock_build,
patch(_PATCH_SETTINGS, _make_mock_settings()),
):
mock_enc_cls.return_value = _make_enc_mock()
mock_creds_fn.return_value = mock_creds
mock_build.return_value = mock_service
result = await calendar_lookup(
date=_DATE,
action="create",
event_summary="Product Demo",
event_start="2026-03-26T10:00:00+00:00",
event_end="2026-03-26T11:00:00+00:00",
tenant_id=_TENANT_ID,
_session=mock_session,
)
assert isinstance(result, str)
assert "created" in result.lower() or "product demo" in result.lower()
async def test_calendar_lookup_create_missing_fields():
"""action="create" without event_summary returns an error message."""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
mock_creds = MagicMock()
mock_creds.expired = False
mock_creds.valid = True
mock_service = MagicMock()
with (
patch(_PATCH_ENC) as mock_enc_cls,
patch(_PATCH_CREDS) as mock_creds_fn,
patch(_PATCH_BUILD) as mock_build,
patch(_PATCH_SETTINGS, _make_mock_settings()),
):
mock_enc_cls.return_value = _make_enc_mock()
mock_creds_fn.return_value = mock_creds
mock_build.return_value = mock_service
result = await calendar_lookup(
date=_DATE,
action="create",
# No event_summary, event_start, event_end
tenant_id=_TENANT_ID,
_session=mock_session,
)
assert isinstance(result, str)
assert "error" in result.lower() or "required" in result.lower() or "missing" in result.lower()
# ---------------------------------------------------------------------------
# Token refresh write-back
# ---------------------------------------------------------------------------
async def test_calendar_lookup_token_refresh_writeback():
"""
When credentials.token changes after an API call (refresh occurred),
the updated token should be encrypted and written back to channel_connections.
"""
from orchestrator.tools.builtins.calendar_lookup import calendar_lookup
conn_id = uuid.uuid4()
mock_session = _make_mock_session(conn_config={"token": _FAKE_ENCRYPTED_TOKEN})
# Get the mock connection to track updates
mock_conn = mock_session.execute.return_value.scalar_one_or_none.return_value
mock_conn.id = conn_id
mock_conn.config = {"token": _FAKE_ENCRYPTED_TOKEN}
# Credentials that change token after API call (simulating refresh)
original_token = "ya29.original_token"
refreshed_token = "ya29.refreshed_token"
mock_creds = MagicMock()
mock_creds.token = original_token
mock_creds.refresh_token = "1//refresh_token"
mock_creds.expired = False
mock_creds.valid = True
def simulate_api_call_that_refreshes():
"""Simulate the side effect of token refresh during API call."""
mock_creds.token = refreshed_token
return {"items": []}
mock_service = MagicMock()
mock_service.events.return_value.list.return_value.execute.side_effect = simulate_api_call_that_refreshes
mock_enc = _make_enc_mock()
with (
patch(_PATCH_ENC) as mock_enc_cls,
patch(_PATCH_CREDS) as mock_creds_fn,
patch(_PATCH_BUILD) as mock_build,
patch(_PATCH_SETTINGS, _make_mock_settings()),
):
mock_enc_cls.return_value = mock_enc
mock_creds_fn.return_value = mock_creds
mock_build.return_value = mock_service
await calendar_lookup(
date=_DATE,
action="list",
tenant_id=_TENANT_ID,
_session=mock_session,
)
# encrypt should have been called for write-back
mock_enc.encrypt.assert_called()
# session.commit should have been called to persist the updated token
mock_session.commit.assert_called()