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

1import re 

2from datetime import UTC, datetime, timedelta 

3 

4import ics 

5import pytest 

6 

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 

15 

16 

17@pytest.fixture(autouse=True) 

18def _(testconfig): 

19 pass 

20 

21 

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 ) 

26 

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 ) 

50 

51 

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 ) 

60 

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 ) 

85 

86 

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 ) 

99 

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 ) 

123 

124 

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

130 

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) 

139 

140 

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) 

144 

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

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

147 

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 

158 

159 moderator.approve_host_request(hr_id) 

160 

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 

164 

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 ) 

174 

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 

183 

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 ) 

193 

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

199 

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 ) 

209 

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" 

216 

217 

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

219 feature_flags.set("email_ics_attachments_enabled", False) 

220 

221 host, host_token = generate_user(complete_profile=True) 

222 surfer, surfer_token = generate_user(complete_profile=True) 

223 

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

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

226 

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 

236 

237 moderator.approve_host_request(hr_id) 

238 

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

240 

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 ) 

250 

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 

254 

255 

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

264 

265 

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