Coverage for app/backend/src/tests/test_schedule_event_occurrences.py: 99%

158 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 20:52 +0000

1""" 

2Integration tests for the job scheduling event recurrences. 

3""" 

4 

5from datetime import UTC, date, datetime, time 

6from typing import cast 

7from zoneinfo import ZoneInfo 

8 

9from google.protobuf import empty_pb2, wrappers_pb2 

10from sqlalchemy import func, select, update 

11 

12from couchers.context import CouchersContext 

13from couchers.db import session_scope 

14from couchers.jobs.handlers import schedule_event_occurrences 

15from couchers.models import EventOccurrence, EventRecurrence, User 

16from couchers.proto import events_pb2 

17from couchers.servicers.auth import create_session 

18from couchers.utils import datetime_to_iso8601_local, to_aware_datetime 

19from tests.fixtures.db import generate_user 

20from tests.fixtures.sessions import _MockCouchersContext, events_session 

21from tests.fixtures.timewarp import FrozenTimewarp 

22from tests.test_communities import create_community 

23 

24 

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

26 """ 

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

28 

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

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

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

32 """ 

33 user, _ = generate_user() 

34 

35 with session_scope() as session: 

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

37 

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

39 context = cast(CouchersContext, _MockCouchersContext()) 

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

41 

42 return user, token 

43 

44 

45DEFAULT_EVENT_TITLE = "Dummy Title" 

46DEFAULT_EVENT_CONTENT = "Dummy content" 

47DEFAULT_EVENT_LOCATION = events_pb2.EventLocation(address="Near Null Island", lat=0.1, lng=0.2) 

48 

49 

50def _create_event( 

51 token: str, 

52 *, 

53 title: str = DEFAULT_EVENT_TITLE, 

54 content: str = DEFAULT_EVENT_CONTENT, 

55 location=DEFAULT_EVENT_LOCATION, 

56 photo_key: str | None = None, 

57 start_date: date, 

58 start_time: time, 

59 end_date: date | None = None, 

60 end_time: time, 

61) -> events_pb2.Event: 

62 if end_date is None: 62 ↛ 65line 62 didn't jump to line 65 because the condition on line 62 was always true

63 end_date = start_date 

64 

65 with events_session(token) as api: 

66 create_res: events_pb2.Event = api.CreateEvent( 

67 events_pb2.CreateEventReq( 

68 title=title, 

69 content=content, 

70 location=location, 

71 photo_key=photo_key, 

72 start_datetime_iso8601_local=datetime.combine(start_date, start_time).isoformat(), 

73 end_datetime_iso8601_local=datetime.combine(end_date, end_time).isoformat(), 

74 ) 

75 ) 

76 

77 return create_res 

78 

79 

80def _make_event_recurring( 

81 event: events_pb2.Event, 

82 *, 

83 rrule_interval: int = 1, 

84 ends_on_date: date, 

85) -> None: 

86 # Make it recurring through DB manipulation. 

87 with session_scope() as session: 

88 occurrence = session.execute(select(EventOccurrence).where(EventOccurrence.id == event.event_id)).scalar_one() 

89 

90 timezone = ZoneInfo(occurrence.timezone) 

91 start_datetime_in_tz = occurrence.start_time.astimezone(timezone) 

92 end_datetime_in_tz = occurrence.end_time.astimezone(timezone) 

93 day_delta = (end_datetime_in_tz.date() - start_datetime_in_tz.date()).days 

94 session.add( 

95 EventRecurrence( 

96 event_id=occurrence.event_id, 

97 content=occurrence.content, 

98 # TODO(#9845): Initialize photo_key. Implies adding admin usage tracking infra. 

99 geom=occurrence.geom, 

100 address=occurrence.address, 

101 timezone=occurrence.timezone, 

102 dtstart_date=start_datetime_in_tz.date(), 

103 start_time=start_datetime_in_tz.time(), 

104 day_delta=day_delta, 

105 end_time=end_datetime_in_tz.time(), 

106 rrule_interval=rrule_interval, 

107 last_scheduled_date=start_datetime_in_tz.date(), 

108 ends_on_date=ends_on_date, 

109 ) 

110 ) 

111 

112 

113def test_progressive_scheduling_until_end_date(db, frozen_timewarp: FrozenTimewarp): 

114 """ 

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

116 """ 

117 user, token = _create_user_in_community() 

118 

