Coverage for app/backend/src/tests/test_calendar_events.py: 100%
95 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-27 19:48 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-09-27 19:48 +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.db import generate_user
11from tests.fixtures.misc import EmailCollector, Moderator
12from tests.fixtures.sessions import requests_session
13from tests.test_requests import valid_request_text
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 with requests_session(surfer_token) as api:
152 hr_id = api.CreateHostRequest(
153 requests_pb2.CreateHostRequestReq(
154 host_user_id=host.id,
155 from_date=from_date.isoformat(),
156 to_date=to_date.isoformat(),
157 text=valid_request_text("can i stay plz"),
158 )
159 ).host_request_id
161 moderator.approve_host_request(hr_id)
163 email = email_collector.pop_for_recipient(host.email, last=True)
164 assert "request" in email.subject and surfer.name in email.subject
165 assert not email.attachments
167 # Host accepts, surfer gets a calendar attachment
168 with requests_session(host_token) as api:
169 api.RespondHostRequest(
170 requests_pb2.RespondHostRequestReq(
171 host_request_id=hr_id,
172 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
173 text="Accepting host request",
174 )
175 )
177 email = email_collector.pop_for_recipient(surfer.email, last=True)
178 assert "accept" in email.subject and host.name in email.subject
179 accepted_ics_event = _get_email_ics_attachment_calendar_event(email)
180 assert not accepted_ics_event.get("status")
181 assert accepted_ics_event.get("dtstart").dt == from_date
182 assert accepted_ics_event.get("dtend").dt == (to_date + timedelta(days=1))
183 assert accepted_ics_event.get("summary") == f"Surfing with {host.name}"
184 assert accepted_ics_event.get("location") == host.city
186 # Surfer confirms, host gets a calendar attachment
187 with requests_session(surfer_token) as api:
188 api.RespondHostRequest(
189 requests_pb2.RespondHostRequestReq(
190 host_request_id=hr_id,
191 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED,
192 text="Confirming host request",
193 )
194 )
196 email = email_collector.pop_for_recipient(host.email, last=True)
197 assert "confirm" in email.subject and surfer.name in email.subject
198 confirmed_ics_event = _get_email_ics_attachment_calendar_event(email)
199 assert not confirmed_ics_event.get("status")
200 assert confirmed_ics_event.get("summary") == f"Hosting {surfer.name}"
202 # Surfer cancels, host gets a calendar attachment
203 with requests_session(surfer_token) as api:
204 api.RespondHostRequest(
205 requests_pb2.RespondHostRequestReq(
206 host_request_id=hr_id,
207 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED,
208 text="Cancelling host request",
209 )
210 )
212 email = email_collector.pop_for_recipient(host.email, last=True)
213 assert "cancel" in email.subject and surfer.name in email.subject
214 cancelled_ics_event = _get_email_ics_attachment_calendar_event(email)
215 # Ideally the sequence number are strictly ascending, but they are based on timestamps so in tests they could be equal.
216 assert (_get_ics_event_sequence(cancelled_ics_event) or 0) >= (_get_ics_event_sequence(accepted_ics_event) or 0)
217 assert cancelled_ics_event.get("status") == "CANCELLED"
220def test_host_request_attachments_disabled(db, email_collector: EmailCollector, feature_flags, moderator: Moderator):
221 feature_flags.set("email_ics_attachments_enabled", False)
223 host, host_token = generate_user(complete_profile=True)
224 surfer, surfer_token = generate_user(complete_profile=True)
226 from_date = today() + timedelta(days=2)
227 to_date = today() + timedelta(days=3)
229 with requests_session(surfer_token) as api:
230 hr_id = api.CreateHostRequest(
231 requests_pb2.CreateHostRequestReq(
232 host_user_id=host.id,
233 from_date=from_date.isoformat(),
234 to_date=to_date.isoformat(),
235 text=valid_request_text("can i stay plz"),
236 )
237 ).host_request_id
239 moderator.approve_host_request(hr_id)
241 email_collector.pop_for_recipient(host.email, last=True)
243 # Host accepts: normally the surfer would get a calendar attachment, but the flag is off
244 with requests_session(host_token) as api:
245 api.RespondHostRequest(
246 requests_pb2.RespondHostRequestReq(
247 host_request_id=hr_id,
248 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
249 text="Accepting host request",
250 )
251 )
253 email = email_collector.pop_for_recipient(surfer.email, last=True)
254 assert "accept" in email.subject and host.name in email.subject
255 assert not email.attachments
258def _get_email_ics_attachment_calendar_event(e) -> icalendar.Event:
259 assert len(e.attachments or []) == 1
260 ics_attachment = e.attachments[0]
261 assert ics_attachment.content_type.startswith("text/calendar")
262 ics_calendar = icalendar.Calendar.from_ical(ics_attachment.data.decode("utf-8"))
263 assert f"method={ics_calendar.get('method')}" in ics_attachment.content_type
264 events = list(ics_calendar.walk("VEVENT"))
265 assert len(events) == 1
266 event = events[0]
267 assert isinstance(event, icalendar.Event)
268 return event
271def _get_ics_event_sequence(event: icalendar.Event) -> int | None:
272 sequence = event.get("sequence")
273 return int(sequence) if sequence is not None else None