"""Tests for RSVP view and token generation.""" # pylint: disable=missing-function-docstring,protected-access import re from datetime import timedelta from unittest.mock import patch from urllib.parse import parse_qs, urlparse from django.core import mail from django.core.signing import BadSignature, SignatureExpired, TimestampSigner from django.template.loader import render_to_string from django.test import RequestFactory, TestCase, override_settings from django.utils import timezone import icalendar import pytest from core import factories from core.api.viewsets_rsvp import RSVPConfirmView, RSVPProcessView from core.services.caldav_service import CalDAVHTTPClient from core.services.calendar_invitation_service import ( CalendarInvitationService, ICalendarParser, ) def _make_ics(uid="test-uid-123", summary="Team Meeting", sequence=0, days_from_now=30): """Build a sample ICS string with a date relative to now.""" dt = timezone.now() + timedelta(days=days_from_now) dtstart = dt.strftime("%Y%m%dT%H%M%SZ") dtend = (dt + timedelta(hours=1)).strftime("%Y%m%dT%H%M%SZ") return ( "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "PRODID:-//Test//EN\r\n" "BEGIN:VEVENT\r\n" f"UID:{uid}\r\n" f"DTSTART:{dtstart}\r\n" f"DTEND:{dtend}\r\n" f"SUMMARY:{summary}\r\n" "ORGANIZER;CN=Alice:mailto:alice@example.com\r\n" "ATTENDEE;CN=Bob;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:bob@example.com\r\n" f"SEQUENCE:{sequence}\r\n" "END:VEVENT\r\n" "END:VCALENDAR" ) SAMPLE_ICS = _make_ics() SAMPLE_ICS_UPDATE = _make_ics(uid="test-uid-456", summary="Updated Meeting", sequence=1) SAMPLE_ICS_PAST = _make_ics( uid="test-uid-past", summary="Past Meeting", days_from_now=-30 ) SAMPLE_CALDAV_RESPONSE = """\ /caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics /caldav/calendars/users/alice%40example.com/cal-uuid/test-uid-123.ics {ics_data} HTTP/1.1 200 OK """ def _make_token( uid="test-uid-123", email="bob@example.com", organizer="alice@example.com" ): """Create a valid signed RSVP token using TimestampSigner.""" signer = TimestampSigner(salt="rsvp") return signer.sign_object( { "uid": uid, "email": email, "organizer": organizer, } ) class TestRSVPTokenGeneration: """Tests for RSVP token generation in invitation service.""" def test_token_roundtrip(self): """A generated token can be unsigned to recover the payload.""" token = _make_token() signer = TimestampSigner(salt="rsvp") payload = signer.unsign_object(token) assert payload["uid"] == "test-uid-123" assert payload["email"] == "bob@example.com" assert payload["organizer"] == "alice@example.com" def test_tampered_token_fails(self): """A tampered token raises BadSignature.""" token = _make_token() + "tampered" signer = TimestampSigner(salt="rsvp") with pytest.raises(BadSignature): signer.unsign_object(token) @pytest.mark.django_db class TestRSVPUrlsInContext: """Tests that RSVP URLs are added to template context for REQUEST method.""" def test_request_method_has_rsvp_urls(self): """REQUEST method should include RSVP URLs in context.""" event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com") service = CalendarInvitationService() context = service._build_template_context(event, "REQUEST") assert "rsvp_accept_url" in context assert "rsvp_tentative_url" in context assert "rsvp_decline_url" in context # Check URLs contain proper action params assert "action=accepted" in context["rsvp_accept_url"] assert "action=tentative" in context["rsvp_tentative_url"] assert "action=declined" in context["rsvp_decline_url"] # Check all URLs contain a token for key in ("rsvp_accept_url", "rsvp_tentative_url", "rsvp_decline_url"): parsed = urlparse(context[key]) params = parse_qs(parsed.query) assert "token" in params def test_cancel_method_has_no_rsvp_urls(self): """CANCEL method should NOT include RSVP URLs.""" event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com") service = CalendarInvitationService() context = service._build_template_context(event, "CANCEL") assert "rsvp_accept_url" not in context def test_reply_method_has_no_rsvp_urls(self): """REPLY method should NOT include RSVP URLs.""" event = ICalendarParser.parse(SAMPLE_ICS, "bob@example.com") service = CalendarInvitationService() context = service._build_template_context(event, "REPLY") assert "rsvp_accept_url" not in context @pytest.mark.django_db class TestRSVPEmailTemplateRendering: """Tests that RSVP buttons appear in email templates.""" def _build_context(self, ics_data, method="REQUEST"): event = ICalendarParser.parse(ics_data, "bob@example.com") service = CalendarInvitationService() return service._build_template_context(event, method) def test_invitation_html_has_rsvp_buttons(self): context = self._build_context(SAMPLE_ICS) html = render_to_string("emails/calendar_invitation.html", context) assert "Accepter" in html assert "Peut-être" in html assert "Refuser" in html def test_invitation_txt_has_rsvp_links(self): context = self._build_context(SAMPLE_ICS) txt = render_to_string("emails/calendar_invitation.txt", context) assert "Accepter" in txt assert "Peut-être" in txt assert "Refuser" in txt def test_update_html_has_rsvp_buttons(self): context = self._build_context(SAMPLE_ICS_UPDATE) html = render_to_string("emails/calendar_invitation_update.html", context) assert "Accepter" in html assert "Peut-être" in html assert "Refuser" in html def test_update_txt_has_rsvp_links(self): context = self._build_context(SAMPLE_ICS_UPDATE) txt = render_to_string("emails/calendar_invitation_update.txt", context) assert "Accepter" in txt assert "Peut-être" in txt assert "Refuser" in txt def test_cancel_html_has_no_rsvp_buttons(self): context = self._build_context(SAMPLE_ICS, method="CANCEL") html = render_to_string("emails/calendar_invitation_cancel.html", context) assert "rsvp" not in html.lower() or "Accepter" not in html def test_invitation_html_no_rsvp_for_cancel(self): """Cancel templates don't have RSVP buttons.""" context = self._build_context(SAMPLE_ICS, method="CANCEL") html = render_to_string("emails/calendar_invitation_cancel.html", context) assert "Accepter" not in html class TestUpdateAttendeePartstat: """Tests for the update_attendee_partstat function.""" def test_update_existing_partstat(self): result = CalDAVHTTPClient.update_attendee_partstat( SAMPLE_ICS, "bob@example.com", "ACCEPTED" ) assert result is not None assert "PARTSTAT=ACCEPTED" in result assert "PARTSTAT=NEEDS-ACTION" not in result def test_update_to_declined(self): result = CalDAVHTTPClient.update_attendee_partstat( SAMPLE_ICS, "bob@example.com", "DECLINED" ) assert result is not None assert "PARTSTAT=DECLINED" in result def test_update_to_tentative(self): result = CalDAVHTTPClient.update_attendee_partstat( SAMPLE_ICS, "bob@example.com", "TENTATIVE" ) assert result is not None assert "PARTSTAT=TENTATIVE" in result def test_unknown_attendee_returns_none(self): result = CalDAVHTTPClient.update_attendee_partstat( SAMPLE_ICS, "unknown@example.com", "ACCEPTED" ) assert result is None def test_preserves_other_attendee_properties(self): result = CalDAVHTTPClient.update_attendee_partstat( SAMPLE_ICS, "bob@example.com", "ACCEPTED" ) assert result is not None assert "CN=Bob" in result assert "mailto:bob@example.com" in result def test_substring_email_does_not_match(self): """Emails that are substrings of the target should NOT match.""" # Create ICS with a similar-but-different email ics = ( "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" "PRODID:-//Test//EN\r\n" "BEGIN:VEVENT\r\n" "UID:test-substring\r\n" "DTSTART:20260401T100000Z\r\n" "DTEND:20260401T110000Z\r\n" "SUMMARY:Test\r\n" "ATTENDEE;PARTSTAT=NEEDS-ACTION:mailto:notbob@example.com\r\n" "END:VEVENT\r\n" "END:VCALENDAR" ) # "bob@example.com" should NOT match "notbob@example.com" result = CalDAVHTTPClient.update_attendee_partstat( ics, "bob@example.com", "ACCEPTED" ) assert result is None @override_settings( CALDAV_URL="http://caldav:80", CALDAV_OUTBOUND_API_KEY="test-api-key", APP_URL="http://localhost:8931", API_VERSION="v1.0", ) class TestRSVPConfirmView(TestCase): """Tests for the RSVPConfirmView (GET handler).""" def setUp(self): self.factory = RequestFactory() self.view = RSVPConfirmView.as_view() def test_valid_token_renders_confirm_page(self): token = _make_token() request = self.factory.get("/rsvp/", {"token": token, "action": "accepted"}) response = self.view(request) assert response.status_code == 200 content = response.content.decode() assert 'method="post"' in content assert token in content def test_invalid_action_returns_400(self): token = _make_token() request = self.factory.get("/rsvp/", {"token": token, "action": "invalid"}) response = self.view(request) assert response.status_code == 400 def test_invalid_token_returns_400(self): request = self.factory.get( "/rsvp/", {"token": "bad-token", "action": "accepted"} ) response = self.view(request) assert response.status_code == 400 @override_settings( CALDAV_URL="http://caldav:80", CALDAV_OUTBOUND_API_KEY="test-api-key", APP_URL="http://localhost:8931", API_VERSION="v1.0", ) class TestRSVPProcessView(TestCase): """Tests for the RSVPProcessView (POST handler).""" def setUp(self): self.factory = RequestFactory() self.view = RSVPProcessView.as_view() # RSVP view looks up organizer from DB self.organizer = factories.UserFactory(email="alice@example.com") def _post(self, token, action): request = self.factory.post( "/api/v1.0/rsvp/", {"token": token, "action": action}, ) return self.view(request) def test_invalid_action_returns_400(self): token = _make_token() response = self._post(token, "invalid") assert response.status_code == 400 def test_missing_action_returns_400(self): token = _make_token() request = self.factory.post("/api/v1.0/rsvp/", {"token": token}) response = self.view(request) assert response.status_code == 400 def test_invalid_token_returns_400(self): response = self._post("bad-token", "accepted") assert response.status_code == 400 def test_missing_token_returns_400(self): request = self.factory.post("/api/v1.0/rsvp/", {"action": "accepted"}) response = self.view(request) assert response.status_code == 400 @patch.object(CalDAVHTTPClient, "put_event") @patch.object(CalDAVHTTPClient, "find_event_by_uid") def test_accept_flow(self, mock_find, mock_put): """Full accept flow: find event, update partstat, put back.""" mock_find.return_value = ( SAMPLE_ICS, "/caldav/calendars/users/alice%40example.com/cal/event.ics", '"etag-123"', ) mock_put.return_value = True token = _make_token() response = self._post(token, "accepted") assert response.status_code == 200 assert "accepted the invitation" in response.content.decode() # Verify CalDAV calls mock_find.assert_called_once() find_args = mock_find.call_args[0] assert find_args[0].email == "alice@example.com" assert find_args[1] == "test-uid-123" mock_put.assert_called_once() # Check the updated data contains ACCEPTED put_args = mock_put.call_args assert "PARTSTAT=ACCEPTED" in put_args[0][2] # Check ETag is passed assert put_args[1]["etag"] == '"etag-123"' @patch.object(CalDAVHTTPClient, "put_event") @patch.object(CalDAVHTTPClient, "find_event_by_uid") def test_decline_flow(self, mock_find, mock_put): mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None) mock_put.return_value = True token = _make_token() response = self._post(token, "declined") assert response.status_code == 200 assert "declined the invitation" in response.content.decode() put_args = mock_put.call_args assert "PARTSTAT=DECLINED" in put_args[0][2] @patch.object(CalDAVHTTPClient, "put_event") @patch.object(CalDAVHTTPClient, "find_event_by_uid") def test_tentative_flow(self, mock_find, mock_put): mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None) mock_put.return_value = True token = _make_token() response = self._post(token, "tentative") assert response.status_code == 200 content = response.content.decode() assert "maybe" in content.lower() put_args = mock_put.call_args assert "PARTSTAT=TENTATIVE" in put_args[0][2] @patch.object(CalDAVHTTPClient, "find_event_by_uid") def test_event_not_found_returns_400(self, mock_find): mock_find.return_value = (None, None, None) token = _make_token() response = self._post(token, "accepted") assert response.status_code == 400 assert "not found" in response.content.decode().lower() @patch.object(CalDAVHTTPClient, "put_event") @patch.object(CalDAVHTTPClient, "find_event_by_uid") def test_put_failure_returns_400(self, mock_find, mock_put): mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None) mock_put.return_value = False token = _make_token() response = self._post(token, "accepted") assert response.status_code == 400 assert "error occurred" in response.content.decode().lower() @patch.object(CalDAVHTTPClient, "find_event_by_uid") def test_attendee_not_in_event_returns_400(self, mock_find): """If the attendee email is not in the event, return error.""" mock_find.return_value = (SAMPLE_ICS, "/path/to/event.ics", None) # Token with an email that's not in the event token = _make_token(email="stranger@example.com") response = self._post(token, "accepted") assert response.status_code == 400 assert "not listed" in response.content.decode().lower() @patch.object(CalDAVHTTPClient, "find_event_by_uid") def test_past_event_returns_400(self, mock_find): """Cannot RSVP to an event that has already ended.""" mock_find.return_value = (SAMPLE_ICS_PAST, "/path/to/event.ics", None) token = _make_token(uid="test-uid-past") response = self._post(token, "accepted") assert response.status_code == 400 assert "already passed" in response.content.decode().lower() def _make_ics_with_method(method="REQUEST"): """Build a sample ICS string that includes a METHOD property.""" return ( "BEGIN:VCALENDAR\r\n" "VERSION:2.0\r\n" f"METHOD:{method}\r\n" "PRODID:-//Test//EN\r\n" "BEGIN:VEVENT\r\n" "UID:itip-test\r\n" "DTSTART:20260301T100000Z\r\n" "DTEND:20260301T110000Z\r\n" "SUMMARY:iTIP test\r\n" "ORGANIZER:mailto:alice@example.com\r\n" "ATTENDEE:mailto:bob@example.com\r\n" "END:VEVENT\r\n" "END:VCALENDAR" ) @pytest.mark.django_db class TestItipSetting: """Tests for CALENDAR_ITIP_ENABLED setting on ICS attachments.""" def _prepare(self, ics_data, method="REQUEST"): service = CalendarInvitationService() return service._prepare_ics_attachment(ics_data, method) @override_settings(CALENDAR_ITIP_ENABLED=False) def test_disabled_strips_existing_method(self): result = self._prepare(_make_ics_with_method("REQUEST")) cal = icalendar.Calendar.from_ical(result) assert "METHOD" not in cal @override_settings(CALENDAR_ITIP_ENABLED=False) def test_disabled_does_not_add_method(self): result = self._prepare(SAMPLE_ICS) cal = icalendar.Calendar.from_ical(result) assert "METHOD" not in cal @override_settings(CALENDAR_ITIP_ENABLED=True) def test_enabled_adds_method(self): result = self._prepare(SAMPLE_ICS, method="REQUEST") cal = icalendar.Calendar.from_ical(result) assert str(cal["METHOD"]) == "REQUEST" @override_settings(CALENDAR_ITIP_ENABLED=True) def test_enabled_updates_existing_method(self): result = self._prepare(_make_ics_with_method("CANCEL"), method="REQUEST") cal = icalendar.Calendar.from_ical(result) assert str(cal["METHOD"]) == "REQUEST" @override_settings( CALDAV_URL="http://caldav:80", CALDAV_OUTBOUND_API_KEY="test-api-key", CALDAV_INBOUND_API_KEY="test-inbound-key", APP_URL="http://localhost:8931", API_VERSION="v1.0", EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", ) class TestRSVPEndToEndFlow(TestCase): """ Integration test: scheduling callback sends email -> extract RSVP links -> follow link (GET confirm -> POST process) -> verify event is updated. """ def setUp(self): self.factory = RequestFactory() self.confirm_view = RSVPConfirmView.as_view() self.process_view = RSVPProcessView.as_view() self.organizer = factories.UserFactory(email="alice@example.com") def test_email_to_rsvp_accept_flow(self): """ 1. CalDAV scheduling callback sends an invitation email 2. Extract RSVP accept link from the email HTML 3. GET the RSVP link (renders auto-submit form) 4. POST to process the RSVP 5. Verify the event PARTSTAT is updated to ACCEPTED """ # Step 1: Send invitation via the CalendarInvitationService service = CalendarInvitationService() success = service.send_invitation( sender_email="alice@example.com", recipient_email="bob@example.com", method="REQUEST", icalendar_data=SAMPLE_ICS, ) assert success is True assert len(mail.outbox) == 1 sent_email = mail.outbox[0] assert "bob@example.com" in sent_email.to # Step 2: Extract RSVP accept link from email HTML html_body = None for alternative in sent_email.alternatives: if alternative[1] == "text/html": html_body = alternative[0] break assert html_body is not None, "Email should have an HTML body" # Find the accept link (green button with "Accepter") accept_match = re.search(r'