Add Handbook.md benchmark tasks
This commit is contained in:
@@ -0,0 +1,528 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user