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

780 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-26 11:50 +0000

1import html 

2import json 

3import re 

4from datetime import timedelta 

5from unittest.mock import Mock, patch 

6from urllib.parse import parse_qs, urlparse 

7 

8import grpc 

9import pytest 

10from google.protobuf import empty_pb2, timestamp_pb2 

11from sqlalchemy import select, update 

12 

13from couchers.config import config 

14from couchers.constants import DATETIME_INFINITY 

15from couchers.context import make_background_user_context 

16from couchers.crypto import b64decode 

17from couchers.db import session_scope 

18from couchers.jobs.handlers import check_expo_push_receipts 

19from couchers.jobs.worker import process_job 

20from couchers.models import ( 

21 DeviceType, 

22 HostingStatus, 

23 MeetupStatus, 

24 Notification, 

25 NotificationDelivery, 

26 NotificationDeliveryType, 

27 NotificationTopicAction, 

28 PushNotificationDeliveryAttempt, 

29 PushNotificationDeliveryOutcome, 

30 PushNotificationPlatform, 

31 PushNotificationSubscription, 

32 User, 

33) 

34from couchers.notifications.background import handle_notification 

35from couchers.notifications.expo_api import get_expo_push_receipts 

36from couchers.notifications.notify import notify 

37from couchers.notifications.settings import get_topic_actions_by_delivery_type 

38from couchers.proto import ( 

39 api_pb2, 

40 auth_pb2, 

41 conversations_pb2, 

42 editor_pb2, 

43 events_pb2, 

44 notification_data_pb2, 

45 notifications_pb2, 

46) 

47from couchers.proto.internal import jobs_pb2, unsubscribe_pb2 

48from couchers.servicers.api import user_model_to_pb 

49from couchers.utils import not_none, now 

50from tests.fixtures.db import generate_user 

51from tests.fixtures.misc import EmailCollector, PushCollector, process_jobs 

52from tests.fixtures.sessions import ( 

53 api_session, 

54 auth_api_session, 

55 conversations_session, 

56 notifications_session, 

57 real_editor_session, 

58) 

59 

60 

61@pytest.fixture(autouse=True) 

62def _(testconfig): 

63 pass 

64 

65 

66@pytest.mark.parametrize("enabled", [True, False]) 

67def test_SetNotificationSettings_preferences_respected_editable(db, enabled): 

68 user, token = generate_user() 

69 

70 # enable a notification type and check it gets delivered 

71 topic_action = NotificationTopicAction.badge__add 

72 

73 with notifications_session(token) as notifications: 

74 notifications.SetNotificationSettings( 

75 notifications_pb2.SetNotificationSettingsReq( 

76 preferences=[ 

77 notifications_pb2.SingleNotificationPreference( 

78 topic=topic_action.topic, 

79 action=topic_action.action, 

80 delivery_method="push", 

81 enabled=enabled, 

82 ) 

83 ], 

84 ) 

85 ) 

86 

87 with session_scope() as session: 

88 notify( 

89 session, 

90 user_id=user.id, 

91 topic_action=topic_action, 

92 key="", 

93 data=notification_data_pb2.BadgeAdd( 

94 badge_id="volunteer", 

95 badge_name="Active Volunteer", 

96 badge_description="This user is an active volunteer for Couchers.org", 

97 ), 

98 ) 

99 

100 process_job() 

101 

102 with session_scope() as session: 

103 deliv = session.execute( 

104 select(NotificationDelivery) 

105 .join(Notification, Notification.id == NotificationDelivery.notification_id) 

106 .where(Notification.user_id == user.id) 

107 .where(Notification.topic_action == topic_action) 

108 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.push) 

109 ).scalar_one_or_none() 

110 

111 if enabled: 

112 assert deliv is not None 

113 else: 

114 assert deliv is None 

115 

116 

117def test_SetNotificationSettings_preferences_not_editable(db): 

118 user, token = generate_user() 

119 

120 # enable a notification type and check it gets delivered 

121 topic_action = NotificationTopicAction.password_reset__start 

122 

123 with notifications_session(token) as notifications: 

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

125 notifications.SetNotificationSettings( 

126 notifications_pb2.SetNotificationSettingsReq( 

127 preferences=[ 

128 notifications_pb2.SingleNotificationPreference( 

129 topic=topic_action.topic, 

130 action=topic_action.action, 

131 delivery_method="push", 

132 enabled=False, 

133 ) 

134 ], 

135 ) 

136 ) 

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

138 assert e.value.details() == "That notification preference is not user editable." 

139 

140 

141def test_unsubscribe(db, email_collector: EmailCollector): 

142 # this is the ugliest test i've written 

143 

144 user, token = generate_user() 

145 

146 topic_action = NotificationTopicAction.badge__add 

147 

148 # first enable email notifs 

149 with notifications_session(token) as notifications: 

150 notifications.SetNotificationSettings( 

151 notifications_pb2.SetNotificationSettingsReq( 

152 preferences=[ 

153 notifications_pb2.SingleNotificationPreference( 

154 topic=topic_action.topic, 

155 action=topic_action.action, 

156 delivery_method=method, 

157 enabled=enabled, 

158 ) 

159 for method, enabled in [("email", True), ("digest", False), ("push", False)] 

160 ], 

161 ) 

162 ) 

163 

164 with session_scope() as session: 

165 notify( 

166 session, 

167 user_id=user.id, 

168 topic_action=topic_action, 

169 key="", 

170 data=notification_data_pb2.BadgeAdd( 

171 badge_id="volunteer", 

172 badge_name="Active Volunteer", 

173 badge_description="This user is an active volunteer for Couchers.org", 

174 ), 

175 ) 

176 

177 email = email_collector.pop_for_recipient(user.email, last=True) 

178 

179 # very ugly 

180 # http://localhost:3000/quick-link?payload=CAEiGAoOZnJpZW5kX3JlcXVlc3QSBmFjY2VwdA==&sig=BQdk024NTATm8zlR0krSXTBhP5U9TlFv7VhJeIHZtUg= 

181 for link in re.findall(r'<a href="(.*?)"', email.html): 181 ↛ 202line 181 didn't jump to line 202 because the loop on line 181 didn't complete

182 if "payload" not in link: 

183 continue 

184 print(link) 

185 url_parts = urlparse(html.unescape(link)) 

186 params = parse_qs(url_parts.query) 

187 print(params["payload"][0]) 

188 payload = unsubscribe_pb2.UnsubscribePayload.FromString(b64decode(params["payload"][0])) 

189 if payload.HasField("topic_action"): 189 ↛ 181line 189 didn't jump to line 181 because the condition on line 189 was always true

190 with auth_api_session() as (auth_api, metadata_interceptor): 

191 assert ( 

192 auth_api.Unsubscribe( 

193 auth_pb2.UnsubscribeReq( 

194 payload=b64decode(params["payload"][0]), 

195 sig=b64decode(params["sig"][0]), 

196 ) 

197 ).response 

198 == "You've been unsubscribed from email notifications of that type." 

199 ) 

200 break 

201 else: 

202 raise Exception("Didn't find link") 

203 

204 with notifications_session(token) as notifications: 

205 res = notifications.GetNotificationSettings(notifications_pb2.GetNotificationSettingsReq()) 

206 

207 for group in res.groups: 

208 for topic in group.topics: 

209 for item in topic.items: 

210 if topic == topic_action.topic and item == topic_action.action: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true

211 assert not item.email 

212 

213 with session_scope() as session: 

214 notify( 

215 session, 

216 user_id=user.id, 

217 topic_action=topic_action, 

218 key="", 

219 data=notification_data_pb2.BadgeAdd( 

220 badge_id="volunteer", 

221 badge_name="Active Volunteer", 

222 badge_description="This user is an active volunteer for Couchers.org", 

223 ), 

224 ) 

225 

226 assert email_collector.count_for_recipient(user.email) == 0 

227 

228 

229def test_unsubscribe_do_not_email(db, email_collector: EmailCollector, moderator): 

230 user, token = generate_user() 

231 

232 _, token2 = generate_user(complete_profile=True) 

233 with api_session(token2) as api: 

234 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user.id)) 

235 res = api.ListFriendRequests(empty_pb2.Empty()) 

236 fr_id = res.sent[0].friend_request_id 

237 

238 # Moderator approves the friend request, which triggers the notification email 

239 moderator.approve_friend_request(fr_id) 

240 

241 email = email_collector.pop_for_recipient(user.email, last=True) 

242 assert email.recipient == user.email 

243 # very ugly 

244 # http://localhost:3000/quick-link?payload=CAEiGAoOZnJpZW5kX3JlcXVlc3QSBmFjY2VwdA==&sig=BQdk024NTATm8zlR0krSXTBhP5U9TlFv7VhJeIHZtUg= 

245 for link in re.findall(r'<a href="(.*?)"', email.html): 245 ↛ 266line 245 didn't jump to line 266 because the loop on line 245 didn't complete

246 if "payload" not in link: 

