Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions python/tests/tools/test_google_calendar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
"""Unit tests for Google Calendar tools (httpx mocked)."""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from pydantic import SecretStr
from timbal.tools.google_calendar import (
GoogleCalendarCreateEvent,
GoogleCalendarUpdateEvent,
_meet_create_request,
)


def _mock_httpx_context(mock_client: MagicMock) -> MagicMock:
cm = MagicMock()
cm.__aenter__ = AsyncMock(return_value=mock_client)
cm.__aexit__ = AsyncMock(return_value=False)
return cm


def _response(payload: dict) -> MagicMock:
response = MagicMock()
response.raise_for_status = MagicMock()
response.json.return_value = payload
return response


def test_meet_create_request_is_unique_per_call():
first = _meet_create_request()["createRequest"]
second = _meet_create_request()["createRequest"]

assert first["conferenceSolutionKey"] == {"type": "hangoutsMeet"}
# A repeated requestId makes Google hand back the conference it already
# created, which is how every event ends up sharing one room.
assert first["requestId"] != second["requestId"]


@pytest.mark.asyncio
async def test_create_event_without_meet_sends_no_conference_data():
mock_client = MagicMock()
mock_client.post = AsyncMock(return_value=_response({"id": "evt1"}))

with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)):
tool = GoogleCalendarCreateEvent(token=SecretStr("token"))
out = await tool.handler(
summary="Sync",
start="2026-01-01T10:00:00Z",
end="2026-01-01T10:30:00Z",
calendar_id="primary",
description=None,
location=None,
attendees=None,
timezone="UTC",
add_google_meet=False,
)

assert out["id"] == "evt1"
call = mock_client.post.await_args
assert "conferenceData" not in call.kwargs["json"]
assert call.kwargs["params"] == {}


@pytest.mark.asyncio
async def test_create_event_with_meet_requests_a_new_conference():
mock_client = MagicMock()
mock_client.post = AsyncMock(
return_value=_response(
{
"id": "evt1",
"hangoutLink": "https://meet.google.com/abc-defg-hij",
"conferenceData": {"status": {"statusCode": "success"}},
}
)
)
mock_client.get = AsyncMock()

with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)):
tool = GoogleCalendarCreateEvent(token=SecretStr("token"))
out = await tool.handler(
summary="Acme <> Timbal AI",
start="2026-01-01T10:00:00Z",
end="2026-01-01T10:30:00Z",
calendar_id="primary",
description=None,
location=None,
attendees=["prospect@acme.com"],
timezone="Europe/Madrid",
add_google_meet=True,
)

assert out["hangoutLink"] == "https://meet.google.com/abc-defg-hij"

call = mock_client.post.await_args
# The opt-in is a query parameter; without it Google drops conferenceData
# from the body and the event comes back with no link at all.
assert call.kwargs["params"]["conferenceDataVersion"] == 1
create_request = call.kwargs["json"]["conferenceData"]["createRequest"]
assert create_request["conferenceSolutionKey"] == {"type": "hangoutsMeet"}
assert create_request["requestId"]

# The link was already there, so no re-read.
mock_client.get.assert_not_awaited()


@pytest.mark.asyncio
async def test_create_event_polls_until_the_conference_is_minted():
pending = _response({"id": "evt1", "conferenceData": {"status": {"statusCode": "pending"}}})
ready = _response(
{
"id": "evt1",
"hangoutLink": "https://meet.google.com/xyz-uvwx-yz",
"conferenceData": {"status": {"statusCode": "success"}},
}
)

mock_client = MagicMock()
mock_client.post = AsyncMock(return_value=pending)
mock_client.get = AsyncMock(return_value=ready)

with (
patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)),
patch("asyncio.sleep", new=AsyncMock()),
):
tool = GoogleCalendarCreateEvent(token=SecretStr("token"))
out = await tool.handler(
summary="Acme <> Timbal AI",
start="2026-01-01T10:00:00Z",
end="2026-01-01T10:30:00Z",
calendar_id="primary",
description=None,
location=None,
attendees=None,
timezone="UTC",
add_google_meet=True,
)