119 # Set time to Wednesday, January 1, 2020 

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

121 

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

123 initial_occurrence = _create_event( 

124 token, 

125 start_date=date(2020, 1, 2), 

126 start_time=time(12, 0), 

127 end_time=time(13, 30), 

128 ) 

129 _make_event_recurring(initial_occurrence, rrule_interval=1, ends_on_date=date(2020, 1, 20)) 

130 

131 with events_session(token) as api: 

132 occurrences_before_scheduling = api.ListEventOccurrences( 

133 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

134 ).events 

135 

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

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

138 

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

140 schedule_event_occurrences(empty_pb2.Empty()) 

141 

142 with events_session(token) as api: 

143 occurrences_after_scheduling = api.ListEventOccurrences( 

144 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

145 ).events 

146 

147 assert len(occurrences_after_scheduling) == 2 

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

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

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

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

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

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

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

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

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

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

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

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

160 

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

162 schedule_event_occurrences(empty_pb2.Empty()) 

163 

164 with events_session(token) as api: 

165 occurrences_after_scheduling_redundantly = api.ListEventOccurrences( 

166 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

167 ).events 

168 

169 assert len(occurrences_after_scheduling_redundantly) == len(occurrences_after_scheduling) 

170 

171 # Set time to Friday, January 3, 2020 

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

173 

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

175 schedule_event_occurrences(empty_pb2.Empty()) 

176 

177 with events_session(token) as api: 

178 occurrences_after_january_3_scheduling = api.ListEventOccurrences( 

179 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

180 ).events 

181 

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

183 # occurrences. 

184 assert len(occurrences_after_january_3_scheduling) == 2 

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

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

187 ) 

188 

189 # Set time to Friday, January 10, 2020 

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

191 

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

193 schedule_event_occurrences(empty_pb2.Empty()) 

194 

195 with events_session(token) as api: 

196 occurrences_after_january_10_scheduling = api.ListEventOccurrences( 

197 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

198 ).events 

199 

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

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

202 assert len(occurrences_after_january_10_scheduling) == 1 

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

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

205 ) 

206 

207 

208def test_biweekly_frequency(db, frozen_timewarp: FrozenTimewarp): 

209 """Test biweekly event scheduling.""" 

210 user, token = _create_user_in_community() 

211 

212 # Set time to Wednesday, January 1, 2020 

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

214 

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

216 initial_occurrence = _create_event( 

217 token, start_date=date(2020, 1, 2), start_time=time(12, 0), end_time=time(13, 30) 

218 ) 

219 _make_event_recurring(initial_occurrence, rrule_interval=2, ends_on_date=date(2020, 1, 20)) 

220 

221 schedule_event_occurrences(empty_pb2.Empty()) 

222 

223 with events_session(token) as api: 

224 scheduled_occurrences = api.ListEventOccurrences( 

225 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

226 ).events 

227 

228 assert len(scheduled_occurrences) == 2 

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

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

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

232 ) # 2 weeks later 

233 

234 

235def test_no_rescheduling_after_edit_or_cancel(db, frozen_timewarp: FrozenTimewarp): 

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

237 user, token = _create_user_in_community() 

238 

239 # Set time to Wednesday, January 1, 2020 

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

241 

242 # Create a recurring event on Fridays 

243 initial_occurrence = _create_event(token, start_date=date(2020, 1, 3), start_time=time(12, 0), end_time=time(13, 0)) 

244 _make_event_recurring(initial_occurrence, rrule_interval=1, ends_on_date=date(2020, 6, 30)) 

245 

246 schedule_event_occurrences(empty_pb2.Empty()) 

247 

248 with events_session(token) as api: 

249 initial_scheduled_occurrences = api.ListEventOccurrences( 

250 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

251 ).events 

252 

253 assert len(initial_scheduled_occurrences) == 2 

254 to_cancel, to_move = initial_scheduled_occurrences 

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

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

257 

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

259 with events_session(token) as api: 

260 api.CancelEvent(events_pb2.CancelEventReq(event_id=to_cancel.event_id)) 

261 

262 api.UpdateEvent( 

263 events_pb2.UpdateEventReq( 

264 event_id=to_move.event_id, 

265 start_datetime_iso8601_local=wrappers_pb2.StringValue( 

266 value=datetime_to_iso8601_local(datetime(2020, 1, 9, 12, 0, tzinfo=UTC)) 

267 ), 

268 end_datetime_iso8601_local=wrappers_pb2.StringValue( 

269 value=datetime_to_iso8601_local(datetime(2020, 1, 9, 13, 0, tzinfo=UTC)) 

270 ), 

271 ) 

272 ) 