247 continue 

248 print(link) 

249 url_parts = urlparse(html.unescape(link)) 

250 params = parse_qs(url_parts.query) 

251 print(params["payload"][0]) 

252 payload = unsubscribe_pb2.UnsubscribePayload.FromString(b64decode(params["payload"][0])) 

253 if payload.HasField("do_not_email"): 

254 with auth_api_session() as (auth_api, metadata_interceptor): 

255 assert ( 

256 auth_api.Unsubscribe( 

257 auth_pb2.UnsubscribeReq( 

258 payload=b64decode(params["payload"][0]), 

259 sig=b64decode(params["sig"][0]), 

260 ) 

261 ).response 

262 == "You will not receive any non-security emails, and your hosting status has been turned off. You may still receive the newsletter, and need to unsubscribe from it separately." 

263 ) 

264 break 

265 else: 

266 raise Exception("Didn't find link") 

267 

268 _, token3 = generate_user(complete_profile=True) 

269 with api_session(token3) as api: 

270 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user.id)) 

271 res = api.ListFriendRequests(empty_pb2.Empty()) 

272 fr_id3 = res.sent[0].friend_request_id 

273 

274 # Approving this friend request should NOT send an email since user has do_not_email set 

275 moderator.approve_friend_request(fr_id3) 

276 

277 assert email_collector.count_for_recipient(user.email) == 0 

278 

279 with session_scope() as session: 

280 user_ = session.execute(select(User).where(User.id == user.id)).scalar_one() 

281 assert user_.do_not_email 

282 

283 

284def test_get_do_not_email(db): 

285 _, token = generate_user() 

286 

287 with session_scope() as session: 

288 user = session.execute(select(User)).scalar_one() 

289 user.do_not_email = False 

290 

291 with notifications_session(token) as notifications: 

292 res = notifications.GetNotificationSettings(notifications_pb2.GetNotificationSettingsReq()) 

293 assert not res.do_not_email_enabled 

294 

295 with session_scope() as session: 

296 user = session.execute(select(User)).scalar_one() 

297 user.do_not_email = True 

298 user.hosting_status = HostingStatus.cant_host 

299 user.meetup_status = MeetupStatus.does_not_want_to_meetup 

300 

301 with notifications_session(token) as notifications: 

302 res = notifications.GetNotificationSettings(notifications_pb2.GetNotificationSettingsReq()) 

303 assert res.do_not_email_enabled 

304 

305 

306def test_set_do_not_email(db): 

307 _, token = generate_user() 

308 

309 with session_scope() as session: 

310 user = session.execute(select(User)).scalar_one() 

311 user.do_not_email = False 

312 user.hosting_status = HostingStatus.can_host 

313 user.meetup_status = MeetupStatus.wants_to_meetup 

314 

315 with notifications_session(token) as notifications: 

316 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=False)) 

317 

318 with session_scope() as session: 

319 user = session.execute(select(User)).scalar_one() 

320 assert not user.do_not_email 

321 

322 with notifications_session(token) as notifications: 

323 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=True)) 

324 

325 with session_scope() as session: 

326 user = session.execute(select(User)).scalar_one() 

327 assert user.do_not_email 

328 assert user.hosting_status == HostingStatus.cant_host 

329 assert user.meetup_status == MeetupStatus.does_not_want_to_meetup 

330 

331 with notifications_session(token) as notifications: 

332 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=False)) 

333 

334 with session_scope() as session: 

335 user = session.execute(select(User)).scalar_one() 

336 assert not user.do_not_email 

337 

338 

339def test_list_notifications(db, push_collector: PushCollector, moderator): 

340 user1, token1 = generate_user() 

341 user2, token2 = generate_user() 

342 

343 with api_session(token2) as api: 

344 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id)) 

345 res = api.ListFriendRequests(empty_pb2.Empty()) 

346 fr_id = res.sent[0].friend_request_id 

347 

348 # Moderator approves the friend request so the notification is sent 

349 moderator.approve_friend_request(fr_id) 

350 

351 with notifications_session(token1) as notifications: 

352 res = notifications.ListNotifications(notifications_pb2.ListNotificationsReq()) 

353 assert len(res.notifications) == 1 

354 

355 n = res.notifications[0] 

356 

357 assert n.topic == "friend_request" 

358 assert n.action == "create" 

359 assert n.key == str(user2.id) 

360 assert n.title == f"Friend request from {user2.name}" 

361 assert n.body == f"{user2.name} wants to be your friend." 

362 assert n.icon.startswith("http://localhost:5001/img/thumbnail/") 

363 assert n.url == f"http://localhost:3000/connections/friends/?from={user2.id}" 

364 

365 with conversations_session(token2) as c: 

366 res = c.CreateGroupChat(conversations_pb2.CreateGroupChatReq(recipient_user_ids=[user1.id])) 

367 group_chat_id = res.group_chat_id 

368 moderator.approve_group_chat(group_chat_id) 

369 for i in range(17): 

370 c.SendMessage(conversations_pb2.SendMessageReq(group_chat_id=group_chat_id, text=f"Test message {i}")) 

371 

372 process_jobs() 

373 

374 all_notifs = [] 

375 with notifications_session(token1) as notifications: 

376 page_token = None 

377 for _ in range(100): 377 ↛ 390line 377 didn't jump to line 390

378 res = notifications.ListNotifications( 

379 notifications_pb2.ListNotificationsReq( 

380 page_size=5, 

381 page_token=page_token, 

382 ) 

383 ) 

384 assert len(res.notifications) == 5 or not res.next_page_token 

385 all_notifs += res.notifications 

386 page_token = res.next_page_token 

387 if not page_token: 

388 break 

389 

390 bodys = [f"Test message {16 - i}" for i in range(17)] + [f"{user2.name} wants to be your friend."] 

391 assert bodys == [n.body for n in all_notifs] 

392 

393 

394def test_notifications_seen(db, push_collector: PushCollector, moderator): 

395 user1, token1 = generate_user() 

396 user2, token2 = generate_user() 

397 user3, token3 = generate_user() 

398 user4, token4 = generate_user() 

399 

400 with api_session(token2) as api: 

401 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id)) 

402 res = api.ListFriendRequests(empty_pb2.Empty()) 

403 fr_id2 = res.sent[0].friend_request_id 

404 

405 with api_session(token3) as api: 

406 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id)) 

407 res = api.ListFriendRequests(empty_pb2.Empty()) 

408 fr_id3 = res.sent[0].friend_request_id 

409 

410 # Moderator approves the friend requests so notifications are sent 

411 moderator.approve_friend_request(fr_id2) 

412 moderator.approve_friend_request(fr_id3) 

413 

414 with notifications_session(token1) as notifications, api_session(token1) as api: 

415 res = notifications.ListNotifications(notifications_pb2.ListNotificationsReq()) 

416 assert len(res.notifications) == 2 

417 assert [n.is_seen for n in res.notifications] == [False, False] 

418 notification_ids = [n.notification_id for n in res.notifications] 

419 # should be listed desc time 

420 assert notification_ids[0] > notification_ids[1] 

421 

422 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 2 

423 

424 with api_session(token4) as api: 

425 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id)) 

426 res = api.ListFriendRequests(empty_pb2.Empty()) 

427 fr_id4 = res.sent[0].friend_request_id 

428 

429 # Moderator approves the friend request so notification is sent 

430 moderator.approve_friend_request(fr_id4) 

431 

432 with notifications_session(token1) as notifications, api_session(token1) as api: 

433 # mark everything before just the last one as seen (pretend we didn't load the last one yet in the api) 

434 notifications.MarkAllNotificationsSeen( 

435 notifications_pb2.MarkAllNotificationsSeenReq(latest_notification_id=notification_ids[0]) 

436 ) 

437 

438 # last one is still unseen 

439 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 1 

440 

441 # mark the first one unseen 

442 notifications.MarkNotificationSeen( 

443 notifications_pb2.MarkNotificationSeenReq(notification_id=notification_ids[1], set_seen=False) 

444 ) 

445 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 2 

446 

447 # mark the last one seen 

448 res = notifications.ListNotifications(notifications_pb2.ListNotificationsReq()) 

449 assert len(res.notifications) == 3 

450 assert [n.is_seen for n in res.notifications] == [False, True, False] 

451 notification_ids2 = [n.notification_id for n in res.notifications] 

452 

453 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 2 

454 

455 notifications.MarkNotificationSeen( 

456 notifications_pb2.MarkNotificationSeenReq(notification_id=notification_ids2[0], set_seen=True) 

457 ) 

458 

459 res = notifications.ListNotifications(notifications_pb2.ListNotificationsReq()) 

460 assert len(res.notifications) == 3 

461 assert [n.is_seen for n in res.notifications] == [True, True, False] 

462 

463 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 1 

464 

465 

466def test_unseen_notification_count_excludes_ums_hidden(db, moderator): 

467 user1, token1 = generate_user() 

468 user2, token2 = generate_user() 

469 

