fix(events): tolerate corrupt end_dt + reject end<=start at write time

A prod event surfaced today with `start_dt=2026-05-01T12:00Z` and
`end_dt=2026-03-30T12:00Z` — end was 32 days BEFORE start, almost
certainly from an earlier tool-call mishap (Fable #161). The
list_events filter trusted the bogus end_dt and excluded the event
from every read path that hit the upcoming window, even though
start_dt was correctly in range. The event stayed visible in the
calendar grid (different range) but vanished from "Upcoming",
search, briefings, and journal prep events list.

This is the hotfix half of the response. The structural follow-up is
Fable #160 — replace end_dt with a duration column so invalid state
becomes inexpressible.

## A. Filter robustness in list_events

Treat `end_dt <= start_dt` as if no end_dt exists. The filter now
splits into two branches:
- valid duration: end_dt IS NOT NULL AND end_dt > start_dt AND
  end_dt >= date_from
- no/invalid duration: (end_dt IS NULL OR end_dt <= start_dt) AND
  start_dt >= date_from

Same change applied to the recurring-event expansion's `duration`
calculation, which was producing negative timedeltas for corrupted
rows and computing nonsensical occurrence end times.

## B. Write-side validation in create/update

`create_event` and `update_event` now raise ValueError when the
resulting state would have end_dt <= start_dt. Update validates
against the *post-update* state, not just the field being changed —
so pushing start_dt past an existing end_dt also fails loudly. Bad
data shouldn't be persistable from any write path.

Surfaced cleanly:
- Calendar tool wrappers (create_event_tool / update_event_tool)
  catch ValueError and return `{success: false, error: ...}`, which
  the model can read and self-correct.
- Route handlers (POST /api/events, PATCH /api/events/<id>) catch
  and return HTTP 400 with the validator's message instead of
  letting it bubble to a 500.

4 new tests in test_events_service.py:
- create rejects end before start
- create rejects equal start/end (zero duration)
- update validates the post-update state (start pushed past existing end)
- list_events surfaces an event whose end_dt is before its start_dt

34 event-related tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 13:48:28 -04:00
parent 2db23cec7a
commit 94b169f31c
4 changed files with 189 additions and 47 deletions
+86
View File
@@ -124,6 +124,92 @@ async def test_update_event_fires_caldav_push():
assert mock_task.called
@pytest.mark.asyncio
async def test_create_event_rejects_end_before_start():
"""Write-side validation: end_dt <= start_dt must raise rather than
persisting invalid data. Discovered 2026-04-29: a tool-call mishap
left an event with end_dt 32 days BEFORE start_dt; the bad data then
made the event invisible to the upcoming-list filter. Catching at
the boundary prevents the same shape from recurring."""
from fabledassistant.services.events import create_event
start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
end_before = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="must be after start_dt"):
await create_event(
user_id=1, title="Bad",
start_dt=start, end_dt=end_before,
)
@pytest.mark.asyncio
async def test_create_event_rejects_end_equal_to_start():
"""Equal start/end is also invalid (zero-duration); pass end=None
for point events instead."""
from fabledassistant.services.events import create_event
same = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
with pytest.raises(ValueError, match="must be after start_dt"):
await create_event(
user_id=1, title="Zero",
start_dt=same, end_dt=same,
)
@pytest.mark.asyncio
async def test_update_event_rejects_post_state_with_end_before_start():
"""update_event must validate against the *post-update* state, not
just the field passed in. A user changing only start_dt to a value
after the existing end_dt would otherwise sneak past validation."""
mock_event = _make_mock_event() # start = 2026-03-25 10:00, end = 11:00
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_event
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import update_event
# Push start_dt past the existing end_dt (without touching end_dt)
with pytest.raises(ValueError, match="must be after start_dt"):
await update_event(
user_id=1, event_id=1,
start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc),
)
@pytest.mark.asyncio
async def test_list_events_returns_event_with_invalid_end_dt():
"""Filter robustness: an event whose end_dt landed before start_dt
(corrupt data state from earlier bugs) must still surface in the
upcoming list when its start_dt is in range. Without this, the
event becomes invisible everywhere except via direct id lookup."""
mock_event = _make_mock_event()
# Corrupt state: end is 32 days BEFORE start
mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
mock_event.end_dt = datetime(2026, 3, 30, 12, 0, tzinfo=timezone.utc)
mock_event.to_dict.return_value = {
"id": 1, "title": "Corrupt event",
"start_dt": mock_event.start_dt.isoformat(),
"end_dt": mock_event.end_dt.isoformat(),
}
mock_session = _make_mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [mock_event]
mock_session.execute = AsyncMock(return_value=mock_result)
with patch("fabledassistant.services.events.async_session") as mock_cls:
mock_cls.return_value = mock_session
from fabledassistant.services.events import list_events
# Window covers start_dt (May 1) but not end_dt (March 30).
results = await list_events(
user_id=1,
date_from=datetime(2026, 4, 29, tzinfo=timezone.utc),
date_to=datetime(2026, 5, 27, tzinfo=timezone.utc),
)
# Pre-fix: filter would exclude this event. Post-fix: present.
assert len(results) == 1
assert results[0]["id"] == 1
@pytest.mark.asyncio
async def test_tools_calendar_always_available():
"""Calendar tools must appear in get_tools_for_user even without CalDAV."""