Coverage for app/backend/src/tests/test_calendar_events.py: 100%
85 statements
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-12 12:41 +0000
« prev ^ index » next coverage.py v7.17.3, created at 2026-11-12 12:41 +0000
1import re
2from datetime import UTC, datetime, timedelta
4import icalendar
6from couchers.email.calendar_events import create_event_ics_calendar, create_host_request_ics_calendar
7from couchers.i18n.context import LocalizationContext
8from couchers.proto import events_pb2, messages_pb2, requests_pb2
9from couchers.utils import Timestamp_from_datetime, today
10from tests.fixtures.api import requests as requests_api
11from tests.fixtures.db import generate_user
12from tests.fixtures.feature_flags import FeatureFlags
13from tests.fixtures.misc import EmailCollector, Moderator
16def test_host_request_ics_content():
17 host_request = requests_pb2.HostRequest(
18 host_request_id=42, from_date="2000-01-01", to_date="2000-01-02", hosting_city="New York"
19 )
21 ics: str = (
22 create_host_request_ics_calendar(
23 host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
24 )
25 .to_ical()
26 .decode()
27 )
28 _assert_ics_matches_pattern(
29 ics,
30 """
31BEGIN:VCALENDAR
32VERSION:2.0
33PRODID:-//Couchers.org//Couchers//EN
34BEGIN:VEVENT
35SEQUENCE:***
36DTSTART;VALUE=DATE:20000101
37DTEND;VALUE=DATE:20000103
38DESCRIPTION:***/42
39LOCATION:New York
40SUMMARY:Hosting Bob
41UID:host_request.42@***
42URL:***/42
43END:VEVENT
44METHOD:PUBLISH
45END:VCALENDAR
46 """,
47 )
50def test_host_request_cancelled_ics_content():
51 host_request = requests_pb2.HostRequest(
52 host_request_id=42,
53 from_date="2000-01-01",
54 to_date="2000-01-02",
55 hosting_city="New York",
56 status=messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED,
57 )
59 ics: str = (
60 create_host_request_ics_calendar(
61 host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
62 )
63 .to_ical()
64 .decode()
65 )
66 _assert_ics_matches_pattern(
67 ics,
68 """
69BEGIN:VCALENDAR
70VERSION:2.0
71PRODID:-//Couchers.org//Couchers//EN
72BEGIN:VEVENT
73SEQUENCE:***
74DTSTART;VALUE=DATE:20000101
75DTEND;VALUE=DATE:20000103
76DESCRIPTION:***/42
77LOCATION:New York
78STATUS:CANCELLED
79SUMMARY:Cancelled: Hosting Bob
80UID:host_request.42@***
81URL:***/42
82END:VEVENT
83METHOD:PUBLISH
84END:VCALENDAR
85 """,
86 )
89def test_event_ics_content():
90 event = events_pb2.Event(
91 event_id=42,
92 title="Event Title",
93 slug="event-slug",
94 content="Event description",
95 created=Timestamp_from_datetime(datetime(2000, 1, 1, 0, 0, tzinfo=UTC)),
96 start_time=Timestamp_from_datetime(datetime(2000, 1, 2, 12, 0, tzinfo=UTC)),
97 end_time=Timestamp_from_datetime(datetime(2000, 1, 3, 12, 0, tzinfo=UTC)),
98 location=events_pb2.EventLocation(address="City, Country", lat=0.1, lng=0.1),
99 timezone="Etc/GMT-1", # Confusingly represents GMT+1, no DST
100 )
102 ics: str = create_event_ics_calendar(event, loc_context=LocalizationContext.en_utc()).to_ical().decode()
103 _assert_ics_matches_pattern(
104 ics,
105 """
106BEGIN:VCALENDAR
107VERSION:2.0
108PRODID:-//Couchers.org//Couchers//EN
109BEGIN:VEVENT
110UID:event.42@***
111SEQUENCE:***
112SUMMARY:Event Title
113DESCRIPTION:Event description***/event/42/event-slug
114DTSTART;TZID=Etc/GMT-1:20000102T130000
115DTEND;TZID=Etc/GMT-1:20000103T130000
116LAST-MODIFIED:20000101T000000Z
117LOCATION:City\\, Country
118GEO:0.1;0.1
119URL:***/event/42/event-slug
120END:VEVENT
121METHOD:PUBLISH
122END:VCALENDAR
123 """,
124 )
127def _assert_ics_matches_pattern(actual: str, expected_pattern: str) -> None:
128 """Assert that an ics file's content matches a pattern that includes "***" wildcards."""
129 # Normalize whitespace
130 actual = actual.replace("\r\n", "\n").strip()
131 expected_pattern = expected_pattern.strip()
133 # The ics library writes properties in an order that's hard to make sense of, and not important.
134 # So approximate by sorting lines, and test them one at a time so we know which one fails.
135 actual_lines = sorted(actual.splitlines())
136 expected_pattern_lines = sorted(expected_pattern.splitlines())
137 for actual_line, expected_pattern_line in zip(actual_lines, expected_pattern_lines, strict=True):
138 # Convert the expected pattern from *** wildcards to a regex
139 expected_line_regex = re.escape(expected_pattern_line).replace("\\*\\*\\*", ".*?")
140 assert re.fullmatch(expected_line_regex, actual_line)
143def test_host_request_attachments(db, email_collector: EmailCollector, moderator: Moderator):
144 host, host_token = generate_user(complete_profile=True)
145 surfer, surfer_token = generate_user(complete_profile=True)
147 from_date = today() + timedelta(days=2)
148 to_date = today() + timedelta(days=3)
150 # Send the host request, no calendar attachment yet
151 hr_id = requests_api.create_host_request(
152 token=surfer_token, host_user_id=host.id, from_date=from_date, to_date=to_date, approve_by=moderator
153 )
155 email = email_collector.pop_for_recipient(host.email, last=True)
156 assert "request" in email.subject and surfer.name in email.subject
157 assert not email.attachments
159 # Host accepts, surfer gets a calendar attachment
160 requests_api.respond_host_request(
161 token=host_token, host_request_id=hr_id, status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED
162 )
164 email = email_collector.pop_for_recipient(surfer.email, last=True)
165 assert "accept" in email.subject and host.name in email.subject
166 accepted_ics_event = _get_email_ics_attachment_calendar_event(email)
167 assert not accepted_ics_event.get("status")
168 assert accepted_ics_event.get("dtstart").dt == from_date
169 assert accepted_ics_event.get("dtend").dt == (to_date + timedelta(days=1))
170 assert accepted_ics_event.get("summary") == f"Surfing with {host.name}"
171 assert accepted_ics_event.get("location") == host.city
173 # Surfer confirms, host gets a calendar attachment
174 requests_api.respond_host_request(
175 token=surfer_token, host_request_id=hr_id, status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED
176 )
178 email = email_collector.pop_for_recipient(host.email, last=True)
179 assert "confirm" in email.subject and surfer.name in email.subject
180 confirmed_ics_event = _get_email_ics_attachment_calendar_event(email)
181 assert not confirmed_ics_event.get("status")
182 assert confirmed_ics_event.get("summary") == f"Hosting {surfer.name}"
184 # Surfer cancels, host gets a calendar attachment
185 requests_api.respond_host_request(
186 token=surfer_token, host_request_id=hr_id, status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED
187 )
189 email = email_collector.pop_for_recipient(host.email, last=True)
190 assert "cancel" in email.subject and surfer.name in email.subject
191 cancelled_ics_event = _get_email_ics_attachment_calendar_event(email)
192 # Ideally the sequence number are strictly ascending, but they are based on timestamps so in tests they could be equal.
193 assert (_get_ics_event_sequence(cancelled_ics_event) or 0) >= (_get_ics_event_sequence(accepted_ics_event) or 0)
194 assert cancelled_ics_event.get("status") == "CANCELLED"
197def test_host_request_attachments_disabled(
198 db, email_collector: EmailCollector, feature_flags: FeatureFlags, moderator: Moderator
199):
200 feature_flags.set("email_ics_attachments_enabled", False)
202 host, host_token = generate_user(complete_profile=True)
203 surfer, surfer_token = generate_user(complete_profile=True)
205 hr_id = requests_api.create_host_request(token=surfer_token, host_user_id=host.id, approve_by=moderator)
207 email_collector.pop_for_recipient(host.email, last=True)
209 # Host accepts: normally the surfer would get a calendar attachment, but the flag is off
210 requests_api.respond_host_request(
211 token=host_token, host_request_id=hr_id, status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED
212 )
214 email = email_collector.pop_for_recipient(surfer.email, last=True)
215 assert "accept" in email.subject and host.name in email.subject
216 assert not email.attachments
219def _get_email_ics_attachment_calendar_event(e) -> icalendar.Event:
220 assert len(e.attachments or []) == 1
221 ics_attachment = e.attachments[0]
222 assert ics_attachment.content_type.startswith("text/calendar")
223 ics_calendar = icalendar.Calendar.from_ical(ics_attachment.data.decode("utf-8"))
224 assert f"method={ics_calendar.get('method')}" in ics_attachment.content_type
225 events = list(ics_calendar.walk("VEVENT"))
226 assert len(events) == 1
227 event = events[0]
228 assert isinstance(event, icalendar.Event)
229 return event
232def _get_ics_event_sequence(event: icalendar.Event) -> int | None:
233 sequence = event.get("sequence")
234 return int(sequence) if sequence is not None else None