470 with api_session(token2) as api: 

471 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id)) 

472 res = api.ListFriendRequests(empty_pb2.Empty()) 

473 fr_id = res.sent[0].friend_request_id 

474 

475 # Before moderation the friend request is shadowed, so the resulting notification 

476 # is not visible to the recipient and must not contribute to their unseen count. 

477 with api_session(token1) as api: 

478 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 0 

479 

480 moderator.approve_friend_request(fr_id) 

481 

482 with api_session(token1) as api: 

483 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 1 

484 

485 

486def test_GetVapidPublicKey(db): 

487 _, token = generate_user() 

488 

489 with notifications_session(token) as notifications: 

490 assert ( 

491 notifications.GetVapidPublicKey(empty_pb2.Empty()).vapid_public_key 

492 == "BApMo2tGuon07jv-pEaAKZmVo6E_d4HfcdDeV6wx2k9wV8EovJ0ve00bdLzZm9fizDrGZXRYJFqCcRJUfBcgA0A" 

493 ) 

494 

495 

496def test_RegisterPushNotificationSubscription(db): 

497 _, token = generate_user() 

498 

499 subscription_info = { 

500 "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABmW2_iYKVyZRJPhAhktbkXd6Bc8zjIUvtVi5diYL7ZYn8FHka94kIdF46Mp8DwCDWlACnbKOEo97ikaa7JYowGLiGz3qsWL7Vo19LaV4I71mUDUOIKxWIsfp_kM77MlRJQKDUddv-sYyiffOyg63d1lnc_BMIyLXt69T5SEpfnfWTNb6I", 

501 "expirationTime": None, 

502 "keys": { 

503 "auth": "TnuEJ1OdfEkf6HKcUovl0Q", 

504 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0", 

505 }, 

506 } 

507 

508 with notifications_session(token) as notifications: 

509 res = notifications.RegisterPushNotificationSubscription( 

510 notifications_pb2.RegisterPushNotificationSubscriptionReq( 

511 full_subscription_json=json.dumps(subscription_info), 

512 ) 

513 ) 

514 

515 

516def test_RegisterPushNotificationSubscription_invalid_endpoint(db): 

517 _, token = generate_user() 

518 

519 subscription_info = { 

520 "endpoint": "https://permanently-removed.invalid/some-id", 

521 "expirationTime": None, 

522 "keys": { 

523 "auth": "TnuEJ1OdfEkf6HKcUovl0Q", 

524 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0", 

525 }, 

526 } 

527 

528 with notifications_session(token) as notifications: 

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

530 notifications.RegisterPushNotificationSubscription( 

531 notifications_pb2.RegisterPushNotificationSubscriptionReq( 

532 full_subscription_json=json.dumps(subscription_info), 

533 ) 

534 ) 

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

536 

537 

538def test_SendTestPushNotification(db, push_collector: PushCollector): 

539 user, token = generate_user() 

540 

541 with notifications_session(token) as notifications: 

542 notifications.SendTestPushNotification(empty_pb2.Empty()) 

543 

544 assert push_collector.count_for_user(user.id) == 1 

545 push = push_collector.pop_for_user(user.id, last=True) 

546 assert push.content.title == "Push notifications test" 

547 assert push.content.body == "If you see this, then it's working :)" 

548 

549 

550def test_SendBlogPostNotification(db, email_collector: EmailCollector, push_collector: PushCollector): 

551 super_user, super_token = generate_user(is_superuser=True) 

552 

553 user1, user1_token = generate_user() 

554 # enabled email 

555 user2, user2_token = generate_user() 

556 # disabled push 

557 user3, user3_token = generate_user() 

558 

559 topic_action = NotificationTopicAction.general__new_blog_post 

560 

561 with notifications_session(user2_token) as notifications: 

562 notifications.SetNotificationSettings( 

563 notifications_pb2.SetNotificationSettingsReq( 

564 preferences=[ 

565 notifications_pb2.SingleNotificationPreference( 

566 topic=topic_action.topic, 

567 action=topic_action.action, 

568 delivery_method="email", 

569 enabled=True, 

570 ) 

571 ], 

572 ) 

573 ) 

574 

575 with notifications_session(user3_token) as notifications: 

576 notifications.SetNotificationSettings( 

577 notifications_pb2.SetNotificationSettingsReq( 

578 preferences=[ 

579 notifications_pb2.SingleNotificationPreference( 

580 topic=topic_action.topic, 

581 action=topic_action.action, 

582 delivery_method="push", 

583 enabled=False, 

584 ) 

585 ], 

586 ) 

587 ) 

588 

589 with real_editor_session(super_token) as editor_api: 

590 editor_api.SendBlogPostNotification( 

591 editor_pb2.SendBlogPostNotificationReq( 

592 title="Couchers.org v0.9.9 Release Notes", 

593 blurb="Read about last major updates before v1!", 

594 url="https://couchers.org/blog/2025/05/11/v0.9.9-release", 

595 ) 

596 ) 

597 

598 email = email_collector.pop_for_recipient(user2.email, last=True) 

599 assert email.recipient == user2.email 

600 assert "Couchers.org v0.9.9 Release Notes" in email.html 

601 assert "Couchers.org v0.9.9 Release Notes" in email.plain 

602 assert "Read about last major updates before v1!" in email.html 

603 assert "Read about last major updates before v1!" in email.plain 

604 assert "https://couchers.org/blog/2025/05/11/v0.9.9-release" in email.html 

605 assert "https://couchers.org/blog/2025/05/11/v0.9.9-release" in email.plain 

606 

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

608 assert push.content.title == "New blog post: Couchers.org v0.9.9 Release Notes" 

609 assert push.content.body == "Read about last major updates before v1!" 

610 assert push.content.action_url == "https://couchers.org/blog/2025/05/11/v0.9.9-release" 

611 

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

613 assert push.content.title == "New blog post: Couchers.org v0.9.9 Release Notes" 

614 assert push.content.body == "Read about last major updates before v1!" 

615 assert push.content.action_url == "https://couchers.org/blog/2025/05/11/v0.9.9-release" 

616 

617 assert push_collector.count_for_user(user3.id) == 0 

618 

619 

620def test_get_topic_actions_by_delivery_type(db): 

621 user, token = generate_user() 

622 

623 # these are enabled by default 

624 assert NotificationDeliveryType.push in NotificationTopicAction.reference__receive_friend.defaults 

625 assert NotificationDeliveryType.push in NotificationTopicAction.host_request__accept.defaults 

626 

627 # these are disabled by default 

628 assert NotificationDeliveryType.push not in NotificationTopicAction.event__create_any.defaults 

629 assert NotificationDeliveryType.push not in NotificationTopicAction.discussion__create.defaults 

630 

631 with notifications_session(token) as notifications: 

632 notifications.SetNotificationSettings( 

633 notifications_pb2.SetNotificationSettingsReq( 

634 preferences=[ 

635 notifications_pb2.SingleNotificationPreference( 

636 topic=NotificationTopicAction.reference__receive_friend.topic, 

637 action=NotificationTopicAction.reference__receive_friend.action, 

638 delivery_method="push", 

639 enabled=False, 

640 ), 

641 notifications_pb2.SingleNotificationPreference( 

642 topic=NotificationTopicAction.event__create_any.topic, 

643 action=NotificationTopicAction.event__create_any.action, 

644 delivery_method="push", 

645 enabled=True, 

646 ), 

647 ], 

648 ) 

649 ) 

650 

651 with session_scope() as session: 

652 deliver = get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push) 

653 assert NotificationTopicAction.reference__receive_friend not in deliver 

654 assert NotificationTopicAction.host_request__accept in deliver 

655 assert NotificationTopicAction.event__create_any in deliver 

656 assert NotificationTopicAction.discussion__create not in deliver 

657 assert NotificationTopicAction.account_deletion__start in deliver 

658 

659 

660def test_event_reminder_email_sent(db, email_collector: EmailCollector): 

661 user, token = generate_user() 

662 title = "Board Game Night" 

663 

664 # Saturday, July 5, 2025 at 4:40:00 AM UTC 

665 start_time = timestamp_pb2.Timestamp(seconds=1751690400) 

666 timezone = "Etc/GMT-2" # Etc/GMT-2 means GMT+2 

667 expected_time_str = "Saturday, July 5, 6:40 AM" 

668 

669 with session_scope() as session: 

670 user_in_session = session.get_one(User, user.id) 

671 

672 notify( 

673 session, 

674 user_id=user.id, 

675 topic_action=NotificationTopicAction.event__reminder, 

676 key="", 

677 data=notification_data_pb2.EventReminder( 

678 event=events_pb2.Event( 

679 event_id=1, slug="board-game-night", title=title, start_time=start_time, timezone=timezone 

680 ), 

681 user=user_model_to_pb(user_in_session, session, make_background_user_context(user_id=user.id)), 

682 ), 

683 ) 

684 

685 email = email_collector.pop_for_recipient(user.email, last=True) 

