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

1394 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 11:56 +0000

1import re 

2from datetime import datetime, timedelta 

3from zoneinfo import ZoneInfo 

4 

5import grpc 

6import pytest 

7from google.protobuf import empty_pb2, wrappers_pb2 

8from psycopg.types.range import TimestamptzRange 

9from sqlalchemy import select 

10from sqlalchemy.sql.expression import update 

11 

12from couchers.db import session_scope 

13from couchers.jobs.handlers import send_event_reminders 

14from couchers.models import ( 

15 BackgroundJob, 

16 BackgroundJobState, 

17 Comment, 

18 EventOccurrence, 

19 ModerationState, 

20 ModerationVisibility, 

21 Notification, 

22 NotificationDelivery, 

23 NotificationTopicAction, 

24 Reply, 

25 Upload, 

26 User, 

27) 

28from couchers.proto import editor_pb2, events_pb2, threads_pb2 

29from couchers.tasks import enforce_community_memberships 

30from couchers.utils import now, to_aware_datetime 

31from tests.fixtures.api import events as events_api 

32from tests.fixtures.db import generate_user 

33from tests.fixtures.misc import EmailCollector, Moderator, PushCollector, process_jobs 

34from tests.fixtures.sessions import real_editor_session, threads_session 

35from tests.fixtures.timewarp import FrozenTimewarp 

36from tests.test_communities import create_community, create_group 

37from tests.utils import local_date_time 

38 

39 

40def _to_event_time_granularity(value: datetime) -> datetime: 

41 """Events are scheduled at the minute granularity.""" 

42 return value.replace(second=0, microsecond=0) 

43 

44 

45def _is_utc_or_gmt(timezone: str) -> bool: 

46 # Our lightweight "timezone_areas.sql-fake" uses Etc/UTC, whereas the real file uses Etc/GMT. 

47 # Tests should be agnostic to which one we're using. 

48 return timezone in ("Etc/UTC", "Etc/GMT") 

49 

50 

51def test_CreateEvent(db, frozen_timewarp, push_collector: PushCollector, moderator: Moderator): 

52 # test cases: 

53 # can create event 

54 # cannot create event with missing details 

55 # can't create event that starts in the past 

56 # can create in different timezones 

57 

58 creator_user, creator_token = generate_user() 

59 mod_user, mod_token = generate_user() 

60 other_user, other_token = generate_user() 

61 

62 with session_scope() as session: 

63 c_id = create_community(session, 0, 2, "Community", [mod_user], [], None).id 

64 

65 time_before = now() 

66 start_time = _to_event_time_granularity(time_before + timedelta(hours=2)) 

67 end_time = start_time + timedelta(hours=3) 

68 

69 # Can create an event 

70 create_res = events_api.create_event( 

71 token=creator_token, 

72 title="Dummy Title", 

73 content="Dummy content.", 

74 location=events_pb2.EventLocation( 

75 address="Near Null Island", 

76 lat=0.1, 

77 lng=0.2, 

78 ), 

79 start_date_time=local_date_time(start_time), 

80 end_date_time=local_date_time(end_time), 

81 approve_by=moderator, 

82 ) 

83 assert create_res.is_next 

84 assert create_res.title == "Dummy Title" 

85 assert create_res.slug == "dummy-title" 

86 assert create_res.content == "Dummy content." 

87 assert not create_res.photo_url 

88 assert create_res.HasField("location") 

89 assert create_res.location.lat == 0.1 

90 assert create_res.location.lng == 0.2 

91 assert create_res.location.address == "Near Null Island" 

92 assert time_before <= to_aware_datetime(create_res.created) <= now() 

93 assert time_before <= to_aware_datetime(create_res.last_edited) <= now() 

94 assert create_res.creator_user_id == creator_user.id 

95 assert to_aware_datetime(create_res.start_time) == start_time 

96 assert to_aware_datetime(create_res.end_time) == end_time 

97 assert _is_utc_or_gmt(create_res.timezone) 

98 assert create_res.attendance_state == events_pb2.ATTENDANCE_STATE_GOING 

99 assert create_res.organizer 

100 assert create_res.subscriber 

101 assert create_res.going_count == 1 

102 assert create_res.organizer_count == 1 

103 assert create_res.subscriber_count == 1 

104 assert create_res.owner_user_id == creator_user.id 

105 assert not create_res.owner_community_id 

106 assert not create_res.owner_group_id 

107 assert create_res.thread.thread_id 

108 assert create_res.can_edit 

109 assert not create_res.can_moderate 

110 

111 event_id = create_res.event_id 

112 

113 get_res = events_api.get_event(token=mod_token, event_id=event_id) 

114 assert get_res.is_next 

115 assert get_res.title == "Dummy Title" 

116 assert get_res.slug == "dummy-title" 

117 assert get_res.content == "Dummy content." 

118 assert not get_res.photo_url 

119 assert get_res.HasField("location") 

120 assert get_res.location.lat == 0.1 

121 assert get_res.location.lng == 0.2 

122 assert get_res.location.address == "Near Null Island" 

123 assert time_before <= to_aware_datetime(get_res.created) <= now() 

124 assert time_before <= to_aware_datetime(get_res.last_edited) <= now() 

125 assert get_res.creator_user_id == creator_user.id 

126 assert to_aware_datetime(get_res.start_time) == start_time 

127 assert to_aware_datetime(get_res.end_time) == end_time 

128 assert _is_utc_or_gmt(get_res.timezone) 

129 assert get_res.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

130 assert not get_res.organizer 

131 assert not get_res.subscriber 

132 assert get_res.going_count == 1 

133 assert get_res.organizer_count == 1 

134 assert get_res.subscriber_count == 1 

135 assert get_res.owner_user_id == creator_user.id 

136 assert not get_res.owner_community_id 

137 assert not get_res.owner_group_id 

138 assert get_res.thread.thread_id 

139 assert get_res.can_edit 

140 assert get_res.can_moderate 

141 

142 other_get_res = events_api.get_event(token=other_token, event_id=event_id) 

143 assert other_get_res.is_next 

144 assert other_get_res.title == "Dummy Title" 

145 assert other_get_res.slug == "dummy-title" 

146 assert other_get_res.content == "Dummy content." 

147 assert not other_get_res.photo_url 

148 assert other_get_res.HasField("location") 

149 assert other_get_res.location.lat == 0.1 

150 assert other_get_res.location.lng == 0.2 

151 assert other_get_res.location.address == "Near Null Island" 

152 assert time_before <= to_aware_datetime(other_get_res.created) <= now() 

153 assert time_before <= to_aware_datetime(other_get_res.last_edited) <= now() 

154 assert other_get_res.creator_user_id == creator_user.id 

155 assert to_aware_datetime(other_get_res.start_time) == start_time 

156 assert to_aware_datetime(other_get_res.end_time) == end_time 

157 assert _is_utc_or_gmt(other_get_res.timezone) 

158 assert other_get_res.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

159 assert not other_get_res.organizer 

160 assert not other_get_res.subscriber 

161 assert other_get_res.going_count == 1 

162 assert other_get_res.organizer_count == 1 

163 assert other_get_res.subscriber_count == 1 

164 assert other_get_res.owner_user_id == creator_user.id 

165 assert not other_get_res.owner_community_id 

166 assert not other_get_res.owner_group_id 

167 assert other_get_res.thread.thread_id 

168 assert not other_get_res.can_edit 

169 assert not other_get_res.can_moderate 

170 

171 # Failure cases 

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

173 events_api.create_event(token=creator_token, title=None) 

174 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

175 assert e.value.details() == "Missing event title." 

176 

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

178 events_api.create_event(token=creator_token, content=None) 

179 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

180 assert e.value.details() == "Missing event content." 

181 

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

183 events_api.create_event(token=creator_token, photo_key="nonexistent") 

184 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

185 assert e.value.details() == "Photo not found." 

186 

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

188 events_api.create_event(token=creator_token, location=None) 

189 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

190 assert e.value.details() == "Missing event address or location." 

191 

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

193 events_api.create_event(token=creator_token, location=events_pb2.EventLocation(address="Address only")) 

194 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

195 assert e.value.details() == "Invalid coordinate." 

196 

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

198 events_api.create_event(token=creator_token, location=events_pb2.EventLocation(lat=0.1, lng=0.1)) 

199 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

200 assert e.value.details() == "Missing event address or location." 

201 

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

203 events_api.create_event(token=creator_token, start_date_time=local_date_time(now() - timedelta(hours=2))) 

204 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

205 assert e.value.details() == "The event must be in the future." 

206 

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

208 events_api.create_event( 

209 token=creator_token, 

210 start_date_time=local_date_time(end_time), 

211 end_date_time=local_date_time(start_time), 

212 ) 

213 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

214 assert e.value.details() == "The event must end after it starts." 

215 

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

217 events_api.create_event( 

218 token=creator_token, 

219 start_date_time=local_date_time(now() + timedelta(days=500, hours=2)), 

220 end_date_time=local_date_time(now() + timedelta(days=500, hours=5)), 

221 ) 

222 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

223 assert e.value.details() == "The event needs to start within the next year." 

224 

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

226 events_api.create_event( 

227 token=creator_token, 

228 start_date_time=local_date_time(start_time), 

229 end_date_time=local_date_time(now() + timedelta(days=100)), 

230 ) 

231 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

232 assert e.value.details() == "Events cannot last longer than 7 days." 

233 

234 

235def test_CreateEvent_incomplete_profile(db): 

236 user1, token1 = generate_user(complete_profile=False) 

237 user2, token2 = generate_user() 

238 

239 with session_scope() as session: 

240 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

241 

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

243 events_api.create_event(token=token1) 

244 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

245 assert e.value.details() == "You have to complete your profile before you can create an event." 

246 

247 

248def test_ScheduleEvent(db, frozen_timewarp): 

249 # test cases: 

250 # can schedule a new event occurrence 

251 

252 user, token = generate_user() 

253 

254 with session_scope() as session: 

255 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

256 

257 time_before = now() 

258 start_time = _to_event_time_granularity(now() + timedelta(hours=2)) 

259 end_time = start_time + timedelta(hours=3) 

260 

261 create_res = events_api.create_event( 

262 token=token, 

263 parent_community_id=c_id, 

264 start_date_time=local_date_time(start_time), 

265 end_date_time=local_date_time(end_time), 

266 ) 

267 

268 new_start_time = _to_event_time_granularity(now() + timedelta(hours=6)) 

269 new_end_time = new_start_time + timedelta(hours=2) 

270 

271 schedule_res = events_api.schedule_event( 

272 token=token, 

273 event_id=create_res.event_id, 

274 content="New event occurrence", 

275 location=events_pb2.EventLocation( 

276 address="A bit further but still near Null Island", 

277 lat=0.3, 

278 lng=0.2, 

279 ), 

280 start_date_time=local_date_time(new_start_time), 

281 end_date_time=local_date_time(new_end_time), 

282 ) 

283 

284 # Each occurrence is independent and has an independent thread 

285 assert schedule_res.event_id != create_res.event_id 

286 assert schedule_res.thread.thread_id != create_res.thread.thread_id 

287 

288 res = events_api.get_event(token=token, event_id=schedule_res.event_id) 

289 

290 assert not res.is_next 

291 assert res.title == "Dummy Title" 

292 assert res.slug == "dummy-title" 

293 assert res.content == "New event occurrence" 

294 assert not res.photo_url 

295 assert res.HasField("location") 

296 assert res.location.lat == 0.3 

297 assert res.location.lng == 0.2 

298 assert res.location.address == "A bit further but still near Null Island" 

299 assert time_before <= to_aware_datetime(res.created) <= now() 

300 assert time_before <= to_aware_datetime(res.last_edited) <= now() 

301 assert res.creator_user_id == user.id 

302 assert to_aware_datetime(res.start_time) == new_start_time 

303 assert to_aware_datetime(res.end_time) == new_end_time 

304 assert _is_utc_or_gmt(res.timezone) 

305 assert res.attendance_state == events_pb2.ATTENDANCE_STATE_GOING 

306 assert res.organizer 

307 assert res.subscriber 

308 assert res.going_count == 1 

309 assert res.organizer_count == 1 

310 assert res.subscriber_count == 1 

311 assert res.owner_user_id == user.id 

312 assert not res.owner_community_id 

313 assert not res.owner_group_id 

314 assert res.thread.thread_id 

315 assert res.can_edit 

316 assert res.can_moderate 

317 

318 

319def test_cannot_overlap_occurrences_schedule(db): 

320 user, token = generate_user() 

321 

322 with session_scope() as session: 

323 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

324 

325 start = now() 

326 

327 res = events_api.create_event( 

328 token=token, 

329 parent_community_id=c_id, 

330 start_date_time=local_date_time(start + timedelta(hours=1)), 

331 end_date_time=local_date_time(start + timedelta(hours=3)), 

332 ) 

333 

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

335 events_api.schedule_event( 

336 token=token, 

337 event_id=res.event_id, 

338 start_date_time=local_date_time(start + timedelta(hours=2)), 

339 end_date_time=local_date_time(start + timedelta(hours=6)), 

340 ) 

341 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

342 assert e.value.details() == "An event cannot have overlapping occurrences." 

343 

344 

345def test_cannot_overlap_occurrences_update(db): 

346 user, token = generate_user() 

347 

348 with session_scope() as session: 

349 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

350 

351 start = now() 

352 

353 res = events_api.create_event( 

354 token=token, 

355 parent_community_id=c_id, 

356 start_date_time=local_date_time(start + timedelta(hours=1)), 

357 end_date_time=local_date_time(start + timedelta(hours=3)), 

358 ) 

359 