assert out["hangoutLink"] == "https://meet.google.com/xyz-uvwx-yz"
assert mock_client.get.await_count == 1
assert mock_client.get.await_args.kwargs["params"]["conferenceDataVersion"] == 1


@pytest.mark.asyncio
async def test_create_event_stops_polling_when_the_conference_failed():
failed = _response({"id": "evt1", "conferenceData": {"status": {"statusCode": "failure"}}})

mock_client = MagicMock()
mock_client.post = AsyncMock(return_value=failed)
mock_client.get = AsyncMock()

with (
patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)),
patch("asyncio.sleep", new=AsyncMock()),
):
tool = GoogleCalendarCreateEvent(token=SecretStr("token"))
out = await tool.handler(
summary="Sync",
start="2026-01-01T10:00:00Z",
end="2026-01-01T10:30:00Z",
calendar_id="primary",
description=None,
location=None,
attendees=None,
timezone="UTC",
add_google_meet=True,
)

# The event still exists; it simply has no link. Callers decide what to do.
assert out["id"] == "evt1"
assert "hangoutLink" not in out
mock_client.get.assert_not_awaited()


@pytest.mark.asyncio
async def test_update_event_can_attach_a_conference():
mock_client = MagicMock()
mock_client.patch = AsyncMock(
return_value=_response(
{
"id": "evt1",
"hangoutLink": "https://meet.google.com/abc-defg-hij",
"conferenceData": {"status": {"statusCode": "success"}},
}
)
)

with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)):
tool = GoogleCalendarUpdateEvent(token=SecretStr("token"))
out = await tool.handler(
event_id="evt1",
calendar_id="primary",
summary=None,
start="2026-01-02T10:00:00Z",
end="2026-01-02T10:30:00Z",
description=None,
location=None,
timezone="Europe/Madrid",
add_google_meet=True,
)

assert out["hangoutLink"] == "https://meet.google.com/abc-defg-hij"
call = mock_client.patch.await_args
assert call.kwargs["params"]["conferenceDataVersion"] == 1
assert call.kwargs["json"]["conferenceData"]["createRequest"]["requestId"]


@pytest.mark.asyncio
async def test_update_event_leaves_an_existing_conference_alone():
mock_client = MagicMock()
mock_client.patch = AsyncMock(return_value=_response({"id": "evt1"}))

with patch("httpx.AsyncClient", return_value=_mock_httpx_context(mock_client)):
tool = GoogleCalendarUpdateEvent(token=SecretStr("token"))
await tool.handler(
event_id="evt1",
calendar_id="primary",
summary="Moved",
start=None,
end=None,
description=None,
location=None,
timezone=None,
add_google_meet=False,
)

call = mock_client.patch.await_args
# PATCH leaves unspecified fields untouched, so a plain reschedule must not
# send conferenceData — the event keeps whatever link it already had.
assert "conferenceData" not in call.kwargs["json"]
assert call.kwargs["params"] == {}
103 changes: 99 additions & 4 deletions python/timbal/tools/google_calendar.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import asyncio
import uuid
from typing import Annotated, Any

from pydantic import Field, SecretStr
Expand All @@ -7,6 +9,61 @@

_CALENDAR_BASE = "https://www.googleapis.com/calendar/v3"

# How long to keep re-reading an event whose conference is still being minted.
_CONFERENCE_POLL_DELAYS = (0.5, 1.0, 2.0)


def _meet_create_request() -> dict[str, Any]:
"""Ask Google for a brand new Meet room.

`requestId` has to differ per call: Google treats a repeated id as a retry of
the same request and hands back the conference it already made, so a constant
value would quietly share one room across every event.
"""
return {
"createRequest": {
"requestId": str(uuid.uuid4()),
"conferenceSolutionKey": {"type": "hangoutsMeet"},
}
}


async def _await_conference(
client: Any,
token: str,
calendar_id: str,
event: dict[str, Any],
) -> dict[str, Any]:
"""Re-read an event until Google has finished attaching its conference.

Conferences are created asynchronously, so `events.insert` can answer with
`conferenceData.status.statusCode == "pending"` and no `hangoutLink` yet. The
link is the whole point of asking for a conference — it goes in front of a
human — so it is worth a few cheap reads rather than returning an event that
nobody can join.
"""
event_id = event.get("id")
if not event_id:
return event

