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
« 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"""
5from datetime import UTC, date, datetime, time
6from typing import cast
8import grpc
9import pytest
10from google.protobuf import empty_pb2
11from sqlalchemy import func, select, update
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
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.
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()
38 with session_scope() as session:
39 create_community(session, 0, 2, "Community", [user], [], None)
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)
45 return user, token
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()
54 # Set time to Wednesday, January 1, 2020
55 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
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
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
81 # Schedule recurring occurrences, expect a new occurrence on Thursday, January 9
82 schedule_event_occurrences(empty_pb2.Empty())
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
101 # Schedule again, expect a no-op (idempotency)
102 schedule_event_occurrences(empty_pb2.Empty())
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)
109 # Set time to Friday, January 3, 2020
110 frozen_timewarp.freeze_at(datetime(2020, 1, 3, tzinfo=UTC))
112 # Test that new occurrences get scheduled now that the January 2 occurrence has gone by
113 schedule_event_occurrences(empty_pb2.Empty())
115 occurrences_after_january_3_scheduling = events_api.list_event_occurrences(
116 token=token, event_id=initial_occurrence.event_id, past=False
117 ).events
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 )
126 # Set time to Friday, January 10, 2020
127 frozen_timewarp.freeze_at(datetime(2020, 1, 10, tzinfo=UTC))
129 # Test that no occurrence gets scheduled past the ends_on_date of January 20, 2020
130 schedule_event_occurrences(empty_pb2.Empty())
132 occurrences_after_january_10_scheduling = events_api.list_event_occurrences(
133 token=token, event_id=initial_occurrence.event_id, past=False
134 ).events
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 )
144def test_biweekly_frequency(db, frozen_timewarp: FrozenTimewarp):
145 """Test biweekly event scheduling."""
146 user, token = _create_user_in_community()
148 # Set time to Wednesday, January 1, 2020
149 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
159 schedule_event_occurrences(empty_pb2.Empty())
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
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()
175 # Set time to Wednesday, January 1, 2020
176 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
186 schedule_event_occurrences(empty_pb2.Empty())
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)
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)
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 )
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())
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 )
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()
227 # Set time to Wednesday, January 1, 2020
228 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
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 )
244 # New scheduled occurrences should not care
245 schedule_event_occurrences(empty_pb2.Empty())
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"
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()
258 # Set time to Wednesday, January 1, 2020
259 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
269 # Schedules January 9
270 schedule_event_occurrences(empty_pb2.Empty())
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
277 with session_scope() as session:
278 session.execute(update(User).where(User.id == user.id).values(banned_at=func.now()))
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))
283 schedule_event_occurrences(empty_pb2.Empty())
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))
289 occurrences_after_ban = events_api.list_event_occurrences(
290 token=token, event_id=initial_occurrence.event_id, past=False
291 ).events
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 ]
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()
304 # Set time to Wednesday, January 1, 2020
305 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
315 events_api.invite_event_organizer(token=token, event_id=initial_occurrence.event_id, user_id=co_organizer.id)
317 # Schedules January 9
318 schedule_event_occurrences(empty_pb2.Empty())
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
328def test_CreateEvent_recurring_disabled(db, frozen_timewarp: FrozenTimewarp, feature_flags):
329 frozen_timewarp.freeze_at(datetime(2000, 1, 1, tzinfo=UTC))
331 user, token = _create_user_in_community()
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
342def test_UpdateEvent_all_future_recurring(db, frozen_timewarp: FrozenTimewarp):
343 user, token = generate_user()
345 with session_scope() as session:
346 create_community(session, 0, 2, "Community", [user], [], None)
348 # Set time to Wednesday, January 1, 2020
349 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
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 )
370 # Newly scheduled occurrences should be based on the updated template
371 schedule_event_occurrences(empty_pb2.Empty())
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
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()
385 with session_scope() as session:
386 create_community(session, 0, 2, "Community", [creator_user], [other_user], None)
388 # Set time to Wednesday, January 1, 2020
389 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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 )
400 # Schedules January 9
401 schedule_event_occurrences(empty_pb2.Empty())
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
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")
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())
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)
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")