686 assert email.recipient == user.email 

687 assert title in email.html 

688 assert title in email.plain 

689 assert expected_time_str in email.html 

690 assert expected_time_str in email.plain 

691 

692 

693def test_RegisterMobilePushNotificationSubscription(db): 

694 user, token = generate_user() 

695 

696 with notifications_session(token) as notifications: 

697 notifications.RegisterMobilePushNotificationSubscription( 

698 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

699 token="ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", 

700 device_name="My iPhone", 

701 device_type="ios", 

702 ) 

703 ) 

704 

705 # Check subscription was created 

706 with session_scope() as session: 

707 sub = session.execute( 

708 select(PushNotificationSubscription).where(PushNotificationSubscription.user_id == user.id) 

709 ).scalar_one() 

710 assert sub.platform == PushNotificationPlatform.expo 

711 assert sub.token == "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]" 

712 assert sub.device_name == "My iPhone" 

713 assert sub.device_type == DeviceType.ios 

714 assert sub.disabled_at == DATETIME_INFINITY 

715 

716 

717def test_RegisterMobilePushNotificationSubscription_android(db): 

718 user, token = generate_user() 

719 

720 with notifications_session(token) as notifications: 

721 notifications.RegisterMobilePushNotificationSubscription( 

722 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

723 token="ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]", 

724 device_name="My Android", 

725 device_type="android", 

726 ) 

727 ) 

728 

729 with session_scope() as session: 

730 sub = session.execute( 

731 select(PushNotificationSubscription).where(PushNotificationSubscription.user_id == user.id) 

732 ).scalar_one() 

733 assert sub.platform == PushNotificationPlatform.expo 

734 assert sub.device_type == DeviceType.android 

735 

736 

737def test_RegisterMobilePushNotificationSubscription_no_device_type(db): 

738 user, token = generate_user() 

739 

740 with notifications_session(token) as notifications: 

741 notifications.RegisterMobilePushNotificationSubscription( 

742 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

743 token="ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]", 

744 ) 

745 ) 

746 

747 with session_scope() as session: 

748 sub = session.execute( 

749 select(PushNotificationSubscription).where(PushNotificationSubscription.user_id == user.id) 

750 ).scalar_one() 

751 assert sub.platform == PushNotificationPlatform.expo 

752 assert sub.device_name is None 

753 assert sub.device_type is None 

754 

755 

756def test_RegisterMobilePushNotificationSubscription_re_enable(db): 

757 user, token = generate_user() 

758 

759 # Create a disabled subscription directly in the DB 

760 with session_scope() as session: 

761 sub = PushNotificationSubscription( 

762 user_id=user.id, 

763 platform=PushNotificationPlatform.expo, 

764 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

765 device_name="Old Device", 

766 device_type=DeviceType.ios, 

767 ) 

768 sub.disabled_at = now() 

769 session.add(sub) 

770 session.flush() 

771 sub_id = sub.id 

772 

773 # Re-register with the same token 

774 with notifications_session(token) as notifications: 

775 notifications.RegisterMobilePushNotificationSubscription( 

776 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

777 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

778 device_name="New Device Name", 

779 device_type="android", 

780 ) 

781 ) 

782 

783 # Check subscription was re-enabled and updated 

784 with session_scope() as session: 

785 sub = session.execute( 

786 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id) 

787 ).scalar_one() 

788 assert sub.disabled_at == DATETIME_INFINITY 

789 assert sub.device_name == "New Device Name" 

790 assert sub.device_type == DeviceType.android 

791 

792 

793def test_RegisterMobilePushNotificationSubscription_already_exists(db): 

794 user, token = generate_user() 

795 

796 # Create an active subscription directly in the DB 

797 with session_scope() as session: 

798 sub = PushNotificationSubscription( 

799 user_id=user.id, 

800 platform=PushNotificationPlatform.expo, 

801 token="ExponentPushToken[existingtoken]", 

802 device_name="Existing Device", 

803 device_type=DeviceType.ios, 

804 ) 

805 session.add(sub) 

806 

807 # Try to register with the same token - should just return without error 

808 with notifications_session(token) as notifications: 

809 notifications.RegisterMobilePushNotificationSubscription( 

810 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

811 token="ExponentPushToken[existingtoken]", 

812 device_name="Different Name", 

813 ) 

814 ) 

815 

816 # Check subscription was NOT modified (already active) 

817 with session_scope() as session: 

818 sub = session.execute( 

819 select(PushNotificationSubscription).where( 

820 PushNotificationSubscription.token == "ExponentPushToken[existingtoken]" 

821 ) 

822 ).scalar_one() 

823 assert sub.device_name == "Existing Device" # unchanged 

824 

825 

826def test_SendTestMobilePushNotification(db, push_collector: PushCollector): 

827 user, token = generate_user() 

828 

829 with notifications_session(token) as notifications: 

830 notifications.SendTestMobilePushNotification(empty_pb2.Empty()) 

831 

832 push = push_collector.pop_for_user(user.id, last=True) 

833 assert push.content.title == "Mobile notifications test" 

834 assert push.content.body == "If you see this on your phone, everything is wired up correctly 🎉" 

835 

836 

837def test_get_expo_push_receipts(db): 

838 mock_response = Mock() 

839 mock_response.status_code = 200 

840 mock_response.json.return_value = { 

841 "data": { 

842 "ticket-1": {"status": "ok"}, 

843 "ticket-2": {"status": "error", "details": {"error": "DeviceNotRegistered"}}, 

844 } 

845 } 

846 

847 with patch("couchers.notifications.expo_api.requests.post", return_value=mock_response) as mock_post: 

848 result = get_expo_push_receipts(["ticket-1", "ticket-2"]) 

849 

850 mock_post.assert_called_once() 

851 call_args = mock_post.call_args 

852 assert call_args[0][0] == "https://exp.host/--/api/v2/push/getReceipts" 

853 assert call_args[1]["json"] == {"ids": ["ticket-1", "ticket-2"]} 

854 

855 assert result == { 

856 "ticket-1": {"status": "ok"}, 

857 "ticket-2": {"status": "error", "details": {"error": "DeviceNotRegistered"}}, 

858 } 

859 

860 

861def test_get_expo_push_receipts_empty(db): 

862 result = get_expo_push_receipts([]) 

863 assert result == {} 

864 

865 

866def test_check_expo_push_receipts_success(db): 

867 """Test batch receipt checking with successful delivery.""" 

868 user, token = generate_user() 

869 

870 # Create a push subscription and delivery attempt (old enough to be checked) 

871 with session_scope() as session: 

872 sub = PushNotificationSubscription( 

873 user_id=user.id, 

874 platform=PushNotificationPlatform.expo, 

875 token="ExponentPushToken[testtoken123]", 

876 device_name="Test Device", 

877 device_type=DeviceType.ios, 

878 ) 

879 session.add(sub) 

880 session.flush() 

881 

882 attempt = PushNotificationDeliveryAttempt( 

883 push_notification_subscription_id=sub.id, 

884 outcome=PushNotificationDeliveryOutcome.success, 

885 status_code=200, 

886 expo_ticket_id="test-ticket-id", 

887 ) 

888 session.add(attempt) 

889 session.flush() 

890 # Make the attempt old enough to be checked (>15 min) 

891 attempt.time = now() - timedelta(minutes=20) 

892 attempt_id = attempt.id 

893 sub_id = sub.id 

894 

895 # Mock the receipt API call 

896 with patch("couchers.notifications.expo_api.requests.post") as mock_post: 

897 mock_post.return_value.status_code = 200 

898 mock_post.return_value.json.return_value = {"data": {"test-ticket-id": {"status": "ok"}}} 

899 

900 check_expo_push_receipts(empty_pb2.Empty()) 

901 

902 # Verify the attempt was updated 

903 with session_scope() as session: 

904 attempt = session.execute( 

905 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id) 

906 ).scalar_one() 

907 assert attempt.receipt_checked_at is not None 

908 assert attempt.receipt_status == "ok" 

909 assert attempt.receipt_error_code is None 

910 

911 # Subscription should still be enabled 

912 sub = session.execute( 

913 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id) 

914 ).scalar_one() 

915 assert sub.disabled_at == DATETIME_INFINITY 

916 

917 

918def test_check_expo_push_receipts_device_not_registered(db): 

919 """Test batch receipt checking with DeviceNotRegistered error disables subscription.""" 

920 user, token = generate_user() 

921 

922 # Create a push subscription and delivery attempt 

923 with session_scope() as session: 

924 sub = PushNotificationSubscription( 

925 user_id=user.id, 

926 platform=PushNotificationPlatform.expo, 

927 token="ExponentPushToken[devicegone]", 

928 device_name="Test Device", 

929 device_type=DeviceType.android, 

930 ) 

931 session.add(sub) 

932 session.flush() 

933 

934 attempt = PushNotificationDeliveryAttempt( 

935 push_notification_subscription_id=sub.id, 

936 outcome=PushNotificationDeliveryOutcome.success, 

937 status_code=200, 

938 expo_ticket_id="ticket-device-gone", 

939 ) 