for delay in _CONFERENCE_POLL_DELAYS:
status = ((event.get("conferenceData") or {}).get("status") or {}).get("statusCode")
# Only `pending` will change. No status at all means Google never took the
# request — polling a calendar that cannot host Meet just wastes seconds.
if event.get("hangoutLink") or status != "pending":
break

await asyncio.sleep(delay)
response = await client.get(
f"{_CALENDAR_BASE}/calendars/{calendar_id}/events/{event_id}",
headers={"Authorization": f"Bearer {token}"},
params={"conferenceDataVersion": 1},
)
response.raise_for_status()
event = response.json()

return event


async def _resolve_token(tool: Any) -> str:
if isinstance(tool.integration, Integration):
Expand Down Expand Up @@ -69,7 +126,7 @@ async def _list_events(

class GoogleCalendarCreateEvent(Tool):
name: str = "google_calendar_create_event"
description: str | None = "Create a new event in Google Calendar."
description: str | None = "Create a new event in Google Calendar, optionally with its own Google Meet link."
integration: Annotated[str, Integration("google_calendar")] | None = None
token: SecretStr | None = None

Expand All @@ -89,6 +146,13 @@ async def _create_event(
location: str | None = Field(None, description="Event location or venue."),
attendees: list[str] | None = Field(None, description="List of attendee email addresses."),
timezone: str = Field("UTC", description="Timezone for the event, e.g. 'America/New_York'"),
add_google_meet: bool = Field(
False,
description=(
"If true, create a Google Meet conference for this event and return its join "
"link in 'hangoutLink'. Each event gets its own room."
),
),
) -> Any:
token = await _resolve_token(self)
import httpx
Expand All @@ -105,21 +169,34 @@ async def _create_event(
if attendees:
body["attendees"] = [{"email": email} for email in attendees]

# Conference data is ignored unless the request opts into it, and the
# opt-in is a query parameter rather than a body field. Both or neither.
params: dict[str, Any] = {}
if add_google_meet:
body["conferenceData"] = _meet_create_request()
params["conferenceDataVersion"] = 1

async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client:
response = await client.post(
f"{_CALENDAR_BASE}/calendars/{calendar_id}/events",
headers={"Authorization": f"Bearer {token}"},
params=params,
json=body,
)
response.raise_for_status()
return response.json()
event = response.json()

if add_google_meet:
event = await _await_conference(client, token, calendar_id, event)

return event

super().__init__(handler=_create_event, **kwargs)


class GoogleCalendarUpdateEvent(Tool):
name: str = "google_calendar_update_event"
description: str | None = "Update an existing event in Google Calendar."
description: str | None = "Update an existing event in Google Calendar, optionally attaching a Google Meet link."
integration: Annotated[str, Integration("google_calendar")] | None = None
token: SecretStr | None = None

Expand All @@ -141,6 +218,13 @@ async def _update_event(
description: str | None = Field(None, description="Updated event description or notes."),
location: str | None = Field(None, description="Updated event location or venue."),
timezone: str | None = Field(None, description="Updated timezone for the event, e.g. 'America/New_York'"),
add_google_meet: bool = Field(
False,
description=(
"If true, attach a Google Meet conference to this event and return its join "
"link in 'hangoutLink'. Events that already have one keep it."
),
),
) -> Any:
token = await _resolve_token(self)
import httpx
Expand All @@ -157,14 +241,25 @@ async def _update_event(
if end:
body["end"] = {"dateTime": end, "timeZone": timezone or "UTC"}

params: dict[str, Any] = {}
if add_google_meet:
body["conferenceData"] = _meet_create_request()
params["conferenceDataVersion"] = 1

async with httpx.AsyncClient(timeout=httpx.Timeout(30.0, connect=10.0)) as client:
response = await client.patch(
f"{_CALENDAR_BASE}/calendars/{calendar_id}/events/{event_id}",
headers={"Authorization": f"Bearer {token}"},
params=params,
json=body,
)
response.raise_for_status()
return response.json()
event = response.json()

if add_google_meet:
event = await _await_conference(client, token, calendar_id, event)

return event

super().__init__(handler=_update_event, **kwargs)

Expand Down
Loading