360 event_id = events_api.schedule_event( 

361 token=token, 

362 event_id=res.event_id, 

363 content="New event occurrence", 

364 location=events_pb2.EventLocation( 

365 address="A bit further but still near Null Island", 

366 lat=0.3, 

367 lng=0.2, 

368 ), 

369 start_date_time=local_date_time(start + timedelta(hours=4)), 

370 end_date_time=local_date_time(start + timedelta(hours=6)), 

371 ).event_id 

372 

373 # can overlap with this current existing occurrence 

374 events_api.update_event( 

375 token=token, 

376 event_id=event_id, 

377 start_date_time=local_date_time(start + timedelta(hours=5)), 

378 end_date_time=local_date_time(start + timedelta(hours=6)), 

379 ) 

380 

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

382 events_api.update_event( 

383 token=token, 

384 event_id=event_id, 

385 start_date_time=local_date_time(start + timedelta(hours=2)), 

386 end_date_time=local_date_time(start + timedelta(hours=4)), 

387 ) 

388 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

389 assert e.value.details() == "An event cannot have overlapping occurrences." 

390 

391 

392def test_UpdateEvent_single(db, frozen_timewarp: FrozenTimewarp, moderator: Moderator): 

393 # test cases: 

394 # owner can update 

395 # community owner can update 

396 # notifies attendees 

397 

398 # event creator 

399 user1, token1 = generate_user() 

400 # community moderator 

401 user2, token2 = generate_user() 

402 # third parties 

403 user3, token3 = generate_user() 

404 user4, token4 = generate_user() 

405 user5, token5 = generate_user() 

406 user6, token6 = generate_user() 

407 

408 with session_scope() as session: 

409 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

410 

411 time_before = now() 

412 start_time = _to_event_time_granularity(now() + timedelta(hours=2)) 

413 end_time = start_time + timedelta(hours=3) 

414 

415 res = events_api.create_event( 

416 token=token1, 

417 parent_community_id=c_id, 

418 location=events_pb2.EventLocation( 

419 address="Near Null Island", 

420 lat=0.1, 

421 lng=0.2, 

422 ), 

423 start_date_time=local_date_time(start_time), 

424 end_date_time=local_date_time(end_time), 

425 approve_by=moderator, 

426 ) 

427 

428 event_id = res.event_id 

429 

430 events_api.set_event_subscription(token=token4, event_id=event_id, subscribe=True) 

