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=""): 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.end_dt = datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc) 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": datetime(2026, 3, 25, 10, 0, tzinfo=timezone.utc).isoformat(), "end_dt": datetime(2026, 3, 25, 11, 0, tzinfo=timezone.utc).isoformat(), } return e @pytest.mark.asyncio async def test_create_event_stores_to_db(): mock_session = _make_mock_session() with patch("fabledassistant.services.events.async_session") as mock_cls, \ patch("fabledassistant.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from fabledassistant.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("fabledassistant.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from fabledassistant.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("fabledassistant.services.events.async_session") as mock_cls: mock_cls.return_value = mock_session from fabledassistant.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("fabledassistant.services.events.async_session") as mock_cls, \ patch("fabledassistant.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from fabledassistant.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("fabledassistant.services.events.async_session") as mock_cls, \ patch("fabledassistant.services.events.asyncio.create_task") as mock_task: mock_cls.return_value = mock_session from fabledassistant.services.events import update_event await update_event(user_id=1, event_id=1, title="Updated Title") 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.""" with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock) as mock_configured, \ patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock) as mock_setting: mock_configured.return_value = False mock_setting.return_value = "false" from fabledassistant.services.tools import get_tools_for_user tools = await get_tools_for_user(user_id=1) tool_names = {t["function"]["name"] for t in tools} assert "create_event" in tool_names assert "list_events" in tool_names assert "search_events" in tool_names assert "update_event" in tool_names assert "delete_event" in tool_names