Coverage for app/backend/src/tests/test_events_recurring.py: 100%

168 statements  

« prev     ^ index     » next       coverage.py v7.17.3, created at 2026-11-12 12:41 +0000

1""" 

2Integration tests for the job scheduling event recurrences. 

3""" 

4 

5from datetime import UTC, date, datetime, time 

6from typing import cast 

7 

8import grpc 

9import pytest 

10from google.protobuf import empty_pb2 

11from sqlalchemy import func, select, update 

12 

13from couchers.context import CouchersContext 

14from couchers.db import session_scope 

15from couchers.jobs.handlers import schedule_event_occurrences 

16from couchers.models import User 

17from couchers.proto import events_pb2 

18from couchers.servicers.auth import create_session 

19from couchers.utils import to_aware_datetime 

20from tests.fixtures.api import events as events_api 

21from tests.fixtures.db import generate_user 

22from tests.fixtures.misc import Moderator 

23from tests.fixtures.sessions import _MockCouchersContext 

24from tests.fixtures.timewarp import FrozenTimewarp 

25from tests.test_communities import create_community 

26 

27 

28def _create_user_in_community() -> tuple[User, str]: 

29 """ 

30 CreateEvent needs a community node whose area covers the test location to resolve a timezone. 

31 

32 Issues a long-lived session token rather than generate_user()'s default short-lived one: these 

33 tests jump the frozen clock forward by a week or more at a time, and a short-lived session 

34 expires after 168 hours of inactivity, which a jump that size blows straight through. 

35 """ 

36 user, _ = generate_user() 

37 

38 with session_scope() as session: 

39 create_community(session, 0, 2, "Community", [user], [], None) 

40 

41 live_user = session.execute(select(User).where(User.id == user.id)).scalar_one() 

42 context = cast(CouchersContext, _MockCouchersContext()) 

43 token, _ = create_session(context, session, live_user, True, set_cookie=False) 

44 

45 return user, token 

46 

47 

48def test_recurring_event(db, frozen_timewarp: FrozenTimewarp): 

49 """ 

50 A weekly recurring event should get scheduled progressively (2 occurrences at a time) until the ends_on date. 

51 """ 

52 user, token = _create_user_in_community() 

53 

54 # Set time to Wednesday, January 1, 2020 

55 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

56 

57 # Create a recurring weekly event on Thursday, January 2, 2020, 12:00-13:30 UTC. 

58 initial_occurrence = events_api.create_event( 

59 token=token, 

60 start_date_time=(date(2020, 1, 2), time(12, 0)), 

61 end_date_time=(date(2020, 1, 2), time(13, 30)), 

62 recurrence=events_api.weekly_recurrence(interval=1, ends_on_date=date(2020, 1, 20)), 

63 ) 

64 

65 expected_recurrence = events_pb2.EventRecurrence( 

66 weekly=events_pb2.WeeklyEventRecurrence( 

67 day_of_week=events_pb2.DayOfWeek.DAY_OF_WEEK_THURSDAY, 

68 weekly_interval=1, 

69 ), 

70 ends_on_date="2020-01-20", 

71 ) 

72 assert initial_occurrence.recurrence == expected_recurrence 

73 assert events_api.get_event(token=token, event_id=initial_occurrence.event_id).recurrence == expected_recurrence 

74 

75 occurrences_before_scheduling = events_api.list_event_occurrences( 

76 token=token, event_id=initial_occurrence.event_id, past=False 

77 ).events 

78 assert len(occurrences_before_scheduling) == 1, "No other occurrences should exist yet." 

79 assert occurrences_before_scheduling[0].event_id == initial_occurrence.event_id 

80 

81 # Schedule recurring occurrences, expect a new occurrence on Thursday, January 9 

82 schedule_event_occurrences(empty_pb2.Empty()) 

83 

84 occurrences_after_scheduling = events_api.list_event_occurrences( 

85 token=token, event_id=initial_occurrence.event_id, past=False 

86 ).events 

87 assert len(occurrences_after_scheduling) == 2 

88 assert occurrences_after_scheduling[0].event_id != occurrences_after_scheduling[1].event_id 

89 assert to_aware_datetime(occurrences_after_scheduling[0].start_time) == datetime(2020, 1, 2, 12, 0, tzinfo=UTC) 

90 assert to_aware_datetime(occurrences_after_scheduling[0].end_time) == datetime(2020, 1, 2, 13, 30, tzinfo=UTC) 

