Coverage for app/backend/src/tests/test_calendar_events.py: 97%
98 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-25 18:54 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-25 18:54 +0000
1import re
2from datetime import UTC, datetime, timedelta
4import ics
5import pytest
7from couchers.email.calendar_events import create_event_ics_calendar, create_host_request_ics_calendar
8from couchers.i18n.context import LocalizationContext
9from couchers.proto import events_pb2, messages_pb2, requests_pb2
10from couchers.utils import Timestamp_from_datetime, today
11from tests.fixtures.db import generate_user
12from tests.fixtures.misc import EmailCollector, Moderator
13from tests.fixtures.sessions import requests_session
14from tests.test_requests import valid_request_text
17@pytest.fixture(autouse=True)
18def _(testconfig):
19 pass
22def test_host_request_ics_content():
23 host_request = requests_pb2.HostRequest(
24 host_request_id=42, from_date="2000-01-01", to_date="2000-01-02", hosting_city="New York"
25 )
27 ics: str = create_host_request_ics_calendar(
28 host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
29 ).serialize()
30 _assert_ics_matches_pattern(
31 ics,
32 """
33BEGIN:VCALENDAR
34VERSION:2.0
35PRODID:-//Couchers.org//Couchers//EN
36BEGIN:VEVENT
37SEQUENCE:***
38DTSTART;VALUE=DATE:20000101
39DTEND;VALUE=DATE:20000103
40DESCRIPTION:***/42
41LOCATION:New York
42SUMMARY:Hosting Bob
43UID:host_request.42@***
44URL:***/42
45END:VEVENT
46METHOD:PUBLISH
47END:VCALENDAR
48 """,
49 )
52def test_host_request_cancelled_ics_content():
53 host_request = requests_pb2.HostRequest(
54 host_request_id=42,
55 from_date="2000-01-01",
56 to_date="2000-01-02",
57 hosting_city="New York",
58 status=messages_pb2.HostRequestStatus.HOST_REQUEST_STATUS_CANCELLED,
59 )
61 ics: str = create_host_request_ics_calendar(
62 host_request, other_name="Bob", hosting=True, loc_context=LocalizationContext.en_utc()
63 ).serialize()
64 _assert_ics_matches_pattern(
65 ics,
66 """
67BEGIN:VCALENDAR
68VERSION:2.0
69PRODID:-//Couchers.org//Couchers//EN
70BEGIN:VEVENT
71SEQUENCE:***
72DTSTART;VALUE=DATE:20000101
73DTEND;VALUE=DATE:20000103
74DESCRIPTION:***/42
75LOCATION:New York
76STATUS:CANCELLED
77SUMMARY:Cancelled: Hosting Bob
78UID:host_request.42@***
79URL:***/42
80END:VEVENT
81METHOD:PUBLISH
82END:VCALENDAR
83 """,
84 )
87def test_event_ics_content():
88 event = events_pb2.Event(
89 event_id=42,
90 title="Event Title",
91 slug="event-slug",
92 content="Event description",
93 created=Timestamp_from_datetime(datetime(2000, 1, 1, 0, 0, tzinfo=UTC)),
94 start_time=Timestamp_from_datetime(datetime(2000, 1, 2, 12, 0, tzinfo=UTC)),
95 end_time=Timestamp_from_datetime(datetime(2000, 1, 3, 12, 0, tzinfo=UTC)),
96 location=events_pb2.EventLocation(address="City, Country", lat=0.1, lng=0.1),
97 timezone="Etc/GMT-1", # Confusingly represents GMT+1, no DST
98 )
100 ics: str = create_event_ics_calendar(event, loc_context=LocalizationContext.en_utc()).serialize()
101 _assert_ics_matches_pattern(
102 ics,
103 """
104BEGIN:VCALENDAR
105VERSION:2.0
106PRODID:-//Couchers.org//Couchers//EN
107BEGIN:VEVENT
108UID:event.42@***
109SEQUENCE:***
110SUMMARY:Event Title
111DESCRIPTION:Event description***/event/42/event-slug
112DTSTART;TZID=Etc/GMT-1:20000102T130000
113DTEND;TZID=Etc/GMT-1:20000103T130000
114LAST-MODIFIED:20000101T000000Z
115LOCATION:City\\, Country
116GEO:0.100000;0.100000
117URL:***/event/42/event-slug
118END:VEVENT
119METHOD:PUBLISH
120END:VCALENDAR
121 """,
122 )
125def _assert_ics_matches_pattern(actual: str, expected_pattern: str) -> str:
126 """Assert that an ics file's content matches a pattern that includes "***" wildcards."""
127 # Normalize whitespace
128 actual = actual.replace("\r\n", "\n").strip()
129 expected_pattern = expected_pattern.strip()
131 # The ics library writes properties in an order that's hard to make sense of, and not important.
132 # So approximate by sorting lines, and test them one at a time so we know which one fails.
133 actual_lines = sorted(actual.splitlines())
134 expected_pattern_lines = sorted(expected_pattern.splitlines())
135 for actual_line, expected_pattern_line in zip(actual_lines, expected_pattern_lines, strict=True):
136 # Convert the expected pattern from *** wildcards to a regex
137 expected_line_regex = re.escape(expected_pattern_line).replace("\\*\\*\\*", ".*?")
138 assert re.fullmatch(expected_line_regex, actual_line)
141def test_host_request_attachments(db, email_collector: EmailCollector, moderator: Moderator):
142 host, host_token = generate_user(complete_profile=True)
143 surfer, surfer_token = generate_user(complete_profile=True)
145 from_date = today() + timedelta(days=2)
146 to_date = today() + timedelta(days=3)
148 # Send the host request, no calendar attachment yet
149 with requests_session(surfer_token) as api:
150 hr_id = api.CreateHostRequest(
151 requests_pb2.CreateHostRequestReq(
152 host_user_id=host.id,
153 from_date=from_date.isoformat(),
154 to_date=to_date.isoformat(),
155 text=valid_request_text("can i stay plz"),
156 )
157 ).host_request_id
159 moderator.approve_host_request(hr_id)
161 email = email_collector.pop_for_recipient(host.email, last=True)
162 assert "request" in email.subject and surfer.name in email.subject
163 assert not email.attachments
165 # Host accepts, surfer gets a calendar attachment
166 with requests_session(host_token) as api:
167 api.RespondHostRequest(
168 requests_pb2.RespondHostRequestReq(
169 host_request_id=hr_id,
170 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
171 text="Accepting host request",
172 )
173 )
175 email = email_collector.pop_for_recipient(surfer.email, last=True)
176 assert "accept" in email.subject and host.name in email.subject
177 accepted_ics_event = _get_email_ics_attachment_calendar_event(email)
178 assert not accepted_ics_event.status
179 assert accepted_ics_event.begin.date() == from_date
180 assert accepted_ics_event.end.date() == (to_date + timedelta(days=1))
181 assert accepted_ics_event.name == f"Surfing with {host.name}"
182 assert accepted_ics_event.location == host.city
184 # Surfer confirms, host gets a calendar attachment
185 with requests_session(surfer_token) as api:
186 api.RespondHostRequest(
187 requests_pb2.RespondHostRequestReq(
188 host_request_id=hr_id,
189 status=messages_pb2.HOST_REQUEST_STATUS_CONFIRMED,
190 text="Confirming host request",
191 )
192 )
194 email = email_collector.pop_for_recipient(host.email, last=True)
195 assert "confirm" in email.subject and surfer.name in email.subject
196 confirmed_ics_event = _get_email_ics_attachment_calendar_event(email)
197 assert not confirmed_ics_event.status
198 assert confirmed_ics_event.name == f"Hosting {surfer.name}"
200 # Surfer cancels, host gets a calendar attachment
201 with requests_session(surfer_token) as api:
202 api.RespondHostRequest(
203 requests_pb2.RespondHostRequestReq(
204 host_request_id=hr_id,
205 status=messages_pb2.HOST_REQUEST_STATUS_CANCELLED,
206 text="Cancelling host request",
207 )
208 )
210 email = email_collector.pop_for_recipient(host.email, last=True)
211 assert "cancel" in email.subject and surfer.name in email.subject
212 cancelled_ics_event = _get_email_ics_attachment_calendar_event(email)
213 # Ideally the sequence number are strictly ascending, but they are based on timestamps so in tests they could be equal.
214 assert (_get_ics_event_sequence(cancelled_ics_event) or 0) >= (_get_ics_event_sequence(accepted_ics_event) or 0)
215 assert cancelled_ics_event.status == "CANCELLED"
218def test_host_request_attachments_disabled(db, email_collector: EmailCollector, feature_flags, moderator: Moderator):
219 feature_flags.set("email_ics_attachments_enabled", False)
221 host, host_token = generate_user(complete_profile=True)
222 surfer, surfer_token = generate_user(complete_profile=True)
224 from_date = today() + timedelta(days=2)
225 to_date = today() + timedelta(days=3)
227 with requests_session(surfer_token) as api:
228 hr_id = api.CreateHostRequest(
229 requests_pb2.CreateHostRequestReq(
230 host_user_id=host.id,
231 from_date=from_date.isoformat(),
232 to_date=to_date.isoformat(),
233 text=valid_request_text("can i stay plz"),
234 )
235 ).host_request_id
237 moderator.approve_host_request(hr_id)
239 email_collector.pop_for_recipient(host.email, last=True)
241 # Host accepts: normally the surfer would get a calendar attachment, but the flag is off
242 with requests_session(host_token) as api:
243 api.RespondHostRequest(
244 requests_pb2.RespondHostRequestReq(
245 host_request_id=hr_id,
246 status=messages_pb2.HOST_REQUEST_STATUS_ACCEPTED,
247 text="Accepting host request",
248 )
249 )
251 email = email_collector.pop_for_recipient(surfer.email, last=True)
252 assert "accept" in email.subject and host.name in email.subject
253 assert not email.attachments
256def _get_email_ics_attachment_calendar_event(e) -> ics.Event:
257 assert len(e.attachments or []) == 1
258 ics_attachment = e.attachments[0]
259 assert ics_attachment.content_type.startswith("text/calendar")
260 ics_calendar = ics.Calendar(ics_attachment.data.decode("utf-8"))
261 assert f"method={ics_calendar.method}" in ics_attachment.content_type
262 assert len(ics_calendar.events) == 1
263 return next(iter(ics_calendar.events))
266def _get_ics_event_sequence(event: ics.Event) -> int | None:
267 for x in event.extra: 267 ↛ 270line 267 didn't jump to line 270 because the loop on line 267 didn't complete
268 if x.name == "SEQUENCE": 268 ↛ 267line 268 didn't jump to line 267 because the condition on line 268 was always true
269 return int(x.value)
270 return None