940 session.add(attempt) 

941 session.flush() 

942 # Make the attempt old enough to be checked 

943 attempt.time = now() - timedelta(minutes=15) 

944 attempt_id = attempt.id 

945 sub_id = sub.id 

946 

947 # Mock the receipt API call with DeviceNotRegistered error 

948 with patch("couchers.notifications.expo_api.requests.post") as mock_post: 

949 mock_post.return_value.status_code = 200 

950 mock_post.return_value.json.return_value = { 

951 "data": { 

952 "ticket-device-gone": { 

953 "status": "error", 

954 "details": {"error": "DeviceNotRegistered"}, 

955 } 

956 } 

957 } 

958 

959 check_expo_push_receipts(empty_pb2.Empty()) 

960 

961 # Verify the attempt was updated and subscription disabled 

962 with session_scope() as session: 

963 attempt = session.execute( 

964 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id) 

965 ).scalar_one() 

966 assert attempt.receipt_checked_at is not None 

967 assert attempt.receipt_status == "error" 

968 assert attempt.receipt_error_code == "DeviceNotRegistered" 

969 

970 # Subscription should be disabled 

971 sub = session.execute( 

972 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id) 

973 ).scalar_one() 

974 assert sub.disabled_at <= now() 

975 

976 

977def test_check_expo_push_receipts_not_found(db): 

978 """Test batch receipt checking when ticket not found (expired).""" 

979 user, token = generate_user() 

980 

981 with session_scope() as session: 

982 sub = PushNotificationSubscription( 

983 user_id=user.id, 

984 platform=PushNotificationPlatform.expo, 

985 token="ExponentPushToken[notfound]", 

986 ) 

987 session.add(sub) 

988 session.flush() 

989 

990 attempt = PushNotificationDeliveryAttempt( 

991 push_notification_subscription_id=sub.id, 

992 outcome=PushNotificationDeliveryOutcome.success, 

993 status_code=200, 

994 expo_ticket_id="unknown-ticket", 

995 ) 

996 session.add(attempt) 

997 session.flush() 

998 # Make the attempt old enough to be checked 

999 attempt.time = now() - timedelta(minutes=15) 

1000 attempt_id = attempt.id 

1001 sub_id = sub.id 

1002 

1003 # Mock empty receipt response (ticket not found) 

1004 with patch("couchers.notifications.expo_api.requests.post") as mock_post: 

1005 mock_post.return_value.status_code = 200 

1006 mock_post.return_value.json.return_value = {"data": {}} 

1007 

1008 check_expo_push_receipts(empty_pb2.Empty()) 

1009 

1010 with session_scope() as session: 

1011 attempt = session.execute( 

1012 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id) 

1013 ).scalar_one() 

1014 assert attempt.receipt_checked_at is not None 

1015 assert attempt.receipt_status == "not_found" 

1016 

1017 # Subscription should still be enabled 

1018 sub = session.execute( 

1019 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id) 

1020 ).scalar_one() 

1021 assert sub.disabled_at == DATETIME_INFINITY 

1022 

1023 

1024def test_check_expo_push_receipts_skips_already_checked(db): 

1025 """Test that already-checked receipts are not re-checked.""" 

1026 user, token = generate_user() 

1027 

1028 # Create an attempt that was already checked 

1029 with session_scope() as session: 

1030 sub = PushNotificationSubscription( 

1031 user_id=user.id, 

1032 platform=PushNotificationPlatform.expo, 

1033 token="ExponentPushToken[alreadychecked]", 

1034 ) 

1035 session.add(sub) 

1036 session.flush() 

1037 

1038 attempt = PushNotificationDeliveryAttempt( 

1039 push_notification_subscription_id=sub.id, 

1040 outcome=PushNotificationDeliveryOutcome.success, 

1041 status_code=200, 

1042 expo_ticket_id="already-checked-ticket", 

1043 receipt_checked_at=now(), 

1044 receipt_status="ok", 

1045 ) 

1046 session.add(attempt) 

1047 session.flush() 

1048 # Make the attempt old enough 

1049 attempt.time = now() - timedelta(minutes=15) 

1050 

1051 # Should not call the API since the only attempt is already checked 

1052 with patch("couchers.notifications.expo_api.requests.post") as mock_post: 

1053 check_expo_push_receipts(empty_pb2.Empty()) 

1054 mock_post.assert_not_called() 

1055 

1056 

1057def test_SendDevPushNotification_success(db, push_collector: PushCollector): 

1058 """Test SendDevPushNotification sends push with all specified parameters.""" 

1059 user, token = generate_user() 

1060 

1061 # Enable dev APIs for this test 

1062 config.ENABLE_DEV_APIS = True 

1063 

1064 with notifications_session(token) as notifications: 

1065 notifications.SendDevPushNotification( 

1066 notifications_pb2.SendDevPushNotificationReq( 

1067 title="Test Dev Title", 

1068 body="Test dev notification body", 

1069 icon="https://example.com/icon.png", 

1070 url="https://example.com/action", 

1071 key="test-key", 

1072 ttl=3600, 

1073 ) 

1074 ) 

1075 

1076 push = push_collector.pop_for_user(user.id, last=True) 

1077 assert push.content.title == "Test Dev Title" 

1078 assert push.content.body == "Test dev notification body" 

1079 assert push.content.action_url == "https://example.com/action" 

1080 assert push.content.icon_url == "https://example.com/icon.png" 

1081 assert push.topic_action == "adhoc:testing" 

1082 assert push.key == "test-key" 

1083 assert push.ttl == 3600 

1084 

1085 

1086def test_SendDevPushNotification_minimal(db, push_collector: PushCollector): 

1087 """Test SendDevPushNotification with minimal parameters.""" 

1088 user, token = generate_user() 

1089 

1090 config.ENABLE_DEV_APIS = True 

1091 

1092 with notifications_session(token) as notifications: 

1093 notifications.SendDevPushNotification( 

1094 notifications_pb2.SendDevPushNotificationReq( 

1095 title="Minimal Title", 

1096 body="Minimal body", 

1097 ) 

1098 ) 

1099 

1100 push = push_collector.pop_for_user(user.id, last=True) 

1101 assert push.content.title == "Minimal Title" 

1102 assert push.content.body == "Minimal body" 

1103 assert push.topic_action == "adhoc:testing" 

1104 

1105 

1106def test_SendDevPushNotification_disabled(db, push_collector: PushCollector): 

1107 """Test SendDevPushNotification fails when ENABLE_DEV_APIS is disabled.""" 

1108 user, token = generate_user() 

1109 

1110 # Ensure dev APIs are disabled (default in tests) 

1111 config.ENABLE_DEV_APIS = False 

1112 

1113 with notifications_session(token) as notifications: 

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

1115 notifications.SendDevPushNotification( 

1116 notifications_pb2.SendDevPushNotificationReq( 

1117 title="Should Fail", 

1118 body="This should not be sent", 

1119 ) 

1120 ) 

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

1122 assert "Development APIs are not enabled" in not_none(e.value.details()) 

1123 

1124 assert push_collector.count_for_user(user.id) == 0 

1125 

1126 

1127def test_SendDevPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

1128 """Test SendDevPushNotification fails when push notifications are disabled.""" 

1129 user, token = generate_user() 

1130 

1131 config.ENABLE_DEV_APIS = True 

1132 config.PUSH_NOTIFICATIONS_ENABLED = False 

1133 

1134 with notifications_session(token) as notifications: 

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

1136 notifications.SendDevPushNotification( 

1137 notifications_pb2.SendDevPushNotificationReq( 

1138 title="Should Fail", 

1139 body="This should not be sent", 

1140 ) 

1141 ) 

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

1143 assert "Push notifications are currently disabled" in not_none(e.value.details()) 

1144 

1145 assert push_collector.count_for_user(user.id) == 0 

1146 

1147 

1148def test_check_expo_push_receipts_skips_too_recent(db): 

1149 """Test that too-recent receipts (<15 min) are not checked.""" 

1150 user, token = generate_user() 

1151 

1152 # Create a recent attempt (not old enough to check) 

1153 with session_scope() as session: 

1154 sub = PushNotificationSubscription( 

1155 user_id=user.id, 

1156 platform=PushNotificationPlatform.expo, 

1157 token="ExponentPushToken[recent]", 

1158 ) 

1159 session.add(sub) 

1160 session.flush() 

1161 

1162 attempt = PushNotificationDeliveryAttempt( 

1163 push_notification_subscription_id=sub.id, 

1164 outcome=PushNotificationDeliveryOutcome.success, 

1165 status_code=200, 

1166 expo_ticket_id="recent-ticket", 

1167 ) 

1168 session.add(attempt) 

1169 session.flush() 

1170 # Make the attempt only 5 minutes old (too recent) 

1171 attempt.time = now() - timedelta(minutes=5) 

1172 

