fix(mcp): list_events tz-aware UTC range + end-of-day inclusive
Phase 6 smoke caught:
Error executing tool list_events:
can't compare offset-naive and offset-aware datetimes
Event.start_dt is stored timezone-aware; the wrapper was passing naive
datetimes built from datetime.fromisoformat("YYYY-MM-DD"), so the SQL
comparison crashed. Also: the docstring promises "date_to inclusive at
end-of-day" but the code was using midnight-of-date_to, which would
silently miss same-day events after midnight.
Extracted the range math into _day_range_utc() so create/update_event's
_combine() can stay as-is (it stays naive — the service localizes
create/update inputs against the user's tz, that path didn't crash).
Test updated to match: assert tz-aware UTC datetimes and the +24h
bump for end-of-day-inclusive semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,18 +17,40 @@ For update, sentinels:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fabledassistant.mcp._context import current_user_id
|
||||
from fabledassistant.services import events as events_svc
|
||||
|
||||
|
||||
def _combine(start_date: str, start_time: str) -> datetime:
|
||||
"""Combine YYYY-MM-DD + HH:MM into a naive datetime."""
|
||||
"""Combine YYYY-MM-DD + HH:MM into a naive datetime.
|
||||
|
||||
The events service interprets naive datetimes for create/update against
|
||||
the user's configured timezone, so we don't attach tzinfo here.
|
||||
"""
|
||||
t = start_time or "00:00"
|
||||
return datetime.fromisoformat(f"{start_date}T{t}:00")
|
||||
|
||||
|
||||
def _day_range_utc(date_from: str, date_to: str) -> tuple[datetime, datetime]:
|
||||
"""Return a UTC datetime range [start_of_date_from, end_of_date_to).
|
||||
|
||||
Event.start_dt is stored timezone-aware in the DB; comparing it against a
|
||||
naive datetime raises TypeError. We anchor the range in UTC, which is a
|
||||
reasonable default — refining to the user's local timezone for the
|
||||
range boundaries is a separate improvement.
|
||||
"""
|
||||
start = datetime.fromisoformat(date_from).replace(tzinfo=timezone.utc)
|
||||
# `date_to` is inclusive at the day level — bump by 24h so events later
|
||||
# on date_to are included.
|
||||
end = (
|
||||
datetime.fromisoformat(date_to).replace(tzinfo=timezone.utc)
|
||||
+ timedelta(days=1)
|
||||
)
|
||||
return start, end
|
||||
|
||||
|
||||
def _event_dict(event) -> dict:
|
||||
"""Render an Event model to a dict, handling list_events (already dicts)."""
|
||||
return event if isinstance(event, dict) else event.to_dict()
|
||||
@@ -41,8 +63,7 @@ async def list_events(date_from: str, date_to: str) -> dict:
|
||||
Recurring events are expanded into individual occurrences within the range.
|
||||
"""
|
||||
uid = current_user_id()
|
||||
start = datetime.fromisoformat(date_from)
|
||||
end = datetime.fromisoformat(date_to)
|
||||
start, end = _day_range_utc(date_from, date_to)
|
||||
rows = await events_svc.list_events(uid, start, end)
|
||||
return {"events": [_event_dict(e) for e in rows], "total": len(rows)}
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ def _fake_event(**overrides) -> MagicMock:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_events_passes_datetime_range():
|
||||
async def test_list_events_passes_timezone_aware_range():
|
||||
"""Range must be tz-aware (UTC) and date_to inclusive at end-of-day —
|
||||
Event.start_dt is tz-aware in the DB; naive comparisons raise TypeError."""
|
||||
from datetime import timezone
|
||||
mock = AsyncMock(return_value=[
|
||||
{"id": 1, "title": "morning standup"},
|
||||
])
|
||||
@@ -39,8 +42,9 @@ async def test_list_events_passes_datetime_range():
|
||||
out = await list_events(date_from="2026-06-01", date_to="2026-06-08")
|
||||
args, _ = mock.call_args
|
||||
assert args[0] == 7 # user_id
|
||||
assert args[1] == datetime(2026, 6, 1)
|
||||
assert args[2] == datetime(2026, 6, 8)
|
||||
assert args[1] == datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||
# date_to is end-of-day inclusive → start of 2026-06-09 (24h past start of 2026-06-08)
|
||||
assert args[2] == datetime(2026, 6, 9, tzinfo=timezone.utc)
|
||||
assert out["total"] == 1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user