431 events_api.set_event_attendance(token=token5, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

432 events_api.set_event_subscription(token=token6, event_id=event_id, subscribe=True) 

433 

434 # the clock is stopped, so the edit below needs the test to move it on for last_edited to change 

435 frozen_timewarp.advance(timedelta(minutes=1)) 

436 time_before_update = now() 

437 

438 res = events_api.update_event(token=token1, event_id=event_id) 

439 res = events_api.get_event(token=token1, event_id=event_id) 

440 

441 assert res.is_next 

442 assert res.title == "Dummy Title" 

443 assert res.slug == "dummy-title" 

444 assert res.content == "Dummy content." 

445 assert not res.photo_url 

446 assert res.HasField("location") 

447 assert res.location.lat == 0.1 

448 assert res.location.lng == 0.2 

449 assert res.location.address == "Near Null Island" 

450 assert time_before <= to_aware_datetime(res.created) <= time_before_update 

451 assert time_before_update <= to_aware_datetime(res.last_edited) <= now() 

452 assert res.creator_user_id == user1.id 

453 assert to_aware_datetime(res.start_time) == start_time 

454 assert to_aware_datetime(res.end_time) == end_time 

455 assert _is_utc_or_gmt(res.timezone) 

456 assert res.attendance_state == events_pb2.ATTENDANCE_STATE_GOING 

457 assert res.organizer 

458 assert res.subscriber 

459 assert res.going_count == 2 

460 assert res.organizer_count == 1 

461 assert res.subscriber_count == 3 

462 assert res.owner_user_id == user1.id 

463 assert not res.owner_community_id 

464 assert not res.owner_group_id 

465 assert res.thread.thread_id 

466 assert res.can_edit 

467 assert not res.can_moderate 

468 

469 res = events_api.get_event(token=token2, event_id=event_id) 

470 

471 assert res.is_next 

472 assert res.title == "Dummy Title" 

473 assert res.slug == "dummy-title" 

474 assert res.content == "Dummy content." 

475 assert not res.photo_url 

476 assert res.HasField("location") 

477 assert res.location.lat == 0.1 

478 assert res.location.lng == 0.2 

479 assert res.location.address == "Near Null Island" 

480 assert time_before <= to_aware_datetime(res.created) <= time_before_update 

481 assert time_before_update <= to_aware_datetime(res.last_edited) <= now() 

482 assert res.creator_user_id == user1.id 

483 assert to_aware_datetime(res.start_time) == start_time 

484 assert to_aware_datetime(res.end_time) == end_time 

485 assert _is_utc_or_gmt(res.timezone) 

486 assert res.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

487 assert not res.organizer 

488 assert not res.subscriber 

489 assert res.going_count == 2 

490 assert res.organizer_count == 1 

491 assert res.subscriber_count == 3 

492 assert res.owner_user_id == user1.id 

493 assert not res.owner_community_id 

494 assert not res.owner_group_id 

495 assert res.thread.thread_id 

496 assert res.can_edit 

497 assert res.can_moderate 

498 

499 res = events_api.get_event(token=token3, event_id=event_id) 

500 

501 assert res.is_next 

502 assert res.title == "Dummy Title" 

503 assert res.slug == "dummy-title" 

504 assert res.content == "Dummy content." 

505 assert not res.photo_url 

506 assert res.HasField("location") 

507 assert res.location.lat == 0.1 

508 assert res.location.lng == 0.2 

509 assert res.location.address == "Near Null Island" 

510 assert time_before <= to_aware_datetime(res.created) <= time_before_update 

511 assert time_before_update <= to_aware_datetime(res.last_edited) <= now() 

512 assert res.creator_user_id == user1.id 

513 assert to_aware_datetime(res.start_time) == start_time 

514 assert to_aware_datetime(res.end_time) == end_time 

515 assert _is_utc_or_gmt(res.timezone) 

516 assert res.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

517 assert not res.organizer 

518 assert not res.subscriber 

519 assert res.going_count == 2 

520 assert res.organizer_count == 1 

521 assert res.subscriber_count == 3 

522 assert res.owner_user_id == user1.id 

523 assert not res.owner_community_id 

524 assert not res.owner_group_id 

525 assert res.thread.thread_id 

526 assert not res.can_edit 

527 assert not res.can_moderate 

528 

529 res = events_api.update_event( 

530 token=token1, 

531 event_id=event_id, 

532 location=events_pb2.EventLocation( 

533 address="Nearer Null Island", 

534 lat=0.01, 

535 lng=0.02, 

536 ), 

537 ) 

538 

539 res = events_api.get_event(token=token3, event_id=event_id) 

540 

541 assert res.HasField("location") 

542 assert res.location.address == "Nearer Null Island" 

543 assert res.location.lat == 0.01 

544 assert res.location.lng == 0.02 

545 

546 

547def test_UpdateEvent_all(db, frozen_timewarp: FrozenTimewarp, moderator: Moderator): 

548 # event creator 

549 user1, token1 = generate_user() 

550 # community moderator 

551 user2, token2 = generate_user() 

552 # third parties 

553 user3, token3 = generate_user() 

554 user4, token4 = generate_user() 

555 user5, token5 = generate_user() 

556 user6, token6 = generate_user() 

557 

558 with session_scope() as session: 

559 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

560 

561 time_before = now() 

562 start_time = now() + timedelta(hours=1) 

563 end_time = start_time + timedelta(hours=1.5) 

564 

565 event_ids = [] 

566 

567 event_id = events_api.create_event( 

568 token=token1, 

569 content="0th occurrence", 

570 start_date_time=local_date_time(start_time), 

571 end_date_time=local_date_time(end_time), 

572 approve_by=moderator, 

573 ).event_id 

574 event_ids.append(event_id) 

575 

576 events_api.set_event_subscription(token=token4, event_id=event_id, subscribe=True) 

577 events_api.set_event_attendance(token=token5, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

578 events_api.set_event_subscription(token=token6, event_id=event_id, subscribe=True) 

579 

580 for i in range(5): 

581 occurrence_id = events_api.schedule_event( 

582 token=token1, 

583 event_id=event_ids[-1], 

584 content=f"{i + 1}th occurrence", 

585 start_date_time=local_date_time(start_time + timedelta(hours=2 + i)), 

586 end_date_time=local_date_time(start_time + timedelta(hours=2.5 + i)), 

587 approve_by=moderator, 

588 ).event_id 

589 event_ids.append(occurrence_id) 

590 

591 updated_event_id = event_ids[3] 

592 

593 # the clock is stopped, so the edit below needs the test to move it on for last_edited to change 

594 frozen_timewarp.advance(timedelta(minutes=1)) 

595 time_before_update = now() 

596 

597 res = events_api.update_event( 

598 token=token1, 

599 event_id=updated_event_id, 

600 title="New Title", 

601 content="New content.", 

602 location=events_pb2.EventLocation( 

603 address="Not so near Null Island", 

604 lat=0.2, 

605 lng=0.2, 

606 ), 

607 update_all_future=True, 

608 ) 

609 

610 time_after_update = now() 

611 

612 for i in range(3): 

613 res = events_api.get_event(token=token2, event_id=event_ids[i]) 

614 assert res.content == f"{i}th occurrence" 

615 assert time_before <= to_aware_datetime(res.last_edited) <= time_before_update 

616 

617 for i in range(3, 6): 

618 res = events_api.get_event(token=token2, event_id=event_ids[i]) 

619 assert res.content == "New content." 

620 assert time_before_update <= to_aware_datetime(res.last_edited) <= time_after_update 

621 

622 

623def test_UpdateEvent_all_leaves_other_events_alone(db, moderator: Moderator): 

624 """update_all_future must only touch the occurrences of the event being edited.""" 

625 # creator of the event that gets edited 

626 user1, token1 = generate_user() 

627 # creator of an unrelated event in the same time window 

628 user2, token2 = generate_user() 

629 # community moderator, so that neither creator has edit rights on the other's event 

630 user3, token3 = generate_user() 

631 

632 with session_scope() as session: 

633 create_community(session, 0, 2, "Community", [user3], [], None) 

634 

635 start_time = now() + timedelta(hours=1) 

636 

637 edited_id = events_api.create_event( 

638 token=token1, 

639 title="Edited Event", 

640 content="0th occurrence", 

641 start_date_time=local_date_time(start_time), 

642 end_date_time=local_date_time(start_time + timedelta(hours=1)), 

643 approve_by=moderator, 

644 ).event_id 

645 

646 second_id = events_api.schedule_event( 

647 token=token1, 

648 event_id=edited_id, 

649 content="1th occurrence", 

650 start_date_time=local_date_time(start_time + timedelta(hours=2)), 

651 end_date_time=local_date_time(start_time + timedelta(hours=3)), 

652 approve_by=moderator, 

653 ).event_id 

654 

655 # an occurrence of a different event, starting after the edited one and ending well after the cutoff 

656 other_id = events_api.create_event( 

657 token=token2, 

658 title="Other Event", 

659 content="Other content.", 

660 location=events_pb2.EventLocation( 

661 address="Somewhere else entirely", 

662 lat=0.5, 

663 lng=0.5, 

664 ), 

665 start_date_time=local_date_time(start_time + timedelta(hours=4)), 

666 end_date_time=local_date_time(start_time + timedelta(hours=5)), 

667 approve_by=moderator, 

668 ).event_id 

669 

670 other_before = events_api.get_event(token=token2, event_id=other_id) 

671 

672 events_api.update_event( 

673 token=token1, 

674 event_id=edited_id, 

675 content="New content.", 

676 location=events_pb2.EventLocation( 

677 address="Not so near Null Island", 

678 lat=0.2, 

679 lng=0.2, 

680 ), 

681 update_all_future=True, 

682 ) 

683 

684 for occurrence_id in (edited_id, second_id): 

685 res = events_api.get_event(token=token3, event_id=occurrence_id) 

686 assert res.content == "New content." 

687 assert res.location.address == "Not so near Null Island" 

688 

689 res = events_api.get_event(token=token3, event_id=other_id) 

690 assert res.content == "Other content." 

691 assert res.location.address == "Somewhere else entirely" 

692 assert res.location.lat == 0.5 

693 assert res.location.lng == 0.5 

694 assert res.timezone == other_before.timezone 

695 assert res.last_edited == other_before.last_edited 

696 

697 

698def test_UpdateEvent_all_cant_change_times(db, moderator: Moderator): 

699 """Every future occurrence would get the same times, which the exclusion constraint forbids.""" 

700 user1, token1 = generate_user() 

701 user2, token2 = generate_user() 

702 

703 with session_scope() as session: 

704 create_community(session, 0, 2, "Community", [user2], [], None) 

705 

706 start_time = now() + timedelta(hours=1) 

707 

708 event_id = events_api.create_event( 

709 token=token1, 

710 content="0th occurrence", 

711 start_date_time=local_date_time(start_time), 

712 end_date_time=local_date_time(start_time + timedelta(hours=1)), 

713 ).event_id 

714 

715 events_api.schedule_event( 

716 token=token1, 

717 event_id=event_id, 

718 content="1th occurrence", 

719 start_date_time=local_date_time(start_time + timedelta(hours=2)), 

720 end_date_time=local_date_time(start_time + timedelta(hours=3)), 

721 ) 

722 

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

724 events_api.update_event( 

725 token=token1, 

726 event_id=event_id, 

727 start_date_time=local_date_time(start_time + timedelta(minutes=30)), 

728 end_date_time=local_date_time(start_time + timedelta(hours=1, minutes=30)), 

729 update_all_future=True, 

730 ) 

731 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

732 assert e.value.details() == "You cannot update all events if you're modifying start or end times." 

733 

734 

735def test_GetEvent(db, frozen_timewarp, moderator: Moderator): 

736 # event creator 

737 user1, token1 = generate_user() 

738 # community moderator 

739 user2, token2 = generate_user() 

740 # third parties 

741 user3, token3 = generate_user() 

742 user4, token4 = generate_user() 

743 user5, token5 = generate_user() 

744 user6, token6 = generate_user() 

745 

746 with session_scope() as session: 

747 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

748 

749 time_before = now() 

750 start_time = _to_event_time_granularity(now() + timedelta(hours=2)) 

751 end_time = start_time + timedelta(hours=3) 

752 

753 # in person event 

754 res = events_api.create_event( 

755 token=token1, 

756 location=events_pb2.EventLocation( 

757 address="Near Null Island", 

758 lat=0.1, 

759 lng=0.2, 

760 ), 

761 start_date_time=local_date_time(start_time), 

762 end_date_time=local_date_time(end_time), 

763 approve_by=moderator, 

764 ) 

765 

766 event_id = res.event_id 

767 

768 events_api.set_event_subscription(token=token4, event_id=event_id, subscribe=True) 

769 events_api.set_event_attendance(token=token5, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

770 events_api.set_event_subscription(token=token6, event_id=event_id, subscribe=True) 

771 

772 res = events_api.get_event(token=token1, event_id=event_id) 

773 

774 assert res.is_next 

775 assert res.title == "Dummy Title" 

776 assert res.slug == "dummy-title" 

777 assert res.content == "Dummy content." 

778 assert not res.photo_url 

779 assert res.HasField("location") 

780 assert res.location.lat == 0.1 

781 assert res.location.lng == 0.2 

782 assert res.location.address == "Near Null Island" 

783 assert time_before <= to_aware_datetime(res.created) <= now() 

784 assert time_before <= to_aware_datetime(res.last_edited) <= now() 

785 assert res.creator_user_id == user1.id 

786 assert to_aware_datetime(res.start_time) == start_time 

787 assert to_aware_datetime(res.end_time) == end_time 

788 assert _is_utc_or_gmt(res.timezone) 

789 assert res.attendance_state == events_pb2.ATTENDANCE_STATE_GOING 

790 assert res.organizer 

791 assert res.subscriber 

792 assert res.going_count == 2 

793 assert res.organizer_count == 1 

794 assert res.subscriber_count == 3 

795 assert res.owner_user_id == user1.id 

796 assert not res.owner_community_id 

797 assert not res.owner_group_id 

798 assert res.thread.thread_id 

799 assert res.can_edit 

800 assert not res.can_moderate 

801 

802 res = events_api.get_event(token=token2, event_id=event_id) 

803 

804 assert res.is_next 

805 assert res.title == "Dummy Title" 

806 assert res.slug == "dummy-title" 

807 assert res.content == "Dummy content." 

808 assert not res.photo_url 

809 assert res.HasField("location") 

810 assert res.location.lat == 0.1 

811 assert res.location.lng == 0.2 

812 assert res.location.address == "Near Null Island" 

813 assert time_before <= to_aware_datetime(res.created) <= now() 

814 assert time_before <= to_aware_datetime(res.last_edited) <= now() 

815 assert res.creator_user_id == user1.id 

816 assert to_aware_datetime(res.start_time) == start_time 

817 assert to_aware_datetime(res.end_time) == end_time 

818 assert _is_utc_or_gmt(res.timezone) 

819 assert res.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

820 assert not res.organizer 

821 assert not res.subscriber 

822 assert res.going_count == 2 

823 assert res.organizer_count == 1 

824 assert res.subscriber_count == 3 

825 assert res.owner_user_id == user1.id 

826 assert not res.owner_community_id 

827 assert not res.owner_group_id 

828 assert res.thread.thread_id 

829 assert res.can_edit 

830 assert res.can_moderate 

831 

832 res = events_api.get_event(token=token3, event_id=event_id) 

833 

834 assert res.is_next 

835 assert res.title == "Dummy Title" 

836 assert res.slug == "dummy-title" 

837 assert res.content == "Dummy content." 

838 assert not res.photo_url 

839 assert res.HasField("location") 

840 assert res.location.lat == 0.1 

841 assert res.location.lng == 0.2 

842 assert res.location.address == "Near Null Island" 

843 assert time_before <= to_aware_datetime(res.created) <= now() 

844 assert time_before <= to_aware_datetime(res.last_edited) <= now() 

845 assert res.creator_user_id == user1.id 

846 assert to_aware_datetime(res.start_time) == start_time 

847 assert to_aware_datetime(res.end_time) == end_time 

848 assert _is_utc_or_gmt(res.timezone) 

849 assert res.attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

850 assert not res.organizer 

851 assert not res.subscriber 

852 assert res.going_count == 2 

853 assert res.organizer_count == 1 

854 assert res.subscriber_count == 3 

855 assert res.owner_user_id == user1.id 

856 assert not res.owner_community_id 

857 assert not res.owner_group_id 

858 assert res.thread.thread_id 

859 assert not res.can_edit 

860 assert not res.can_moderate 

861 

862 

863def test_CancelEvent(db, moderator: Moderator): 

864 # event creator 

865 user1, token1 = generate_user() 

866 # community moderator 

867 user2, token2 = generate_user() 

868 # third parties 

869 user3, token3 = generate_user() 

870 user4, token4 = generate_user() 

871 user5, token5 = generate_user() 

872 user6, token6 = generate_user() 

873 

874 with session_scope() as session: 

875 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

876 

877 event_id = events_api.create_event(token=token1, approve_by=moderator).event_id 

878 

879 events_api.set_event_subscription(token=token4, event_id=event_id, subscribe=True) 

880 events_api.set_event_attendance(token=token5, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

881 events_api.set_event_subscription(token=token6, event_id=event_id, subscribe=True) 

882 

883 events_api.cancel_event(token=token1, event_id=event_id) 

884 assert events_api.get_event(token=token1, event_id=event_id).is_cancelled 

885 

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

887 events_api.update_event(token=token1, event_id=event_id, title="New Title") 

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

889 assert e.value.details() == "You can't modify, subscribe to, or attend to an event that's been cancelled." 

890 

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

892 events_api.invite_event_organizer(token=token1, event_id=event_id, user_id=user3.id) 

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

894 assert e.value.details() == "You can't modify, subscribe to, or attend to an event that's been cancelled." 

895 

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

897 events_api.transfer_event(token=token1, event_id=event_id, new_owner_community_id=c_id) 

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

899 assert e.value.details() == "You can't modify, subscribe to, or attend to an event that's been cancelled." 

900 

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

902 events_api.set_event_subscription(token=token3, event_id=event_id, subscribe=True) 

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

904 assert e.value.details() == "You can't modify, subscribe to, or attend to an event that's been cancelled." 

905 

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

907 events_api.set_event_attendance( 

908 token=token3, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING 

909 ) 

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

911 assert e.value.details() == "You can't modify, subscribe to, or attend to an event that's been cancelled." 

912 

913 for include_cancelled in [True, False]: 

914 occurrences = events_api.list_event_occurrences( 

915 token=token1, event_id=event_id, include_cancelled=include_cancelled 

916 ).events 

917 if include_cancelled: 

918 assert len(occurrences) > 0 

919 else: 

920 assert len(occurrences) == 0 

921 

922 mine = events_api.list_my_events(token=token1, include_cancelled=include_cancelled).events 

923 if include_cancelled: 

924 assert len(mine) > 0 

925 else: 

926 assert len(mine) == 0 

927 

928 

929def test_ListEventAttendees(db, moderator: Moderator): 

930 # event creator 

931 user1, token1 = generate_user() 

932 # others 

933 user2, token2 = generate_user() 

934 user3, token3 = generate_user() 

935 user4, token4 = generate_user() 

936 user5, token5 = generate_user() 

937 user6, token6 = generate_user() 

938 

939 with session_scope() as session: 

940 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

941 

942 event_id = events_api.create_event(token=token1, approve_by=moderator).event_id 

943 

944 for token in [token2, token3, token4, token5]: 

945 events_api.set_event_attendance( 

946 token=token, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING 

947 ) 

948 

949 assert events_api.get_event(token=token6, event_id=event_id).going_count == 5 

950 

951 res = events_api.list_event_attendees(token=token6, event_id=event_id, page_size=2) 

952 assert res.attendee_user_ids == [user1.id, user2.id] 

953 

954 res = events_api.list_event_attendees(token=token6, event_id=event_id, page_size=2, page_token=res.next_page_token) 

955 assert res.attendee_user_ids == [user3.id, user4.id] 

956 

957 res = events_api.list_event_attendees(token=token6, event_id=event_id, page_size=2, page_token=res.next_page_token) 

958 assert res.attendee_user_ids == [user5.id] 

959 assert not res.next_page_token 

960 

961 

962def test_ListEventSubscribers(db, moderator: Moderator): 

963 # event creator 

964 user1, token1 = generate_user() 

965 # others 

966 user2, token2 = generate_user() 

967 user3, token3 = generate_user() 

968 user4, token4 = generate_user() 

969 user5, token5 = generate_user() 

970 user6, token6 = generate_user() 

971 

972 with session_scope() as session: 

973 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

974 

975 event_id = events_api.create_event(token=token1, approve_by=moderator).event_id 

976 

977 for token in [token2, token3, token4, token5]: 

978 events_api.set_event_subscription(token=token, event_id=event_id, subscribe=True) 

979 

980 assert events_api.get_event(token=token6, event_id=event_id).subscriber_count == 5 

981 

982 res = events_api.list_event_subscribers(token=token6, event_id=event_id, page_size=2) 

983 assert res.subscriber_user_ids == [user1.id, user2.id] 

984 

985 res = events_api.list_event_subscribers( 

986 token=token6, event_id=event_id, page_size=2, page_token=res.next_page_token 

987 ) 

988 assert res.subscriber_user_ids == [user3.id, user4.id] 

989 

990 res = events_api.list_event_subscribers( 

991 token=token6, event_id=event_id, page_size=2, page_token=res.next_page_token 

992 ) 

993 assert res.subscriber_user_ids == [user5.id] 

994 assert not res.next_page_token 

995 

996 

997def test_ListEventOrganizers(db, moderator: Moderator): 

998 # event creator 

999 user1, token1 = generate_user() 

1000 # others 

1001 user2, token2 = generate_user() 

1002 user3, token3 = generate_user() 

1003 user4, token4 = generate_user() 

1004 user5, token5 = generate_user() 

1005 user6, token6 = generate_user() 

1006 

1007 with session_scope() as session: 

1008 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

1009 

1010 event_id = events_api.create_event(token=token1, approve_by=moderator).event_id 

1011 

1012 for user_id in [user2.id, user3.id, user4.id, user5.id]: 

1013 events_api.invite_event_organizer(token=token1, event_id=event_id, user_id=user_id) 

1014 

1015 assert events_api.get_event(token=token6, event_id=event_id).organizer_count == 5 

1016 

1017 res = events_api.list_event_organizers(token=token6, event_id=event_id, page_size=2) 

1018 assert res.organizer_user_ids == [user1.id, user2.id] 

1019 

1020 res = events_api.list_event_organizers(token=token6, event_id=event_id, page_size=2, page_token=res.next_page_token) 

1021 assert res.organizer_user_ids == [user3.id, user4.id] 

1022 

1023 res = events_api.list_event_organizers(token=token6, event_id=event_id, page_size=2, page_token=res.next_page_token) 

1024 assert res.organizer_user_ids == [user5.id] 

1025 assert not res.next_page_token 

1026 

1027 

1028def test_TransferEvent(db): 

1029 user1, token1 = generate_user() 

1030 user2, token2 = generate_user() 

1031 user3, token3 = generate_user() 

1032 user4, token4 = generate_user() 

1033 

1034 with session_scope() as session: 

1035 c = create_community(session, 0, 2, "Community", [user3], [], None) 

1036 h = create_group(session, "Group", [user4], [], c) 

1037 c_id = c.id 

1038 h_id = h.id 

1039 

1040 event_id = events_api.create_event(token=token1).event_id 

1041 events_api.transfer_event(token=token1, event_id=event_id, new_owner_community_id=c_id) 

1042 

1043 # remove ourselves as organizer, otherwise we can still edit it 

1044 events_api.remove_event_organizer(token=token1, event_id=event_id) 

1045 

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

1047 events_api.transfer_event(token=token1, event_id=event_id, new_owner_group_id=h_id) 

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

1049 assert e.value.details() == "You're not allowed to transfer that event." 

1050 

1051 event_id = events_api.create_event(token=token1).event_id 

1052 events_api.transfer_event(token=token1, event_id=event_id, new_owner_group_id=h_id) 

1053 

1054 # remove ourselves as organizer, otherwise we can still edit it 

1055 events_api.remove_event_organizer(token=token1, event_id=event_id) 

1056 

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

1058 events_api.transfer_event(token=token1, event_id=event_id, new_owner_community_id=c_id) 

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

1060 assert e.value.details() == "You're not allowed to transfer that event." 

1061 

1062 

1063def test_SetEventSubscription(db, moderator: Moderator): 

1064 user1, token1 = generate_user() 

1065 user2, token2 = generate_user() 

1066 

1067 with session_scope() as session: 

1068 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

1069 

1070 event_id = events_api.create_event( 

1071 token=token1, 

1072 approve_by=moderator, 

1073 ).event_id 

1074 

1075 assert not events_api.get_event(token=token2, event_id=event_id).subscriber 

1076 events_api.set_event_subscription(token=token2, event_id=event_id, subscribe=True) 

1077 assert events_api.get_event(token=token2, event_id=event_id).subscriber 

1078 events_api.set_event_subscription(token=token2, event_id=event_id, subscribe=False) 

1079 assert not events_api.get_event(token=token2, event_id=event_id).subscriber 

1080 

1081 

1082def test_SetEventAttendance(db, moderator: Moderator): 

1083 user1, token1 = generate_user() 

1084 user2, token2 = generate_user() 

1085 

1086 with session_scope() as session: 

1087 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

1088 

1089 event_id = events_api.create_event( 

1090 token=token1, 

1091 approve_by=moderator, 

1092 ).event_id 

1093 

1094 assert ( 

1095 events_api.get_event(token=token2, event_id=event_id).attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

1096 ) 

1097 events_api.set_event_attendance(token=token2, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

1098 assert events_api.get_event(token=token2, event_id=event_id).attendance_state == events_pb2.ATTENDANCE_STATE_GOING 

1099 events_api.set_event_attendance( 

1100 token=token2, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_NOT_GOING 

1101 ) 

1102 assert ( 

1103 events_api.get_event(token=token2, event_id=event_id).attendance_state == events_pb2.ATTENDANCE_STATE_NOT_GOING 

1104 ) 

1105 

1106 

1107def test_InviteEventOrganizer(db, moderator: Moderator): 

1108 user1, token1 = generate_user() 

1109 user2, token2 = generate_user() 

1110 

1111 with session_scope() as session: 

1112 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

1113 

1114 event_id = events_api.create_event( 

1115 token=token1, 

1116 approve_by=moderator, 

1117 ).event_id 

1118 

1119 assert not events_api.get_event(token=token2, event_id=event_id).organizer 

1120 

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

1122 events_api.invite_event_organizer(token=token2, event_id=event_id, user_id=user1.id) 

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

1124 assert e.value.details() == "You're not allowed to edit that event." 

1125 

1126 assert not events_api.get_event(token=token2, event_id=event_id).organizer 

1127 

1128 events_api.invite_event_organizer(token=token1, event_id=event_id, user_id=user2.id) 

1129 assert events_api.get_event(token=token2, event_id=event_id).organizer 

1130 

1131 

1132def test_ListEventOccurrences(db): 

1133 user1, token1 = generate_user() 

1134 user2, token2 = generate_user() 

1135 

1136 with session_scope() as session: 

1137 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

1138 

1139 start = now() 

1140 

1141 event_ids = [ 

1142 events_api.create_event( 

1143 token=token1, 

1144 title="First occurrence", 

1145 parent_community_id=c_id, 

1146 start_date_time=local_date_time(start + timedelta(hours=1)), 

1147 end_date_time=local_date_time(start + timedelta(hours=1.5)), 

1148 ).event_id 

1149 ] 

1150 

1151 for i in range(5): 

1152 event_ids.append( 

1153 events_api.schedule_event( 

1154 token=token1, 

1155 event_id=event_ids[-1], 

1156 content=f"{i}th occurrence", 

1157 start_date_time=local_date_time(start + timedelta(hours=2 + i)), 

1158 end_date_time=local_date_time(start + timedelta(hours=2.5 + i)), 

1159 ).event_id 

1160 ) 

1161 

1162 res = events_api.list_event_occurrences(token=token1, event_id=event_ids[-1], page_size=2) 

1163 assert [event.event_id for event in res.events] == event_ids[:2] 

1164 

1165 res = events_api.list_event_occurrences( 

1166 token=token1, event_id=event_ids[-1], page_size=2, page_token=res.next_page_token 

1167 ) 

1168 assert [event.event_id for event in res.events] == event_ids[2:4] 

1169 

1170 res = events_api.list_event_occurrences( 

1171 token=token1, event_id=event_ids[-1], page_size=2, page_token=res.next_page_token 

1172 ) 

1173 assert [event.event_id for event in res.events] == event_ids[4:6] 

1174 assert not res.next_page_token 

1175 

1176 

1177def test_ListMyEvents(db, moderator: Moderator): 

1178 user1, token1 = generate_user() 

1179 user2, token2 = generate_user() 

1180 user3, token3 = generate_user() 

1181 user4, token4 = generate_user() 

1182 user5, token5 = generate_user() 

1183 

1184 with session_scope() as session: 

1185 # Create global (world) -> macroregion -> region -> subregion hierarchy 

1186 # my_communities_exclude_global filters out world, macroregion, and region level communities 

1187 global_community = create_community(session, 0, 100, "Global", [user3], [], None) 

1188 c_id = global_community.id 

1189 macroregion_community = create_community( 

1190 session, 0, 75, "Macroregion Community", [user3, user4], [], global_community 

1191 ) 

1192 region_community = create_community( 

1193 session, 0, 50, "Region Community", [user3, user4], [], macroregion_community 

1194 ) 

1195 subregion_community = create_community( 

1196 session, 0, 25, "Subregion Community", [user3, user4], [], region_community 

1197 ) 

1198 c2_id = subregion_community.id 

1199 

1200 start = now() 

1201 

1202 def new_event(token: str, hours_from_now: int, community_id: int) -> int: 

1203 return events_api.create_event( 

1204 token=token, 

1205 parent_community_id=community_id, 

1206 start_date_time=local_date_time(start + timedelta(hours=hours_from_now)), 

1207 end_date_time=local_date_time(start + timedelta(hours=hours_from_now + 0.5)), 

1208 approve_by=moderator, 

1209 ).event_id 

1210 

1211 e2 = new_event(token1, 2, c_id) 

1212 e1 = new_event(token2, 1, c_id) 

1213 e3 = new_event(token1, 3, c_id) 

1214 e5 = new_event(token2, 5, c_id) 

1215 e4 = new_event(token3, 4, c_id) 

1216 e6 = new_event(token4, 6, c2_id) 

1217 

1218 events_api.invite_event_organizer(token=token1, event_id=e3, user_id=user3.id) 

1219 

1220 events_api.set_event_attendance(token=token1, event_id=e1, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

1221 events_api.set_event_subscription(token=token1, event_id=e4, subscribe=True) 

1222 events_api.set_event_attendance(token=token2, event_id=e3, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

1223 events_api.set_event_subscription(token=token3, event_id=e2, subscribe=True) 

1224 

1225 # test pagination with token first 

1226 list_res = events_api.list_my_events(token=token1, page_size=2) 

1227 assert [event.event_id for event in list_res.events] == [e1, e2] 

1228 list_res = events_api.list_my_events(token=token1, page_size=2, page_token=list_res.next_page_token) 

1229 assert [event.event_id for event in list_res.events] == [e3, e4] 

1230 assert not list_res.next_page_token 

1231 

1232 listed_events = events_api.list_my_events(token=token1, subscribed=True, attending=True, organizing=True).events 

1233 assert [event.event_id for event in listed_events] == [e1, e2, e3, e4] 

1234 

1235 listed_events = events_api.list_my_events(token=token1).events 

1236 assert [event.event_id for event in listed_events] == [e1, e2, e3, e4] 

1237 

1238 listed_events = events_api.list_my_events(token=token1, subscribed=True).events 

1239 assert [event.event_id for event in listed_events] == [e2, e3, e4] 

1240 

1241 listed_events = events_api.list_my_events(token=token1, attending=True).events 

1242 assert [event.event_id for event in listed_events] == [e1, e2, e3] 

1243 

1244 listed_events = events_api.list_my_events(token=token1, organizing=True).events 

1245 assert [event.event_id for event in listed_events] == [e2, e3] 

1246 

1247 # Test pagination with page_number and verify total_items as user1 

1248 list_res = events_api.list_my_events( 

1249 token=token1, subscribed=True, attending=True, organizing=True, page_size=2, page_number=1 

1250 ) 

1251 assert [event.event_id for event in list_res.events] == [e1, e2] 

1252 assert list_res.total_items == 4 

1253 

1254 list_res = events_api.list_my_events( 

1255 token=token1, subscribed=True, attending=True, organizing=True, page_size=2, page_number=2 

1256 ) 

1257 assert [event.event_id for event in list_res.events] == [e3, e4] 

1258 assert list_res.total_items == 4 

1259 

1260 # Verify no more pages 

1261 list_res = events_api.list_my_events( 

1262 token=token1, subscribed=True, attending=True, organizing=True, page_size=2, page_number=3 

1263 ) 

1264 assert not list_res.events 

1265 assert list_res.total_items == 4 

1266 

1267 # Verify as user2 

1268 listed_events = events_api.list_my_events(token=token2).events 

1269 assert [event.event_id for event in listed_events] == [e1, e3, e5] 

1270 

1271 listed_events = events_api.list_my_events(token=token2, subscribed=True).events 

1272 assert [event.event_id for event in listed_events] == [e1, e5] 

1273 

1274 listed_events = events_api.list_my_events(token=token2, attending=True).events 

1275 assert [event.event_id for event in listed_events] == [e1, e3, e5] 

1276 

1277 listed_events = events_api.list_my_events(token=token2, organizing=True).events 

1278 assert [event.event_id for event in listed_events] == [e1, e5] 

1279 

1280 # user3 is member of both global (c_id) and child (c2_id) communities 

1281 listed_events = events_api.list_my_events(token=token3).events 

1282 assert [event.event_id for event in listed_events] == [e1, e2, e3, e4, e5, e6] 

1283 

1284 listed_events = events_api.list_my_events(token=token3, subscribed=True).events 

1285 assert [event.event_id for event in listed_events] == [e2, e4] 

1286 

1287 listed_events = events_api.list_my_events(token=token3, attending=True).events 

1288 assert [event.event_id for event in listed_events] == [e4] 

1289 

1290 listed_events = events_api.list_my_events(token=token3, organizing=True).events 

1291 assert [event.event_id for event in listed_events] == [e3, e4] 

1292 

1293 # my_communities returns events from both communities user3 is a member of 

1294 listed_events = events_api.list_my_events(token=token3, my_communities=True).events 

1295 assert [event.event_id for event in listed_events] == [e1, e2, e3, e4, e5, e6] 

1296 

1297 # my_communities_exclude_global filters out events from global community (node_id=1) 

1298 listed_events = events_api.list_my_events( 

1299 token=token3, my_communities=True, my_communities_exclude_global=True 

1300 ).events 

1301 assert [event.event_id for event in listed_events] == [e6] 

1302 

1303 # my_communities_exclude_global works independently of my_communities flag 

1304 listed_events = events_api.list_my_events(token=token3, my_communities_exclude_global=True).events 

1305 assert [event.event_id for event in listed_events] == [e6] 

1306 

1307 # my_communities_exclude_global filters organizing results too 

1308 listed_events = events_api.list_my_events(token=token3, organizing=True, my_communities_exclude_global=True).events 

1309 assert [event.event_id for event in listed_events] == [] 

1310 

1311 # my_communities_exclude_global filters subscribed results too 

1312 listed_events = events_api.list_my_events(token=token3, subscribed=True, my_communities_exclude_global=True).events 

1313 assert [event.event_id for event in listed_events] == [] 

1314 

1315 listed_events = events_api.list_all_events(token=token5).events 

1316 assert [event.event_id for event in listed_events] == [e1, e2, e3, e4, e5, e6] 

1317 

1318 

1319def _paginate_my_events(token: str, page_size: int) -> list[int]: 

1320 event_ids = [] 

1321 page_token = "" 

1322 for _ in range(10): 1322 ↛ 1328line 1322 didn't jump to line 1328 because the loop on line 1322 didn't complete

1323 res = events_api.list_my_events(token=token, page_size=page_size, page_token=page_token) 

1324 event_ids += [event.event_id for event in res.events] 

1325 page_token = res.next_page_token 

1326 if not page_token: 

1327 return event_ids 

1328 raise AssertionError("pagination did not terminate") 

1329 

1330 

1331def test_ListMyEvents_pagination_overlapping_durations(db, moderator: Moderator): 

1332 user, token = generate_user() 

1333 

1334 with session_scope() as session: 

1335 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

1336 

1337 start = now() 

1338 

1339 def new_event(start_offset: timedelta, duration: timedelta) -> int: 

1340 return events_api.create_event( 

1341 token=token, 

1342 parent_community_id=c_id, 

1343 start_date_time=local_date_time(start + start_offset), 

1344 end_date_time=local_date_time(start + start_offset + duration), 

1345 approve_by=moderator, 

1346 ).event_id 

1347 

1348 # a multi-day event overlapping all the short events below: it ends last but starts first, 

1349 # so an end time based cursor would repeat it on every page and skip the short events 

1350 long_event = new_event(timedelta(hours=1), timedelta(days=3)) 

1351 short_events = [new_event(timedelta(hours=2 + i), timedelta(hours=1)) for i in range(4)] 

1352 

1353 assert _paginate_my_events(token, page_size=2) == [long_event, *short_events] 

1354 

1355 

1356def test_ListMyEvents_pagination_identical_start_times(db, moderator: Moderator): 

1357 user, token = generate_user() 

1358 

1359 with session_scope() as session: 

1360 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

1361 

1362 start = now() 

1363 

1364 event_ids = [ 

1365 events_api.create_event( 

1366 token=token, 

1367 parent_community_id=c_id, 

1368 start_date_time=local_date_time(start + timedelta(hours=1)), 

1369 end_date_time=local_date_time(start + timedelta(hours=2)), 

1370 approve_by=moderator, 

1371 ).event_id 

1372 for _ in range(5) 

1373 ] 

1374 

1375 assert _paginate_my_events(token, page_size=2) == event_ids 

1376 

1377 

1378def test_list_my_events_exclude_attending(db, moderator: Moderator): 

1379 user1, token1 = generate_user() 

1380 user2, token2 = generate_user() 

1381 

1382 with session_scope() as session: 

1383 c = create_community(session, 0, 100, "Community", [user1, user2], [], None) 

1384 c_id = c.id 

1385 

1386 def make_event(token: str) -> int: 

1387 return events_api.create_event(token=token, parent_community_id=c_id, approve_by=moderator).event_id 

1388 

1389 # user1 organizes e_own; user2 organizes e_attending and e_community_only 

1390 e_own = make_event(token1) 

1391 e_attending = make_event(token2) 

1392 e_community_only = make_event(token2) 

1393 # e_both: user1 will be both organizer and attendee 

1394 e_both = make_event(token2) 

1395 

1396 # invite user1 as organizer of e_both 

1397 events_api.invite_event_organizer(token=token2, event_id=e_both, user_id=user1.id) 

1398 

1399 # user1 RSVPs to e_attending and e_both 

1400 events_api.set_event_attendance( 

1401 token=token1, event_id=e_attending, attendance_state=events_pb2.ATTENDANCE_STATE_GOING 

1402 ) 

1403 events_api.set_event_attendance(token=token1, event_id=e_both, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

1404 

1405 # baseline: all four community events visible 

1406 res = events_api.list_my_events(token=token1, my_communities=True) 

1407 assert {e.event_id for e in res.events} == {e_own, e_attending, e_community_only, e_both} 

1408 

1409 # exclude_attending removes events user1 is attending (e_attending, e_both) 

1410 # and events user1 is organizing (e_own, e_both) — leaving only e_community_only 

1411 res = events_api.list_my_events(token=token1, my_communities=True, exclude_attending=True) 

1412 assert [e.event_id for e in res.events] == [e_community_only] 

1413 

1414 # exclude_attending with attending=True: invalid combination 

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

1416 events_api.list_my_events(token=token1, attending=True, exclude_attending=True) 

1417 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT 

1418 

1419 # user2 has no attendance/organizing relationship with e_community_only, so exclude_attending has no effect on it 

1420 res = events_api.list_my_events(token=token2, my_communities=True, exclude_attending=True) 

1421 # user2 organizes e_attending, e_community_only, e_both — all excluded except e_own (user2 has no relation) 

1422 assert [e.event_id for e in res.events] == [e_own] 

1423 

1424 

1425def test_RemoveEventOrganizer(db, moderator: Moderator): 

1426 user1, token1 = generate_user() 

1427 user2, token2 = generate_user() 

1428 

1429 with session_scope() as session: 

1430 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

1431 

1432 event_id = events_api.create_event(token=token1, approve_by=moderator).event_id 

1433 

1434 assert not events_api.get_event(token=token2, event_id=event_id).organizer 

1435 

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

1437 events_api.remove_event_organizer(token=token2, event_id=event_id) 

1438 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1439 assert e.value.details() == "You're not allowed to edit that event." 

1440 

1441 assert not events_api.get_event(token=token2, event_id=event_id).organizer 

1442 

1443 events_api.invite_event_organizer(token=token1, event_id=event_id, user_id=user2.id) 

1444 

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

1446 events_api.remove_event_organizer(token=token1, event_id=event_id) 

1447 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1448 assert e.value.details() == "You cannot remove the event owner as an organizer." 

1449 

1450 res = events_api.get_event(token=token2, event_id=event_id) 

1451 assert res.organizer 

1452 assert res.organizer_count == 2 

1453 

1454 events_api.remove_event_organizer(token=token2, event_id=event_id) 

1455 assert not events_api.get_event(token=token2, event_id=event_id).organizer 

1456 

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

1458 events_api.remove_event_organizer(token=token2, event_id=event_id) 

1459 assert e.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1460 assert e.value.details() == "You're not allowed to edit that event." 

1461 

1462 res = events_api.get_event(token=token2, event_id=event_id) 

1463 assert not res.organizer 

1464 assert res.organizer_count == 1 

1465 

1466 # Test that event owner can remove co-organizers 

1467 

1468 # Add user2 back as organizer and verify 

1469 events_api.invite_event_organizer(token=token1, event_id=event_id, user_id=user2.id) 

1470 res = events_api.get_event(token=token1, event_id=event_id) 

1471 assert res.organizer_count == 2 

1472 

1473 # Event owner can remove co-organizer 

1474 events_api.remove_event_organizer(token=token1, event_id=event_id, user_id=user2.id) 

1475 res = events_api.get_event(token=token1, event_id=event_id) 

1476 assert res.organizer_count == 1 

1477 

1478 # Test that non-organizers cannot remove other organizers 

1479 # User2 cannot invite themselves as organizer (not the owner) 

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

1481 events_api.invite_event_organizer(token=token2, event_id=event_id, user_id=user2.id) 

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

1483 assert e.value.details() == "You're not allowed to edit that event." 

1484 

1485 # Test that non-organizers cannot remove other organizers (user1 adds user2 back first) 

1486 events_api.invite_event_organizer(token=token1, event_id=event_id, user_id=user2.id) 

1487 

1488 

1489def test_ListEventAttendees_regression(db): 

1490 # see issue #1617: 

1491 # 

1492 # 1. Create an event 

1493 # 2. Transfer the event to a community (although this step probably not necessarily, only needed for it to show up in UI/`ListEvents` from `communities.proto` 

1494 # 3. Change the current user's attendance state to "not going" (with `SetEventAttendance`) 

1495 # 4. Change the current user's attendance state to "going" again 

1496 # 

1497 # **Expected behaviour** 

1498 # `ListEventAttendees` should return the current user's ID 

1499 # 

1500 # **Actual/current behaviour** 

1501 # `ListEventAttendees` returns another user's ID. This ID seems to be determined from the row's auto increment ID in `event_occurrence_attendees` in the database 

1502 

1503 user1, token1 = generate_user() 

1504 

1505 with session_scope() as session: 

1506 c_id = create_community(session, 0, 2, "Community", [user1], [], None).id 

1507 

1508 event_id = events_api.create_event(token=token1, parent_community_id=c_id).event_id 

1509 event_id = events_api.transfer_event(token=token1, event_id=event_id, new_owner_community_id=c_id).event_id 

1510 

1511 events_api.set_event_attendance( 

1512 token=token1, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_NOT_GOING 

1513 ) 

1514 events_api.set_event_attendance(token=token1, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

1515 

1516 attendee_user_ids = events_api.list_event_attendees(token=token1, event_id=event_id).attendee_user_ids 

1517 assert len(attendee_user_ids) == 1 

1518 assert attendee_user_ids[0] == user1.id 

1519 

1520 

1521def test_GetEventCalendarFile(db, moderator: Moderator): 

1522 user1, token1 = generate_user() 

1523 user2, token2 = generate_user() 

1524 

1525 with session_scope() as session: 

1526 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

1527 

1528 start_time = now() + timedelta(hours=2) 

1529 end_time = start_time + timedelta(hours=3) 

1530 

1531 event_id = events_api.create_event( 

1532 token=token1, 

1533 parent_community_id=c_id, 

1534 title="Dummy Title", 

1535 content="Dummy content.", 

1536 location=events_pb2.EventLocation( 

1537 address="Near Null Island", 

1538 lat=0.1, 

1539 lng=0.2, 

1540 ), 

1541 start_date_time=local_date_time(start_time), 

1542 end_date_time=local_date_time(end_time), 

1543 approve_by=moderator, 

1544 ).event_id 

1545 

1546 file_res = events_api.get_event_calendar_file(token=token1, event_id=event_id) 

1547 assert file_res.content_type == "text/calendar" 

1548 ics_string = file_res.data.decode("utf-8") 

1549 assert "SUMMARY:Dummy Title" in ics_string 

1550 assert "DESCRIPTION:Dummy content." in ics_string 

1551 assert "LOCATION:Near Null Island" in ics_string 

1552 assert "STATUS:CANCELLED" not in ics_string 

1553 pre_cancel_match = re.search(r"SEQUENCE:(\d+)", ics_string) 

1554 assert pre_cancel_match is not None 

1555 pre_cancel_sequence = int(pre_cancel_match.group(1)) 

1556 

1557 events_api.cancel_event(token=token1, event_id=event_id) 

1558 

1559 file_res = events_api.get_event_calendar_file(token=token1, event_id=event_id) 

1560 ics_string = file_res.data.decode("utf-8") 

1561 assert "SUMMARY:Cancelled: Dummy Title" in ics_string 

1562 assert "STATUS:CANCELLED" in ics_string 

1563 post_cancel_match = re.search(r"SEQUENCE:(\d+)", ics_string) 

1564 assert post_cancel_match is not None 

1565 post_cancel_sequence = int(post_cancel_match.group(1)) 

1566 # Ideally the sequence number are strictly ascending, but they are based on timestamps so in tests they could be equal. 

1567 assert post_cancel_sequence >= pre_cancel_sequence 

1568 

1569 

1570def test_event_threads(db, push_collector: PushCollector, moderator: Moderator): 

1571 user1, token1 = generate_user() 

1572 user2, token2 = generate_user() 

1573 user3, token3 = generate_user() 

1574 user4, token4 = generate_user() 

1575 

1576 with session_scope() as session: 

1577 c = create_community(session, 0, 2, "Community", [user3], [], None) 

1578 h = create_group(session, "Group", [user4], [], c) 

1579 c_id = c.id 

1580 h_id = h.id 

1581 user4_id = user4.id 

1582 

1583 event = events_api.create_event( 

1584 token=token1, 

1585 approve_by=moderator, 

1586 ) 

1587 

1588 with threads_session(token2) as api: 

1589 reply_id = api.PostReply(threads_pb2.PostReplyReq(thread_id=event.thread.thread_id, content="hi")).thread_id 

1590 

1591 moderator.approve_thread_post(reply_id) 

1592 

1593 res = events_api.get_event(token=token3, event_id=event.event_id) 

1594 assert res.thread.num_responses == 1 

1595 

1596 with threads_session(token3) as api: 

1597 ret = api.GetThread(threads_pb2.GetThreadReq(thread_id=res.thread.thread_id)) 

1598 assert len(ret.replies) == 1 

1599 assert not ret.next_page_token 

1600 assert ret.replies[0].thread_id == reply_id 

1601 assert ret.replies[0].content == "hi" 

1602 assert ret.replies[0].author_user_id == user2.id 

1603 assert ret.replies[0].num_replies == 0 

1604 

1605 nested_reply_id = api.PostReply( 

1606 threads_pb2.PostReplyReq(thread_id=reply_id, content="what a silly comment") 

1607 ).thread_id 

1608 

1609 moderator.approve_thread_post(nested_reply_id) 

1610 

1611 process_jobs() 

1612 

1613 push = push_collector.pop_for_user(user1.id, last=True) 

1614 assert push.topic_action == NotificationTopicAction.event__comment.display 

1615 assert push.content.title == f"{user2.name} • Dummy Title" 

1616 assert push.content.ios_title == user2.name 

1617 assert push.content.ios_subtitle == "Commented on Dummy Title" 

1618 assert push.content.body == "hi" 

1619 

1620 push = push_collector.pop_for_user(user2.id, last=True) 

1621 assert push.content.title == f"{user3.name} • Dummy Title" 

1622 

1623 assert push_collector.count_for_user(user4_id) == 0 

1624 

1625 

1626def test_can_overlap_other_events_schedule_regression(db): 

1627 # we had a bug where we were checking overlapping for *all* occurrences of *all* events, not just the ones for this event 

1628 user, token = generate_user() 

1629 

1630 with session_scope() as session: 

1631 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

1632 

1633 start = now() 

1634 

1635 # create another event, should be able to overlap with this one 

1636 events_api.create_event( 

1637 token=token, 

1638 parent_community_id=c_id, 

1639 start_date_time=local_date_time(start + timedelta(hours=1)), 

1640 end_date_time=local_date_time(start + timedelta(hours=5)), 

1641 ) 

1642 

1643 # this event 

1644 res = events_api.create_event( 

1645 token=token, 

1646 parent_community_id=c_id, 

1647 start_date_time=local_date_time(start + timedelta(hours=1)), 

1648 end_date_time=local_date_time(start + timedelta(hours=2)), 

1649 ) 

1650 

1651 # this doesn't overlap with the just created event, but does overlap with the occurrence from earlier; which should be no problem 

1652 events_api.schedule_event( 

1653 token=token, 

1654 event_id=res.event_id, 

1655 content="New event occurrence", 

1656 location=events_pb2.EventLocation( 

1657 address="A bit further but still near Null Island", 

1658 lat=0.3, 

1659 lng=0.2, 

1660 ), 

1661 start_date_time=local_date_time(start + timedelta(hours=3)), 

1662 end_date_time=local_date_time(start + timedelta(hours=6)), 

1663 ) 

1664 

1665 

1666def test_can_overlap_other_events_update_regression(db): 

1667 user, token = generate_user() 

1668 

1669 with session_scope() as session: 

1670 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

1671 

1672 start = now() 

1673 

1674 # create another event, should be able to overlap with this one 

1675 events_api.create_event( 

1676 token=token, 

1677 parent_community_id=c_id, 

1678 start_date_time=local_date_time(start + timedelta(hours=1)), 

1679 end_date_time=local_date_time(start + timedelta(hours=3)), 

1680 ) 

1681 

1682 res = events_api.create_event( 

1683 token=token, 

1684 parent_community_id=c_id, 

1685 start_date_time=local_date_time(start + timedelta(hours=7)), 

1686 end_date_time=local_date_time(start + timedelta(hours=8)), 

1687 ) 

1688 

1689 event_id = events_api.schedule_event( 

1690 token=token, 

1691 event_id=res.event_id, 

1692 content="New event occurrence", 

1693 location=events_pb2.EventLocation( 

1694 address="A bit further but still near Null Island", 

1695 lat=0.3, 

1696 lng=0.2, 

1697 ), 

1698 start_date_time=local_date_time(start + timedelta(hours=4)), 

1699 end_date_time=local_date_time(start + timedelta(hours=6)), 

1700 ).event_id 

1701 

1702 # can overlap with this current existing occurrence 

1703 events_api.update_event( 

1704 token=token, 

1705 event_id=event_id, 

1706 start_date_time=local_date_time(start + timedelta(hours=5)), 

1707 end_date_time=local_date_time(start + timedelta(hours=6)), 

1708 ) 

1709 

1710 events_api.update_event( 

1711 token=token, 

1712 event_id=event_id, 

1713 start_date_time=local_date_time(start + timedelta(hours=2)), 

1714 end_date_time=local_date_time(start + timedelta(hours=4)), 

1715 ) 

1716 

1717 

1718def test_list_past_events_regression(db): 

1719 # test for a bug where listing past events didn't work if they didn't have a future occurrence 

1720 user, token = generate_user() 

1721 

1722 with session_scope() as session: 

1723 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

1724 

1725 start = now() 

1726 

1727 events_api.create_event( 

1728 token=token, 

1729 parent_community_id=c_id, 

1730 start_date_time=local_date_time(start + timedelta(hours=3)), 

1731 end_date_time=local_date_time(start + timedelta(hours=4)), 

1732 ) 

1733 

1734 with session_scope() as session: 

1735 session.execute( 

1736 update(EventOccurrence).values( 

1737 during=TimestamptzRange(start + timedelta(hours=-5), start + timedelta(hours=-4)) 

1738 ) 

1739 ) 

1740 

1741 res = events_api.list_all_events(token=token, past=True) 

1742 assert len(res.events) == 1 

1743 

1744 

1745def test_community_invite_requests(db, email_collector: EmailCollector, moderator: Moderator): 

1746 user1, token1 = generate_user(complete_profile=True) 

1747 user2, token2 = generate_user() 

1748 user3, token3 = generate_user() 

1749 user4, token4 = generate_user() 

1750 user5, token5 = generate_user(is_superuser=True) 

1751 

1752 with session_scope() as session: 

1753 w = create_community(session, 0, 2, "World Community", [user5], [], None) 

1754 mr = create_community(session, 0, 2, "Macroregion", [user5], [], w) 

1755 r = create_community(session, 0, 2, "Region", [user5], [], mr) 

1756 c_id = create_community(session, 0, 2, "Community", [user1, user3, user4], [], r).id 

1757 

1758 enforce_community_memberships() 

1759 

1760 res = events_api.create_event(token=token1, parent_community_id=c_id, approve_by=moderator) 

1761 

1762 user_url = f"http://localhost:3000/user/{user1.username}" 

1763 event_url = f"http://localhost:3000/event/{res.event_id}/{res.slug}" 

1764 

1765 event_id = res.event_id 

1766 assert res.community_invite_requested is False 

1767 

1768 res = events_api.get_event(token=token1, event_id=event_id) 

1769 assert res.community_invite_requested is False 

1770 

1771 events_api.request_community_invite(token=token1, event_id=event_id) 

1772 res = events_api.get_event(token=token1, event_id=event_id) 

1773 assert res.community_invite_requested is True 

1774 

1775 email = email_collector.pop_for_mods(last=True) 

1776 assert user_url in email.plain 

1777 assert event_url in email.plain 

1778 

1779 # can't send another req 

1780 with pytest.raises(grpc.RpcError) as err: 

1781 events_api.request_community_invite(token=token1, event_id=event_id) 

1782 assert err.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1783 assert err.value.details() == "You have already requested a community invite for this event." 

1784 

1785 # another user can send one though 

1786 # api.RequestCommunityInvite(events_pb2.RequestCommunityInviteReq(event_id=event_id)) 

1787 res = events_api.get_event(token=token3, event_id=event_id) 

1788 assert res.community_invite_requested is False 

1789 

1790 events_api.request_community_invite(token=token3, event_id=event_id) 

1791 

1792 res = events_api.get_event(token=token3, event_id=event_id) 

1793 assert res.community_invite_requested is True 

1794 

1795 # but not a non-admin 

1796 with pytest.raises(grpc.RpcError) as err: 

1797 events_api.request_community_invite(token=token2, event_id=event_id) 

1798 assert err.value.code() == grpc.StatusCode.PERMISSION_DENIED 

1799 assert err.value.details() == "You're not allowed to edit that event." 

1800 

1801 with real_editor_session(token5) as editor: 

1802 res = editor.ListEventCommunityInviteRequests(editor_pb2.ListEventCommunityInviteRequestsReq()) 

1803 assert len(res.requests) == 2 

1804 assert res.requests[0].user_id == user1.id 

1805 # user1 is the event organizer, so they're excluded from the notify count (only user3 and user4 remain) 

1806 assert res.requests[0].approx_users_to_notify == 2 

1807 assert res.requests[1].user_id == user3.id 

1808 assert res.requests[1].approx_users_to_notify == 2 

1809 

1810 editor.DecideEventCommunityInviteRequest( 

1811 editor_pb2.DecideEventCommunityInviteRequestReq( 

1812 event_community_invite_request_id=res.requests[0].event_community_invite_request_id, 

1813 approve=False, 

1814 ) 

1815 ) 

1816 

1817 editor.DecideEventCommunityInviteRequest( 

1818 editor_pb2.DecideEventCommunityInviteRequestReq( 

1819 event_community_invite_request_id=res.requests[1].event_community_invite_request_id, 

1820 approve=True, 

1821 ) 

1822 ) 

1823 

1824 # Once a request is approved, the event still reports that a 

1825 # community invite has been requested. 

1826 res = events_api.get_event(token=token1, event_id=event_id) 

1827 assert res.community_invite_requested is True 

1828 

1829 # not after approve 

1830 with pytest.raises(grpc.RpcError) as err: 

1831 events_api.request_community_invite(token=token4, event_id=event_id) 

1832 assert err.value.code() == grpc.StatusCode.FAILED_PRECONDITION 

1833 assert err.value.details() == "A community invite has already been sent out for this event." 

1834 

1835 

1836def test_list_decided_community_invite_requests(db, moderator: Moderator): 

1837 user1, token1 = generate_user() 

1838 user2, token2 = generate_user() 

1839 user3, token3 = generate_user() 

1840 superuser, superuser_token = generate_user(is_superuser=True) 

1841 

1842 with session_scope() as session: 

1843 w = create_community(session, 0, 2, "World Community", [superuser], [], None) 

1844 mr = create_community(session, 0, 2, "Macroregion", [superuser], [], w) 

1845 r = create_community(session, 0, 2, "Region", [superuser], [], mr) 

1846 c_id = create_community(session, 0, 2, "Community", [user1, user2, user3], [], r).id 

1847 

1848 enforce_community_memberships() 

1849 

1850 def create_event_and_request_invite(token: str, title: str) -> str: 

1851 res = events_api.create_event( 

1852 token=token, 

1853 title=title, 

1854 parent_community_id=c_id, 

1855 approve_by=moderator, 

1856 ) 

1857 events_api.request_community_invite(token=token, event_id=res.event_id) 

1858 return f"http://localhost:3000/event/{res.event_id}/{res.slug}" 

1859 

1860 event1_url = create_event_and_request_invite(token1, "Approved Event") 

1861 event2_url = create_event_and_request_invite(token2, "Declined Event") 

1862 # this one stays pending 

1863 create_event_and_request_invite(token3, "Pending Event") 

1864 

1865 with real_editor_session(superuser_token) as editor: 

1866 pending = editor.ListEventCommunityInviteRequests(editor_pb2.ListEventCommunityInviteRequestsReq()) 

1867 assert len(pending.requests) == 3 

1868 by_user = {req.user_id: req.event_community_invite_request_id for req in pending.requests} 

1869 

1870 # nothing decided yet 

1871 res = editor.ListDecidedEventCommunityInviteRequests(editor_pb2.ListDecidedEventCommunityInviteRequestsReq()) 

1872 assert len(res.requests) == 0 

1873 assert not res.next_page_token 

1874 

1875 editor.DecideEventCommunityInviteRequest( 

1876 editor_pb2.DecideEventCommunityInviteRequestReq( 

1877 event_community_invite_request_id=by_user[user1.id], 

1878 approve=True, 

1879 ) 

1880 ) 

1881 editor.DecideEventCommunityInviteRequest( 

1882 editor_pb2.DecideEventCommunityInviteRequestReq( 

1883 event_community_invite_request_id=by_user[user2.id], 

1884 approve=False, 

1885 ) 

1886 ) 

1887 

1888 # the pending one is not returned, and the most recently decided comes first 

1889 res = editor.ListDecidedEventCommunityInviteRequests(editor_pb2.ListDecidedEventCommunityInviteRequestsReq()) 

1890 assert len(res.requests) == 2 

1891 assert not res.next_page_token 

1892 

1893 declined, approved = res.requests 

1894 

1895 assert declined.event_community_invite_request_id == by_user[user2.id] 

1896 assert declined.user_id == user2.id 

1897 assert declined.event_url == event2_url 

1898 assert declined.community_id == c_id 

1899 assert declined.decided_by_user_id == superuser.id 

1900 assert not declined.approved 

1901 assert declined.created.ToDatetime() <= declined.decided.ToDatetime() 

1902 

1903 assert approved.event_community_invite_request_id == by_user[user1.id] 

1904 assert approved.user_id == user1.id 

1905 assert approved.event_url == event1_url 

1906 assert approved.community_id == c_id 

1907 assert approved.decided_by_user_id == superuser.id 

1908 assert approved.approved 

1909 assert approved.decided.ToDatetime() <= declined.decided.ToDatetime() 

1910 

1911 # filtering 

1912 res = editor.ListDecidedEventCommunityInviteRequests( 

1913 editor_pb2.ListDecidedEventCommunityInviteRequestsReq(approved=wrappers_pb2.BoolValue(value=True)) 

1914 ) 

1915 assert [req.event_community_invite_request_id for req in res.requests] == [by_user[user1.id]] 

1916 

1917 res = editor.ListDecidedEventCommunityInviteRequests( 

1918 editor_pb2.ListDecidedEventCommunityInviteRequestsReq(approved=wrappers_pb2.BoolValue(value=False)) 

1919 ) 

1920 assert [req.event_community_invite_request_id for req in res.requests] == [by_user[user2.id]] 

1921 

1922 # pagination 

1923 res = editor.ListDecidedEventCommunityInviteRequests( 

1924 editor_pb2.ListDecidedEventCommunityInviteRequestsReq(page_size=1) 

1925 ) 

1926 assert [req.event_community_invite_request_id for req in res.requests] == [by_user[user2.id]] 

1927 assert res.next_page_token 

1928 

1929 res = editor.ListDecidedEventCommunityInviteRequests( 

1930 editor_pb2.ListDecidedEventCommunityInviteRequestsReq(page_size=1, page_token=res.next_page_token) 

1931 ) 

1932 assert [req.event_community_invite_request_id for req in res.requests] == [by_user[user1.id]] 

1933 assert not res.next_page_token 

1934 

1935 

1936def test_community_invite_not_sent_to_attendees_or_organizers(db, moderator: Moderator): 

1937 # Regression: users who already RSVP'd (or organize the event) must not get the 

1938 # community invite notification when it is approved. 

1939 organizer, organizer_token = generate_user() 

1940 attendee, attendee_token = generate_user() 

1941 member, _ = generate_user() 

1942 superuser, superuser_token = generate_user(is_superuser=True) 

1943 

1944 with session_scope() as session: 

1945 w = create_community(session, 0, 2, "World Community", [superuser], [], None) 

1946 mr = create_community(session, 0, 2, "Macroregion", [superuser], [], w) 

1947 r = create_community(session, 0, 2, "Region", [superuser], [], mr) 

1948 c_id = create_community(session, 0, 2, "Community", [organizer, attendee, member], [], r).id 

1949 

1950 enforce_community_memberships() 

1951 

1952 event_id = events_api.create_event( 

1953 token=organizer_token, 

1954 parent_community_id=c_id, 

1955 approve_by=moderator, 

1956 ).event_id 

1957 

1958 # the attendee RSVPs before the community invite is approved 

1959 events_api.set_event_attendance( 

1960 token=attendee_token, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING 

1961 ) 

1962 

1963 events_api.request_community_invite(token=organizer_token, event_id=event_id) 

1964 

1965 with real_editor_session(superuser_token) as editor: 

1966 res = editor.ListEventCommunityInviteRequests(editor_pb2.ListEventCommunityInviteRequestsReq()) 

1967 editor.DecideEventCommunityInviteRequest( 

1968 editor_pb2.DecideEventCommunityInviteRequestReq( 

1969 event_community_invite_request_id=res.requests[0].event_community_invite_request_id, 

1970 approve=True, 

1971 ) 

1972 ) 

1973 

1974 process_jobs() 

1975 

1976 with session_scope() as session: 

1977 

1978 def invite_notification_count(user_id: int) -> int: 

1979 notifications = session.execute(select(Notification).where(Notification.user_id == user_id)).scalars().all() 

1980 return len([n for n in notifications if n.topic_action == NotificationTopicAction.event__create_approved]) 

1981 

1982 # a plain community member gets the invite... 

1983 assert invite_notification_count(member.id) == 1 

1984 # ...but the attendee and the organizer don't 

1985 assert invite_notification_count(attendee.id) == 0 

1986 assert invite_notification_count(organizer.id) == 0 

1987 

1988 

1989def test_update_event_should_notify_queues_job(): 

1990 user, token = generate_user() 

1991 start = now() 

1992 

1993 with session_scope() as session: 

1994 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

1995 

1996 # create an event 

1997 create_res = events_api.create_event( 

1998 token=token, 

1999 parent_community_id=c_id, 

2000 start_date_time=local_date_time(start + timedelta(hours=3)), 

2001 end_date_time=local_date_time(start + timedelta(hours=6)), 

2002 ) 

2003 

2004 event_id = create_res.event_id 

2005 

2006 # measure initial background job queue length 

2007 with session_scope() as session: 

2008 jobs = session.query(BackgroundJob).all() 

2009 job_length_before_update = len(jobs) 

2010 

2011 # update with should_notify=False, expect no change in background job queue 

2012 events_api.update_event( 

2013 token=token, event_id=event_id, start_date_time=local_date_time(start + timedelta(hours=4)), should_notify=False 

2014 ) 

2015 

2016 with session_scope() as session: 

2017 jobs = session.query(BackgroundJob).all() 

2018 assert len(jobs) == job_length_before_update 

2019 

2020 # update with should_notify=True, expect one new background job added 

2021 events_api.update_event( 

2022 token=token, event_id=event_id, start_date_time=local_date_time(start + timedelta(hours=5)), should_notify=True 

2023 ) 

2024 

2025 with session_scope() as session: 

2026 jobs = session.query(BackgroundJob).all() 

2027 assert len(jobs) == job_length_before_update + 1 

2028 

2029 

2030def test_event_photo_key(db): 

2031 """Test that events return the photo_key field when a photo is set.""" 

2032 user, token = generate_user() 

2033 

2034 # Create a community and an upload for the event photo 

2035 with session_scope() as session: 

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

2037 upload = Upload( 

2038 key="test_event_photo_key_123", 

2039 filename="test_event_photo_key_123.jpg", 

2040 creator_user_id=user.id, 

2041 ) 

2042 session.add(upload) 

2043 

2044 # Create event without photo 

2045 res = events_api.create_event(token=token) 

2046 assert res.photo_key == "" 

2047 assert res.photo_url == "" 

2048 

2049 # Create event with photo 

2050 res_with_photo = events_api.create_event( 

2051 token=token, 

2052 photo_key="test_event_photo_key_123", 

2053 ) 

2054 

2055 assert res_with_photo.photo_key == "test_event_photo_key_123" 

2056 assert "test_event_photo_key_123" in res_with_photo.photo_url 

2057 

2058 event_id = res_with_photo.event_id 

2059 

2060 # Verify photo_key is returned when getting the event 

2061 get_res = events_api.get_event(token=token, event_id=event_id) 

2062 assert get_res.photo_key == "test_event_photo_key_123" 

2063 assert "test_event_photo_key_123" in get_res.photo_url 

2064 

2065 

2066def test_event_timezone(db): 

2067 user, token = generate_user() 

2068 

2069 with session_scope() as session: 

2070 c_id = create_community(session, 0, 2, "Community", [user], [], None).id 

2071 

2072 # Midnight future day, UTC timezone 

2073 start_time = (now() + timedelta(days=2)).replace(hour=0, minute=0, second=0, microsecond=0) 

2074 end_time = start_time + timedelta(days=1) 

2075 

2076 create_res = events_api.create_event( 

2077 token=token, 

2078 parent_community_id=c_id, 

2079 location=events_api.EVENT_LOCATION_HELSINKI, 

2080 # Should result in YYYY-MM-DDT00:00 (midnight local time) 

2081 start_date_time=local_date_time(start_time), 

2082 end_date_time=local_date_time(end_time), 

2083 ) 

2084 

2085 # Backend should have deduced the helsinki timezone when creating the event, 

2086 # so the datetime in Helsinki should be at midnight, but it shouldn't in UTC. 

2087 assert create_res.timezone == "Europe/Helsinki" 

2088 assert to_aware_datetime(create_res.start_time).hour != 0 

2089 assert create_res.start_time.ToDatetime(tzinfo=ZoneInfo("Europe/Helsinki")).hour == 0 

2090 

2091 # Now update its location such that it gets a new timezone 

2092 update_res = events_api.update_event( 

2093 token=token, 

2094 event_id=create_res.event_id, 

2095 location=events_api.EVENT_LOCATION_NEW_YORK, 

2096 ) 

2097 

2098 # The user didn't touch the datetime components on the frontend, 

2099 # so they expect the event to be at the same local time (midnight), 

2100 # but now in the New York timezone. 

2101 assert update_res.timezone == "America/New_York" 

2102 assert update_res.start_time != create_res.start_time 

2103 assert update_res.start_time.ToDatetime(tzinfo=ZoneInfo("Europe/Helsinki")).hour != 0 

2104 assert update_res.start_time.ToDatetime(tzinfo=ZoneInfo("America/New_York")).hour == 0 

2105 

2106 # Also validate GetEvent 

2107 get_res = events_api.get_event(token=token, event_id=create_res.event_id) 

2108 assert get_res.timezone == update_res.timezone 

2109 assert get_res.start_time == update_res.start_time 

2110 

2111 

2112def test_event_created_with_shadowed_visibility(db): 

2113 """Events start in SHADOWED state when created.""" 

2114 user, token = generate_user() 

2115 

2116 with session_scope() as session: 

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

2118 

2119 event_id = events_api.create_event(token=token).event_id 

2120 

2121 with session_scope() as session: 

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

2123 mod_state = session.execute( 

2124 select(ModerationState).where(ModerationState.id == occurrence.moderation_state_id) 

2125 ).scalar_one() 

2126 assert mod_state.visibility == ModerationVisibility.shadowed 

2127 

2128 

2129def test_shadowed_event_visible_to_creator_only(db): 

2130 """SHADOWED events are visible to the creator but not to other users.""" 

2131 user1, token1 = generate_user() 

2132 user2, token2 = generate_user() 

2133 

2134 with session_scope() as session: 

2135 create_community(session, 0, 2, "Community", [user1], [], None) 

2136 

2137 event_id = events_api.create_event(token=token1, title="Shadowed Event").event_id 

2138 

2139 # Creator can see it 

2140 res = events_api.get_event(token=token1, event_id=event_id) 

2141 assert res.title == "Shadowed Event" 

2142 

2143 # Other user cannot 

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

2145 events_api.get_event(token=token2, event_id=event_id) 

2146 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

2147 

2148 

2149def test_event_visible_after_approval(db, moderator: Moderator): 

2150 """Events become visible to all users after moderation approval.""" 

2151 user1, token1 = generate_user() 

2152 user2, token2 = generate_user() 

2153 

2154 with session_scope() as session: 

2155 create_community(session, 0, 2, "Community", [user1], [], None) 

2156 

2157 event_id = events_api.create_event(token=token1, title="Approved Event").event_id 

2158 

2159 # Other user cannot see it yet 

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

2161 events_api.get_event(token=token2, event_id=event_id) 

2162 assert e.value.code() == grpc.StatusCode.NOT_FOUND 

2163 

2164 # Approve the event 

2165 moderator.approve_event_occurrence(event_id) 

2166 

2167 # Now other user can see it 

2168 res = events_api.get_event(token=token2, event_id=event_id) 

2169 assert res.title == "Approved Event" 

2170 

2171 

2172def test_shadowed_event_hidden_from_list_for_non_creator(db, moderator: Moderator): 

2173 """SHADOWED events appear in lists for the creator but not for other users.""" 

2174 user1, token1 = generate_user() 

2175 user2, token2 = generate_user() 

2176 

2177 with session_scope() as session: 

2178 create_community(session, 0, 2, "Community", [user1], [], None) 

2179 

2180 res = events_api.create_event(token=token1) 

2181 

2182 event_id = res.event_id 

2183 

2184 # Creator can see their own SHADOWED event in lists 

2185 list_res = events_api.list_all_events(token=token1) 

2186 event_ids = [e.event_id for e in list_res.events] 

2187 assert event_id in event_ids 

2188 

2189 # Other user cannot see the SHADOWED event in lists 

2190 list_res = events_api.list_all_events(token=token2) 

2191 event_ids = [e.event_id for e in list_res.events] 

2192 assert event_id not in event_ids 

2193 

2194 # After approval, other user can see it 

2195 moderator.approve_event_occurrence(event_id) 

2196 

2197 list_res = events_api.list_all_events(token=token2) 

2198 event_ids = [e.event_id for e in list_res.events] 

2199 assert event_id in event_ids 

2200 

2201 

2202def test_event_create_notification_deferred_until_approval(db, push_collector: PushCollector, moderator: Moderator): 

2203 """Event create notifications are deferred while SHADOWED, then unblocked after approval.""" 

2204 user1, token1 = generate_user() 

2205 user2, token2 = generate_user() 

2206 

2207 # Need world -> macroregion -> region -> subregion so the subregion community gets notifications 

2208 with session_scope() as session: 

2209 world = create_community(session, 0, 10, "World", [user1], [], None) 

2210 macroregion = create_community(session, 0, 7, "Macroregion", [user1], [], world) 

2211 region = create_community(session, 0, 5, "Region", [user1], [], macroregion) 

2212 create_community(session, 0, 2, "Child", [user2], [], region) 

2213 

2214 event_id = events_api.create_event(token=token1).event_id 

2215 

2216 # Process all jobs — notification should be deferred (event is SHADOWED) 

2217 process_jobs() 

2218 

2219 with session_scope() as session: 

2220 notif = session.execute(select(Notification).where(Notification.user_id == user2.id)).scalar_one() 

2221 # Notification was created with moderation_state_id for deferral 

2222 assert notif.moderation_state_id is not None 

2223 # No delivery exists (deferred because event is SHADOWED) 

2224 delivery_count = session.execute( 

2225 select(NotificationDelivery).where(NotificationDelivery.notification_id == notif.id) 

2226 ).scalar_one_or_none() 

2227 assert delivery_count is None 

2228 

2229 # Approve the event — handle_notification is re-queued for deferred notifications 

2230 moderator.approve_event_occurrence(event_id) 

2231 

2232 # Verify handle_notification job was queued 

2233 with session_scope() as session: 

2234 pending_jobs = ( 

2235 session.execute(select(BackgroundJob).where(BackgroundJob.state == BackgroundJobState.pending)) 

2236 .scalars() 

2237 .all() 

2238 ) 

2239 assert any("handle_notification" in j.job_type for j in pending_jobs) 

2240 

2241 

2242def test_event_update_notification_has_moderation_state(db, push_collector: PushCollector, moderator: Moderator): 

2243 """Event update notifications should carry the event's moderation_state_id for deferral.""" 

2244 user1, token1 = generate_user() 

2245 user2, token2 = generate_user() 

2246 

2247 with session_scope() as session: 

2248 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

2249 

2250 event_id = events_api.create_event(token=token1, title="Update Test", approve_by=moderator).event_id 

2251 

2252 process_jobs() 

2253 # Clear any create notifications 

2254 while push_collector.count_for_user(user2.id): 2254 ↛ 2255line 2254 didn't jump to line 2255 because the condition on line 2254 was never true

2255 push_collector.pop_for_user(user2.id) 

2256 

2257 # User2 subscribes to the event 

2258 events_api.set_event_subscription(token=token2, event_id=event_id, subscribe=True) 

2259 

2260 # User1 updates the event with should_notify=True 

2261 events_api.update_event(token=token1, event_id=event_id, title="Updated Title", should_notify=True) 

2262 

2263 process_jobs() 

2264 

2265 # Verify that the update notification for user2 has moderation_state_id set 

2266 with session_scope() as session: 

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

2268 

2269 notifications = session.execute(select(Notification).where(Notification.user_id == user2.id)).scalars().all() 

2270 # Find the update notification (most recent one) 

2271 update_notifs = [n for n in notifications if n.topic_action.action == "update"] 

2272 assert len(update_notifs) == 1 

2273 assert update_notifs[0].moderation_state_id == occurrence.moderation_state_id 

2274 

2275 

2276def test_event_cancel_notification_has_moderation_state(db, push_collector: PushCollector, moderator: Moderator): 

2277 """Event cancel notifications should carry the event's moderation_state_id for deferral.""" 

2278 user1, token1 = generate_user() 

2279 user2, token2 = generate_user() 

2280 

2281 with session_scope() as session: 

2282 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

2283 

2284 event_id = events_api.create_event(token=token1, approve_by=moderator).event_id 

2285 

2286 process_jobs() 

2287 while push_collector.count_for_user(user2.id): 2287 ↛ 2288line 2287 didn't jump to line 2288 because the condition on line 2287 was never true

2288 push_collector.pop_for_user(user2.id) 

2289 

2290 # User2 subscribes 

2291 events_api.set_event_subscription(token=token2, event_id=event_id, subscribe=True) 

2292 

2293 # User1 cancels the event 

2294 events_api.cancel_event(token=token1, event_id=event_id) 

2295 

2296 process_jobs() 

2297 

2298 # Verify that the cancel notification for user2 has moderation_state_id set 

2299 with session_scope() as session: 

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

2301 

2302 notifications = session.execute(select(Notification).where(Notification.user_id == user2.id)).scalars().all() 

2303 cancel_notifs = [n for n in notifications if n.topic_action.action == "cancel"] 

2304 assert len(cancel_notifs) == 1 

2305 assert cancel_notifs[0].moderation_state_id == occurrence.moderation_state_id 

2306 

2307 

2308def test_event_update_and_cancel_notifications_not_sent_to_actor( 

2309 db, push_collector: PushCollector, moderator: Moderator 

2310): 

2311 """The user who updates or cancels an event shouldn't be notified about their own action.""" 

2312 organizer_user, organizer_token = generate_user() 

2313 attendee_user, attendee_token = generate_user() 

2314 

2315 with session_scope() as session: 

2316 create_community(session, 0, 2, "Community", [attendee_user], [], None) 

2317 

2318 event_id = events_api.create_event(token=organizer_token, title="Actor Test", approve_by=moderator).event_id 

2319 

2320 process_jobs() 

2321 

2322 # The attendee subscribes to notifications 

2323 events_api.set_event_subscription(token=attendee_token, event_id=event_id, subscribe=True) 

2324 

2325 # The organizer updates and then cancels their own event 

2326 events_api.update_event(token=organizer_token, event_id=event_id, title="Updated Title", should_notify=True) 

2327 events_api.cancel_event(token=organizer_token, event_id=event_id) 

2328 

2329 process_jobs() 

2330 

2331 # The organizer should not receive any notifications 

2332 assert push_collector.count_for_user(organizer_user.id) == 0 

2333 

2334 # But the attendee should receive both the update and cancel notifications 

2335 assert push_collector.pop_for_user(attendee_user.id).topic_action == NotificationTopicAction.event__update.display 

2336 assert push_collector.pop_for_user(attendee_user.id).topic_action == NotificationTopicAction.event__cancel.display 

2337 

2338 

2339def test_event_reminder_notification_has_moderation_state(db, push_collector: PushCollector, moderator: Moderator): 

2340 """Event reminder notifications should carry the event's moderation_state_id for deferral.""" 

2341 user1, token1 = generate_user() 

2342 user2, token2 = generate_user() 

2343 

2344 with session_scope() as session: 

2345 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

2346 

2347 # Create event starting 23 hours from now (within 24h reminder window) 

2348 start_time = now() + timedelta(hours=23) 

2349 end_time = start_time + timedelta(hours=1) 

2350 

2351 res = events_api.create_event( 

2352 token=token1, 

2353 start_date_time=local_date_time(start_time), 

2354 end_date_time=local_date_time(end_time), 

2355 approve_by=moderator, 

2356 ) 

2357 

2358 event_id = res.event_id 

2359 process_jobs() 

2360 while push_collector.count_for_user(user2.id): 2360 ↛ 2361line 2360 didn't jump to line 2361 because the condition on line 2360 was never true

2361 push_collector.pop_for_user(user2.id) 

2362 

2363 # User2 marks attendance 

2364 events_api.set_event_attendance(token=token2, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

2365 

2366 # Run the event reminder handler 

2367 send_event_reminders(empty_pb2.Empty()) 

2368 process_jobs() 

2369 

2370 # Verify that the reminder notification for user2 has moderation_state_id set 

2371 with session_scope() as session: 

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

2373 

2374 notifications = session.execute(select(Notification).where(Notification.user_id == user2.id)).scalars().all() 

2375 reminder_notifs = [n for n in notifications if n.topic_action.action == "reminder"] 

2376 assert len(reminder_notifs) == 1 

2377 assert reminder_notifs[0].moderation_state_id == occurrence.moderation_state_id 

2378 

2379 

2380def test_event_reminder_not_sent_for_cancelled_event(db, push_collector: PushCollector, moderator: Moderator): 

2381 """Event reminders should not be sent for cancelled events.""" 

2382 user1, token1 = generate_user() 

2383 user2, token2 = generate_user() 

2384 

2385 with session_scope() as session: 

2386 create_community(session, 0, 2, "Community", [user2], [], None) 

2387 

2388 # Create event starting 23 hours from now (within 24h reminder window) 

2389 start_time = now() + timedelta(hours=23) 

2390 end_time = start_time + timedelta(hours=1) 

2391 

2392 event_id = events_api.create_event( 

2393 token=token1, 

2394 start_date_time=local_date_time(start_time), 

2395 end_date_time=local_date_time(end_time), 

2396 approve_by=moderator, 

2397 ).event_id 

2398 

2399 process_jobs() 

2400 

2401 # User2 marks attendance 

2402 events_api.set_event_attendance(token=token2, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

2403 

2404 # User1 cancels the event 

2405 events_api.cancel_event(token=token1, event_id=event_id) 

2406 

2407 process_jobs() 

2408 # Drain any cancellation-related notifications so we can cleanly assert on reminders 

2409 while push_collector.count_for_user(user2.id): 

2410 push_collector.pop_for_user(user2.id) 

2411 

2412 # Run the event reminder handler 

2413 send_event_reminders(empty_pb2.Empty()) 

2414 process_jobs() 

2415 

2416 # Verify that no reminder notification was sent for user2 

2417 with session_scope() as session: 

2418 notifications = session.execute(select(Notification).where(Notification.user_id == user2.id)).scalars().all() 

2419 reminder_notifs = [n for n in notifications if n.topic_action == NotificationTopicAction.event__reminder] 

2420 assert len(reminder_notifs) == 0 

2421 

2422 

2423@pytest.mark.parametrize("invisible_field", ["deleted_at", "banned_at", "shadowed_at"]) 

2424def test_event_reminder_not_sent_for_invisible_attendee( 

2425 db, push_collector: PushCollector, moderator: Moderator, invisible_field 

2426): 

2427 user1, token1 = generate_user() 

2428 user2, token2 = generate_user() 

2429 

2430 with session_scope() as session: 

2431 create_community(session, 0, 2, "Community", [user2], [], None) 

2432 

2433 start_time = now() + timedelta(hours=23) 

2434 end_time = start_time + timedelta(hours=1) 

2435 

2436 event_id = events_api.create_event( 

2437 token=token1, 

2438 start_date_time=local_date_time(start_time), 

2439 end_date_time=local_date_time(end_time), 

2440 approve_by=moderator, 

2441 ).event_id 

2442 

2443 process_jobs() 

2444 

2445 events_api.set_event_attendance(token=token2, event_id=event_id, attendance_state=events_pb2.ATTENDANCE_STATE_GOING) 

2446 

2447 with session_scope() as session: 

2448 session.execute(update(User).where(User.id == user2.id).values({invisible_field: now()})) 

2449 

2450 send_event_reminders(empty_pb2.Empty()) 

2451 process_jobs() 

2452 

2453 with session_scope() as session: 

2454 notifications = session.execute(select(Notification).where(Notification.user_id == user2.id)).scalars().all() 

2455 reminder_notifs = [n for n in notifications if n.topic_action == NotificationTopicAction.event__reminder] 

2456 assert len(reminder_notifs) == 0 

2457 

2458 

2459def test_ListEventOccurrences_does_not_leak_other_events(db, moderator: Moderator): 

2460 """ListEventOccurrences should only return occurrences for the requested event, not other events.""" 

2461 user1, token1 = generate_user() 

2462 user2, token2 = generate_user() 

2463 

2464 with session_scope() as session: 

2465 c_id = create_community(session, 0, 2, "Community", [user1, user2], [], None).id 

2466 

2467 start = now() 

2468 

2469 # User1 creates event A with 3 occurrences 

2470 event_a_ids = [ 

2471 events_api.create_event( 

2472 token=token1, 

2473 title="Event A", 

2474 content="Content A.", 

2475 parent_community_id=c_id, 

2476 start_date_time=local_date_time(start + timedelta(hours=1)), 

2477 end_date_time=local_date_time(start + timedelta(hours=1.5)), 

2478 approve_by=moderator, 

2479 ).event_id 

2480 ] 

2481 

2482 for i in range(2): 

2483 event_a_ids.append( 

2484 events_api.schedule_event( 

2485 token=token1, 

2486 event_id=event_a_ids[-1], 

2487 content=f"A occurrence {i}", 

2488 start_date_time=local_date_time(start + timedelta(hours=2 + i)), 

2489 end_date_time=local_date_time(start + timedelta(hours=2.5 + i)), 

2490 ).event_id 

2491 ) 

2492 

2493 # User2 creates event B with 2 occurrences 

2494 event_b_ids = [ 

2495 events_api.create_event( 

2496 token=token2, 

2497 title="Event B", 

2498 content="Content B.", 

2499 parent_community_id=c_id, 

2500 start_date_time=local_date_time(start + timedelta(hours=10)), 

2501 end_date_time=local_date_time(start + timedelta(hours=10.5)), 

2502 approve_by=moderator, 

2503 ).event_id 

2504 ] 

2505 

2506 event_b_ids.append( 

2507 events_api.schedule_event( 

2508 token=token2, 

2509 event_id=event_b_ids[-1], 

2510 content="B occurrence 1", 

2511 start_date_time=local_date_time(start + timedelta(hours=11)), 

2512 end_date_time=local_date_time(start + timedelta(hours=11.5)), 

2513 ).event_id 

2514 ) 

2515 

2516 # List occurrences for event A — should only get event A's 3 occurrences 

2517 listed_occurrences = events_api.list_event_occurrences(token=token1, event_id=event_a_ids[-1]).events 

2518 returned_ids = [e.event_id for e in listed_occurrences] 

2519 assert sorted(returned_ids) == sorted(event_a_ids) 

2520 

2521 # List occurrences for event B — should only get event B's 2 occurrences 

2522 listed_occurrences = events_api.list_event_occurrences(token=token2, event_id=event_b_ids[-1]).events 

2523 returned_ids = [e.event_id for e in listed_occurrences] 

2524 assert sorted(returned_ids) == sorted(event_b_ids) 

2525 

2526 

2527def test_event_comment_notification_has_moderation_state(db, push_collector: PushCollector, moderator: Moderator): 

2528 """Event comment notifications should carry the comment's moderation_state_id for deferral.""" 

2529 user1, token1 = generate_user() 

2530 user2, token2 = generate_user() 

2531 

2532 with session_scope() as session: 

2533 c_id = create_community(session, 0, 2, "Community", [user2], [], None).id 

2534 

2535 res = events_api.create_event(token=token1, parent_community_id=c_id, approve_by=moderator) 

2536 event_id = res.event_id 

2537 thread_id = res.thread.thread_id 

2538 

2539 process_jobs() 

2540 while push_collector.count_for_user(user1.id): 2540 ↛ 2541line 2540 didn't jump to line 2541 because the condition on line 2540 was never true

2541 push_collector.pop_for_user(user1.id) 

2542 

2543 # User1 subscribes (creator is auto-subscribed, but let's be explicit) 

2544 events_api.set_event_subscription(token=token1, event_id=event_id, subscribe=True) 

2545 

2546 # User2 posts a top-level comment on the event thread 

2547 with threads_session(token2) as api: 

2548 comment_thread_id = api.PostReply( 

2549 threads_pb2.PostReplyReq(thread_id=thread_id, content="Hello event!") 

2550 ).thread_id 

2551 

2552 process_jobs() 

2553 

2554 # The comment notification for user1 should be gated on the comment's own moderation_state_id 

2555 comment_db_id = comment_thread_id // 10 

2556 with session_scope() as session: 

2557 comment = session.execute(select(Comment).where(Comment.id == comment_db_id)).scalar_one() 

2558 

2559 notifications = session.execute(select(Notification).where(Notification.user_id == user1.id)).scalars().all() 

2560 comment_notifs = [n for n in notifications if n.topic_action.action == "comment"] 

2561 assert len(comment_notifs) == 1 

2562 assert comment_notifs[0].moderation_state_id == comment.moderation_state_id 

2563 

2564 

2565def test_event_thread_reply_notification_has_moderation_state(db, push_collector: PushCollector, moderator: Moderator): 

2566 """Event thread reply notifications should carry the reply's moderation_state_id for deferral.""" 

2567 user1, token1 = generate_user() 

2568 user2, token2 = generate_user() 

2569 user3, token3 = generate_user() 

2570 

2571 with session_scope() as session: 

2572 c_id = create_community(session, 0, 2, "Community", [user2, user3], [], None).id 

2573 

2574 res = events_api.create_event(token=token1, approve_by=moderator) 

2575 event_id = res.event_id 

2576 thread_id = res.thread.thread_id 

2577 

2578 process_jobs() 

2579 while push_collector.count_for_user(user1.id): 2579 ↛ 2580line 2579 didn't jump to line 2580 because the condition on line 2579 was never true

2580 push_collector.pop_for_user(user1.id) 

2581 

2582 # User2 posts a top-level comment 

2583 with threads_session(token2) as api: 

2584 comment_thread_id = api.PostReply( 

2585 threads_pb2.PostReplyReq(thread_id=thread_id, content="Top-level comment") 

2586 ).thread_id 

2587 

2588 process_jobs() 

2589 while push_collector.count_for_user(user1.id): 2589 ↛ 2590line 2589 didn't jump to line 2590 because the condition on line 2589 was never true

2590 push_collector.pop_for_user(user1.id) 

2591 

2592 # User3 replies to user2's comment (depth=2 reply) 

2593 with threads_session(token3) as api: 

2594 nested_reply_thread_id = api.PostReply( 

2595 threads_pb2.PostReplyReq(thread_id=comment_thread_id, content="Nested reply") 

2596 ).thread_id 

2597 

2598 process_jobs() 

2599 

2600 # The nested reply notification for user2 should be gated on the reply's own moderation_state_id 

2601 nested_reply_db_id = nested_reply_thread_id // 10 

2602 with session_scope() as session: 

2603 nested_reply = session.execute(select(Reply).where(Reply.id == nested_reply_db_id)).scalar_one() 

2604 

2605 notifications = session.execute(select(Notification).where(Notification.user_id == user2.id)).scalars().all() 

2606 reply_notifs = [n for n in notifications if n.topic_action.action == "reply"] 

2607 assert len(reply_notifs) == 1 

2608 assert reply_notifs[0].moderation_state_id == nested_reply.moderation_state_id