1173 # Should not call the API since the attempt is too recent 

1174 with patch("couchers.notifications.expo_api.requests.post") as mock_post: 

1175 check_expo_push_receipts(empty_pb2.Empty()) 

1176 mock_post.assert_not_called() 

1177 

1178 

1179def test_check_expo_push_receipts_batch(db): 

1180 """Test that multiple receipts are checked in a single batch.""" 

1181 user, token = generate_user() 

1182 

1183 # Create multiple delivery attempts 

1184 attempt_ids = [] 

1185 with session_scope() as session: 

1186 sub = PushNotificationSubscription( 

1187 user_id=user.id, 

1188 platform=PushNotificationPlatform.expo, 

1189 token="ExponentPushToken[batch]", 

1190 ) 

1191 session.add(sub) 

1192 session.flush() 

1193 

1194 for i in range(3): 

1195 attempt = PushNotificationDeliveryAttempt( 

1196 push_notification_subscription_id=sub.id, 

1197 outcome=PushNotificationDeliveryOutcome.success, 

1198 status_code=200, 

1199 expo_ticket_id=f"batch-ticket-{i}", 

1200 ) 

1201 session.add(attempt) 

1202 session.flush() 

1203 attempt.time = now() - timedelta(minutes=20) 

1204 attempt_ids.append(attempt.id) 

1205 

1206 # Mock the batch receipt API call 

1207 with patch("couchers.notifications.expo_api.requests.post") as mock_post: 

1208 mock_post.return_value.status_code = 200 

1209 mock_post.return_value.json.return_value = { 

1210 "data": { 

1211 "batch-ticket-0": {"status": "ok"}, 

1212 "batch-ticket-1": {"status": "ok"}, 

1213 "batch-ticket-2": {"status": "ok"}, 

1214 } 

1215 } 

1216 

1217 check_expo_push_receipts(empty_pb2.Empty()) 

1218 

1219 # Should only call the API once for all tickets 

1220 assert mock_post.call_count == 1 

1221 

1222 # Verify all attempts were updated 

1223 with session_scope() as session: 

1224 for attempt_id in attempt_ids: 

1225 attempt = session.execute( 

1226 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id) 

1227 ).scalar_one() 

1228 assert attempt.receipt_checked_at is not None 

1229 assert attempt.receipt_status == "ok" 

1230 

1231 

1232def test_DebugRedeliverPushNotification_success(db, push_collector: PushCollector): 

1233 """Test DebugRedeliverPushNotification redelivers an existing notification.""" 

1234 user, token = generate_user() 

1235 

1236 config.ENABLE_DEV_APIS = True 

1237 

1238 # Create a notification for the user 

1239 with session_scope() as session: 

1240 notify( 

1241 session, 

1242 user_id=user.id, 

1243 topic_action=NotificationTopicAction.badge__add, 

1244 key="test-badge", 

1245 data=notification_data_pb2.BadgeAdd( 

1246 badge_id="volunteer", 

1247 badge_name="Active Volunteer", 

1248 badge_description="This user is an active volunteer for Couchers.org", 

1249 ), 

1250 ) 

1251 

1252 process_job() 

1253 

1254 # Pop the initial push notification 

1255 push_collector.pop_for_user(user.id, last=True) 

1256 

1257 # Get the notification_id 

1258 with session_scope() as session: 

1259 notification = session.execute(select(Notification).where(Notification.user_id == user.id)).scalar_one() 

1260 notification_id = notification.id 

1261 

1262 # Redeliver the notification 

1263 with notifications_session(token) as notifications: 

1264 notifications.DebugRedeliverPushNotification( 

1265 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1266 ) 

1267 

1268 # Verify a new push was sent 

1269 push = push_collector.pop_for_user(user.id, last=True) 

1270 assert "Active Volunteer" in push.content.title 

1271 assert push.topic_action == "badge:add" 

1272 assert push.key == "test-badge" 

1273 

1274 

1275def test_DebugRedeliverPushNotification_not_found(db, push_collector: PushCollector): 

1276 """Test DebugRedeliverPushNotification fails when notification doesn't exist.""" 

1277 user, token = generate_user() 

1278 

1279 config.ENABLE_DEV_APIS = True 

1280 

1281 with notifications_session(token) as notifications: 

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

1283 notifications.DebugRedeliverPushNotification( 

1284 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=999999) 

1285 ) 

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

1287 assert "notification not found" in not_none(e.value.details()).lower() 

1288 

1289 assert push_collector.count_for_user(user.id) == 0 

1290 

1291 

1292def test_DebugRedeliverPushNotification_wrong_user(db, push_collector: PushCollector): 

1293 """Test DebugRedeliverPushNotification fails when notification belongs to another user.""" 

1294 user1, token1 = generate_user() 

1295 user2, token2 = generate_user() 

1296 

1297 config.ENABLE_DEV_APIS = True 

1298 

1299 # Create a notification for user1 

1300 with session_scope() as session: 

1301 notify( 

1302 session, 

1303 user_id=user1.id, 

1304 topic_action=NotificationTopicAction.badge__add, 

1305 key="test-badge", 

1306 data=notification_data_pb2.BadgeAdd( 

1307 badge_id="volunteer", 

1308 badge_name="Active Volunteer", 

1309 badge_description="This user is an active volunteer for Couchers.org", 

1310 ), 

1311 ) 

1312 

1313 process_job() 

1314 

1315 # Get the notification_id 

1316 with session_scope() as session: 

1317 notification = session.execute(select(Notification).where(Notification.user_id == user1.id)).scalar_one() 

1318 notification_id = notification.id 

1319 

1320 # user2 tries to redeliver user1's notification 

1321 with notifications_session(token2) as notifications: 

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

1323 notifications.DebugRedeliverPushNotification( 

1324 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1325 ) 

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

1327 assert "notification not found" in not_none(e.value.details()).lower() 

1328 

1329 assert push_collector.count_for_user(user2.id) == 0 

1330 

1331 

1332def test_DebugRedeliverPushNotification_disabled(db, push_collector: PushCollector): 

1333 """Test DebugRedeliverPushNotification fails when ENABLE_DEV_APIS is disabled.""" 

1334 user, token = generate_user() 

1335 

1336 config.ENABLE_DEV_APIS = False 

1337 

1338 with notifications_session(token) as notifications: 

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

1340 notifications.DebugRedeliverPushNotification( 

1341 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1342 ) 

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

1344 assert "Development APIs are not enabled" in not_none(e.value.details()) 

1345 

1346 assert push_collector.count_for_user(user.id) == 0 

1347 

1348 

1349def test_DebugRedeliverPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

1350 """Test DebugRedeliverPushNotification fails when push notifications are disabled.""" 

1351 user, token = generate_user() 

1352 

1353 config.ENABLE_DEV_APIS = True 

1354 config.PUSH_NOTIFICATIONS_ENABLED = False 

1355 

1356 with notifications_session(token) as notifications: 

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

1358 notifications.DebugRedeliverPushNotification( 

1359 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1360 ) 

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

1362 assert "Push notifications are currently disabled" in not_none(e.value.details()) 

1363 

1364 assert push_collector.count_for_user(user.id) == 0 

1365 

1366 

1367def test_handle_notification_email_delivery(db, email_collector: EmailCollector): 

1368 """Test that email notifications are delivered when email preference is enabled.""" 

1369 user, token = generate_user() 

1370 

1371 topic_action = NotificationTopicAction.badge__add 

1372 

1373 # Enable email notifications for this topic 

1374 with notifications_session(token) as notifications: 

1375 notifications.SetNotificationSettings( 

1376 notifications_pb2.SetNotificationSettingsReq( 

1377 preferences=[ 

1378 notifications_pb2.SingleNotificationPreference( 

1379 topic=topic_action.topic, 

1380 action=topic_action.action, 

1381 delivery_method="email", 

1382 enabled=True, 

1383 ) 

1384 ], 

1385 ) 

1386 ) 

1387 

1388 with session_scope() as session: 

1389 notify( 

1390 session, 

1391 user_id=user.id, 

1392 topic_action=topic_action, 

1393 key="test-badge", 

1394 data=notification_data_pb2.BadgeAdd( 

1395 badge_id="volunteer", 

1396 badge_name="Active Volunteer", 

1397 badge_description="This user is an active volunteer", 

1398 ), 

1399 ) 

1400 

1401 email = email_collector.pop_for_recipient(user.email, last=True) 

1402 assert email.recipient == user.email 

1403 

1404 with session_scope() as session: 

1405 delivery = session.execute( 

1406 select(NotificationDelivery) 

1407 .join(Notification, Notification.id == NotificationDelivery.notification_id) 

1408 .where(Notification.user_id == user.id) 

1409 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.email) 

1410 ).scalar_one() 

1411 assert delivery.delivered is not None 

1412 

1413 

1414def test_handle_notification_push_delivery(db, push_collector: PushCollector): 

1415 """Test that push notifications are delivered immediately when push preference is enabled.""" 

