e4e1d1da49
Two related bugs where the server defaulted naive datetimes to UTC instead of the configured user timezone, causing all-day events to land on the previous day and briefings to "disappear" at UTC midnight. - New services/tz.py helpers: get_user_tz, user_today, user_briefing_date (the briefing day flips at 4am local to align with the compilation slot, so the 00:00-04:00 local window still shows yesterday's briefing until the new one is generated). - calendar create/list/update tools now parse naive datetimes in the user's TZ before converting to UTC for storage, and tool descriptions tell the model to pass plain local dates. - briefing_conversations.get_or_create_today_conversation and the reset-today route use user_briefing_date so the in-progress briefing doesn't get replaced at 19:00 NY / UTC midnight. - _run_profile_closeout targets user-local "yesterday" for consistency. Regression tests added for the TZ helpers and the calendar tool. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""Regression tests: calendar tool respects the user's configured timezone."""
|
|
|
|
from datetime import timezone
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_event_all_day_bare_date_interprets_local_midnight():
|
|
"""Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY,
|
|
stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would
|
|
be 2026-09-29 19:00 NY and land on the wrong day)."""
|
|
from fabledassistant.services.tools.calendar import create_event_tool
|
|
|
|
captured = {}
|
|
|
|
async def fake_create_event(**kwargs):
|
|
captured.update(kwargs)
|
|
event = AsyncMock()
|
|
event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}}
|
|
return event
|
|
|
|
with patch(
|
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
|
), patch(
|
|
"fabledassistant.services.tools.calendar.events_create_event",
|
|
side_effect=fake_create_event,
|
|
):
|
|
result = await create_event_tool(
|
|
user_id=1,
|
|
arguments={"title": "Birthday", "start": "2026-09-30"},
|
|
)
|
|
|
|
assert result["success"] is True
|
|
start_dt = captured["start_dt"]
|
|
assert start_dt.tzinfo is not None
|
|
# Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day
|
|
utc = start_dt.astimezone(timezone.utc)
|
|
assert (utc.year, utc.month, utc.day) == (2026, 9, 30)
|
|
assert utc.hour == 4 # EDT offset; would be 5 in EST
|
|
assert captured["all_day"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_event_naive_datetime_is_user_local():
|
|
"""'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC."""
|
|
from fabledassistant.services.tools.calendar import create_event_tool
|
|
|
|
captured = {}
|
|
|
|
async def fake_create_event(**kwargs):
|
|
captured.update(kwargs)
|
|
event = AsyncMock()
|
|
event.to_dict.return_value = {"id": 1}
|
|
return event
|
|
|
|
with patch(
|
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
|
), patch(
|
|
"fabledassistant.services.tools.calendar.events_create_event",
|
|
side_effect=fake_create_event,
|
|
):
|
|
await create_event_tool(
|
|
user_id=1,
|
|
arguments={"title": "Meeting", "start": "2026-03-15T14:30"},
|
|
)
|
|
|
|
utc = captured["start_dt"].astimezone(timezone.utc)
|
|
# 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC
|
|
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_event_explicit_offset_is_respected():
|
|
"""Model-supplied timezone offsets must not be overridden by user TZ."""
|
|
from fabledassistant.services.tools.calendar import create_event_tool
|
|
|
|
captured = {}
|
|
|
|
async def fake_create_event(**kwargs):
|
|
captured.update(kwargs)
|
|
event = AsyncMock()
|
|
event.to_dict.return_value = {"id": 1}
|
|
return event
|
|
|
|
with patch(
|
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
|
), patch(
|
|
"fabledassistant.services.tools.calendar.events_create_event",
|
|
side_effect=fake_create_event,
|
|
):
|
|
await create_event_tool(
|
|
user_id=1,
|
|
arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"},
|
|
)
|
|
|
|
utc = captured["start_dt"].astimezone(timezone.utc)
|
|
assert (utc.hour, utc.minute) == (14, 30)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_list_events_bare_date_range_covers_local_day():
|
|
"""date_from/date_to as bare dates should cover the full LOCAL day."""
|
|
from fabledassistant.services.tools.calendar import list_events_tool
|
|
|
|
captured = {}
|
|
|
|
async def fake_list_events(*, user_id, date_from, date_to):
|
|
captured["date_from"] = date_from
|
|
captured["date_to"] = date_to
|
|
return []
|
|
|
|
with patch(
|
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
|
), patch(
|
|
"fabledassistant.services.tools.calendar.events_list_events",
|
|
side_effect=fake_list_events,
|
|
):
|
|
await list_events_tool(
|
|
user_id=1,
|
|
arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"},
|
|
)
|
|
|
|
df = captured["date_from"].astimezone(timezone.utc)
|
|
dt = captured["date_to"].astimezone(timezone.utc)
|
|
# 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day
|
|
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
|
|
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
|
|
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
|