91 assert to_aware_datetime(occurrences_after_scheduling[1].start_time) == datetime(2020, 1, 9, 12, 0, tzinfo=UTC) 

92 assert to_aware_datetime(occurrences_after_scheduling[1].end_time) == datetime(2020, 1, 9, 13, 30, tzinfo=UTC) 

93 assert occurrences_after_scheduling[0].title == occurrences_after_scheduling[1].title 

94 assert occurrences_after_scheduling[0].content == occurrences_after_scheduling[1].content 

95 assert occurrences_after_scheduling[0].location.address == occurrences_after_scheduling[1].location.address 

96 assert occurrences_after_scheduling[0].location.lat == occurrences_after_scheduling[1].location.lat 

97 assert occurrences_after_scheduling[0].location.lng == occurrences_after_scheduling[1].location.lng 

98 assert occurrences_after_scheduling[0].timezone == occurrences_after_scheduling[1].timezone 

99 assert occurrences_after_scheduling[0].thread.thread_id != occurrences_after_scheduling[1].thread.thread_id 

100 

101 # Schedule again, expect a no-op (idempotency) 

102 schedule_event_occurrences(empty_pb2.Empty()) 

103 

104 occurrences_after_scheduling_redundantly = events_api.list_event_occurrences( 

105 token=token, event_id=initial_occurrence.event_id, past=False 

106 ).events 

107 assert len(occurrences_after_scheduling_redundantly) == len(occurrences_after_scheduling) 

108 

109 # Set time to Friday, January 3, 2020 

110 frozen_timewarp.freeze_at(datetime(2020, 1, 3, tzinfo=UTC)) 

111 

112 # Test that new occurrences get scheduled now that the January 2 occurrence has gone by 

113 schedule_event_occurrences(empty_pb2.Empty()) 

114 

115 occurrences_after_january_3_scheduling = events_api.list_event_occurrences( 

116 token=token, event_id=initial_occurrence.event_id, past=False 

117 ).events 

118 

119 # January 2 has now ended and drops off the upcoming list, so this is January 9 and 16, not 3 

120 # occurrences. 

121 assert len(occurrences_after_january_3_scheduling) == 2 

122 assert to_aware_datetime(occurrences_after_january_3_scheduling[1].start_time) == datetime( 

123 2020, 1, 16, 12, 0, tzinfo=UTC 

124 ) 

125 

126 # Set time to Friday, January 10, 2020 

127 frozen_timewarp.freeze_at(datetime(2020, 1, 10, tzinfo=UTC)) 

128 

129 # Test that no occurrence gets scheduled past the ends_on_date of January 20, 2020 

130 schedule_event_occurrences(empty_pb2.Empty()) 

131 

132 occurrences_after_january_10_scheduling = events_api.list_event_occurrences( 

133 token=token, event_id=initial_occurrence.event_id, past=False 

134 ).events 

135 

136 # January 9 has now also ended; January 23 shouldn't be scheduled because it's past the 

137 # ends_on_date of January 20, so only January 16 remains upcoming. 

138 assert len(occurrences_after_january_10_scheduling) == 1 

139 assert to_aware_datetime(occurrences_after_january_10_scheduling[0].start_time) == datetime( 

140 2020, 1, 16, 12, 0, tzinfo=UTC 

141 ) 

142 

143 

144def test_biweekly_frequency(db, frozen_timewarp: FrozenTimewarp): 

145 """Test biweekly event scheduling.""" 

146 user, token = _create_user_in_community() 

147 

148 # Set time to Wednesday, January 1, 2020 

149 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

150 

151 # Create a recurring biweekly event on Thursday, January 2, 2020, 12:00-13:30 UTC. 

152 initial_occurrence = events_api.create_event( 

153 token=token, 

154 start_date_time=(date(2020, 1, 2), time(12, 0)), 

155 end_date_time=(date(2020, 1, 2), time(13, 30)), 

156 recurrence=events_api.weekly_recurrence(interval=2, ends_on_date=date(2020, 1, 20)), 

157 ) 

158 

159 schedule_event_occurrences(empty_pb2.Empty()) 

160 

161 scheduled_occurrences = events_api.list_event_occurrences( 

162 token=token, event_id=initial_occurrence.event_id, past=False 

163 ).events 

164 assert len(scheduled_occurrences) == 2 