1416 user, token = generate_user() 

1417 

1418 topic_action = NotificationTopicAction.badge__add 

1419 

1420 with session_scope() as session: 

1421 notify( 

1422 session, 

1423 user_id=user.id, 

1424 topic_action=topic_action, 

1425 key="test-badge", 

1426 data=notification_data_pb2.BadgeAdd( 

1427 badge_id="volunteer", 

1428 badge_name="Active Volunteer", 

1429 badge_description="This user is an active volunteer", 

1430 ), 

1431 ) 

1432 

1433 process_job() 

1434 

1435 push = push_collector.pop_for_user(user.id, last=True) 

1436 assert "Active Volunteer" in push.content.title 

1437 

1438 with session_scope() as session: 

1439 delivery = session.execute( 

1440 select(NotificationDelivery) 

1441 .join(Notification, Notification.id == NotificationDelivery.notification_id) 

1442 .where(Notification.user_id == user.id) 

1443 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.push) 

1444 ).scalar_one() 

1445 assert delivery.delivered is not None 

1446 

1447 

1448def test_handle_notification_digest_delivery(db): 

1449 """Test that digest notifications are queued without a delivered timestamp.""" 

1450 user, token = generate_user() 

1451 

1452 topic_action = NotificationTopicAction.badge__add 

1453 

1454 # Enable only digest notifications for this topic 

1455 with notifications_session(token) as notifications: 

1456 notifications.SetNotificationSettings( 

1457 notifications_pb2.SetNotificationSettingsReq( 

1458 preferences=[ 

1459 notifications_pb2.SingleNotificationPreference( 

1460 topic=topic_action.topic, 

1461 action=topic_action.action, 

1462 delivery_method="push", 

1463 enabled=False, 

1464 ), 

1465 notifications_pb2.SingleNotificationPreference( 

1466 topic=topic_action.topic, 

1467 action=topic_action.action, 

1468 delivery_method="digest", 

1469 enabled=True, 

1470 ), 

1471 ], 

1472 ) 

1473 ) 

1474 

1475 with session_scope() as session: 

1476 notify( 

1477 session, 

1478 user_id=user.id, 

1479 topic_action=topic_action, 

1480 key="test-badge", 

1481 data=notification_data_pb2.BadgeAdd( 

1482 badge_id="volunteer", 

1483 badge_name="Active Volunteer", 

1484 badge_description="This user is an active volunteer", 

1485 ), 

1486 ) 

1487 

1488 process_job() 

1489 

1490 # Verify digest NotificationDelivery was created WITHOUT delivered timestamp 

1491 with session_scope() as session: 

1492 delivery = session.execute( 

1493 select(NotificationDelivery) 

1494 .join(Notification, Notification.id == NotificationDelivery.notification_id) 

1495 .where(Notification.user_id == user.id) 

1496 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.digest) 

1497 ).scalar_one() 

1498 assert delivery.delivered is None 

1499 

1500 

1501def test_handle_notification_banned_user_no_email(db, email_collector: EmailCollector): 

1502 """Test that banned users don't receive email notifications.""" 

1503 user, token = generate_user() 

1504 

1505 topic_action = NotificationTopicAction.badge__add 

1506 

1507 # Enable email notifications 

1508 with notifications_session(token) as notifications: 

1509 notifications.SetNotificationSettings( 

1510 notifications_pb2.SetNotificationSettingsReq( 

1511 preferences=[ 

1512 notifications_pb2.SingleNotificationPreference( 

1513 topic=topic_action.topic, 

1514 action=topic_action.action, 

1515 delivery_method="email", 

1516 enabled=True, 

1517 ) 

1518 ], 

1519 ) 

1520 ) 

1521 

1522 # Ban the user 

1523 with session_scope() as session: 

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

1525 

1526 with session_scope() as session: 

1527 notify( 

1528 session, 

1529 user_id=user.id, 

1530 topic_action=topic_action, 

1531 key="test-badge", 

1532 data=notification_data_pb2.BadgeAdd( 

1533 badge_id="volunteer", 

1534 badge_name="Active Volunteer", 

1535 badge_description="This user is an active volunteer", 

1536 ), 

1537 ) 

1538 

1539 # Email should not be sent to the banned user 

1540 assert email_collector.count_for_recipient(user.email) == 0 

1541 

1542 

1543def test_handle_notification_deleted_user_no_regular_email(db, email_collector: EmailCollector): 

1544 """Test that deleted users don't receive non-account-deletion email notifications.""" 

1545 user, token = generate_user() 

1546 

1547 topic_action = NotificationTopicAction.badge__add 

1548 

1549 # Enable email notifications 

1550 with notifications_session(token) as notifications: 

1551 notifications.SetNotificationSettings( 

1552 notifications_pb2.SetNotificationSettingsReq( 

1553 preferences=[ 

1554 notifications_pb2.SingleNotificationPreference( 

1555 topic=topic_action.topic, 

1556 action=topic_action.action, 

1557 delivery_method="email", 

1558 enabled=True, 

1559 ) 

1560 ], 

1561 ) 

1562 ) 

1563 

1564 # Delete the user 

1565 with session_scope() as session: 

1566 session.execute(update(User).where(User.id == user.id).values(deleted_at=now())) 

1567 

1568 with session_scope() as session: 

1569 notify( 

1570 session, 

1571 user_id=user.id, 

1572 topic_action=topic_action, 

1573 key="test-badge", 

1574 data=notification_data_pb2.BadgeAdd( 

1575 badge_id="volunteer", 

1576 badge_name="Active Volunteer", 

1577 badge_description="This user is an active volunteer", 

1578 ), 

1579 ) 

1580 

1581 # Email should not be sent to deleted user for non-account-deletion notification 

1582 assert email_collector.count_for_recipient(user.email) == 0 

1583 

1584 

1585def test_handle_notification_deleted_user_receives_account_deletion_email(db, email_collector: EmailCollector): 

1586 """Test that deleted users CAN receive account deletion notifications.""" 

1587 user, token = generate_user() 

1588 

1589 topic_action = NotificationTopicAction.account_deletion__complete 

1590 

1591 # Delete the user 

1592 with session_scope() as session: 

1593 session.execute(update(User).where(User.id == user.id).values(deleted_at=now())) 

1594 

1595 with session_scope() as session: 

1596 notify( 

1597 session, 

1598 user_id=user.id, 

1599 topic_action=topic_action, 

1600 key="", 

1601 data=notification_data_pb2.AccountDeletionComplete( 

1602 undelete_token="test-token", 

1603 undelete_days=7, 

1604 ), 

1605 ) 

1606 

1607 # Email SHOULD be sent to deleted user for account deletion notification 

1608 email = email_collector.pop_for_recipient(user.email, last=True) 

1609 assert email.recipient == user.email 

1610 

1611 

1612def test_handle_notification_do_not_email_respected(db, email_collector: EmailCollector): 

1613 """Test that users with do_not_email set don't receive non-critical emails.""" 

1614 user, token = generate_user() 

1615 

1616 topic_action = NotificationTopicAction.badge__add 

1617 

1618 # Enable email notifications 

1619 with notifications_session(token) as notifications: 

1620 notifications.SetNotificationSettings( 

1621 notifications_pb2.SetNotificationSettingsReq( 

1622 preferences=[ 

1623 notifications_pb2.SingleNotificationPreference( 

1624 topic=topic_action.topic, 

1625 action=topic_action.action, 

1626 delivery_method="email", 

1627 enabled=True, 

1628 ) 

1629 ], 

1630 ) 

1631 ) 

1632 

1633 # Set do_not_email (requires hosting/meetup status to be set due to DB constraint) 

1634 with session_scope() as session: 

1635 session.execute( 

1636 update(User) 

1637 .where(User.id == user.id) 

1638 .values( 

1639 hosting_status=HostingStatus.cant_host, 

1640 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1641 do_not_email=True, 

1642 ) 

1643 ) 

1644 

1645 with session_scope() as session: 

1646 notify( 

1647 session, 

1648 user_id=user.id, 

1649 topic_action=topic_action, 

1650 key="test-badge", 

1651 data=notification_data_pb2.BadgeAdd( 

1652 badge_id="volunteer", 

1653 badge_name="Active Volunteer", 

1654 badge_description="This user is an active volunteer", 

1655 ), 

1656 ) 

1657 

1658 # Email should not be sent when do_not_email is True 

1659 assert email_collector.count_for_recipient(user.email) == 0 

1660 

1661 

1662def test_handle_notification_critical_bypasses_do_not_email(db, email_collector: EmailCollector): 

1663 """Test that critical notifications bypass do_not_email setting.""" 

1664 user, token = generate_user() 

1665 

1666 topic_action = NotificationTopicAction.password__change 

1667 

1668 # Set do_not_email (requires hosting/meetup status to be set due to DB constraint) 

1669 with session_scope() as session: 

1670 session.execute( 

1671 update(User) 

1672 .where(User.id == user.id) 

1673 .values( 

1674 hosting_status=HostingStatus.cant_host, 

1675 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1676 do_not_email=True, 

1677 ) 

1678 ) 