273 

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

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

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

277 schedule_event_occurrences(empty_pb2.Empty()) 

278 

279 with events_session(token) as api: 

280 scheduled_occurrences_after_changes = api.ListEventOccurrences( 

281 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False, include_cancelled=True) 

282 ).events 

283 

284 assert len(scheduled_occurrences_after_changes) == 2 

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

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

287 ) 

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

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

290 ) 

291 

292 

293def test_new_occurrence_not_based_on_previous(db, frozen_timewarp: FrozenTimewarp): 

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

295 user, token = _create_user_in_community() 

296 

297 # Set time to Wednesday, January 1, 2020 

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

299 

300 # Schedule on Thursdays 

301 initial_occurrence = _create_event(token, start_date=date(2020, 1, 2), start_time=time(12, 0), end_time=time(13, 0)) 

302 _make_event_recurring(initial_occurrence, rrule_interval=1, ends_on_date=date(2020, 6, 30)) 

303 

304 # Change the original occurrence 

305 with events_session(token) as api: 

306 initial_occurrence = api.UpdateEvent( 

307 events_pb2.UpdateEventReq( 

308 event_id=initial_occurrence.event_id, 

309 content=wrappers_pb2.StringValue(value="Updated content"), 

310 ) 

311 ) 

312 

313 # New scheduled occurrences should not care 

314 schedule_event_occurrences(empty_pb2.Empty()) 

315 

316 with events_session(token) as api: 

317 second_occurrence = api.ListEventOccurrences( 

318 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

319 ).events[-1] 

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

321 assert second_occurrence.content == DEFAULT_EVENT_CONTENT 

322 

323 

324def test_no_scheduling_after_creator_banned(db, frozen_timewarp: FrozenTimewarp): 

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

326 user, token = _create_user_in_community() 

327 

328 # Set time to Wednesday, January 1, 2020 

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

330 

331 # Schedule on Thursdays 

332 initial_occurrence = _create_event(token, start_date=date(2020, 1, 2), start_time=time(12, 0), end_time=time(13, 0)) 

333 _make_event_recurring(initial_occurrence, rrule_interval=1, ends_on_date=date(2020, 6, 30)) 

334 

335 # Schedules January 9 

336 schedule_event_occurrences(empty_pb2.Empty()) 

337 

338 with events_session(token) as api: 

339 occurrences_before_ban = api.ListEventOccurrences( 

340 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

341 ).events 

342 

343 assert len(occurrences_before_ban) == 2 

344 

345 with session_scope() as session: 

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

347 

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

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

350 

351 schedule_event_occurrences(empty_pb2.Empty()) 

352 

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

354 with session_scope() as session: 

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

356 

357 with events_session(token) as api: 

358 occurrences_after_ban = api.ListEventOccurrences( 

359 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

360 ).events 

361 

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

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

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

365 ] 

366 

367 

368def test_organizers_attend_scheduled_occurrences(db, frozen_timewarp: FrozenTimewarp): 

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

370 user, token = _create_user_in_community() 

371 co_organizer, _ = generate_user() 

372 

373 # Set time to Wednesday, January 1, 2020 

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

375 

376 # Schedule on Thursdays 

377 initial_occurrence = _create_event(token, start_date=date(2020, 1, 2), start_time=time(12, 0), end_time=time(13, 0)) 

378 _make_event_recurring(initial_occurrence, rrule_interval=1, ends_on_date=date(2020, 6, 30)) 

379 

380 with events_session(token) as api: 

381 api.InviteEventOrganizer( 

382 events_pb2.InviteEventOrganizerReq(event_id=initial_occurrence.event_id, user_id=co_organizer.id) 

383 ) 

384 

385 # Schedules January 9 

386 schedule_event_occurrences(empty_pb2.Empty()) 

387 

388 with events_session(token) as api: 

389 scheduled_occurrence = api.ListEventOccurrences( 

390 events_pb2.ListEventOccurrencesReq(event_id=initial_occurrence.event_id, past=False) 

391 ).events[-1] 

392 

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

394 assert scheduled_occurrence.attendance_state == events_pb2.ATTENDANCE_STATE_GOING 

395 assert scheduled_occurrence.going_count == 2