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
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Tests for the nested bundle-input layout: <BUNDLEDIR>/services/<name>/state.json."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
# A minimal, valid CalendarState seed. CalendarState has all-default fields, so
|
||||
# an empty events dict is valid; one event keeps the round-trip meaningful.
|
||||
SEED = {
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Seeded Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_state():
|
||||
"""Keep state in-memory and reset the account registry around each test."""
|
||||
state = _get_state()
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
yield
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_prefers_state_json(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
service_dir = tmp_path / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
state_json = service_dir / "state.json"
|
||||
state_json.write_text(json.dumps(SEED))
|
||||
(service_dir / "events.json").write_text(json.dumps(SEED))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_path() == state_json
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_globs_when_no_state_json(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
service_dir = tmp_path / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
a_json = service_dir / "a.json"
|
||||
a_json.write_text(json.dumps(SEED))
|
||||
(service_dir / "b.json").write_text(json.dumps(SEED))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_path() == a_json
|
||||
|
||||
|
||||
def test_resolve_bundle_state_path_missing_subdir(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
(tmp_path / "services").mkdir()
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
assert state.resolve_bundle_state_path() is None
|
||||
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
assert state.resolve_bundle_state_path() is None
|
||||
|
||||
|
||||
def test_resolve_bundle_output_path(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
output_dir = tmp_path / "services" / "google_calendar"
|
||||
|
||||
monkeypatch.setenv("BUNDLE_OUTPUT_DIR", str(output_dir))
|
||||
assert state.resolve_bundle_output_path() == output_dir / "state.json"
|
||||
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
assert state.resolve_bundle_output_path() is None
|
||||
|
||||
|
||||
def test_bundle_state_json_matches_inputdir(tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
|
||||
bundle_dir = tmp_path / "bundle"
|
||||
service_dir = bundle_dir / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
(service_dir / "state.json").write_text(json.dumps(SEED))
|
||||
|
||||
inputdir = tmp_path / "input"
|
||||
inputdir.mkdir()
|
||||
(inputdir / "calendar.json").write_text(json.dumps(SEED))
|
||||
|
||||
# Load from the nested bundle layout.
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_dir))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
state.load_seed_state_from_env()
|
||||
bundle_state = state.state_to_json()
|
||||
|
||||
# Reset and load the same seed from the INPUTDIR fallback.
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
state.set_agent_workspace(None)
|
||||
monkeypatch.delenv("BUNDLEDIR", raising=False)
|
||||
monkeypatch.setenv("INPUTDIR", str(inputdir))
|
||||
state.load_seed_state_from_env()
|
||||
inputdir_state = state.state_to_json()
|
||||
|
||||
assert bundle_state == inputdir_state
|
||||
|
||||
|
||||
# Two distinguishable accounts: different seeded event so a swap would be caught.
|
||||
ACCT_A = {
|
||||
"events": {
|
||||
"evt-a": {
|
||||
"id": "evt-a",
|
||||
"summary": "Account A Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
ACCT_B = {
|
||||
"events": {
|
||||
"evt-b": {
|
||||
"id": "evt-b",
|
||||
"summary": "Account B Event",
|
||||
"start": {"dateTime": "2025-07-02T14:00:00Z"},
|
||||
"end": {"dateTime": "2025-07-02T15:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _load_from_bundle(state, monkeypatch, bundle_dir):
|
||||
"""Reset the registry and load seed state from *bundle_dir* in isolation."""
|
||||
state.set_agent_workspace(None)
|
||||
state._accounts.clear()
|
||||
state._active_account_id = "default"
|
||||
monkeypatch.setenv("BUNDLEDIR", str(bundle_dir))
|
||||
monkeypatch.delenv("INPUTDIR", raising=False)
|
||||
monkeypatch.delenv("OUTPUTDIR", raising=False)
|
||||
monkeypatch.delenv("BUNDLE_OUTPUT_DIR", raising=False)
|
||||
state.load_seed_state_from_env()
|
||||
return state.state_to_json()
|
||||
|
||||
|
||||
def test_bundle_multifile_folder_matches_consolidated_state(tmp_path, monkeypatch):
|
||||
"""A multi-file bundle folder coalesces to the same state as a single
|
||||
consolidated state.json with the same accounts."""
|
||||
state = _get_state()
|
||||
|
||||
# (a) Consolidated: one state.json with both accounts.
|
||||
consolidated = tmp_path / "consolidated"
|
||||
consolidated_dir = consolidated / "services" / "google_calendar"
|
||||
consolidated_dir.mkdir(parents=True)
|
||||
(consolidated_dir / "state.json").write_text(json.dumps({"accounts": {"default": ACCT_A, "work": ACCT_B}}))
|
||||
|
||||
# (b) Split: two wrapper files, no state.json.
|
||||
split = tmp_path / "split"
|
||||
split_dir = split / "services" / "google_calendar"
|
||||
split_dir.mkdir(parents=True)
|
||||
(split_dir / "a.json").write_text(json.dumps({"accounts": {"default": ACCT_A}}))
|
||||
(split_dir / "b.json").write_text(json.dumps({"accounts": {"work": ACCT_B}}))
|
||||
|
||||
consolidated_state = _load_from_bundle(state, monkeypatch, consolidated)
|
||||
split_state = _load_from_bundle(state, monkeypatch, split)
|
||||
|
||||
assert consolidated_state == split_state
|
||||
assert set(consolidated_state["accounts"]) == {"default", "work"}
|
||||
assert set(split_state["accounts"]) == {"default", "work"}
|
||||
|
||||
|
||||
def test_resolve_bundle_state_paths_returns_whole_folder(tmp_path, monkeypatch):
|
||||
"""The plural resolver returns ALL sorted *.json when there's no state.json,
|
||||
and exactly [state.json] when one is present."""
|
||||
state = _get_state()
|
||||
service_dir = tmp_path / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
a_json = service_dir / "a.json"
|
||||
b_json = service_dir / "b.json"
|
||||
a_json.write_text(json.dumps({"accounts": {"default": ACCT_A}}))
|
||||
b_json.write_text(json.dumps({"accounts": {"work": ACCT_B}}))
|
||||
|
||||
monkeypatch.setenv("BUNDLEDIR", str(tmp_path))
|
||||
|
||||
assert state.resolve_bundle_state_paths() == [a_json, b_json]
|
||||
|
||||
state_json = service_dir / "state.json"
|
||||
state_json.write_text(json.dumps({"accounts": {"default": ACCT_A}}))
|
||||
assert state.resolve_bundle_state_paths() == [state_json]
|
||||
|
||||
|
||||
def test_bundle_flat_files_merge_into_one_account(tmp_path, monkeypatch):
|
||||
"""the raw entities layout splits ONE account across per-entity files
|
||||
(e.g. events.json + calendars.json, no {accounts} wrapper). Flat files must
|
||||
merge into a single default account, not fragment into a phantom account
|
||||
per file that the server never activates."""
|
||||
state = _get_state()
|
||||
|
||||
service_dir = tmp_path / "bundle" / "services" / "google_calendar"
|
||||
service_dir.mkdir(parents=True)
|
||||
# One account split into its two collection halves.
|
||||
(service_dir / "events.json").write_text(json.dumps({"events": ACCT_A["events"]}))
|
||||
(service_dir / "calendars.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"calendars": {
|
||||
"team": {
|
||||
"summary": "Team",
|
||||
"events": {
|
||||
"evt-team": {
|
||||
"id": "evt-team",
|
||||
"summary": "Team Event",
|
||||
"start": {"dateTime": "2025-08-03T09:00:00Z"},
|
||||
"end": {"dateTime": "2025-08-03T10:00:00Z"},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
merged = _load_from_bundle(state, monkeypatch, tmp_path / "bundle")
|
||||
|
||||
# Single (default) account holding BOTH halves — events and the calendar.
|
||||
assert "accounts" not in merged, "flat per-entity files must merge into ONE account"
|
||||
assert "evt-a" in merged["events"]
|
||||
assert "team" in merged["calendars"]
|
||||
@@ -0,0 +1,980 @@
|
||||
"""Tests for multiple calendar support."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from google_calendar.models import EventTransparency, EventType
|
||||
|
||||
|
||||
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):
|
||||
"""Seed with primary events (backward-compatible flat format)."""
|
||||
external_services = tmp_path / "external_services"
|
||||
external_services.mkdir()
|
||||
data_file = external_services / "calendar_data.json"
|
||||
data_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"timeZone": "America/New_York",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Primary Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
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 TestBackwardCompatibility:
|
||||
"""Existing flat events dict works as 'primary' calendar."""
|
||||
|
||||
def test_get_calendar_events_attaches_missing_primary_events_dict(self):
|
||||
state = _get_state()
|
||||
data = {}
|
||||
|
||||
events = state.get_calendar_events(data, "primary")
|
||||
events["evt-1"] = {"id": "evt-1"}
|
||||
|
||||
assert data["events"] is events
|
||||
assert data["events"]["evt-1"]["id"] == "evt-1"
|
||||
|
||||
def test_get_calendar_events_attaches_missing_secondary_events_dict(self):
|
||||
state = _get_state()
|
||||
data = {"calendars": {"work": {"summary": "Work"}}}
|
||||
|
||||
events = state.get_calendar_events(data, "work")
|
||||
events["evt-1"] = {"id": "evt-1"}
|
||||
|
||||
assert data["calendars"]["work"]["events"] is events
|
||||
assert data["calendars"]["work"]["events"]["evt-1"]["id"] == "evt-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_from_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.get_event(eventId="evt-1")
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Primary Event"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_with_explicit_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.get_event(eventId="evt-1", calendar_id="primary")
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_defaults_to_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="New Event",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
# Should be in the flat events dict
|
||||
data = _get_state().load_data()
|
||||
assert result["event"]["id"] in data["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_default_includes_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
)
|
||||
assert result["count"] >= 1
|
||||
summaries = [e["summary"] for e in result["events"]]
|
||||
assert "Primary Event" in summaries
|
||||
|
||||
def test_save_data_rejects_invalid_calendar_state(self):
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {"primary": {"summary": "Duplicate Primary", "events": {}}}
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
state.save_data(data)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_returns_error_when_existing_state_is_invalid(self, calendar_data):
|
||||
gc = _get_gc()
|
||||
invalid_state = _get_state().load_data()
|
||||
invalid_state["calendars"] = {"primary": {"summary": "Duplicate Primary", "events": {}}}
|
||||
calendar_data.write_text(json.dumps(invalid_state))
|
||||
|
||||
result = await gc.create_calendar(summary="Work")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "Primary calendar events must be stored in top-level events" in result["message"]
|
||||
persisted = json.loads(calendar_data.read_text())
|
||||
assert "work" not in persisted["calendars"]
|
||||
|
||||
|
||||
class TestCreateCalendar:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_calendar(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="Personal")
|
||||
assert result["status"] == "success"
|
||||
assert result["calendar"]["id"] == "personal"
|
||||
assert result["calendar"]["summary"] == "Personal"
|
||||
assert result["calendar"]["primary"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_calendar_with_description(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="Team Meetings", description="Shared team calendar")
|
||||
assert result["calendar"]["description"] == "Shared team calendar"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_calendar_with_timezone(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="London Office", timeZone="Europe/London")
|
||||
assert result["status"] == "success"
|
||||
assert result["calendar"]["timeZone"] == "Europe/London"
|
||||
|
||||
data = _get_state().load_data()
|
||||
assert data["calendars"]["london-office"]["timeZone"] == "Europe/London"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Personal")
|
||||
result = await gc.create_calendar(summary="Personal")
|
||||
assert result["status"] == "error"
|
||||
assert "already exists" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_create_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="Primary")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cannot_create_empty_sanitized_calendar_id(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_calendar(summary="!!!")
|
||||
assert result["status"] == "error"
|
||||
assert "letter or number" in result["message"]
|
||||
assert "" not in _get_state().load_data().get("calendars", {})
|
||||
|
||||
|
||||
class TestListCalendars:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_includes_primary(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_calendars()
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] >= 1
|
||||
ids = [c["id"] for c in result["calendars"]]
|
||||
assert "primary" in ids
|
||||
primary = next(c for c in result["calendars"] if c["id"] == "primary")
|
||||
assert primary["timeZone"] == "America/New_York"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_includes_created_calendars(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_calendar(summary="Personal")
|
||||
result = await gc.list_calendars()
|
||||
ids = [c["id"] for c in result["calendars"]]
|
||||
assert "work" in ids
|
||||
assert "personal" in ids
|
||||
assert result["count"] == 3 # primary + work + personal
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_primary_shows_event_count(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_calendars()
|
||||
primary = next(c for c in result["calendars"] if c["id"] == "primary")
|
||||
assert primary["eventCount"] == 1 # from fixture
|
||||
|
||||
|
||||
class TestMultiCalendarEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_in_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
result = await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Work Meeting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_event_from_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.get_event(eventId=event_id, calendar_id="work")
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Work Meeting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_not_found_in_wrong_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.get_event(eventId=event_id, calendar_id="primary")
|
||||
assert result["status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_in_nonexistent_calendar(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Orphan",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="nonexistent",
|
||||
)
|
||||
assert result["status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_all_calendars(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
|
||||
# List without calendar_id = all calendars
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
)
|
||||
summaries = [e["summary"] for e in result["events"]]
|
||||
assert "Primary Event" in summaries
|
||||
assert "Work Meeting" in summaries
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_preserves_duplicate_ids_across_calendars(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
)
|
||||
|
||||
summaries = [e["summary"] for e in result["events"]]
|
||||
assert "Primary Event" in summaries
|
||||
assert "Work Event With Same ID" in summaries
|
||||
assert result["count"] == 2
|
||||
|
||||
def test_viewer_events_include_secondary_calendars(self):
|
||||
from google_calendar.viewer import _get_events
|
||||
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
events = _get_events()
|
||||
|
||||
assert events["evt-1"]["summary"] == "Primary Event"
|
||||
assert events["evt-1"]["calendar_id"] == "primary"
|
||||
assert events["evt-1"]["lookup_id"] == "evt-1"
|
||||
assert events["work:evt-1"]["summary"] == "Work Event With Same ID"
|
||||
assert events["work:evt-1"]["calendar_id"] == "work"
|
||||
assert events["work:evt-1"]["lookup_id"] == "work:evt-1"
|
||||
|
||||
def test_viewer_events_are_pinned_to_default_account(self):
|
||||
from google_calendar.viewer import _get_events
|
||||
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {
|
||||
"events": {
|
||||
"default-event": {
|
||||
"id": "default-event",
|
||||
"summary": "Default Account Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
state.set_active_account("work")
|
||||
|
||||
events = _get_events()
|
||||
|
||||
assert "default-event" in events
|
||||
assert "work-event" not in events
|
||||
|
||||
def test_viewer_events_use_first_account_when_default_is_absent(self):
|
||||
from google_calendar.viewer import _get_events
|
||||
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
events = _get_events()
|
||||
|
||||
assert list(events) == ["work-event"]
|
||||
assert events["work-event"]["summary"] == "Work Account Event"
|
||||
|
||||
def test_viewer_event_detail_uses_unique_lookup_id(self):
|
||||
from google_calendar.viewer import create_calendar_viewer_app
|
||||
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
client = TestClient(create_calendar_viewer_app())
|
||||
|
||||
primary = client.get("/api/events/evt-1")
|
||||
work = client.get("/api/events/work%3Aevt-1")
|
||||
|
||||
assert primary.status_code == 200
|
||||
assert primary.json()["event"]["summary"] == "Primary Event"
|
||||
assert primary.json()["event"]["lookup_id"] == "evt-1"
|
||||
assert work.status_code == 200
|
||||
assert work.json()["event"]["summary"] == "Work Event With Same ID"
|
||||
assert work.json()["event"]["lookup_id"] == "work:evt-1"
|
||||
|
||||
def test_viewer_stats_handles_timed_events(self):
|
||||
from google_calendar.viewer import create_calendar_viewer_app
|
||||
|
||||
client = TestClient(create_calendar_viewer_app(), raise_server_exceptions=False)
|
||||
response = client.get("/api/stats")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["total_events"] >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_single_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
|
||||
# List only work calendar
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
calendar_id="work",
|
||||
)
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["summary"] == "Work Meeting"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_max_results_zero_returns_no_events(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-12-31T23:59:59Z",
|
||||
maxResults=0,
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_from_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="To Delete",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.delete_event(eventId=event_id, calendar_id="work")
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_in_secondary_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
create_result = await gc.create_event(
|
||||
summary="Original",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
event_id = create_result["event"]["id"]
|
||||
|
||||
result = await gc.update_event(eventId=event_id, summary="Updated", calendar_id="work")
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Updated"
|
||||
|
||||
|
||||
class TestCheckAvailability:
|
||||
"""Tests for free/busy availability checking."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fully_free_range(self):
|
||||
gc = _get_gc()
|
||||
# Primary has one event at 10-11am on June 1. Check June 2 = all free.
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-02T08:00:00Z",
|
||||
timeMax="2025-06-02T18:00:00Z",
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
assert len(result["busy"]) == 0
|
||||
assert len(result["free_slots"]) == 1
|
||||
assert result["free_slots"][0]["duration_minutes"] == 600 # 10 hours
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_busy_period_detected(self):
|
||||
gc = _get_gc()
|
||||
# Check range that includes the fixture event (10-11am June 1)
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
)
|
||||
assert len(result["busy"]) == 1
|
||||
assert "Primary Event" in result["busy"][0]["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transparent_events_do_not_block_availability(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="FYI hold",
|
||||
start={"dateTime": "2025-06-05T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-05T10:00:00Z"},
|
||||
transparency="transparent",
|
||||
)
|
||||
assert result["event"]["transparency"] == "transparent"
|
||||
|
||||
availability = await gc.check_availability(
|
||||
timeMin="2025-06-05T08:00:00Z",
|
||||
timeMax="2025-06-05T12:00:00Z",
|
||||
)
|
||||
assert availability["busy"] == []
|
||||
assert availability["total_free_minutes"] == 240
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_from_gmail_events_default_transparent_and_do_not_block_availability(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Flight to Denver",
|
||||
start={"dateTime": "2025-06-05T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-05T10:00:00Z"},
|
||||
eventType=EventType.FROM_GMAIL,
|
||||
)
|
||||
assert result["event"]["transparency"] == "transparent"
|
||||
|
||||
availability = await gc.check_availability(
|
||||
timeMin="2025-06-05T08:00:00Z",
|
||||
timeMax="2025-06-05T12:00:00Z",
|
||||
)
|
||||
assert availability["busy"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_can_mark_event_transparent(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Optional office hours",
|
||||
start={"dateTime": "2025-06-05T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-05T11:00:00Z"},
|
||||
)
|
||||
|
||||
updated = await gc.update_event(eventId=result["event"]["id"], transparency=EventTransparency.TRANSPARENT)
|
||||
assert updated["event"]["transparency"] == "transparent"
|
||||
|
||||
availability = await gc.check_availability(
|
||||
timeMin="2025-06-05T08:00:00Z",
|
||||
timeMax="2025-06-05T12:00:00Z",
|
||||
)
|
||||
assert availability["busy"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_slots_around_event(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
duration_minutes=30,
|
||||
)
|
||||
# Should have free slot before (8-10am) and after (11am-6pm) the event
|
||||
assert len(result["free_slots"]) == 2
|
||||
assert result["free_slots"][0]["duration_minutes"] == 120 # 8am-10am
|
||||
assert result["free_slots"][1]["duration_minutes"] == 420 # 11am-6pm
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duration_filter(self):
|
||||
gc = _get_gc()
|
||||
# Create two events close together, leaving only a 30min gap
|
||||
await gc.create_event(
|
||||
summary="Meeting 1",
|
||||
start={"dateTime": "2025-06-03T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-03T10:00:00Z"},
|
||||
)
|
||||
await gc.create_event(
|
||||
summary="Meeting 2",
|
||||
start={"dateTime": "2025-06-03T10:30:00Z"},
|
||||
end={"dateTime": "2025-06-03T11:30:00Z"},
|
||||
)
|
||||
# Ask for 60min slots — the 30min gap should be filtered out
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-03T08:00:00Z",
|
||||
timeMax="2025-06-03T12:00:00Z",
|
||||
duration_minutes=60,
|
||||
)
|
||||
free_durations = [s["duration_minutes"] for s in result["free_slots"]]
|
||||
assert 30 not in free_durations # 30min gap filtered
|
||||
assert 60 in free_durations # 8-9am slot
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_specific_calendar(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_calendar(summary="Work")
|
||||
await gc.create_event(
|
||||
summary="Work Meeting",
|
||||
start={"dateTime": "2025-06-01T14:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T15:00:00Z"},
|
||||
calendar_id="work",
|
||||
)
|
||||
|
||||
# Check only work calendar — should not see primary event
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
calendar_id="work",
|
||||
)
|
||||
assert len(result["busy"]) == 1
|
||||
assert "Work Meeting" in result["busy"][0]["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_availability_preserves_duplicate_ids_across_calendars(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
data = state.load_data()
|
||||
data["calendars"] = {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"events": {
|
||||
"evt-1": {
|
||||
"id": "evt-1",
|
||||
"summary": "Work Event With Same ID",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
"created": "2025-01-01T00:00:00Z",
|
||||
"updated": "2025-01-01T00:00:00Z",
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
state.save_data(data)
|
||||
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
)
|
||||
|
||||
busy_event_names = [name for period in result["busy"] for name in period["events"]]
|
||||
assert "Primary Event" in busy_event_names
|
||||
assert "Work Event With Same ID" in busy_event_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overlapping_events_merged(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="A",
|
||||
start={"dateTime": "2025-06-04T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-04T10:30:00Z"},
|
||||
)
|
||||
await gc.create_event(
|
||||
summary="B",
|
||||
start={"dateTime": "2025-06-04T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-04T11:00:00Z"},
|
||||
)
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-04T08:00:00Z",
|
||||
timeMax="2025-06-04T12:00:00Z",
|
||||
)
|
||||
# A and B overlap, so should merge into one busy period
|
||||
assert len(result["busy"]) == 1
|
||||
assert result["total_busy_minutes"] == 120 # 9am-11am
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_totals_correct(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T18:00:00Z",
|
||||
)
|
||||
assert result["total_busy_minutes"] + result["total_free_minutes"] == 600 # 10 hours
|
||||
|
||||
|
||||
class TestMultiAccountSupport:
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_accounts_reports_default_for_flat_state(self):
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.list_accounts()
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] == 1
|
||||
assert result["accounts"][0]["account_id"] == "default"
|
||||
assert result["accounts"][0]["event_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_account_state_routes_reads_and_writes_by_account(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {
|
||||
"events": {
|
||||
"default-event": {
|
||||
"id": "default-event",
|
||||
"summary": "Default Account Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
accounts = await gc.list_accounts()
|
||||
assert {account["account_id"] for account in accounts["accounts"]} == {"default", "work"}
|
||||
|
||||
default_result = await gc.get_event(eventId="default-event")
|
||||
assert default_result["status"] == "success"
|
||||
assert default_result["event"]["summary"] == "Default Account Event"
|
||||
|
||||
work_result = await gc.get_event(eventId="work-event", account_id="work")
|
||||
assert work_result["status"] == "success"
|
||||
assert work_result["event"]["summary"] == "Work Account Event"
|
||||
|
||||
missing_cross_account = await gc.get_event(eventId="work-event", account_id="default")
|
||||
assert missing_cross_account["status"] == "error"
|
||||
|
||||
created = await gc.create_event(
|
||||
summary="Work Follow-up",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
account_id="work",
|
||||
)
|
||||
assert created["status"] == "success"
|
||||
|
||||
exported = state.state_to_json()
|
||||
created_id = created["event"]["id"]
|
||||
assert created_id in exported["accounts"]["work"]["events"]
|
||||
assert created_id not in exported["accounts"]["default"]["events"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_and_availability_respect_account_id(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {
|
||||
"events": {
|
||||
"default-event": {
|
||||
"id": "default-event",
|
||||
"summary": "Shared Planning",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Shared Planning",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
default_search = await gc.search_events(query="shared planning")
|
||||
work_search = await gc.search_events(query="shared planning", account_id="work")
|
||||
assert [event["id"] for event in default_search["events"]] == ["default-event"]
|
||||
assert [event["id"] for event in work_search["events"]] == ["work-event"]
|
||||
|
||||
default_availability = await gc.check_availability(
|
||||
timeMin="2025-06-01T09:00:00Z",
|
||||
timeMax="2025-06-01T14:00:00Z",
|
||||
account_id="default",
|
||||
)
|
||||
work_availability = await gc.check_availability(
|
||||
timeMin="2025-06-01T09:00:00Z",
|
||||
timeMax="2025-06-01T14:00:00Z",
|
||||
account_id="work",
|
||||
)
|
||||
assert default_availability["busy"][0]["events"] == ["Shared Planning"]
|
||||
assert default_availability["busy"][0]["start"] == "2025-06-01T10:00:00+00:00"
|
||||
assert work_availability["busy"][0]["events"] == ["Shared Planning"]
|
||||
assert work_availability["busy"][0]["start"] == "2025-06-01T12:00:00+00:00"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_write_in_one_account_leaves_other_accounts_untouched(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"default": {"events": {}},
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Event",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Broken Event",
|
||||
start={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
)
|
||||
|
||||
exported = state.state_to_json()
|
||||
assert result["status"] == "error"
|
||||
assert exported["accounts"]["default"]["events"] == {}
|
||||
assert exported["accounts"]["work"]["events"]["work-event"]["summary"] == "Work Event"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_state_from_json_resets_active_account_to_default(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json({"accounts": {"default": {"events": {}}, "work": {"events": {}}}})
|
||||
await gc.create_event(
|
||||
summary="Work Event",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
account_id="work",
|
||||
)
|
||||
assert state.get_active_account_id() == "work"
|
||||
|
||||
state.state_from_json({"accounts": {"default": {"events": {}}, "personal": {"events": {}}}})
|
||||
|
||||
assert state.get_active_account_id() == "default"
|
||||
result = await gc.create_event(
|
||||
summary="Default Event",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
)
|
||||
exported = state.state_to_json()
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["id"] in exported["accounts"]["default"]["events"]
|
||||
assert exported["accounts"]["personal"]["events"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_omitted_account_uses_first_loaded_account_when_default_is_absent(self):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json(
|
||||
{
|
||||
"accounts": {
|
||||
"work": {
|
||||
"events": {
|
||||
"work-event": {
|
||||
"id": "work-event",
|
||||
"summary": "Work Account Event",
|
||||
"start": {"dateTime": "2025-06-01T12:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T13:00:00Z"},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
result = await gc.get_event(eventId="work-event")
|
||||
|
||||
assert state.get_active_account_id() == "work"
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["summary"] == "Work Account Event"
|
||||
|
||||
def test_ensure_loaded_uses_first_persisted_account_when_default_is_absent(self, calendar_data):
|
||||
state = _get_state()
|
||||
workspace = calendar_data.parent.parent / "workspace"
|
||||
calendar_data.write_text(json.dumps({"accounts": {"work": {"events": {}}}}))
|
||||
state.set_agent_workspace(str(workspace))
|
||||
|
||||
assert state.load_data() == {"events": {}}
|
||||
assert state.get_active_account_id() == "work"
|
||||
|
||||
def test_no_workspace_mode_stays_in_memory(self, tmp_path, monkeypatch):
|
||||
state = _get_state()
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("AGENT_WORKSPACE", raising=False)
|
||||
state.set_agent_workspace(None)
|
||||
|
||||
state.state_from_json({"events": {}})
|
||||
state.save_data({"events": {}})
|
||||
|
||||
assert not (tmp_path / "calendar_data.json").exists()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_account_snapshot_preserves_wrapper(self, outputdir):
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.state_from_json({"accounts": {"default": {"events": {}}, "personal": {"events": {}}}})
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Personal Event",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
account_id="personal",
|
||||
)
|
||||
assert result["status"] == "success"
|
||||
|
||||
snapshot = json.loads((outputdir / "final.json").read_text())
|
||||
assert set(snapshot["accounts"]) == {"default", "personal"}
|
||||
assert result["event"]["id"] in snapshot["accounts"]["personal"]["events"]
|
||||
@@ -0,0 +1,414 @@
|
||||
"""Tests for recurring events and reminders."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter, ValidationError
|
||||
|
||||
from google_calendar.models import EventReminders
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reminders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReminders:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_with_reminders(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"},
|
||||
reminders={"useDefault": False, "overrides": [{"method": "popup", "minutes": 15}]},
|
||||
)
|
||||
assert result["event"]["reminders"]["overrides"][0]["minutes"] == 15
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_reminders(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,
|
||||
reminders={"useDefault": False, "overrides": [{"method": "email", "minutes": 30}]},
|
||||
)
|
||||
assert updated["event"]["reminders"]["overrides"][0]["method"] == "email"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_reverts_to_default_reminders(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"},
|
||||
reminders={"useDefault": False, "overrides": [{"method": "email", "minutes": 30}]},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
updated = await gc.update_event(eventId=event_id, reminders={"useDefault": True})
|
||||
|
||||
assert updated["event"]["reminders"] == {"useDefault": True}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_without_reminders(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="No Reminders",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
assert "reminders" not in result["event"]
|
||||
|
||||
def test_reminders_are_schema_model(self):
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.create_event.__annotations__["reminders"] == EventReminders | None
|
||||
assert gc.update_event.__annotations__["reminders"] == EventReminders | None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"useDefault": False, "overrides": [{"method": "sms", "minutes": 10}]},
|
||||
{"useDefault": False, "overrides": [{"method": "popup", "minutes": -1}]},
|
||||
{"useDefault": False, "overrides": [{"method": "popup", "minutes": 40321}]},
|
||||
{"useDefault": True, "overrides": [{"method": "popup", "minutes": 10}]},
|
||||
{"useDefault": False},
|
||||
{"useDefault": False, "overrides": []},
|
||||
{
|
||||
"useDefault": False,
|
||||
"overrides": [
|
||||
{"method": "popup", "minutes": 1},
|
||||
{"method": "popup", "minutes": 2},
|
||||
{"method": "popup", "minutes": 3},
|
||||
{"method": "popup", "minutes": 4},
|
||||
{"method": "popup", "minutes": 5},
|
||||
{"method": "popup", "minutes": 6},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_invalid_reminders_are_rejected(self, payload):
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(EventReminders).validate_python(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recurring Events
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecurringEvents:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_daily_recurring_event(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Daily Standup",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
assert result["event"]["recurrence"] == ["RRULE:FREQ=DAILY;COUNT=5"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_rejects_bad_recurrence_prefix(self):
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Bad recurrence",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "String should match pattern" in result["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_accepts_google_calendar_recurrence_exception_prefixes(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Recurring With Exceptions",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5", "EXDATE:20250603T090000Z", "RDATE:20250610T090000Z"],
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["event"]["recurrence"] == [
|
||||
"RRULE:FREQ=DAILY;COUNT=5",
|
||||
"EXDATE:20250603T090000Z",
|
||||
"RDATE:20250610T090000Z",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_expands_daily_recurring(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Daily Standup",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
# Should have 5 instances (June 1-5)
|
||||
standups = [e for e in result["events"] if "Standup" in e["summary"]]
|
||||
assert len(standups) == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_expands_weekly_recurring(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Weekly Sync",
|
||||
start={"dateTime": "2025-06-02T10:00:00Z"}, # Monday
|
||||
end={"dateTime": "2025-06-02T11:00:00Z"},
|
||||
recurrence=["RRULE:FREQ=WEEKLY;COUNT=4"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-07-01T00:00:00Z",
|
||||
)
|
||||
syncs = [e for e in result["events"] if "Weekly Sync" in e["summary"]]
|
||||
assert len(syncs) == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_expands_weekly_with_byday(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Tue/Thu Meeting",
|
||||
start={"dateTime": "2025-06-03T14:00:00Z"}, # Tuesday
|
||||
end={"dateTime": "2025-06-03T15:00:00Z"},
|
||||
recurrence=["RRULE:FREQ=WEEKLY;BYDAY=TU,TH;COUNT=6"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-30T00:00:00Z",
|
||||
)
|
||||
meetings = [e for e in result["events"] if "Tue/Thu" in e["summary"]]
|
||||
assert len(meetings) == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_have_unique_ids(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Daily",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=3"],
|
||||
)
|
||||
parent_id = result["event"]["id"]
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
ids = [e["id"] for e in listed["events"] if "Daily" in e["summary"]]
|
||||
# Each instance has a unique ID derived from parent
|
||||
assert len(ids) == 3
|
||||
assert len(set(ids)) == 3 # all unique
|
||||
assert all(parent_id in eid for eid in ids)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_have_recurringEventId(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Recurring",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=2"],
|
||||
)
|
||||
parent_id = result["event"]["id"]
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
instances = [e for e in listed["events"] if "Recurring" in e["summary"]]
|
||||
for inst in instances:
|
||||
assert inst["recurringEventId"] == parent_id
|
||||
assert "recurrence" not in inst # instances don't carry the rule
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_preserve_parent_time_zone(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Recurring with time zone",
|
||||
start={"dateTime": "2025-06-01T09:00:00", "timeZone": "America/New_York"},
|
||||
end={"dateTime": "2025-06-01T09:30:00", "timeZone": "America/New_York"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=2"],
|
||||
)
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
instances = [e for e in listed["events"] if "Recurring with time zone" in e["summary"]]
|
||||
assert len(instances) == 2
|
||||
for inst in instances:
|
||||
assert inst["start"]["timeZone"] == "America/New_York"
|
||||
assert inst["end"]["timeZone"] == "America/New_York"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_instances_use_named_time_zone_across_dst(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="DST recurring",
|
||||
start={"dateTime": "2025-03-08T09:00:00-05:00", "timeZone": "America/New_York"},
|
||||
end={"dateTime": "2025-03-08T09:30:00-05:00", "timeZone": "America/New_York"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=3"],
|
||||
)
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-03-08T00:00:00Z",
|
||||
timeMax="2025-03-12T00:00:00Z",
|
||||
)
|
||||
instances = [e for e in listed["events"] if "DST recurring" in e["summary"]]
|
||||
|
||||
assert [inst["start"]["dateTime"] for inst in instances] == [
|
||||
"2025-03-08T09:00:00-05:00",
|
||||
"2025-03-09T09:00:00-04:00",
|
||||
"2025-03-10T09:00:00-04:00",
|
||||
]
|
||||
assert {inst["start"]["timeZone"] for inst in instances} == {"America/New_York"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_only_within_query_range(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Daily",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=30"],
|
||||
)
|
||||
|
||||
# Query only June 5-7
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-05T00:00:00Z",
|
||||
timeMax="2025-06-08T00:00:00Z",
|
||||
)
|
||||
daily = [e for e in result["events"] if "Daily" in e["summary"]]
|
||||
assert len(daily) == 3 # June 5, 6, 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monthly_recurring(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Monthly Review",
|
||||
start={"dateTime": "2025-01-15T10:00:00Z"},
|
||||
end={"dateTime": "2025-01-15T11:00:00Z"},
|
||||
recurrence=["RRULE:FREQ=MONTHLY;COUNT=6"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-01-01T00:00:00Z",
|
||||
timeMax="2025-07-01T00:00:00Z",
|
||||
)
|
||||
reviews = [e for e in result["events"] if "Monthly Review" in e["summary"]]
|
||||
assert len(reviews) == 6
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_with_until(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Until Event",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;UNTIL=2025-06-04T00:00:00Z"],
|
||||
)
|
||||
|
||||
result = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
events = [e for e in result["events"] if "Until Event" in e["summary"]]
|
||||
assert len(events) == 3 # June 1, 2, 3 (UNTIL is exclusive-ish)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remove_recurrence_via_update(self):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Was Recurring",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:30:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
# Remove recurrence by passing empty list
|
||||
await gc.update_event(eventId=event_id, recurrence=[])
|
||||
|
||||
listed = await gc.list_events(
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
matching = [e for e in listed["events"] if "Was Recurring" in e["summary"]]
|
||||
assert len(matching) == 1 # Just the single event now
|
||||
|
||||
|
||||
class TestRecurringAvailability:
|
||||
@pytest.mark.asyncio
|
||||
async def test_recurring_events_block_availability(self):
|
||||
gc = _get_gc()
|
||||
await gc.create_event(
|
||||
summary="Daily Standup",
|
||||
start={"dateTime": "2025-06-01T09:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T09:15:00Z"},
|
||||
recurrence=["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
)
|
||||
|
||||
result = await gc.check_availability(
|
||||
timeMin="2025-06-01T08:00:00Z",
|
||||
timeMax="2025-06-01T10:00:00Z",
|
||||
)
|
||||
assert len(result["busy"]) == 1
|
||||
assert "Daily Standup" in result["busy"][0]["events"]
|
||||
assert result["total_busy_minutes"] == 15
|
||||
@@ -0,0 +1,369 @@
|
||||
"""Event schema enforces the supported Google Calendar mock shape."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from google_calendar.models import (
|
||||
Calendar,
|
||||
CalendarPerson,
|
||||
CalendarState,
|
||||
Event,
|
||||
EventAttendee,
|
||||
EventDateTime,
|
||||
)
|
||||
from google_calendar.tools.search import _parse_event_end, _parse_event_start
|
||||
|
||||
|
||||
def _minimal_event(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"id": "evt-1",
|
||||
"summary": "s",
|
||||
"start": {"dateTime": "2025-06-01T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T11:00:00Z"},
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_event_accepts_payload_without_created_and_updated():
|
||||
event = Event.model_validate(_minimal_event())
|
||||
assert event.created is None
|
||||
assert event.updated is None
|
||||
|
||||
|
||||
def test_event_rejects_unmodeled_extra_fields():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(conferenceData={"entryPoints": []}))
|
||||
|
||||
|
||||
def test_event_coerces_single_recurrence_string_to_list():
|
||||
event = Event.model_validate(_minimal_event(recurrence="RRULE:FREQ=WEEKLY"))
|
||||
assert event.recurrence == ["RRULE:FREQ=WEEKLY"]
|
||||
|
||||
|
||||
def test_event_recurrence_requires_supported_prefix():
|
||||
for recurrence in ["RRULE:FREQ=WEEKLY", "EXRULE:FREQ=WEEKLY", "RDATE:20250601", "EXDATE:20250601"]:
|
||||
assert Event.model_validate(_minimal_event(recurrence=[recurrence]))
|
||||
|
||||
for recurrence in ["FREQ=WEEKLY", "BAD:FREQ=WEEKLY"]:
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(recurrence=[recurrence]))
|
||||
|
||||
|
||||
def test_calendar_state_round_trips_legacy_event():
|
||||
state = CalendarState.model_validate({"events": {"evt-1": _minimal_event(recurrence="RRULE:FREQ=DAILY")}})
|
||||
dumped = state.model_dump(mode="json", exclude_none=True)
|
||||
reloaded = CalendarState.model_validate(dumped)
|
||||
assert reloaded == state
|
||||
|
||||
|
||||
def test_calendar_state_round_trips_multi_calendar_state():
|
||||
state = CalendarState.model_validate(
|
||||
{
|
||||
"timeZone": "America/New_York",
|
||||
"events": {},
|
||||
"calendars": {
|
||||
"work": {
|
||||
"summary": "Work",
|
||||
"timeZone": "Europe/London",
|
||||
"events": {"evt-1": _minimal_event()},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
dumped = state.model_dump(mode="json", exclude_none=True)
|
||||
assert dumped["timeZone"] == "America/New_York"
|
||||
assert dumped["calendars"]["work"]["timeZone"] == "Europe/London"
|
||||
assert dumped["calendars"]["work"]["events"]["evt-1"]["id"] == "evt-1"
|
||||
assert CalendarState.model_validate(dumped) == state
|
||||
|
||||
|
||||
def test_calendar_state_rejects_explicit_primary_calendar():
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarState.model_validate(
|
||||
{
|
||||
"events": {"evt-1": _minimal_event()},
|
||||
"calendars": {
|
||||
"primary": {
|
||||
"summary": "Primary",
|
||||
"events": {"evt-2": _minimal_event(id="evt-2")},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_calendar_events_must_be_keyed_by_event_id():
|
||||
with pytest.raises(ValidationError):
|
||||
Calendar.model_validate(
|
||||
{
|
||||
"summary": "Work",
|
||||
"events": {"wrong-key": _minimal_event(id="evt-1")},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarState.model_validate({"events": {"wrong-key": _minimal_event(id="evt-1")}})
|
||||
|
||||
|
||||
def test_calendar_time_zone_is_validated():
|
||||
assert Calendar.model_validate({"summary": "Work", "timeZone": "America/Los_Angeles"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Calendar.model_validate({"summary": "Work", "timeZone": "Not/AZone"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarState.model_validate({"timeZone": "Not/AZone"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{"dateTime": "2025-06-01T10:00:00Z"},
|
||||
{"dateTime": "2025-06-01T10:00:00-04:00"},
|
||||
{"dateTime": "2025-06-01T10:00:00.123Z"},
|
||||
{"dateTime": "2025-06-01T10:00:00", "timeZone": "America/New_York"},
|
||||
{"dateTime": "2025-06-01T10:00:00-04:00", "timeZone": "America/New_York"},
|
||||
{"date": "2025-06-01"},
|
||||
],
|
||||
)
|
||||
def test_event_datetime_accepts_google_calendar_shapes(payload):
|
||||
assert EventDateTime.model_validate(payload)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"payload",
|
||||
[
|
||||
{},
|
||||
{"dateTime": "2025-06-01T10:00:00Z", "date": "2025-06-01"},
|
||||
{"dateTime": "2025-06-01 10:00:00Z"},
|
||||
{"dateTime": "2025-06-01T10:00:00"},
|
||||
{"date": "2025-6-1"},
|
||||
{"date": "2025-02-31"},
|
||||
{"dateTime": "2025-06-01T10:00:00", "timeZone": "Not/AZone"},
|
||||
{"dateTime": "2025-06-01T10:00:00Z", "timeZone": "America/New_York"},
|
||||
],
|
||||
)
|
||||
def test_event_datetime_rejects_invalid_shapes(payload):
|
||||
with pytest.raises(ValidationError):
|
||||
EventDateTime.model_validate(payload)
|
||||
|
||||
|
||||
def test_offsetless_datetime_uses_event_timezone():
|
||||
event = _minimal_event(
|
||||
start={"dateTime": "2025-06-01T10:00:00", "timeZone": "America/New_York"},
|
||||
end={"dateTime": "2025-06-01T11:00:00", "timeZone": "America/New_York"},
|
||||
)
|
||||
|
||||
parsed_start = _parse_event_start(event)
|
||||
|
||||
assert parsed_start is not None
|
||||
assert parsed_start.isoformat() == "2025-06-01T10:00:00-04:00"
|
||||
|
||||
|
||||
def test_legacy_all_day_same_day_end_is_normalized():
|
||||
event = _minimal_event(
|
||||
start={"date": "2025-06-01"},
|
||||
end={"date": "2025-06-01"},
|
||||
)
|
||||
|
||||
assert _parse_event_end(event) == datetime(2025, 6, 2, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_event_rejects_mixed_start_end_shapes():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(start={"dateTime": "2025-06-01T10:00:00Z"}, end={"date": "2025-06-02"}))
|
||||
|
||||
|
||||
def test_event_rejects_end_before_start():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_event_rejects_empty_id():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(id=""))
|
||||
|
||||
|
||||
def test_event_rejects_invalid_audit_timestamp():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(created="2025-06-01T10:00:00"))
|
||||
|
||||
|
||||
def test_event_transparency_is_typed():
|
||||
event = Event.model_validate(_minimal_event(transparency="transparent"))
|
||||
assert event.transparency == "transparent"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(transparency="busy-ish"))
|
||||
|
||||
|
||||
def test_from_gmail_events_default_to_transparent():
|
||||
event = Event.model_validate(_minimal_event(eventType="fromGmail"))
|
||||
|
||||
assert event.transparency == "transparent"
|
||||
|
||||
|
||||
def test_attendee_email_and_guest_count_are_validated():
|
||||
assert EventAttendee.model_validate({"email": "alice@example.com", "additionalGuests": 1})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
EventAttendee.model_validate({"email": "not-an-email"})
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
EventAttendee.model_validate({"email": "alice@example.com", "additionalGuests": -1})
|
||||
|
||||
|
||||
def test_event_rejects_duplicate_attendees_case_insensitively():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
attendees=[
|
||||
{"email": "alice@example.com"},
|
||||
{"email": "ALICE@example.com"},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_creator_and_organizer_are_typed_person_objects():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
creator={"email": "creator@example.com", "displayName": "Creator"},
|
||||
organizer={"email": "organizer@example.com", "displayName": "Organizer", "self": True},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.creator is not None
|
||||
assert event.creator.email == "creator@example.com"
|
||||
assert event.organizer is not None
|
||||
assert event.organizer.self is True
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
CalendarPerson.model_validate({"email": "not-an-email"})
|
||||
|
||||
|
||||
def test_extended_properties_and_source_are_typed():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
extendedProperties={
|
||||
"private": {"task_id": "task-123"},
|
||||
"shared": {"project": "Migration"},
|
||||
},
|
||||
source={"title": "Sprint planning notes"},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.extendedProperties is not None
|
||||
assert event.extendedProperties.private == {"task_id": "task-123"}
|
||||
assert event.source is not None
|
||||
assert event.source.title == "Sprint planning notes"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(extendedProperties={"private": {"task_id": {"nested": "no"}}}))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(source={"title": ""}))
|
||||
|
||||
|
||||
def test_extended_properties_reject_unmodeled_top_level_keys():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
extendedProperties={
|
||||
"private": {"task_id": "task-123"},
|
||||
"unexpected": {"not": "modeled"},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_event_type_defaults_to_default():
|
||||
event = Event.model_validate(_minimal_event())
|
||||
assert event.eventType == "default"
|
||||
|
||||
|
||||
def test_focus_time_event_accepts_matching_properties():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="focusTime",
|
||||
focusTimeProperties={
|
||||
"autoDeclineMode": "declineOnlyNewConflictingInvitations",
|
||||
"chatStatus": "doNotDisturb",
|
||||
"declineMessage": "Focusing right now",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.focusTimeProperties is not None
|
||||
assert event.focusTimeProperties.chatStatus == "doNotDisturb"
|
||||
|
||||
|
||||
def test_event_type_rejects_missing_or_mismatched_properties():
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(eventType="focusTime"))
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(_minimal_event(eventType="default", focusTimeProperties={"chatStatus": "available"}))
|
||||
|
||||
|
||||
def test_working_location_properties_match_location_type():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="workingLocation",
|
||||
workingLocationProperties={"type": "customLocation", "customLocation": {"label": "Client office"}},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.workingLocationProperties is not None
|
||||
assert event.workingLocationProperties.type == "customLocation"
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="workingLocation", workingLocationProperties={"type": "customLocation"})
|
||||
)
|
||||
|
||||
|
||||
def test_working_location_home_office_is_empty_marker():
|
||||
event = Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="workingLocation",
|
||||
workingLocationProperties={"type": "homeOffice", "homeOffice": {}},
|
||||
)
|
||||
)
|
||||
|
||||
assert event.workingLocationProperties is not None
|
||||
assert event.workingLocationProperties.type == "homeOffice"
|
||||
assert event.workingLocationProperties.homeOffice is not None
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(
|
||||
eventType="workingLocation",
|
||||
workingLocationProperties={"type": "homeOffice", "homeOffice": {"label": "Home"}},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_birthday_properties_validate_special_date_shape():
|
||||
Event.model_validate(_minimal_event(eventType="birthday", birthdayProperties={"type": "birthday"}))
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="birthday", birthdayProperties={"type": "anniversary", "contact": "people/c12345"})
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="birthday", birthdayProperties={"type": "self", "contact": "people/c12345"})
|
||||
)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
Event.model_validate(
|
||||
_minimal_event(eventType="birthday", birthdayProperties={"type": "anniversary", "contact": "people/a"})
|
||||
)
|
||||
@@ -0,0 +1,444 @@
|
||||
"""Tests for event search behavior."""
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from pydantic import EmailStr, TypeAdapter, ValidationError
|
||||
|
||||
from google_calendar.models import (
|
||||
AttendeeResponseStatus,
|
||||
AvailabilityDurationMinutes,
|
||||
ListEventsMaxResults,
|
||||
Rfc3339OffsetDateTimeString,
|
||||
SearchEventsMaxResults,
|
||||
SearchEventsOrderBy,
|
||||
)
|
||||
|
||||
|
||||
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": {
|
||||
"evt-budget": {
|
||||
"id": "evt-budget",
|
||||
"summary": "Budget Review",
|
||||
"description": "Q3 planning with the finance team",
|
||||
"location": "Conference Room A",
|
||||
"start": {"dateTime": "2025-06-03T14:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-03T15:00:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-02T00:00:00Z",
|
||||
"creator": {"email": "manager@example.com", "displayName": "Budget Manager"},
|
||||
"organizer": {"email": "finance-lead@example.com", "displayName": "Finance Lead"},
|
||||
"attendees": [
|
||||
{
|
||||
"email": "alice@example.com",
|
||||
"displayName": "Alice Chen",
|
||||
"responseStatus": "accepted",
|
||||
},
|
||||
{"email": "bob@example.com", "displayName": "Bob Smith", "responseStatus": "declined"},
|
||||
],
|
||||
},
|
||||
"evt-design": {
|
||||
"id": "evt-design",
|
||||
"summary": "Design Sync",
|
||||
"description": "Review updated mobile mockups",
|
||||
"location": "Studio",
|
||||
"start": {"dateTime": "2025-06-05T16:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-05T17:00:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-04T00:00:00Z",
|
||||
"attendees": [
|
||||
{"email": "carol@example.com", "displayName": "Carol Lee", "responseStatus": "tentative"}
|
||||
],
|
||||
},
|
||||
"evt-naive": {
|
||||
"id": "evt-naive",
|
||||
"summary": "Naive Time Review",
|
||||
"description": "Stored without a timezone offset",
|
||||
"start": {"dateTime": "2025-06-07T10:00:00"},
|
||||
"end": {"dateTime": "2025-06-07T11:00:00"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-06T00:00:00Z",
|
||||
},
|
||||
"evt-broken": {
|
||||
"id": "evt-broken",
|
||||
"summary": "Broken Planning",
|
||||
"description": "Missing start and end should be reported when range-filtered",
|
||||
},
|
||||
"evt-standup": {
|
||||
"id": "evt-standup",
|
||||
"summary": "Daily Standup",
|
||||
"description": "Engineering status updates",
|
||||
"start": {"dateTime": "2025-06-01T09:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-01T09:15:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-03T00:00:00Z",
|
||||
"recurrence": ["RRULE:FREQ=DAILY;COUNT=5"],
|
||||
},
|
||||
},
|
||||
"calendars": {
|
||||
"work": {
|
||||
"id": "work",
|
||||
"summary": "Work Calendar",
|
||||
"events": {
|
||||
"evt-roadmap": {
|
||||
"id": "evt-roadmap",
|
||||
"summary": "Roadmap Planning",
|
||||
"description": "Quarterly product planning",
|
||||
"location": "War Room",
|
||||
"start": {"dateTime": "2025-06-04T10:00:00Z"},
|
||||
"end": {"dateTime": "2025-06-04T11:30:00Z"},
|
||||
"created": "2025-05-01T00:00:00Z",
|
||||
"updated": "2025-05-05T00:00:00Z",
|
||||
"creator": {"email": "product@example.com", "displayName": "Product"},
|
||||
"organizer": {"email": "dana@example.com", "displayName": "Dana Patel"},
|
||||
"attendees": [{"email": "dana@example.com", "displayName": "Dana Patel"}],
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_matches_all_query_terms_case_insensitively():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="budget finance")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
assert result["events"][0]["calendar_id"] == "primary"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_supports_quoted_phrases():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query='"Conference Room A"')
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["summary"] == "Budget Review"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_quoted_phrases_do_not_span_field_boundaries():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query='"review q3"')
|
||||
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_matches_attendee_query_text():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="carol@example.com")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-design"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_attendee_filter_can_search_without_keywords():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="", attendee_email="bob@example.com")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_filters_by_response_status_for_attendee():
|
||||
gc = _get_gc()
|
||||
|
||||
accepted = await gc.search_events(
|
||||
query="", attendee_email="alice@example.com", response_status=AttendeeResponseStatus.ACCEPTED
|
||||
)
|
||||
assert accepted["count"] == 1
|
||||
assert accepted["events"][0]["id"] == "evt-budget"
|
||||
|
||||
declined = await gc.search_events(
|
||||
query="", attendee_email="alice@example.com", response_status=AttendeeResponseStatus.DECLINED
|
||||
)
|
||||
assert declined["events"] == []
|
||||
assert declined["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_filters_by_response_status_without_attendee():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="", response_status=AttendeeResponseStatus.DECLINED)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
|
||||
|
||||
def test_search_events_response_status_is_schema_enum():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.search_events.__annotations__["response_status"] == AttendeeResponseStatus | None
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(AttendeeResponseStatus).validate_python("maybe")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_filters_by_creator_and_organizer():
|
||||
gc = _get_gc()
|
||||
|
||||
by_creator = await gc.search_events(query="", creator_email="manager@example.com")
|
||||
assert by_creator["count"] == 1
|
||||
assert by_creator["events"][0]["id"] == "evt-budget"
|
||||
|
||||
by_organizer = await gc.search_events(query="", organizer_email="dana@example.com")
|
||||
assert by_organizer["count"] == 1
|
||||
assert by_organizer["events"][0]["id"] == "evt-roadmap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_matches_creator_and_organizer_query_text():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="finance-lead@example.com")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-budget"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_time_range_filters_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="planning",
|
||||
timeMin="2025-06-04T00:00:00Z",
|
||||
timeMax="2025-06-06T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-roadmap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_handles_naive_event_times_with_aware_ranges():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="naive",
|
||||
timeMin="2025-06-07T00:00:00Z",
|
||||
timeMax="2025-06-08T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-naive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_calendar_id_scopes_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", calendar_id="work")
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-roadmap"
|
||||
assert result["events"][0]["calendar_summary"] == "Work Calendar"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_nonexistent_calendar_returns_empty_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", calendar_id="nonexistent")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_expands_recurring_events_with_time_range():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="standup",
|
||||
timeMin="2025-06-03T00:00:00Z",
|
||||
timeMax="2025-06-05T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["count"] == 2
|
||||
assert [event["start"]["dateTime"] for event in result["events"]] == [
|
||||
"2025-06-03T09:00:00+00:00",
|
||||
"2025-06-04T09:00:00+00:00",
|
||||
]
|
||||
assert {event["recurringEventId"] for event in result["events"]} == {"evt-standup"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_expands_recurring_events_without_time_range():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="standup")
|
||||
|
||||
assert result["count"] == 5
|
||||
assert [event["start"]["dateTime"] for event in result["events"]] == [
|
||||
"2025-06-01T09:00:00+00:00",
|
||||
"2025-06-02T09:00:00+00:00",
|
||||
"2025-06-03T09:00:00+00:00",
|
||||
"2025-06-04T09:00:00+00:00",
|
||||
"2025-06-05T09:00:00+00:00",
|
||||
]
|
||||
assert {event["recurringEventId"] for event in result["events"]} == {"evt-standup"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_can_order_by_updated_and_limit_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", maxResults=1, orderBy=SearchEventsOrderBy.UPDATED)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["events"][0]["id"] == "evt-roadmap"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_max_results_zero_returns_no_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="planning", maxResults=0)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
def test_search_events_max_results_is_schema_non_negative():
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(SearchEventsMaxResults).validate_python(-1)
|
||||
|
||||
|
||||
def test_range_bounds_require_rfc3339_offset_in_schema():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.list_events.__annotations__["timeMin"] == Rfc3339OffsetDateTimeString
|
||||
assert gc.list_events.__annotations__["timeMax"] == Rfc3339OffsetDateTimeString
|
||||
assert gc.search_events.__annotations__["timeMin"] == Rfc3339OffsetDateTimeString | None
|
||||
assert gc.check_availability.__annotations__["timeMin"] == Rfc3339OffsetDateTimeString
|
||||
assert gc.check_availability.__annotations__["attendee_emails"] == list[EmailStr] | None
|
||||
assert gc.check_availability.__annotations__["duration_minutes"] == AvailabilityDurationMinutes
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(Rfc3339OffsetDateTimeString).validate_python("2025-06-01T00:00:00")
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(AvailabilityDurationMinutes).validate_python(0)
|
||||
|
||||
|
||||
def test_search_events_order_by_is_schema_enum():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.search_events.__annotations__["orderBy"] == SearchEventsOrderBy | None
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(SearchEventsOrderBy).validate_python("summary")
|
||||
|
||||
|
||||
def test_list_events_order_by_is_schema_enum():
|
||||
gc = _get_gc()
|
||||
|
||||
assert gc.list_events.__annotations__["orderBy"] == SearchEventsOrderBy | None
|
||||
assert inspect.signature(gc.list_events).parameters["orderBy"].default is None
|
||||
assert gc.list_events.__annotations__["maxResults"] == ListEventsMaxResults | None
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(SearchEventsOrderBy).validate_python("summary")
|
||||
with pytest.raises(ValidationError):
|
||||
TypeAdapter(ListEventsMaxResults).validate_python(-1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_reports_skipped_unparseable_events():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(
|
||||
query="planning",
|
||||
timeMin="2025-06-01T00:00:00Z",
|
||||
timeMax="2025-06-10T00:00:00Z",
|
||||
)
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["skipped_unparseable"] == ["evt-broken"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_no_match_returns_empty_results():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="vendor escalation")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_requires_query_or_attendee_filter():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert result["events"] == []
|
||||
assert result["count"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_reports_unsupported_query_operator():
|
||||
gc = _get_gc()
|
||||
|
||||
result = await gc.search_events(query="before:2024-01-01 planning")
|
||||
|
||||
assert result["status"] == "success"
|
||||
assert result["count"] == 0
|
||||
assert result["warnings"] == [
|
||||
"Unsupported Calendar query operator 'before:'; use search_events parameters such as timeMin, timeMax, "
|
||||
"attendee_email, response_status, organizer_email, or creator_email."
|
||||
]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Tests for the _snapshot_on_write decorator — final.json written after every write tool call."""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _get_gc():
|
||||
"""Import the server module lazily so tests control its module globals."""
|
||||
return importlib.import_module("google_calendar.server")
|
||||
|
||||
|
||||
def _get_state():
|
||||
return importlib.import_module("google_calendar.state")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def calendar_data(tmp_path):
|
||||
"""Seed a minimal calendar data file."""
|
||||
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):
|
||||
"""Patch google_calendar module globals for isolated testing."""
|
||||
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)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_event_writes_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
final = outputdir / "final.json"
|
||||
assert not final.exists()
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="Test Event",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
assert result["event"]["summary"] == "Test Event"
|
||||
assert final.exists(), "final.json must be written after create_event"
|
||||
|
||||
snapshot = json.loads(final.read_text())
|
||||
assert len(snapshot["events"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_event_writes_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="Original",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
final = outputdir / "final.json"
|
||||
final.unlink() # isolate the update's snapshot
|
||||
|
||||
await gc.update_event(eventId=event_id, summary="Updated")
|
||||
|
||||
assert final.exists(), "final.json must be written after update_event"
|
||||
snapshot = json.loads(final.read_text())
|
||||
assert snapshot["events"][event_id]["summary"] == "Updated"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_event_writes_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
result = await gc.create_event(
|
||||
summary="To Delete",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
event_id = result["event"]["id"]
|
||||
|
||||
final = outputdir / "final.json"
|
||||
final.unlink()
|
||||
|
||||
await gc.delete_event(eventId=event_id)
|
||||
|
||||
assert final.exists(), "final.json must be written after delete_event"
|
||||
snapshot = json.loads(final.read_text())
|
||||
assert event_id not in snapshot["events"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_does_not_write_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
final = outputdir / "final.json"
|
||||
await gc.list_events(timeMin="2025-01-01T00:00:00Z", timeMax="2025-12-31T23:59:59Z")
|
||||
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_events_does_not_write_final_json(outputdir):
|
||||
gc = _get_gc()
|
||||
final = outputdir / "final.json"
|
||||
await gc.search_events(query="test")
|
||||
|
||||
assert not final.exists(), "final.json must NOT be written after a read-only tool"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_final_path_skips_snapshot():
|
||||
gc = _get_gc()
|
||||
state = _get_state()
|
||||
state.set_snapshot_paths(final_path=None)
|
||||
|
||||
result = await gc.create_event(
|
||||
summary="No Output",
|
||||
start={"dateTime": "2025-06-01T10:00:00Z"},
|
||||
end={"dateTime": "2025-06-01T11:00:00Z"},
|
||||
)
|
||||
assert result["event"]["summary"] == "No Output"
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Tests for google_calendar utility functions."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from google_calendar.utils import get_calendar_data_path, load_events_from_json
|
||||
|
||||
|
||||
class TestGetCalendarDataPath:
|
||||
def test_computes_external_services_path(self):
|
||||
result = get_calendar_data_path("/workspace/dumps/workspace")
|
||||
assert result == Path("/workspace/dumps/external_services/calendar_data.json")
|
||||
|
||||
def test_path_is_sibling_to_workspace(self):
|
||||
result = get_calendar_data_path("/a/b/workspace")
|
||||
assert result.parent == Path("/a/b/external_services")
|
||||
|
||||
|
||||
class TestLoadEventsFromJson:
|
||||
def test_loads_events_and_adds_timestamps(self, tmp_path):
|
||||
data = {"events": {"evt1": {"summary": "Meeting"}}}
|
||||
json_file = tmp_path / "calendar.json"
|
||||
json_file.write_text(json.dumps(data))
|
||||
|
||||
result = load_events_from_json(json_file)
|
||||
|
||||
assert "evt1" in result["events"]
|
||||
assert result["events"]["evt1"]["id"] == "evt1"
|
||||
assert "created" in result["events"]["evt1"]
|
||||
assert "updated" in result["events"]["evt1"]
|
||||
|
||||
def test_wraps_bare_dict_in_events(self, tmp_path):
|
||||
data = {"evt1": {"summary": "Meeting"}}
|
||||
json_file = tmp_path / "calendar.json"
|
||||
json_file.write_text(json.dumps(data))
|
||||
|
||||
result = load_events_from_json(json_file)
|
||||
assert "events" in result
|
||||
assert "evt1" in result["events"]
|
||||
|
||||
def test_preserves_existing_timestamps(self, tmp_path):
|
||||
data = {"events": {"evt1": {"summary": "Meeting", "created": "2024-01-01", "updated": "2024-01-01"}}}
|
||||
json_file = tmp_path / "calendar.json"
|
||||
json_file.write_text(json.dumps(data))
|
||||
|
||||
result = load_events_from_json(json_file)
|
||||
assert result["events"]["evt1"]["created"] == "2024-01-01"
|
||||
Reference in New Issue
Block a user