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
« 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"""
5from datetime import UTC, date, datetime, time
6from typing import cast
7from zoneinfo import ZoneInfo
9from google.protobuf import empty_pb2, wrappers_pb2
10from sqlalchemy import func, select, update
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
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.
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()
35 with session_scope() as session:
36 create_community(session, 0, 2, "Community", [user], [], None)
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)
42 return user, token
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)
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
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 )
77 return create_res
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()
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 )
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()
119 # Set time to Wednesday, January 1, 2020
120 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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))
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
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
139 # Schedule recurring occurrences, expect a new occurrence on Thursday, January 9
140 schedule_event_occurrences(empty_pb2.Empty())
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
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
161 # Schedule again, expect a no-op (idempotency)
162 schedule_event_occurrences(empty_pb2.Empty())
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
169 assert len(occurrences_after_scheduling_redundantly) == len(occurrences_after_scheduling)
171 # Set time to Friday, January 3, 2020
172 frozen_timewarp.freeze_at(datetime(2020, 1, 3, tzinfo=UTC))
174 # Test that new occurrences get scheduled now that the January 2 occurrence has gone by
175 schedule_event_occurrences(empty_pb2.Empty())
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
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 )
189 # Set time to Friday, January 10, 2020
190 frozen_timewarp.freeze_at(datetime(2020, 1, 10, tzinfo=UTC))
192 # Test that no occurrence gets scheduled past the ends_on_date of January 20, 2020
193 schedule_event_occurrences(empty_pb2.Empty())
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
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 )
208def test_biweekly_frequency(db, frozen_timewarp: FrozenTimewarp):
209 """Test biweekly event scheduling."""
210 user, token = _create_user_in_community()
212 # Set time to Wednesday, January 1, 2020
213 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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))
221 schedule_event_occurrences(empty_pb2.Empty())
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
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
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()
239 # Set time to Wednesday, January 1, 2020
240 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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))
246 schedule_event_occurrences(empty_pb2.Empty())
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
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)
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))
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 )
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())
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
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 )
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()
297 # Set time to Wednesday, January 1, 2020
298 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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))
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 )
313 # New scheduled occurrences should not care
314 schedule_event_occurrences(empty_pb2.Empty())
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
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()
328 # Set time to Wednesday, January 1, 2020
329 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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))
335 # Schedules January 9
336 schedule_event_occurrences(empty_pb2.Empty())
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
343 assert len(occurrences_before_ban) == 2
345 with session_scope() as session:
346 session.execute(update(User).where(User.id == user.id).values(banned_at=func.now()))
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))
351 schedule_event_occurrences(empty_pb2.Empty())
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))
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
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 ]
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()
373 # Set time to Wednesday, January 1, 2020
374 frozen_timewarp.freeze_at(datetime(2020, 1, 1, tzinfo=UTC))
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))
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 )
385 # Schedules January 9
386 schedule_event_occurrences(empty_pb2.Empty())
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]
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