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

1import re 

2from datetime import UTC, datetime, timedelta 

3 

4import icalendar 

5 

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 

14 

15 

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 ) 

20 

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 ) 

48 

49 

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 ) 

58 

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 ) 

87 

88 

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 ) 

101 

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 ) 

125 

126 

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() 

132 

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) 

141 

142 

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) 

146 

147 from_date = today() + timedelta(days=2) 

148 to_date = today() + timedelta(days=3) 

149 

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 

160 

161 moderator.approve_host_request(hr_id) 

162 

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 

166 

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 ) 

176 

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 

185 

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 ) 

195 

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}" 

201 

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 ) 

211 

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" 

218 

219 

220def test_host_request_attachments_disabled(db, email_collector: EmailCollector, feature_flags, moderator: Moderator): 

221 feature_flags.set("email_ics_attachments_enabled", False) 

222 

223 host, host_token = generate_user(complete_profile=True) 

224 surfer, surfer_token = generate_user(complete_profile=True) 

225 

226 from_date = today() + timedelta(days=2) 

227 to_date = today() + timedelta(days=3) 

228 

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 

238 

239 moderator.approve_host_request(hr_id) 

240 

241 email_collector.pop_for_recipient(host.email, last=True) 

242 

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 ) 

252 

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 

256 

257 

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 

269 

270 

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