diff --git a/server/tests/test_ics_dedup_integration.py b/server/tests/test_ics_dedup_integration.py new file mode 100644 index 00000000..cd90ccae --- /dev/null +++ b/server/tests/test_ics_dedup_integration.py @@ -0,0 +1,131 @@ +"""Integration test: ICS dedup against Max's real calendar feed. + +Fetches the live ICS feed, picks an upcoming event, then simulates +a duplicate (different UID, same time window) to verify dedup logic. +""" + +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, patch + +import pytest +from icalendar import Calendar + +from reflector.db import get_database +from reflector.db.calendar_events import CalendarEvent, calendar_events_controller +from reflector.db.meetings import meetings +from reflector.db.rooms import rooms_controller +from reflector.services.ics_sync import ICSFetchService +from reflector.worker.ics_sync import create_upcoming_meetings_for_event + +MAX_ICS_URL = ( + "https://user.fm/calendar/v1-929f6269635c3c3bebd57c32b23bf448/Max%20Calendar.ics" +) + + +def _find_upcoming_event( + fetch_service: ICSFetchService, calendar: Calendar +) -> dict | None: + """Find any upcoming event within next 48h (ignoring room matching).""" + now = datetime.now(timezone.utc) + window_end = now + timedelta(hours=48) + + for component in calendar.walk(): + if component.name != "VEVENT": + continue + status = component.get("STATUS", "").upper() + if status == "CANCELLED": + continue + event_data = fetch_service._parse_event(component) + if event_data and now < event_data["start_time"] < window_end: + return event_data + return None + + +@pytest.mark.asyncio +async def test_dedup_with_real_ics_feed(): + """Fetch Max's real ICS, pick an upcoming event, inject duplicate UID, verify dedup.""" + + # 1. Fetch and parse real ICS feed + fetch_service = ICSFetchService() + ics_content = await fetch_service.fetch_ics(MAX_ICS_URL) + calendar = fetch_service.parse_ics(ics_content) + + upcoming = _find_upcoming_event(fetch_service, calendar) + if upcoming is None: + pytest.skip("No upcoming events in Max's calendar within 48h window") + + # 2. Create a test room + room = await rooms_controller.add( + name="max-dedup-integration-test", + user_id="test-user", + zulip_auto_post=False, + zulip_stream="", + zulip_topic="", + is_locked=False, + room_mode="normal", + recording_type="cloud", + recording_trigger="automatic-2nd-participant", + is_shared=False, + ics_url=MAX_ICS_URL, + ics_enabled=True, + ) + + start_time = upcoming["start_time"] + end_time = upcoming["end_time"] + title = upcoming["title"] + + # 3. Insert two calendar events with different UIDs but same time window + event1 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid=upcoming["ics_uid"], # real UID from the feed + title=title, + start_time=start_time, + end_time=end_time, + ) + ) + event2 = await calendar_events_controller.upsert( + CalendarEvent( + room_id=room.id, + ics_uid=f"SYNTHETIC-DUPLICATE-{upcoming['ics_uid']}", + title=title, + start_time=start_time, + end_time=end_time, + ) + ) + + create_window = datetime.now(timezone.utc) - timedelta(minutes=6) + call_count = 0 + + # 4. Mock the platform client, run both events through meeting creation + with patch( + "reflector.worker.ics_sync.create_platform_client" + ) as mock_platform_factory: + mock_client = AsyncMock() + + async def mock_create_meeting(room_name_prefix, *, end_date, room): + nonlocal call_count + call_count += 1 + return AsyncMock( + meeting_id=f"integ-meeting-{call_count}", + room_name=f"max-dedup-integration-test-{call_count}", + room_url=f"https://mock.video/max-dedup-integration-test-{call_count}", + host_room_url=f"https://mock.video/max-dedup-integration-test-{call_count}?host=true", + ) + + mock_client.create_meeting = mock_create_meeting + mock_client.upload_logo = AsyncMock() + mock_platform_factory.return_value = mock_client + + await create_upcoming_meetings_for_event(event1, create_window, room) + await create_upcoming_meetings_for_event(event2, create_window, room) + + # 5. Verify: only 1 meeting created + results = await get_database().fetch_all( + meetings.select().where(meetings.c.room_id == room.id) + ) + assert len(results) == 1, ( + f"Expected 1 meeting (dedup should block duplicate), got {len(results)}. " + f"Event: {title} @ {start_time} - {end_time}" + ) + assert call_count == 1, f"create_meeting called {call_count} times, expected 1"