1679 

1680 with session_scope() as session: 

1681 notify( 

1682 session, 

1683 user_id=user.id, 

1684 topic_action=topic_action, 

1685 key="", 

1686 data=None, 

1687 ) 

1688 

1689 # Critical email SHOULD be sent even with do_not_email=True 

1690 email = email_collector.pop_for_recipient(user.email, last=True) 

1691 assert email.recipient == user.email 

1692 

1693 

1694def test_handle_notification_duplicate_delivery_skipped(db, push_collector: PushCollector): 

1695 """Test that duplicate deliveries are skipped when NotificationDelivery already exists.""" 

1696 user, token = generate_user() 

1697 

1698 topic_action = NotificationTopicAction.badge__add 

1699 

1700 # Create notification manually 

1701 with session_scope() as session: 

1702 notification = Notification( 

1703 user_id=user.id, 

1704 topic_action=topic_action, 

1705 key="test-badge", 

1706 data=notification_data_pb2.BadgeAdd( 

1707 badge_id="volunteer", 

1708 badge_name="Active Volunteer", 

1709 badge_description="This user is an active volunteer", 

1710 ).SerializeToString(), 

1711 ) 

1712 session.add(notification) 

1713 session.flush() 

1714 notification_id = notification.id 

1715 

1716 # Manually create a push delivery (simulating it was already delivered) 

1717 session.add( 

1718 NotificationDelivery( 

1719 notification_id=notification_id, 

1720 delivery_type=NotificationDeliveryType.push, 

1721 delivered=now(), 

1722 ) 

1723 ) 

1724 

1725 # Try to handle the notification again 

1726 handle_notification(jobs_pb2.HandleNotificationPayload(notification_id=notification_id)) 

1727 

1728 # No new push should be sent since delivery already exists 

1729 assert push_collector.count_for_user(user.id) == 0 

1730 

1731 # Verify only one delivery exists 

1732 with session_scope() as session: 

1733 delivery_count = len( 

1734 session.execute( 

1735 select(NotificationDelivery) 

1736 .where(NotificationDelivery.notification_id == notification_id) 

1737 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.push) 

1738 ) 

1739 .scalars() 

1740 .all() 

1741 ) 

1742 assert delivery_count == 1 

1743 

1744 

1745def test_handle_notification_deferred_when_content_not_visible(db, moderator): 

1746 """Test that notifications linked to non-visible moderated content are deferred.""" 

1747 user1, token1 = generate_user(complete_profile=True) 

1748 user2, token2 = generate_user(complete_profile=True) 

1749 

1750 # Create a friend request (which creates a moderation state) 

1751 # This also queues a notification via SendFriendRequest 

1752 with api_session(token2) as api: 

1753 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id)) 

1754 

1755 # Process the queued job (handle_notification) 

1756 process_job() 

1757 

1758 # The notification should exist but have no deliveries because content is shadowed 

1759 with session_scope() as session: 

1760 notification = session.execute( 

1761 select(Notification) 

1762 .where(Notification.user_id == user1.id) 

1763 .where(Notification.topic_action == NotificationTopicAction.friend_request__create) 

1764 ).scalar_one() 

1765 

1766 deliveries = ( 

1767 session.execute(select(NotificationDelivery).where(NotificationDelivery.notification_id == notification.id)) 

1768 .scalars() 

1769 .all() 

1770 ) 

1771 # No deliveries because content is not yet visible (shadowed) 

1772 assert len(deliveries) == 0 

1773 

1774 

1775def test_handle_notification_delivered_when_content_visible(db, moderator): 

1776 """Test that notifications linked to visible moderated content are delivered.""" 

1777 user1, token1 = generate_user(complete_profile=True) 

1778 user2, token2 = generate_user(complete_profile=True) 

1779 

1780 # Create a friend request 

1781 with api_session(token2) as api: 

1782 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id)) 

1783 res = api.ListFriendRequests(empty_pb2.Empty()) 

1784 fr_id = res.sent[0].friend_request_id 

1785 

1786 # Process initial job (which is deferred because content is shadowed) 

1787 process_job() 

1788 

1789 # Approve the friend request so it becomes visible (this queues the notification job again) 

1790 moderator.approve_friend_request(fr_id) 

1791 

1792 # Process the notification job that was re-queued after approval 

1793 process_jobs() 

1794 

1795 # Notification should have been delivered 

1796 with session_scope() as session: 

1797 notification = session.execute( 

1798 select(Notification) 

1799 .where(Notification.user_id == user1.id) 

1800 .where(Notification.topic_action == NotificationTopicAction.friend_request__create) 

1801 ).scalar_one() 

1802 

1803 deliveries = ( 

1804 session.execute(select(NotificationDelivery).where(NotificationDelivery.notification_id == notification.id)) 

1805 .scalars() 

1806 .all() 

1807 ) 

1808 # At least one delivery should exist 

1809 assert len(deliveries) > 0 

1810 

1811 

1812def test_notification_serializes_shadowed_actor(db, moderator): 

1813 recipient, _ = generate_user(complete_profile=True) 

1814 sender, sender_token = generate_user(complete_profile=True) 

1815 

1816 with session_scope() as session: 

1817 session.execute(update(User).where(User.id == sender.id).values(shadowed_at=now())) 

1818 

1819 with api_session(sender_token) as api: 

1820 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=recipient.id)) 

1821 

1822 process_job() 

1823 

1824 with session_scope() as session: 

1825 notification = session.execute( 

1826 select(Notification) 

1827 .where(Notification.user_id == recipient.id) 

1828 .where(Notification.topic_action == NotificationTopicAction.friend_request__create) 

1829 ).scalar_one() 

1830 data = notification_data_pb2.FriendRequestCreate.FromString(notification.data) 

1831 assert data.other_user.user_id == sender.id 

1832 assert not data.other_user.is_ghost 

1833 

1834 

1835def test_handle_notification_multiple_delivery_types( 

1836 db, email_collector: EmailCollector, push_collector: PushCollector 

1837): 

1838 """Test that multiple delivery types are processed for a single notification.""" 

1839 user, token = generate_user() 

1840 

1841 topic_action = NotificationTopicAction.badge__add 

1842 

1843 # Enable both email and push notifications 

1844 with notifications_session(token) as notifications: 

1845 notifications.SetNotificationSettings( 

1846 notifications_pb2.SetNotificationSettingsReq( 

1847 preferences=[ 

1848 notifications_pb2.SingleNotificationPreference( 

1849 topic=topic_action.topic, 

1850 action=topic_action.action, 

1851 delivery_method="email", 

1852 enabled=True, 

1853 ), 

1854 notifications_pb2.SingleNotificationPreference( 

1855 topic=topic_action.topic, 

1856 action=topic_action.action, 

1857 delivery_method="push", 

1858 enabled=True, 

1859 ), 

1860 notifications_pb2.SingleNotificationPreference( 

1861 topic=topic_action.topic, 

1862 action=topic_action.action, 

1863 delivery_method="digest", 

1864 enabled=True, 

1865 ), 

1866 ], 

1867 ) 

1868 ) 

1869 

1870 with session_scope() as session: 

1871 notify( 

1872 session, 

1873 user_id=user.id, 

1874 topic_action=topic_action, 

1875 key="test-badge", 

1876 data=notification_data_pb2.BadgeAdd( 

1877 badge_id="volunteer", 

1878 badge_name="Active Volunteer", 

1879 badge_description="This user is an active volunteer", 

1880 ), 

1881 ) 

1882 

1883 # Email should be sent 

1884 email_collector.pop_for_recipient(user.email, last=True) 

1885 

1886 # Push should be sent 

1887 push = push_collector.pop_for_user(user.id, last=True) 

1888 assert "Active Volunteer" in push.content.title 

1889 

1890 # All three delivery types should have deliveries 

1891 with session_scope() as session: 

1892 notification = session.execute(select(Notification).where(Notification.user_id == user.id)).scalar_one() 

1893 

1894 deliveries = ( 

1895 session.execute(select(NotificationDelivery).where(NotificationDelivery.notification_id == notification.id)) 

1896 .scalars() 

1897 .all() 

1898 ) 

1899 

1900 delivery_types = {d.delivery_type for d in deliveries} 

1901 assert NotificationDeliveryType.email in delivery_types 

1902 assert NotificationDeliveryType.push in delivery_types 

1903 assert NotificationDeliveryType.digest in delivery_types 

1904 

1905 # Email and push should have delivered timestamps 

1906 for delivery in deliveries: 

1907 if delivery.delivery_type in [NotificationDeliveryType.email, NotificationDeliveryType.push]: 

1908 assert delivery.delivered is not None 

1909 elif delivery.delivery_type == NotificationDeliveryType.digest: 1909 ↛ 1906line 1909 didn't jump to line 1906 because the condition on line 1909 was always true

1910 assert delivery.delivered is None