diff --git a/server/reflector/services/ics_sync.py b/server/reflector/services/ics_sync.py index d9f3029c..1399f1b8 100644 --- a/server/reflector/services/ics_sync.py +++ b/server/reflector/services/ics_sync.py @@ -55,8 +55,8 @@ import httpx import pytz import structlog from icalendar import Calendar, Event +from sqlalchemy.ext.asyncio import AsyncSession -from reflector.db import get_session_factory from reflector.db.calendar_events import CalendarEvent, calendar_events_controller from reflector.db.rooms import Room, rooms_controller from reflector.redis_cache import RedisAsyncLock @@ -295,7 +295,7 @@ class ICSSyncService: def __init__(self): self.fetch_service = ICSFetchService() - async def sync_room_calendar(self, room: Room) -> SyncResult: + async def sync_room_calendar(self, session: AsyncSession, room: Room) -> SyncResult: async with RedisAsyncLock( f"ics_sync_room:{room.id}", skip_if_locked=True ) as lock: @@ -306,9 +306,11 @@ class ICSSyncService: "reason": "Sync already in progress", } - return await self._sync_room_calendar(room) + return await self._sync_room_calendar(session, room) - async def _sync_room_calendar(self, room: Room) -> SyncResult: + async def _sync_room_calendar( + self, session: AsyncSession, room: Room + ) -> SyncResult: if not room.ics_enabled or not room.ics_url: return {"status": SyncStatus.SKIPPED, "reason": "ICS not configured"} @@ -341,20 +343,18 @@ class ICSSyncService: events, total_events = self.fetch_service.extract_room_events( calendar, room.name, room_url ) - sync_result = await self._sync_events_to_database(room.id, events) + sync_result = await self._sync_events_to_database(session, room.id, events) # Update room sync metadata - session_factory = get_session_factory() - async with session_factory() as session: - await rooms_controller.update( - session, - room, - { - "ics_last_sync": datetime.now(timezone.utc), - "ics_last_etag": content_hash, - }, - mutate=False, - ) + await rooms_controller.update( + session, + room, + { + "ics_last_sync": datetime.now(timezone.utc), + "ics_last_etag": content_hash, + }, + mutate=False, + ) return { "status": SyncStatus.SUCCESS, @@ -376,34 +376,32 @@ class ICSSyncService: return time_since_sync.total_seconds() >= room.ics_fetch_interval async def _sync_events_to_database( - self, room_id: str, events: list[EventData] + self, session: AsyncSession, room_id: str, events: list[EventData] ) -> SyncStats: created = 0 updated = 0 current_ics_uids = [] - session_factory = get_session_factory() - async with session_factory() as session: - for event_data in events: - calendar_event = CalendarEvent(room_id=room_id, **event_data) - existing = await calendar_events_controller.get_by_ics_uid( - session, room_id, event_data["ics_uid"] - ) - - if existing: - updated += 1 - else: - created += 1 - - await calendar_events_controller.upsert(session, calendar_event) - current_ics_uids.append(event_data["ics_uid"]) - - # Soft delete events that are no longer in calendar - deleted = await calendar_events_controller.soft_delete_missing( - session, room_id, current_ics_uids + for event_data in events: + calendar_event = CalendarEvent(room_id=room_id, **event_data) + existing = await calendar_events_controller.get_by_ics_uid( + session, room_id, event_data["ics_uid"] ) + if existing: + updated += 1 + else: + created += 1 + + await calendar_events_controller.upsert(session, calendar_event) + current_ics_uids.append(event_data["ics_uid"]) + + # Soft delete events that are no longer in calendar + deleted = await calendar_events_controller.soft_delete_missing( + session, room_id, current_ics_uids + ) + return { "events_created": created, "events_updated": updated, diff --git a/server/reflector/views/rooms.py b/server/reflector/views/rooms.py index aeb79b34..ffb8cce8 100644 --- a/server/reflector/views/rooms.py +++ b/server/reflector/views/rooms.py @@ -387,7 +387,7 @@ async def rooms_sync_ics( if not room.ics_enabled or not room.ics_url: raise HTTPException(status_code=400, detail="ICS not configured for this room") - result = await ics_sync_service.sync_room_calendar(room) + result = await ics_sync_service.sync_room_calendar(session, room) if result["status"] == "error": raise HTTPException( diff --git a/server/reflector/worker/ics_sync.py b/server/reflector/worker/ics_sync.py index 2794e3b6..a46cee01 100644 --- a/server/reflector/worker/ics_sync.py +++ b/server/reflector/worker/ics_sync.py @@ -32,7 +32,7 @@ async def sync_room_ics(session: AsyncSession, room_id: str): return logger.info("Starting ICS sync for room", room_id=room_id, room_name=room.name) - result = await ics_sync_service.sync_room_calendar(room) + result = await ics_sync_service.sync_room_calendar(session, room) if result["status"] == SyncStatus.SUCCESS: logger.info( diff --git a/server/tests/test_attendee_parsing_bug.py b/server/tests/test_attendee_parsing_bug.py index 57e94e97..6292bbbf 100644 --- a/server/tests/test_attendee_parsing_bug.py +++ b/server/tests/test_attendee_parsing_bug.py @@ -54,59 +54,45 @@ async def test_attendee_parsing_bug(db_session): ics_content = ics_content.replace("20250910T174000Z", dtstamp) sync_service = ICSSyncService() - from contextlib import asynccontextmanager from unittest.mock import AsyncMock - @asynccontextmanager - async def mock_session_context(): - yield db_session + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content - class MockSessionMaker: - def __call__(self): - return mock_session_context() + calendar = sync_service.fetch_service.parse_ics(ics_content) + from reflector.settings import settings - mock_session_factory = MockSessionMaker() + room_url = f"{settings.UI_BASE_URL}/{room.name}" - with patch("reflector.services.ics_sync.get_session_factory") as mock_get_factory: - mock_get_factory.return_value = mock_session_factory + print(f"Room URL being used for matching: {room_url}") + print(f"ICS content:\n{ics_content}") - with patch.object( - sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock - ) as mock_fetch: - mock_fetch.return_value = ics_content + events, total_events = sync_service.fetch_service.extract_room_events( + calendar, room.name, room_url + ) - calendar = sync_service.fetch_service.parse_ics(ics_content) - from reflector.settings import settings + print(f"Total events in calendar: {total_events}") + print(f"Events matching room: {len(events)}") - room_url = f"{settings.UI_BASE_URL}/{room.name}" + result = await sync_service.sync_room_calendar(db_session, room) - print(f"Room URL being used for matching: {room_url}") - print(f"ICS content:\n{ics_content}") + assert result.get("status") == "success" + assert result.get("events_found", 0) >= 0 - events, total_events = sync_service.fetch_service.extract_room_events( - calendar, room.name, room_url - ) + assert len(events) == 1 + event = events[0] - print(f"Total events in calendar: {total_events}") - print(f"Events matching room: {len(events)}") + attendees = event["attendees"] - result = await sync_service.sync_room_calendar(room) + print(f"Number of attendees: {len(attendees)}") + for i, attendee in enumerate(attendees): + print(f"Attendee {i}: {attendee}") - assert result.get("status") == "success" - assert result.get("events_found", 0) >= 0 + assert len(attendees) == 30, f"Expected 30 attendees, got {len(attendees)}" - assert len(events) == 1 - event = events[0] - - attendees = event["attendees"] - - print(f"Number of attendees: {len(attendees)}") - for i, attendee in enumerate(attendees): - print(f"Attendee {i}: {attendee}") - - assert len(attendees) == 30, f"Expected 30 attendees, got {len(attendees)}" - - assert attendees[0]["email"] == "alice@example.com" - assert attendees[1]["email"] == "bob@example.com" - assert attendees[2]["email"] == "charlie@example.com" - assert any(att["email"] == "organizer@example.com" for att in attendees) + assert attendees[0]["email"] == "alice@example.com" + assert attendees[1]["email"] == "bob@example.com" + assert attendees[2]["email"] == "charlie@example.com" + assert any(att["email"] == "organizer@example.com" for att in attendees) diff --git a/server/tests/test_ics_background_tasks.py b/server/tests/test_ics_background_tasks.py index dfa55918..dbbd152a 100644 --- a/server/tests/test_ics_background_tasks.py +++ b/server/tests/test_ics_background_tasks.py @@ -45,32 +45,17 @@ async def test_sync_room_ics_task(db_session): cal.add_component(event) ics_content = cal.to_ical().decode("utf-8") - from contextlib import asynccontextmanager + with patch( + "reflector.services.ics_sync.ICSFetchService.fetch_ics", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = ics_content - @asynccontextmanager - async def mock_session_context(): - yield db_session + await ics_sync_service.sync_room_calendar(db_session, room) - class MockSessionMaker: - def __call__(self): - return mock_session_context() - - mock_session_factory = MockSessionMaker() - - with patch("reflector.services.ics_sync.get_session_factory") as mock_get_factory: - mock_get_factory.return_value = mock_session_factory - - with patch( - "reflector.services.ics_sync.ICSFetchService.fetch_ics", - new_callable=AsyncMock, - ) as mock_fetch: - mock_fetch.return_value = ics_content - - await ics_sync_service.sync_room_calendar(room) - - events = await calendar_events_controller.get_by_room(db_session, room.id) - assert len(events) == 1 - assert events[0].ics_uid == "task-event-1" + events = await calendar_events_controller.get_by_room(db_session, room.id) + assert len(events) == 1 + assert events[0].ics_uid == "task-event-1" @pytest.mark.asyncio @@ -90,7 +75,7 @@ async def test_sync_room_ics_disabled(db_session): ics_enabled=False, ) - result = await ics_sync_service.sync_room_calendar(room) + result = await ics_sync_service.sync_room_calendar(db_session, room) events = await calendar_events_controller.get_by_room(db_session, room.id) assert len(events) == 0 @@ -259,7 +244,7 @@ async def test_sync_handles_errors_gracefully(db_session): ) as mock_fetch: mock_fetch.side_effect = Exception("Network error") - result = await ics_sync_service.sync_room_calendar(room) + result = await ics_sync_service.sync_room_calendar(db_session, room) assert result["status"] == "error" events = await calendar_events_controller.get_by_room(db_session, room.id) diff --git a/server/tests/test_ics_sync.py b/server/tests/test_ics_sync.py index 1cf356f0..f485b5d9 100644 --- a/server/tests/test_ics_sync.py +++ b/server/tests/test_ics_sync.py @@ -168,74 +168,59 @@ async def test_ics_sync_service_sync_room_calendar(db_session): cal.add_component(event) ics_content = cal.to_ical().decode("utf-8") - from contextlib import asynccontextmanager - - @asynccontextmanager - async def mock_session_context(): - yield db_session - - class MockSessionMaker: - def __call__(self): - return mock_session_context() - - mock_session_factory = MockSessionMaker() - # Create sync service and mock fetch sync_service = ICSSyncService() - with patch("reflector.services.ics_sync.get_session_factory") as mock_get_factory: - mock_get_factory.return_value = mock_session_factory + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.return_value = ics_content - with patch.object( - sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock - ) as mock_fetch: - mock_fetch.return_value = ics_content + # First sync + result = await sync_service.sync_room_calendar(db_session, room) - # First sync - result = await sync_service.sync_room_calendar(room) + assert result["status"] == "success" + assert result["events_found"] == 1 + assert result["events_created"] == 1 + assert result["events_updated"] == 0 + assert result["events_deleted"] == 0 - assert result["status"] == "success" - assert result["events_found"] == 1 - assert result["events_created"] == 1 - assert result["events_updated"] == 0 - assert result["events_deleted"] == 0 + # Verify event was created + events = await calendar_events_controller.get_by_room(db_session, room.id) + assert len(events) == 1 + assert events[0].ics_uid == "sync-event-1" + assert events[0].title == "Sync Test Meeting" - # Verify event was created - events = await calendar_events_controller.get_by_room(db_session, room.id) - assert len(events) == 1 - assert events[0].ics_uid == "sync-event-1" - assert events[0].title == "Sync Test Meeting" + # Second sync with same content (should be unchanged) + # Refresh room to get updated etag and force sync by setting old sync time + room = await rooms_controller.get_by_id(db_session, room.id) + await rooms_controller.update( + db_session, + room, + {"ics_last_sync": datetime.now(timezone.utc) - timedelta(minutes=10)}, + ) + result = await sync_service.sync_room_calendar(db_session, room) + assert result["status"] == "unchanged" - # Second sync with same content (should be unchanged) - # Refresh room to get updated etag and force sync by setting old sync time - room = await rooms_controller.get_by_id(db_session, room.id) - await rooms_controller.update( - db_session, - room, - {"ics_last_sync": datetime.now(timezone.utc) - timedelta(minutes=10)}, - ) - result = await sync_service.sync_room_calendar(room) - assert result["status"] == "unchanged" + # Third sync with updated event + event["summary"] = "Updated Meeting Title" + cal = Calendar() + cal.add_component(event) + ics_content = cal.to_ical().decode("utf-8") + mock_fetch.return_value = ics_content - # Third sync with updated event - event["summary"] = "Updated Meeting Title" - cal = Calendar() - cal.add_component(event) - ics_content = cal.to_ical().decode("utf-8") - mock_fetch.return_value = ics_content + # Force sync by clearing etag + await rooms_controller.update(db_session, room, {"ics_last_etag": None}) - # Force sync by clearing etag - await rooms_controller.update(db_session, room, {"ics_last_etag": None}) + result = await sync_service.sync_room_calendar(db_session, room) + assert result["status"] == "success" + assert result["events_created"] == 0 + assert result["events_updated"] == 1 - result = await sync_service.sync_room_calendar(room) - assert result["status"] == "success" - assert result["events_created"] == 0 - assert result["events_updated"] == 1 - - # Verify event was updated - events = await calendar_events_controller.get_by_room(db_session, room.id) - assert len(events) == 1 - assert events[0].title == "Updated Meeting Title" + # Verify event was updated + events = await calendar_events_controller.get_by_room(db_session, room.id) + assert len(events) == 1 + assert events[0].title == "Updated Meeting Title" @pytest.mark.asyncio @@ -266,7 +251,7 @@ async def test_ics_sync_service_skip_disabled(): room.ics_enabled = False room.ics_url = "https://calendar.example.com/test.ics" - result = await service.sync_room_calendar(room) + result = await service.sync_room_calendar(MagicMock(), room) assert result["status"] == "skipped" assert result["reason"] == "ICS not configured" @@ -274,7 +259,7 @@ async def test_ics_sync_service_skip_disabled(): room.ics_enabled = True room.ics_url = None - result = await service.sync_room_calendar(room) + result = await service.sync_room_calendar(MagicMock(), room) assert result["status"] == "skipped" assert result["reason"] == "ICS not configured" @@ -299,28 +284,13 @@ async def test_ics_sync_service_error_handling(db_session): ) await db_session.flush() - from contextlib import asynccontextmanager - - @asynccontextmanager - async def mock_session_context(): - yield db_session - - class MockSessionMaker: - def __call__(self): - return mock_session_context() - - mock_session_factory = MockSessionMaker() - sync_service = ICSSyncService() - with patch("reflector.services.ics_sync.get_session_factory") as mock_get_factory: - mock_get_factory.return_value = mock_session_factory + with patch.object( + sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock + ) as mock_fetch: + mock_fetch.side_effect = Exception("Network error") - with patch.object( - sync_service.fetch_service, "fetch_ics", new_callable=AsyncMock - ) as mock_fetch: - mock_fetch.side_effect = Exception("Network error") - - result = await sync_service.sync_room_calendar(room) - assert result["status"] == "error" - assert "Network error" in result["error"] + result = await sync_service.sync_room_calendar(db_session, room) + assert result["status"] == "error" + assert "Network error" in result["error"]