165 assert to_aware_datetime(scheduled_occurrences[0].start_time) == datetime(2020, 1, 2, 12, 0, tzinfo=UTC) 

166 assert to_aware_datetime(scheduled_occurrences[1].start_time) == datetime( 

167 2020, 1, 16, 12, 0, tzinfo=UTC 

168 ) # 2 weeks later 

169 

170 

171def test_no_rescheduling_after_edit_or_cancel(db, frozen_timewarp: FrozenTimewarp): 

172 """Editing or cancelling an already-spawned occurrence must not cause it to be rescheduled.""" 

173 user, token = _create_user_in_community() 

174 

175 # Set time to Wednesday, January 1, 2020 

176 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

177 

178 # Create a recurring event on Fridays 

179 initial_occurrence = events_api.create_event( 

180 token=token, 

181 start_date_time=(date(2020, 1, 3), time(12, 0)), 

182 end_date_time=(date(2020, 1, 3), time(13, 0)), 

183 recurrence=events_api.weekly_recurrence(interval=1, ends_on_date=date(2020, 6, 30)), 

184 ) 

185 

186 schedule_event_occurrences(empty_pb2.Empty()) 

187 

188 initial_scheduled_occurrences = events_api.list_event_occurrences( 

189 token=token, event_id=initial_occurrence.event_id, past=False 

190 ).events 

191 assert len(initial_scheduled_occurrences) == 2 

192 to_cancel, to_move = initial_scheduled_occurrences 

193 assert to_aware_datetime(to_cancel.start_time) == datetime(2020, 1, 3, 12, 0, tzinfo=UTC) 

194 assert to_aware_datetime(to_move.start_time) == datetime(2020, 1, 10, 12, 0, tzinfo=UTC) 

195 

196 # Cancel the January 3 occurrence and move the January 10 one to January 9 

197 events_api.cancel_event(token=token, event_id=to_cancel.event_id) 

198 

199 events_api.update_event( 

200 token=token, 

201 event_id=to_move.event_id, 

202 start_date_time=(date(2020, 1, 9), time(12, 0)), 

203 end_date_time=(date(2020, 1, 9), time(13, 0)), 

204 ) 

205 

206 # Ensure no other occurrence gets scheduled, even though the January 9 occurrence was moved to January 8, 

207 # so we're now recurring on Thursdays instead of Fridays, since the next two Thursdays (2 and 8) are earlier 

208 # than the last scheduled date (January 9). 

209 schedule_event_occurrences(empty_pb2.Empty()) 

210 

211 scheduled_occurrences_after_changes = events_api.list_event_occurrences( 

212 token=token, event_id=initial_occurrence.event_id, past=False, include_cancelled=True 

213 ).events 

214 assert len(scheduled_occurrences_after_changes) == 2 

215 assert to_aware_datetime(scheduled_occurrences_after_changes[0].start_time) == datetime( 

216 2020, 1, 3, 12, 0, tzinfo=UTC 

217 ) 

218 assert to_aware_datetime(scheduled_occurrences_after_changes[1].start_time) == datetime( 

219 2020, 1, 9, 12, 0, tzinfo=UTC 

220 ) 

221 

222 

223def test_new_occurrence_not_based_on_previous(db, frozen_timewarp: FrozenTimewarp): 

224 """The latest occurrence is the one that is used as a template for the next one.""" 

225 user, token = _create_user_in_community() 

226 

227 # Set time to Wednesday, January 1, 2020 

228 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

229 

230 # Schedule on Thursdays 

231 initial_occurrence = events_api.create_event( 

232 token=token, 

233 content="Initial content", 

234 start_date_time=(date(2020, 1, 2), time(12, 0)), 

235 end_date_time=(date(2020, 1, 2), time(13, 0)), 

236 recurrence=events_api.weekly_recurrence(interval=1, ends_on_date=date(2020, 6, 30)), 

237 ) 

238 

239 # Change the original occurrence 

240 initial_occurrence = events_api.update_event( 

241 token=token, event_id=initial_occurrence.event_id, content="Updated content" 

242 ) 

243 

244 # New scheduled occurrences should not care 

245 schedule_event_occurrences(empty_pb2.Empty()) 

246 

247 second_occurrence = events_api.list_event_occurrences( 

248 token=token, event_id=initial_occurrence.event_id, past=False 

249 ).events[-1] 

250 assert to_aware_datetime(second_occurrence.start_time) == datetime(2020, 1, 9, 12, 0, tzinfo=UTC) 

