529 lines
19 KiB
Python
529 lines
19 KiB
Python
"""Tests for attendees and RSVP functionality."""
|
|
|
|
import importlib
|
|
import json
|
|
|
|
import pytest
|
|
from pydantic import EmailStr, TypeAdapter, ValidationError
|
|
|
|
from google_calendar.models import (
|
|
AttendeeResponseStatus,
|
|
CalendarPerson,
|
|
EventAttendee,
|
|
EventClearField,
|
|
EventSource,
|
|
EventTransparency,
|
|
EventType,
|
|
ExtendedProperties,
|
|
)
|
|
|
|
|
|
def _get_gc():
|
|
return importlib.import_module("google_calendar.server")
|
|
|
|
|
|
def _get_state():
|
|
return importlib.import_module("google_calendar.state")
|
|
|
|
|
|
@pytest.fixture
|
|
def calendar_data(tmp_path):
|
|
external_services = tmp_path / "external_services"
|
|
external_services.mkdir()
|
|
data_file = external_services / "calendar_data.json"
|
|
data_file.write_text(json.dumps({"events": {}}))
|
|
return data_file
|
|
|
|
|
|
@pytest.fixture
|
|
def outputdir(tmp_path):
|
|
out = tmp_path / "output" / "google_calendar"
|
|
out.mkdir(parents=True)
|
|
return out
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_globals(calendar_data, outputdir):
|
|
state = _get_state()
|
|
workspace = calendar_data.parent.parent / "workspace"
|
|
workspace.mkdir()
|
|
state.set_agent_workspace(str(workspace))
|
|
state.set_snapshot_paths(final_path=outputdir / "final.json", bundle_state_path=None)
|
|
yield
|
|
state.set_agent_workspace(None)
|
|
state.set_snapshot_paths(final_path=None, bundle_state_path=None)
|
|
|
|
|
|
class TestCreateEventWithAttendees:
|
|
def test_create_and_update_attendees_are_schema_models(self):
|
|
gc = _get_gc()
|
|
|
|
assert gc.create_event.__annotations__["attendees"] == list[EventAttendee] | None
|
|
assert gc.update_event.__annotations__["attendees"] == list[EventAttendee] | None
|
|
|
|
def test_create_and_update_people_are_schema_models(self):
|
|
gc = _get_gc()
|
|
|
|
assert gc.create_event.__annotations__["creator"] == CalendarPerson | None
|
|
assert gc.create_event.__annotations__["organizer"] == CalendarPerson | None
|
|
assert gc.update_event.__annotations__["creator"] == CalendarPerson | None
|
|
assert gc.update_event.__annotations__["organizer"] == CalendarPerson | None
|
|
|
|
def test_create_and_update_metadata_are_schema_models(self):
|
|
gc = _get_gc()
|
|
|
|
assert gc.create_event.__annotations__["extendedProperties"] == ExtendedProperties | None
|
|
assert gc.create_event.__annotations__["source"] == EventSource | None
|
|
assert gc.create_event.__annotations__["transparency"] == EventTransparency | None
|
|
assert gc.update_event.__annotations__["extendedProperties"] == ExtendedProperties | None
|
|
assert gc.update_event.__annotations__["source"] == EventSource | None
|
|
assert gc.update_event.__annotations__["transparency"] == EventTransparency | None
|
|
assert gc.update_event.__annotations__["eventType"] == EventType | None
|
|
assert gc.update_event.__annotations__["clear_fields"] == list[EventClearField] | None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_with_attendees(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Team Sync",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[
|
|
{"email": "alice@co.com", "displayName": "Alice"},
|
|
{"email": "bob@co.com"},
|
|
],
|
|
)
|
|
assert result["status"] == "success"
|
|
attendees = result["event"]["attendees"]
|
|
assert len(attendees) == 2
|
|
assert attendees[0]["email"] == "alice@co.com"
|
|
assert attendees[0]["responseStatus"] == "needsAction"
|
|
assert attendees[1]["displayName"] == "bob@co.com" # falls back to email
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_preserves_full_attendee_payload(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Team Sync",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[
|
|
{
|
|
"email": "room@example.com",
|
|
"resource": True,
|
|
"optional": True,
|
|
"comment": "Projector needed",
|
|
"additionalGuests": 2,
|
|
}
|
|
],
|
|
)
|
|
|
|
attendee = result["event"]["attendees"][0]
|
|
assert attendee["resource"] is True
|
|
assert attendee["optional"] is True
|
|
assert attendee["comment"] == "Projector needed"
|
|
assert attendee["additionalGuests"] == 2
|
|
assert attendee["displayName"] == "room@example.com"
|
|
assert attendee["responseStatus"] == "needsAction"
|
|
assert attendee["organizer"] is False
|
|
assert attendee["self"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_rejects_duplicate_attendees(self):
|
|
gc = _get_gc()
|
|
|
|
result = await gc.create_event(
|
|
summary="Team Sync",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[
|
|
{"email": "alice@co.com"},
|
|
{"email": "ALICE@co.com"},
|
|
],
|
|
)
|
|
|
|
assert result["status"] == "error"
|
|
assert "Duplicate attendee email" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_without_attendees(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Solo Event",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
)
|
|
assert "attendees" not in result["event"]
|
|
|
|
|
|
class TestUpdateEventAttendees:
|
|
@pytest.mark.asyncio
|
|
async def test_add_attendees_via_update(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(
|
|
eventId=event_id,
|
|
attendees=[{"email": "carol@co.com", "displayName": "Carol"}],
|
|
)
|
|
assert len(updated["event"]["attendees"]) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_preserves_full_attendee_payload(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(
|
|
eventId=event_id,
|
|
attendees=[
|
|
{
|
|
"email": "room@example.com",
|
|
"resource": True,
|
|
"optional": True,
|
|
"comment": "Projector needed",
|
|
"additionalGuests": 2,
|
|
}
|
|
],
|
|
)
|
|
|
|
attendee = updated["event"]["attendees"][0]
|
|
assert attendee["resource"] is True
|
|
assert attendee["optional"] is True
|
|
assert attendee["comment"] == "Projector needed"
|
|
assert attendee["additionalGuests"] == 2
|
|
assert attendee["displayName"] == "room@example.com"
|
|
assert attendee["responseStatus"] == "needsAction"
|
|
assert attendee["organizer"] is False
|
|
assert attendee["self"] is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_rejects_duplicate_attendees(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(
|
|
eventId=event_id,
|
|
attendees=[
|
|
{"email": "alice@co.com"},
|
|
{"email": "ALICE@co.com"},
|
|
],
|
|
)
|
|
|
|
assert updated["status"] == "error"
|
|
assert "Duplicate attendee email" in updated["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_attendees(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com"}],
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(eventId=event_id, attendees=[])
|
|
assert updated["event"]["attendees"] == []
|
|
|
|
|
|
class TestUpdateEventMetadata:
|
|
@pytest.mark.asyncio
|
|
async def test_update_can_clear_optional_metadata_fields(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Annotated Event",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
creator={"email": "creator@example.com"},
|
|
organizer={"email": "organizer@example.com"},
|
|
extendedProperties={"private": {"task_id": "task-123"}},
|
|
source={"title": "Planning notes"},
|
|
transparency="transparent",
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(
|
|
eventId=event_id,
|
|
clear_fields=[
|
|
EventClearField.CREATOR,
|
|
EventClearField.ORGANIZER,
|
|
EventClearField.EXTENDED_PROPERTIES,
|
|
EventClearField.SOURCE,
|
|
EventClearField.TRANSPARENCY,
|
|
],
|
|
)
|
|
|
|
assert updated["status"] == "success"
|
|
for field in ["creator", "organizer", "extendedProperties", "source", "transparency"]:
|
|
assert field not in updated["event"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_can_switch_special_event_type_back_to_default(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Focus Block",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
eventType="focusTime",
|
|
focusTimeProperties={"chatStatus": "doNotDisturb"},
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(eventId=event_id, eventType="default")
|
|
|
|
assert updated["status"] == "success"
|
|
assert updated["event"]["eventType"] == "default"
|
|
assert "focusTimeProperties" not in updated["event"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_returns_error_for_invalid_event_type_shape(self):
|
|
gc = _get_gc()
|
|
|
|
result = await gc.create_event(
|
|
summary="Focus Block",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
eventType="focusTime",
|
|
)
|
|
|
|
assert result["status"] == "error"
|
|
assert "focusTimeProperties is required" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_returns_error_for_invalid_merged_event(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="All Day",
|
|
start={"date": "2025-06-01"},
|
|
end={"date": "2025-06-02"},
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(eventId=event_id, start={"dateTime": "2025-06-01T10:00:00Z"})
|
|
|
|
assert updated["status"] == "error"
|
|
assert "both use dateTime or both use date" in updated["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_can_switch_default_event_to_special_type_with_properties(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Working location",
|
|
start={"date": "2025-06-01"},
|
|
end={"date": "2025-06-02"},
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
updated = await gc.update_event(
|
|
eventId=event_id,
|
|
eventType=EventType.WORKING_LOCATION,
|
|
workingLocationProperties={"type": "customLocation", "customLocation": {"label": "Client office"}},
|
|
)
|
|
|
|
assert updated["status"] == "success"
|
|
assert updated["event"]["eventType"] == "workingLocation"
|
|
assert updated["event"]["workingLocationProperties"]["customLocation"]["label"] == "Client office"
|
|
|
|
|
|
class TestRespondToEvent:
|
|
@pytest.mark.asyncio
|
|
async def test_accept_invitation(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com"}, {"email": "bob@co.com"}],
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
rsvp = await gc.respond_to_event(
|
|
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.ACCEPTED
|
|
)
|
|
assert rsvp["status"] == "success"
|
|
alice = next(a for a in rsvp["event"]["attendees"] if a["email"] == "alice@co.com")
|
|
assert alice["responseStatus"] == "accepted"
|
|
# Bob should still be needsAction
|
|
bob = next(a for a in rsvp["event"]["attendees"] if a["email"] == "bob@co.com")
|
|
assert bob["responseStatus"] == "needsAction"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_decline_invitation(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com"}],
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
rsvp = await gc.respond_to_event(
|
|
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.DECLINED
|
|
)
|
|
alice = rsvp["event"]["attendees"][0]
|
|
assert alice["responseStatus"] == "declined"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tentative_response(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com"}],
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
rsvp = await gc.respond_to_event(
|
|
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.TENTATIVE
|
|
)
|
|
assert rsvp["event"]["attendees"][0]["responseStatus"] == "tentative"
|
|
|
|
def test_respond_response_is_schema_enum(self):
|
|
gc = _get_gc()
|
|
|
|
assert gc.respond_to_event.__annotations__["response"] == AttendeeResponseStatus
|
|
assert gc.respond_to_event.__annotations__["email"] == EmailStr
|
|
with pytest.raises(ValidationError):
|
|
TypeAdapter(AttendeeResponseStatus).validate_python("maybe")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_respond_attendee_not_found(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com"}],
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
rsvp = await gc.respond_to_event(
|
|
eventId=event_id,
|
|
email="unknown@co.com",
|
|
response=AttendeeResponseStatus.ACCEPTED,
|
|
)
|
|
assert rsvp["status"] == "error"
|
|
assert "not found" in rsvp["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_respond_event_not_found(self):
|
|
gc = _get_gc()
|
|
rsvp = await gc.respond_to_event(
|
|
eventId="nonexistent",
|
|
email="alice@co.com",
|
|
response=AttendeeResponseStatus.ACCEPTED,
|
|
)
|
|
assert rsvp["status"] == "error"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_respond_case_insensitive_email(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "Alice@CO.com"}],
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
rsvp = await gc.respond_to_event(
|
|
eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.ACCEPTED
|
|
)
|
|
assert rsvp["status"] == "success"
|
|
|
|
|
|
class TestAvailabilityWithAttendees:
|
|
@pytest.mark.asyncio
|
|
async def test_filter_by_attendee(self):
|
|
gc = _get_gc()
|
|
# Create event with alice as attendee
|
|
await gc.create_event(
|
|
summary="Alice's Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com", "responseStatus": "accepted"}],
|
|
)
|
|
# Create event with only bob
|
|
await gc.create_event(
|
|
summary="Bob's Meeting",
|
|
start={"dateTime": "2025-06-01T14:00:00Z"},
|
|
end={"dateTime": "2025-06-01T15:00:00Z"},
|
|
attendees=[{"email": "bob@co.com", "responseStatus": "accepted"}],
|
|
)
|
|
|
|
# Check alice's availability — should only see alice's meeting
|
|
result = await gc.check_availability(
|
|
timeMin="2025-06-01T08:00:00Z",
|
|
timeMax="2025-06-01T18:00:00Z",
|
|
attendee_emails=["alice@co.com"],
|
|
)
|
|
assert len(result["busy"]) == 1
|
|
assert "Alice's Meeting" in result["busy"][0]["events"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_declined_events_not_busy(self):
|
|
gc = _get_gc()
|
|
result = await gc.create_event(
|
|
summary="Optional Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com"}],
|
|
)
|
|
event_id = result["event"]["id"]
|
|
|
|
# Alice declines
|
|
await gc.respond_to_event(eventId=event_id, email="alice@co.com", response=AttendeeResponseStatus.DECLINED)
|
|
|
|
# Check alice's availability — declined event should not block
|
|
avail = await gc.check_availability(
|
|
timeMin="2025-06-01T08:00:00Z",
|
|
timeMax="2025-06-01T18:00:00Z",
|
|
attendee_emails=["alice@co.com"],
|
|
)
|
|
assert len(avail["busy"]) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_find_mutual_availability(self):
|
|
gc = _get_gc()
|
|
# Alice busy 10-11am
|
|
await gc.create_event(
|
|
summary="Alice Meeting",
|
|
start={"dateTime": "2025-06-01T10:00:00Z"},
|
|
end={"dateTime": "2025-06-01T11:00:00Z"},
|
|
attendees=[{"email": "alice@co.com", "responseStatus": "accepted"}],
|
|
)
|
|
# Bob busy 2-3pm (non-adjacent so they don't merge)
|
|
await gc.create_event(
|
|
summary="Bob Meeting",
|
|
start={"dateTime": "2025-06-01T14:00:00Z"},
|
|
end={"dateTime": "2025-06-01T15:00:00Z"},
|
|
attendees=[{"email": "bob@co.com", "responseStatus": "accepted"}],
|
|
)
|
|
|
|
# Check availability for both — should see both busy periods
|
|
result = await gc.check_availability(
|
|
timeMin="2025-06-01T08:00:00Z",
|
|
timeMax="2025-06-01T18:00:00Z",
|
|
attendee_emails=["alice@co.com", "bob@co.com"],
|
|
)
|
|
assert len(result["busy"]) == 2
|
|
assert result["total_busy_minutes"] == 120
|