445 lines
15 KiB
Python
445 lines
15 KiB
Python
|
|
"""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."
|
||
|
|
]
|