251 assert second_occurrence.content == "Initial content" 

252 

253 

254def test_no_scheduling_after_creator_banned(db, frozen_timewarp: FrozenTimewarp): 

255 """Occurrences should stop being scheduled once the event creator is banned.""" 

256 user, token = _create_user_in_community() 

257 

258 # Set time to Wednesday, January 1, 2020 

259 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

260 

261 # Schedule on Thursdays 

262 initial_occurrence = events_api.create_event( 

263 token=token, 

264 start_date_time=(date(2020, 1, 2), time(12, 0)), 

265 end_date_time=(date(2020, 1, 2), time(13, 0)), 

266 recurrence=events_api.weekly_recurrence(interval=1, ends_on_date=date(2020, 6, 30)), 

267 ) 

268 

269 # Schedules January 9 

270 schedule_event_occurrences(empty_pb2.Empty()) 

271 

272 occurrences_before_ban = events_api.list_event_occurrences( 

273 token=token, event_id=initial_occurrence.event_id, past=False 

274 ).events 

275 assert len(occurrences_before_ban) == 2 

276 

277 with session_scope() as session: 

278 session.execute(update(User).where(User.id == user.id).values(banned_at=func.now())) 

279 

280 # Set time to Friday, January 3, 2020, when January 16 would otherwise get scheduled 

281 frozen_timewarp.freeze_at(datetime(2020, 1, 3, tzinfo=UTC)) 

282 

283 schedule_event_occurrences(empty_pb2.Empty()) 

284 

285 # Unban so the creator can see their (shadowed) occurrences through the API again 

286 with session_scope() as session: 

287 session.execute(update(User).where(User.id == user.id).values(banned_at=None)) 

288 

289 occurrences_after_ban = events_api.list_event_occurrences( 

290 token=token, event_id=initial_occurrence.event_id, past=False 

291 ).events 

292 

293 # January 2 has ended and drops off the upcoming list, and January 16 was never scheduled. 

294 assert [to_aware_datetime(occurrence.start_time) for occurrence in occurrences_after_ban] == [ 

295 datetime(2020, 1, 9, 12, 0, tzinfo=UTC) 

296 ] 

297 

298 

299def test_organizers_attend_scheduled_occurrences(db, frozen_timewarp: FrozenTimewarp): 

300 """Event organizers should be added as attendees of newly scheduled occurrences.""" 

301 user, token = _create_user_in_community() 

302 co_organizer, _ = generate_user() 

303 

304 # Set time to Wednesday, January 1, 2020 

305 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

306 

307 # Schedule on Thursdays 

308 initial_occurrence = events_api.create_event( 

309 token=token, 

310 start_date_time=(date(2020, 1, 2), time(12, 0)), 

311 end_date_time=(date(2020, 1, 2), time(13, 0)), 

312 recurrence=events_api.weekly_recurrence(interval=1, ends_on_date=date(2020, 6, 30)), 

313 ) 

314 

315 events_api.invite_event_organizer(token=token, event_id=initial_occurrence.event_id, user_id=co_organizer.id) 

316 

317 # Schedules January 9 

318 schedule_event_occurrences(empty_pb2.Empty()) 

319 

320 scheduled_occurrence = events_api.list_event_occurrences( 

321 token=token, event_id=initial_occurrence.event_id, past=False 

322 ).events[-1] 

323 assert to_aware_datetime(scheduled_occurrence.start_time) == datetime(2020, 1, 9, 12, 0, tzinfo=UTC) 

324 assert scheduled_occurrence.attendance_state == events_pb2.ATTENDANCE_STATE_GOING 

325 assert scheduled_occurrence.going_count == 2 

326 

327 

328def test_CreateEvent_recurring_disabled(db, frozen_timewarp: FrozenTimewarp, feature_flags): 

329 frozen_timewarp.freeze_at(datetime(2000, 1, 1, tzinfo=UTC)) 

330 

331 user, token = _create_user_in_community() 

332 

333 feature_flags.set("recurring_event_creation_enabled", False) 

334 with pytest.raises(grpc.RpcError) as e: 

335 events_api.create_event( 

336 token=token, 

337 recurrence=events_api.weekly_recurrence(ends_on_date=date(2000, 6, 30)), 

338 ) 

339 assert e.value.code() == grpc.StatusCode.UNAVAILABLE 

340 

341 

342def test_UpdateEvent_all_future_recurring(db, frozen_timewarp: FrozenTimewarp): 

