Add available meeting intervals across MCP, REST, and A2A
parent
eca444f04a
commit
9508495631
@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
import app.calendar_agent as calendar_module
|
||||
from app.calendar_agent import CalendarAvailabilityAgent
|
||||
|
||||
|
||||
class _FakeFreeBusy:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def query(self, body: dict[str, object]) -> _FakeFreeBusy:
|
||||
return self
|
||||
|
||||
def execute(self) -> dict[str, object]:
|
||||
return self.payload
|
||||
|
||||
|
||||
class _FakeCalendarService:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self._freebusy = _FakeFreeBusy(payload)
|
||||
|
||||
def freebusy(self) -> _FakeFreeBusy:
|
||||
return self._freebusy
|
||||
|
||||
|
||||
def _build_agent(payload: dict[str, object]) -> CalendarAvailabilityAgent:
|
||||
return CalendarAvailabilityAgent(calendar_service=_FakeCalendarService(payload))
|
||||
|
||||
|
||||
def test_weekday_boundaries_and_busy_subtraction() -> None:
|
||||
agent = _build_agent(
|
||||
{
|
||||
"calendars": {
|
||||
"primary": {
|
||||
"busy": [
|
||||
{
|
||||
"start": "2026-03-09T09:00:00+01:00",
|
||||
"end": "2026-03-09T10:00:00+01:00",
|
||||
}
|
||||
]
|
||||
},
|
||||
"work": {
|
||||
"busy": [
|
||||
{
|
||||
"start": "2026-03-09T09:30:00+01:00",
|
||||
"end": "2026-03-09T11:00:00+01:00",
|
||||
},
|
||||
{
|
||||
"start": "2026-03-09T12:00:00+01:00",
|
||||
"end": "2026-03-09T12:20:00+01:00",
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = agent.get_available_meeting_intervals(
|
||||
start="2026-03-09T07:00:00+01:00",
|
||||
end="2026-03-09T22:00:00+01:00",
|
||||
calendar_ids=["primary", "work"],
|
||||
)
|
||||
|
||||
assert result.timezone == "Europe/Paris"
|
||||
assert result.checked_calendars == ["primary", "work"]
|
||||
assert result.meeting_intervals == [
|
||||
{
|
||||
"start": "2026-03-09T08:30:00+01:00",
|
||||
"end": "2026-03-09T09:00:00+01:00",
|
||||
},
|
||||
{
|
||||
"start": "2026-03-09T11:00:00+01:00",
|
||||
"end": "2026-03-09T12:00:00+01:00",
|
||||
},
|
||||
{
|
||||
"start": "2026-03-09T12:20:00+01:00",
|
||||
"end": "2026-03-09T21:30:00+01:00",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_saturday_window_only() -> None:
|
||||
agent = _build_agent({"calendars": {"primary": {"busy": []}}})
|
||||
result = agent.get_available_meeting_intervals(
|
||||
start="2026-03-14T08:00:00+01:00",
|
||||
end="2026-03-14T13:00:00+01:00",
|
||||
calendar_ids=None,
|
||||
)
|
||||
|
||||
assert result.meeting_intervals == [
|
||||
{
|
||||
"start": "2026-03-14T09:30:00+01:00",
|
||||
"end": "2026-03-14T12:00:00+01:00",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_sunday_excluded() -> None:
|
||||
agent = _build_agent({"calendars": {"primary": {"busy": []}}})
|
||||
result = agent.get_available_meeting_intervals(
|
||||
start="2026-03-15T08:00:00+01:00",
|
||||
end="2026-03-15T14:00:00+01:00",
|
||||
calendar_ids=None,
|
||||
)
|
||||
|
||||
assert result.meeting_intervals == []
|
||||
|
||||
|
||||
def test_france_holiday_excluded(monkeypatch) -> None:
|
||||
def _fake_country_holidays(
|
||||
country: str,
|
||||
*,
|
||||
years: range,
|
||||
observed: bool,
|
||||
subdiv: str | None = None,
|
||||
) -> dict[date, str]:
|
||||
assert observed is False
|
||||
if country == "FR":
|
||||
return {date(2026, 7, 14): "Bastille Day"}
|
||||
if country == "CA" and subdiv == "QC":
|
||||
return {}
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(calendar_module.holidays, "country_holidays", _fake_country_holidays)
|
||||
agent = _build_agent({"calendars": {"primary": {"busy": []}}})
|
||||
|
||||
result = agent.get_available_meeting_intervals(
|
||||
start="2026-07-14T08:00:00+02:00",
|
||||
end="2026-07-14T22:00:00+02:00",
|
||||
calendar_ids=None,
|
||||
)
|
||||
|
||||
assert result.meeting_intervals == []
|
||||
|
||||
|
||||
def test_quebec_holiday_excluded(monkeypatch) -> None:
|
||||
def _fake_country_holidays(
|
||||
country: str,
|
||||
*,
|
||||
years: range,
|
||||
observed: bool,
|
||||
subdiv: str | None = None,
|
||||
) -> dict[date, str]:
|
||||
assert observed is False
|
||||
if country == "FR":
|
||||
return {}
|
||||
if country == "CA" and subdiv == "QC":
|
||||
return {date(2026, 6, 24): "Saint-Jean-Baptiste Day"}
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(calendar_module.holidays, "country_holidays", _fake_country_holidays)
|
||||
agent = _build_agent({"calendars": {"primary": {"busy": []}}})
|
||||
|
||||
result = agent.get_available_meeting_intervals(
|
||||
start="2026-06-24T08:00:00+02:00",
|
||||
end="2026-06-24T22:00:00+02:00",
|
||||
calendar_ids=None,
|
||||
)
|
||||
|
||||
assert result.meeting_intervals == []
|
||||
|
||||
|
||||
def test_intervals_shorter_than_30_minutes_are_filtered() -> None:
|
||||
agent = _build_agent(
|
||||
{
|
||||
"calendars": {
|
||||
"primary": {
|
||||
"busy": [
|
||||
{
|
||||
"start": "2026-03-09T08:40:00+01:00",
|
||||
"end": "2026-03-09T09:30:00+01:00",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = agent.get_available_meeting_intervals(
|
||||
start="2026-03-09T08:30:00+01:00",
|
||||
end="2026-03-09T09:30:00+01:00",
|
||||
calendar_ids=None,
|
||||
)
|
||||
|
||||
assert result.meeting_intervals == []
|
||||
|
||||
|
||||
def test_range_greater_than_90_days_rejected() -> None:
|
||||
agent = _build_agent({"calendars": {"primary": {"busy": []}}})
|
||||
|
||||
with pytest.raises(ValueError, match="cannot exceed 90 days"):
|
||||
agent.get_available_meeting_intervals(
|
||||
start="2026-01-01T08:00:00+01:00",
|
||||
end="2026-04-02T08:00:01+02:00",
|
||||
calendar_ids=None,
|
||||
)
|
||||
|
||||
|
||||
def test_requires_timezone_offset() -> None:
|
||||
agent = _build_agent({"calendars": {"primary": {"busy": []}}})
|
||||
|
||||
with pytest.raises(ValueError, match="timezone offset"):
|
||||
agent.get_available_meeting_intervals(
|
||||
start="2026-03-10T08:00:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
calendar_ids=None,
|
||||
)
|
||||
@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import app.core.service as core_module
|
||||
from app.config import get_settings
|
||||
from app.core.service import CoreAgentService
|
||||
|
||||
|
||||
class _FakeFreeBusy:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
|
||||
def query(self, body: dict[str, object]) -> _FakeFreeBusy:
|
||||
return self
|
||||
|
||||
def execute(self) -> dict[str, object]:
|
||||
return self.payload
|
||||
|
||||
|
||||
class _FakeCalendarService:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self._freebusy = _FakeFreeBusy(payload)
|
||||
|
||||
def freebusy(self) -> _FakeFreeBusy:
|
||||
return self._freebusy
|
||||
|
||||
|
||||
def test_core_meeting_intervals_maps_result(monkeypatch) -> None:
|
||||
fake_service = _FakeCalendarService({"calendars": {"primary": {"busy": []}}})
|
||||
monkeypatch.setattr(core_module, "build_calendar_service", lambda _: fake_service)
|
||||
|
||||
service = CoreAgentService(settings=get_settings())
|
||||
result = service.available_meeting_intervals(
|
||||
start="2026-03-10T08:00:00+01:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
calendar_ids=["primary"],
|
||||
)
|
||||
|
||||
assert result.timezone == "Europe/Paris"
|
||||
assert result.checked_calendars == ["primary"]
|
||||
assert result.meeting_intervals == [
|
||||
core_module.CoreMeetingInterval(
|
||||
start="2026-03-10T08:30:00+01:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
)
|
||||
]
|
||||
@ -0,0 +1,95 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import Response
|
||||
|
||||
import app.a2a.router as a2a_module
|
||||
from app.a2a.models import A2ARpcRequest
|
||||
from app.core.models import CoreMeetingInterval
|
||||
import app.main as main_module
|
||||
import app.mcp.server as mcp_server_module
|
||||
import app.mcp.tools as mcp_tools_module
|
||||
|
||||
|
||||
class _DummyCoreService:
|
||||
def available_meeting_intervals(
|
||||
self,
|
||||
start: str,
|
||||
end: str,
|
||||
calendar_ids: list[str] | None,
|
||||
) -> SimpleNamespace:
|
||||
checked = calendar_ids or ["primary"]
|
||||
meeting_intervals = [
|
||||
CoreMeetingInterval(
|
||||
start="2026-03-10T08:30:00+01:00",
|
||||
end="2026-03-10T09:30:00+01:00",
|
||||
)
|
||||
]
|
||||
return SimpleNamespace(
|
||||
start=start,
|
||||
end=end,
|
||||
timezone="Europe/Paris",
|
||||
meeting_intervals=meeting_intervals,
|
||||
checked_calendars=checked,
|
||||
)
|
||||
|
||||
|
||||
class _AllowAuthBackend:
|
||||
def authenticate(
|
||||
self,
|
||||
*,
|
||||
x_api_key: str | None,
|
||||
authorization: str | None,
|
||||
required_scopes: set[str],
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_meeting_intervals_parity_rest_a2a_mcp(monkeypatch) -> None:
|
||||
dummy_core = _DummyCoreService()
|
||||
allow_auth = _AllowAuthBackend()
|
||||
|
||||
monkeypatch.setattr(main_module, "core_service", dummy_core)
|
||||
monkeypatch.setattr(a2a_module, "core_service", dummy_core)
|
||||
monkeypatch.setattr(mcp_tools_module, "core_service", dummy_core)
|
||||
monkeypatch.setattr(a2a_module, "auth_backend", allow_auth)
|
||||
monkeypatch.setattr(mcp_tools_module, "auth_backend", allow_auth)
|
||||
|
||||
rest_response = asyncio.run(
|
||||
main_module.meeting_intervals(
|
||||
main_module.MeetingIntervalsRequest(
|
||||
start="2026-03-10T08:00:00+01:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
calendar_ids=["primary"],
|
||||
)
|
||||
)
|
||||
).model_dump()
|
||||
|
||||
a2a_response = a2a_module.a2a_rpc(
|
||||
A2ARpcRequest(
|
||||
jsonrpc="2.0",
|
||||
id="req-1",
|
||||
method="SendMessage",
|
||||
params={
|
||||
"action": "available_meeting_intervals",
|
||||
"start": "2026-03-10T08:00:00+01:00",
|
||||
"end": "2026-03-10T10:00:00+01:00",
|
||||
"calendar_ids": ["primary"],
|
||||
},
|
||||
),
|
||||
Response(),
|
||||
)
|
||||
assert a2a_response.error is None
|
||||
assert a2a_response.result is not None
|
||||
a2a_payload = a2a_response.result["meeting_intervals"]
|
||||
|
||||
mcp_payload = mcp_server_module.available_meeting_intervals(
|
||||
start="2026-03-10T08:00:00+01:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
calendar_ids=["primary"],
|
||||
ctx=None,
|
||||
)
|
||||
|
||||
assert rest_response == a2a_payload == mcp_payload
|
||||
@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import app.main as main_module
|
||||
|
||||
|
||||
class _DummyCoreService:
|
||||
def available_meeting_intervals(
|
||||
self,
|
||||
start: str,
|
||||
end: str,
|
||||
calendar_ids: list[str] | None,
|
||||
) -> SimpleNamespace:
|
||||
checked = calendar_ids or ["primary"]
|
||||
return SimpleNamespace(
|
||||
start=start,
|
||||
end=end,
|
||||
timezone="Europe/Paris",
|
||||
meeting_intervals=[
|
||||
SimpleNamespace(
|
||||
start="2026-03-10T08:30:00+01:00",
|
||||
end="2026-03-10T09:30:00+01:00",
|
||||
)
|
||||
],
|
||||
checked_calendars=checked,
|
||||
)
|
||||
|
||||
|
||||
def test_rest_meeting_intervals_adapter_returns_expected_payload(monkeypatch) -> None:
|
||||
monkeypatch.setattr(main_module, "core_service", _DummyCoreService())
|
||||
|
||||
response = asyncio.run(
|
||||
main_module.meeting_intervals(
|
||||
main_module.MeetingIntervalsRequest(
|
||||
start="2026-03-10T08:00:00+01:00",
|
||||
end="2026-03-10T10:00:00+01:00",
|
||||
calendar_ids=["primary"],
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
payload = response.model_dump()
|
||||
assert payload["timezone"] == "Europe/Paris"
|
||||
assert payload["checked_calendars"] == ["primary"]
|
||||
assert payload["meeting_intervals"] == [
|
||||
{
|
||||
"start": "2026-03-10T08:30:00+01:00",
|
||||
"end": "2026-03-10T09:30:00+01:00",
|
||||
}
|
||||
]
|
||||
Loading…
Reference in New Issue