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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fabledassistant.mcp._context import current_user_id
|
from fabledassistant.mcp._context import current_user_id
|
||||||
from fabledassistant.services import events as events_svc
|
from fabledassistant.services import events as events_svc
|
||||||
|
|
||||||
|
|
||||||
def _combine(start_date: str, start_time: str) -> datetime:
|
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"
|
t = start_time or "00:00"
|
||||||
return datetime.fromisoformat(f"{start_date}T{t}: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:
|
def _event_dict(event) -> dict:
|
||||||
"""Render an Event model to a dict, handling list_events (already dicts)."""
|
"""Render an Event model to a dict, handling list_events (already dicts)."""
|
||||||
return event if isinstance(event, dict) else event.to_dict()
|
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.
|
Recurring events are expanded into individual occurrences within the range.
|
||||||
"""
|
"""
|
||||||
uid = current_user_id()
|
uid = current_user_id()
|
||||||
start = datetime.fromisoformat(date_from)
|
start, end = _day_range_utc(date_from, date_to)
|
||||||
end = datetime.fromisoformat(date_to)
|
|
||||||
rows = await events_svc.list_events(uid, start, end)
|
rows = await events_svc.list_events(uid, start, end)
|
||||||
return {"events": [_event_dict(e) for e in rows], "total": len(rows)}
|
return {"events": [_event_dict(e) for e in rows], "total": len(rows)}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ def _fake_event(**overrides) -> MagicMock:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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=[
|
mock = AsyncMock(return_value=[
|
||||||
{"id": 1, "title": "morning standup"},
|
{"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")
|
out = await list_events(date_from="2026-06-01", date_to="2026-06-08")
|
||||||
args, _ = mock.call_args
|
args, _ = mock.call_args
|
||||||
assert args[0] == 7 # user_id
|
assert args[0] == 7 # user_id
|
||||||
assert args[1] == datetime(2026, 6, 1)
|
assert args[1] == datetime(2026, 6, 1, tzinfo=timezone.utc)
|
||||||
assert args[2] == datetime(2026, 6, 8)
|
# 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
|
assert out["total"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user