343 user, token = generate_user() 

344 

345 with session_scope() as session: 

346 create_community(session, 0, 2, "Community", [user], [], None) 

347 

348 # Set time to Wednesday, January 1, 2020 

349 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

350 

351 # Weekly on Thursdays 

352 initial_occurrence = events_api.create_event( 

353 token=token, 

354 content="Initial content", 

355 start_date_time=(date(2020, 1, 2), time(12, 0)), 

356 end_date_time=(date(2020, 1, 2), time(13, 0)), 

357 recurrence=events_api.weekly_recurrence(ends_on_date=date(2020, 6, 30)), 

358 ) 

359 

360 # Same timezone, since timezone changes are not supported when updating all future occurrences 

361 new_location = events_pb2.EventLocation(address="Further from Null Island", lat=0.3, lng=0.4) 

362 events_api.update_event( 

363 token=token, 

364 event_id=initial_occurrence.event_id, 

365 content="Updated content", 

366 location=new_location, 

367 update_all_future=True, 

368 ) 

369 

370 # Newly scheduled occurrences should be based on the updated template 

371 schedule_event_occurrences(empty_pb2.Empty()) 

372 

373 occurrences = events_api.list_event_occurrences(token=token, event_id=initial_occurrence.event_id).events 

374 assert len(occurrences) == 2 

375 assert to_aware_datetime(occurrences[1].start_time) == datetime(2020, 1, 9, 12, 0, tzinfo=UTC) 

376 for occurrence in occurrences: 

377 assert occurrence.content == "Updated content" 

378 assert occurrence.location == new_location 

379 

380 

381def test_UpdateEvent_stop_recurrence(db, frozen_timewarp: FrozenTimewarp, moderator: Moderator): 

382 creator_user, creator_token = generate_user() 

383 other_user, other_token = generate_user() 

384 

385 with session_scope() as session: 

386 create_community(session, 0, 2, "Community", [creator_user], [other_user], None) 

387 

388 # Set time to Wednesday, January 1, 2020 

389 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC)) 

390 

391 # Weekly on Thursdays 

392 initial_occurrence = events_api.create_event( 

393 token=creator_token, 

394 start_date_time=(date(2020, 1, 2), time(12, 0)), 

395 end_date_time=(date(2020, 1, 2), time(13, 0)), 

396 recurrence=events_api.weekly_recurrence(ends_on_date=date(2020, 6, 30)), 

397 approve_by=moderator, 

398 ) 

399 

400 # Schedules January 9 

401 schedule_event_occurrences(empty_pb2.Empty()) 

402 

403 # Only organizers can stop the recurrence 

404 with pytest.raises(grpc.RpcError) as e: 

405 events_api.update_event(token=other_token, event_id=initial_occurrence.event_id, stop_recurrence=True) 

406 assert e.value.code() == grpc.StatusCode.PERMISSION_DENIED 

407 

408 updated_occurrence = events_api.update_event( 

409 token=creator_token, event_id=initial_occurrence.event_id, stop_recurrence=True 

410 ) 

411 assert not updated_occurrence.HasField("recurrence") 

412 

413 # Set time to Friday, January 3, 2020, when January 16 would otherwise get scheduled 

414 frozen_timewarp.freeze_at(datetime(2020, 1, 3, tzinfo=UTC)) 

415 schedule_event_occurrences(empty_pb2.Empty()) 

416 

417 # Already scheduled occurrences are kept, but no longer recur 

418 occurrences = [ 

419 *events_api.list_event_occurrences(token=creator_token, event_id=initial_occurrence.event_id, past=True).events, 

420 *events_api.list_event_occurrences(token=creator_token, event_id=initial_occurrence.event_id).events, 

421 ] 

422 assert sorted(to_aware_datetime(occurrence.start_time) for occurrence in occurrences) == [ 

423 datetime(2020, 1, 2, 12, 0, tzinfo=UTC), 

424 datetime(2020, 1, 9, 12, 0, tzinfo=UTC), 

425 ] 

426 assert not any(occurrence.HasField("recurrence") for occurrence in occurrences) 

427 

428 # Stopping the recurrence of a non-recurring event is a no-op 

429 updated_occurrence = events_api.update_event( 

430 token=creator_token, event_id=initial_occurrence.event_id, stop_recurrence=True 

431 ) 

432 assert not updated_occurrence.HasField("recurrence")