import pytest from unittest.mock import AsyncMock, MagicMock, patch from datetime import datetime, timezone def _make_mock_session(): mock_session = AsyncMock() mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.add = MagicMock() mock_session.commit = AsyncMock() mock_session.refresh = AsyncMock() return mock_session def _make_mock_event(id=1, user_id=1, uid="uid-abc", title="Meeting", caldav_uid="", color="", duration_minutes=60): e = MagicMock() e.id = id e.user_id = user_id e.uid = uid e.title = title e.caldav_uid = caldav_uid e.color = color e.start_dt = datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc) e.duration_minutes = duration_minutes # end_dt is derived; mirror the property's behavior on the mock so # service code that reads `event.end_dt` gets a sensible value. if duration_minutes is None: e.end_dt = None else: from datetime import timedelta e.end_dt = e.start_dt + timedelta(minutes=duration_minutes) e.all_day = False e.description = "" e.location = "" e.recurrence = None e.project_id = None e.to_dict.return_value = { "id": id, "uid": uid, "title": title, "caldav_uid": caldav_uid, "color": color, "start_dt": e.start_dt.isoformat(), "end_dt": e.end_dt.isoformat() if e.end_dt else None, "duration_minutes": duration_minutes, } return e @pytest.mark.asyncio async def test_create_event_stores_to_db(): mock_session = _make_mock_session() with patch("scribe.services.events.async_session") as mock_cls, \ patch("scribe.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from scribe.services.events import create_event result = await create_event( user_id=1, title="Dentist", start_dt=datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc), ) assert mock_session.add.called assert mock_session.commit.called # CalDAV push background task should be scheduled assert mock_task.called @pytest.mark.asyncio async def test_find_events_by_query_returns_ilike_results(): mock_event = _make_mock_event(title="Team Meeting") 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("scribe.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from scribe.services.events import find_events_by_query results = await find_events_by_query(user_id=1, query="meeting") assert len(results) == 1 assert results[0].title == "Team Meeting" @pytest.mark.asyncio async def test_list_events_returns_events_in_range(): mock_event = _make_mock_event() 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("scribe.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from scribe.services.events import list_events results = await list_events( user_id=1, date_from=datetime(2026, 3, 1, tzinfo=timezone.utc), date_to=datetime(2026, 3, 31, tzinfo=timezone.utc), ) assert len(results) == 1 @pytest.mark.asyncio async def test_delete_event_fires_caldav_push_when_uid_set(): mock_event = _make_mock_event(caldav_uid="sync-uid") 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("scribe.services.events.async_session") as mock_cls, \ patch("scribe.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from scribe.services.events import delete_event await delete_event(user_id=1, event_id=1) # Push task fired because caldav_uid is set assert mock_task.called @pytest.mark.asyncio async def test_update_event_fires_caldav_push(): mock_event = _make_mock_event(caldav_uid="sync-uid") 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("scribe.services.events.async_session") as mock_cls, \ patch("scribe.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from scribe.services.events import update_event await update_event(user_id=1, event_id=1, title="Updated Title") assert mock_task.called # ── Duration-model write-side guarantees (Fable #160) ───────────────────────── def test_normalize_duration_from_end_dt(): """end_dt sugar converts to a positive minute count anchored on start.""" from scribe.services.events import _normalize_duration start = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc) end = datetime(2026, 5, 1, 9, 30, tzinfo=timezone.utc) assert _normalize_duration(start_dt=start, end_dt=end, duration_minutes=None) == 90 def test_normalize_duration_zero_is_valid_point_event(): """end_dt == start_dt → duration 0. The point-with-zero-duration case is rare but legal (e.g. an instant marker); the duration model treats it the same as duration None for display purposes.""" from scribe.services.events import _normalize_duration same = datetime(2026, 5, 1, 8, 0, tzinfo=timezone.utc) assert _normalize_duration(start_dt=same, end_dt=same, duration_minutes=None) == 0 def test_normalize_duration_rejects_end_before_start(): """The exact 2026-04-29 prod failure: end 32 days before start. The duration model makes this inexpressible at the schema level via a CHECK constraint, but write-path callers still get a helpful ValueError if they construct an inconsistent (start, end) pair via the end_dt sugar.""" from scribe.services.events import _normalize_duration 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="at or after start_dt"): _normalize_duration( start_dt=start, end_dt=end_before, duration_minutes=None, ) def test_normalize_duration_rejects_negative_duration(): """Direct duration_minutes < 0 is rejected. Mirrors the DB CHECK constraint at the service boundary so callers get a clean error rather than a constraint violation from psycopg.""" from scribe.services.events import _normalize_duration start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) with pytest.raises(ValueError, match="must be >= 0"): _normalize_duration(start_dt=start, end_dt=None, duration_minutes=-15) def test_normalize_duration_rejects_inconsistent_end_and_duration(): """If a caller passes both end_dt AND duration_minutes that disagree, the inconsistency is surfaced rather than silently picking one.""" from scribe.services.events import _normalize_duration start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) end = datetime(2026, 5, 1, 13, 0, tzinfo=timezone.utc) # implies 60 min with pytest.raises(ValueError, match="implies 60 minutes"): _normalize_duration( start_dt=start, end_dt=end, duration_minutes=30, ) def test_normalize_duration_none_for_open_ended(): """Both inputs None → None duration (open-ended event).""" from scribe.services.events import _normalize_duration start = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) assert _normalize_duration( start_dt=start, end_dt=None, duration_minutes=None, ) is None @pytest.mark.asyncio async def test_create_event_rejects_end_before_start(): """Service-level rejection — same scenario as the prod bug, surfaced cleanly for tool / route callers via ValueError.""" from scribe.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="at or after start_dt"): await create_event( user_id=1, title="Bad", start_dt=start, end_dt=end_before, ) @pytest.mark.asyncio async def test_update_event_preserves_duration_when_only_start_changes(): """Sliding semantics: when the user moves an event by changing only start_dt, the existing duration_minutes is preserved as-is. The new effective end_dt slides forward with the start. This is a behavioral upgrade vs. the old end_dt model, where moving start past the stored end made the event 'go backward in time'.""" mock_event = _make_mock_event(duration_minutes=60) # start 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("scribe.services.events.async_session") as mock_cls, \ patch("scribe.services.events.asyncio.create_task"): mock_cls.return_value = mock_session from scribe.services.events import update_event # Move start to 12:00; effective end becomes 13:00 automatically. result = await update_event( user_id=1, event_id=1, start_dt=datetime(2026, 3, 25, 12, 0, tzinfo=timezone.utc), ) assert result is not None # duration_minutes was NOT touched; mock_event still has 60. assert mock_event.duration_minutes == 60 @pytest.mark.asyncio async def test_update_event_clearing_end_dt_clears_duration(): """Passing end_dt=None on update is the documented way to clear the end (turn a timed event into a point event). The service must translate that into duration_minutes=None, not leave the prior value in place.""" mock_event = _make_mock_event(duration_minutes=60) 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("scribe.services.events.async_session") as mock_cls, \ patch("scribe.services.events.asyncio.create_task"): mock_cls.return_value = mock_session from scribe.services.events import update_event await update_event(user_id=1, event_id=1, end_dt=None) assert mock_event.duration_minutes is None @pytest.mark.asyncio async def test_list_events_includes_point_event_in_window(): """A point event (duration_minutes=None) surfaces when its start is in the window. Replaces the prior 'corrupt end_dt' regression test — the duration model can't represent that state, but the same code path is exercised here for point events.""" mock_event = _make_mock_event(duration_minutes=None) # Point event in the upcoming window mock_event.start_dt = datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc) mock_event.end_dt = None mock_event.to_dict.return_value = { "id": 1, "title": "Point", "start_dt": mock_event.start_dt.isoformat(), "end_dt": None, "duration_minutes": None, } 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("scribe.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from scribe.services.events import list_events 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), ) assert len(results) == 1 assert results[0]["id"] == 1 @pytest.mark.asyncio async def test_list_events_excludes_timed_event_that_already_ended(): """A timed event whose start + duration is before the window must NOT surface. Verifies the Python-side refinement actually works against the coarse SQL prefilter.""" mock_event = _make_mock_event(duration_minutes=60) # Start 4/20 12:00, end 4/20 13:00; window is 4/29 → 5/27 — fully past. mock_event.start_dt = datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc) mock_event.end_dt = datetime(2026, 4, 20, 13, 0, tzinfo=timezone.utc) mock_event.to_dict.return_value = { "id": 1, "start_dt": mock_event.start_dt.isoformat(), "end_dt": mock_event.end_dt.isoformat(), "duration_minutes": 60, } 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("scribe.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from scribe.services.events import list_events 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), ) assert results == [] # test_tools_calendar_always_available removed in Phase 8 along with the # services/tools/ LLM-tool layer. Event CRUD is now exposed via MCP tools # (see tests/test_mcp_tool_events.py for coverage).