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

782 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-05-05 09:44 +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.i18n import LocalizationContext 

19from couchers.jobs.handlers import check_expo_push_receipts 

20from couchers.jobs.worker import process_job 

21from couchers.models import ( 

22 DeviceType, 

23 HostingStatus, 

24 MeetupStatus, 

25 Notification, 

26 NotificationDelivery, 

27 NotificationDeliveryType, 

28 NotificationTopicAction, 

29 PushNotificationDeliveryAttempt, 

30 PushNotificationDeliveryOutcome, 

31 PushNotificationPlatform, 

32 PushNotificationSubscription, 

33 User, 

34) 

35from couchers.notifications.background import handle_notification 

36from couchers.notifications.expo_api import get_expo_push_receipts 

37from couchers.notifications.notify import notify 

38from couchers.notifications.settings import get_topic_actions_by_delivery_type 

39from couchers.proto import ( 

40 api_pb2, 

41 auth_pb2, 

42 conversations_pb2, 

43 editor_pb2, 

44 events_pb2, 

45 notification_data_pb2, 

46 notifications_pb2, 

47) 

48from couchers.proto.internal import jobs_pb2, unsubscribe_pb2 

49from couchers.servicers.api import user_model_to_pb 

50from couchers.utils import not_none, now 

51from tests.fixtures.db import generate_user 

52from tests.fixtures.misc import PushCollector, email_fields, mock_notification_email, process_jobs 

53from tests.fixtures.sessions import ( 

54 api_session, 

55 auth_api_session, 

56 conversations_session, 

57 notifications_session, 

58 real_editor_session, 

59) 

60 

61 

62@pytest.fixture(autouse=True) 

63def _(testconfig): 

64 pass 

65 

66 

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

68def test_SetNotificationSettings_preferences_respected_editable(db, enabled): 

69 user, token = generate_user() 

70 

71 # enable a notification type and check it gets delivered 

72 topic_action = NotificationTopicAction.badge__add 

73 

74 with notifications_session(token) as notifications: 

75 notifications.SetNotificationSettings( 

76 notifications_pb2.SetNotificationSettingsReq( 

77 preferences=[ 

78 notifications_pb2.SingleNotificationPreference( 

79 topic=topic_action.topic, 

80 action=topic_action.action, 

81 delivery_method="push", 

82 enabled=enabled, 

83 ) 

84 ], 

85 ) 

86 ) 

87 

88 with session_scope() as session: 

89 notify( 

90 session, 

91 user_id=user.id, 

92 topic_action=topic_action, 

93 key="", 

94 data=notification_data_pb2.BadgeAdd( 

95 badge_id="volunteer", 

96 badge_name="Active Volunteer", 

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

98 ), 

99 ) 

100 

101 process_job() 

102 

103 with session_scope() as session: 

104 deliv = session.execute( 

105 select(NotificationDelivery) 

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

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

108 .where(Notification.topic_action == topic_action) 

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

110 ).scalar_one_or_none() 

111 

112 if enabled: 

113 assert deliv is not None 

114 else: 

115 assert deliv is None 

116 

117 

118def test_SetNotificationSettings_preferences_not_editable(db): 

119 user, token = generate_user() 

120 

121 # enable a notification type and check it gets delivered 

122 topic_action = NotificationTopicAction.password_reset__start 

123 

124 with notifications_session(token) as notifications: 

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

126 notifications.SetNotificationSettings( 

127 notifications_pb2.SetNotificationSettingsReq( 

128 preferences=[ 

129 notifications_pb2.SingleNotificationPreference( 

130 topic=topic_action.topic, 

131 action=topic_action.action, 

132 delivery_method="push", 

133 enabled=False, 

134 ) 

135 ], 

136 ) 

137 ) 

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

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

140 

141 

142def test_unsubscribe(db): 

143 # this is the ugliest test i've written 

144 

145 user, token = generate_user() 

146 

147 topic_action = NotificationTopicAction.badge__add 

148 

149 # first enable email notifs 

150 with notifications_session(token) as notifications: 

151 notifications.SetNotificationSettings( 

152 notifications_pb2.SetNotificationSettingsReq( 

153 preferences=[ 

154 notifications_pb2.SingleNotificationPreference( 

155 topic=topic_action.topic, 

156 action=topic_action.action, 

157 delivery_method=method, 

158 enabled=enabled, 

159 ) 

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

161 ], 

162 ) 

163 ) 

164 

165 with mock_notification_email() as mock: 

166 with session_scope() as session: 

167 notify( 

168 session, 

169 user_id=user.id, 

170 topic_action=topic_action, 

171 key="", 

172 data=notification_data_pb2.BadgeAdd( 

173 badge_id="volunteer", 

174 badge_name="Active Volunteer", 

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

176 ), 

177 ) 

178 

179 assert mock.call_count == 1 

180 assert email_fields(mock).recipient == user.email 

181 # very ugly 

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

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

184 if "payload" not in link: 

185 continue 

186 print(link) 

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

188 params = parse_qs(url_parts.query) 

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

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

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

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

193 assert ( 

194 auth_api.Unsubscribe( 

195 auth_pb2.UnsubscribeReq( 

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

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

198 ) 

199 ).response 

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

201 ) 

202 break 

203 else: 

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

205 

206 with notifications_session(token) as notifications: 

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

208 

209 for group in res.groups: 

210 for topic in group.topics: 

211 for item in topic.items: 

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

213 assert not item.email 

214 

215 with mock_notification_email() as mock: 

216 with session_scope() as session: 

217 notify( 

218 session, 

219 user_id=user.id, 

220 topic_action=topic_action, 

221 key="", 

222 data=notification_data_pb2.BadgeAdd( 

223 badge_id="volunteer", 

224 badge_name="Active Volunteer", 

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

226 ), 

227 ) 

228 

229 assert mock.call_count == 0 

230 

231 

232def test_unsubscribe_do_not_email(db, moderator): 

233 user, token = generate_user() 

234 

235 _, token2 = generate_user(complete_profile=True) 

236 with api_session(token2) as api: 

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

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

239 fr_id = res.sent[0].friend_request_id 

240 

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

242 with mock_notification_email() as mock: 

243 moderator.approve_friend_request(fr_id) 

244 

245 assert mock.call_count == 1 

246 assert email_fields(mock).recipient == user.email 

247 # very ugly 

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

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

250 if "payload" not in link: 

251 continue 

252 print(link) 

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

254 params = parse_qs(url_parts.query) 

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

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

257 if payload.HasField("do_not_email"): 

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

259 assert ( 

260 auth_api.Unsubscribe( 

261 auth_pb2.UnsubscribeReq( 

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

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

264 ) 

265 ).response 

266 == "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." 

267 ) 

268 break 

269 else: 

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

271 

272 _, token3 = generate_user(complete_profile=True) 

273 with api_session(token3) as api: 

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

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

276 fr_id3 = res.sent[0].friend_request_id 

277 

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

279 with mock_notification_email() as mock: 

280 moderator.approve_friend_request(fr_id3) 

281 

282 assert mock.call_count == 0 

283 

284 with session_scope() as session: 

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

286 assert user_.do_not_email 

287 

288 

289def test_get_do_not_email(db): 

290 _, token = generate_user() 

291 

292 with session_scope() as session: 

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

294 user.do_not_email = False 

295 

296 with notifications_session(token) as notifications: 

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

298 assert not res.do_not_email_enabled 

299 

300 with session_scope() as session: 

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

302 user.do_not_email = True 

303 user.hosting_status = HostingStatus.cant_host 

304 user.meetup_status = MeetupStatus.does_not_want_to_meetup 

305 

306 with notifications_session(token) as notifications: 

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

308 assert res.do_not_email_enabled 

309 

310 

311def test_set_do_not_email(db): 

312 _, token = generate_user() 

313 

314 with session_scope() as session: 

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

316 user.do_not_email = False 

317 user.hosting_status = HostingStatus.can_host 

318 user.meetup_status = MeetupStatus.wants_to_meetup 

319 

320 with notifications_session(token) as notifications: 

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

322 

323 with session_scope() as session: 

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

325 assert not user.do_not_email 

326 

327 with notifications_session(token) as notifications: 

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

329 

330 with session_scope() as session: 

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

332 assert user.do_not_email 

333 assert user.hosting_status == HostingStatus.cant_host 

334 assert user.meetup_status == MeetupStatus.does_not_want_to_meetup 

335 

336 with notifications_session(token) as notifications: 

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

338 

339 with session_scope() as session: 

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

341 assert not user.do_not_email 

342 

343 

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

345 user1, token1 = generate_user() 

346 user2, token2 = generate_user() 

347 

348 with api_session(token2) as api: 

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

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

351 fr_id = res.sent[0].friend_request_id 

352 

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

354 moderator.approve_friend_request(fr_id) 

355 

356 with notifications_session(token1) as notifications: 

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

358 assert len(res.notifications) == 1 

359 

360 n = res.notifications[0] 

361 

362 assert n.topic == "friend_request" 

363 assert n.action == "create" 

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

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

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

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

368 assert n.url == "http://localhost:3000/connections/friends/" 

369 

370 with conversations_session(token2) as c: 

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

372 group_chat_id = res.group_chat_id 

373 moderator.approve_group_chat(group_chat_id) 

374 for i in range(17): 

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

376 

377 process_jobs() 

378 

379 all_notifs = [] 

380 with notifications_session(token1) as notifications: 

381 page_token = None 

382 for _ in range(100): 382 ↛ 395line 382 didn't jump to line 395

383 res = notifications.ListNotifications( 

384 notifications_pb2.ListNotificationsReq( 

385 page_size=5, 

386 page_token=page_token, 

387 ) 

388 ) 

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

390 all_notifs += res.notifications 

391 page_token = res.next_page_token 

392 if not page_token: 

393 break 

394 

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

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

397 

398 

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

400 user1, token1 = generate_user() 

401 user2, token2 = generate_user() 

402 user3, token3 = generate_user() 

403 user4, token4 = generate_user() 

404 

405 with api_session(token2) as api: 

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

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

408 fr_id2 = res.sent[0].friend_request_id 

409 

410 with api_session(token3) as api: 

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

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

413 fr_id3 = res.sent[0].friend_request_id 

414 

415 # Moderator approves the friend requests so notifications are sent 

416 moderator.approve_friend_request(fr_id2) 

417 moderator.approve_friend_request(fr_id3) 

418 

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

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

421 assert len(res.notifications) == 2 

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

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

424 # should be listed desc time 

425 assert notification_ids[0] > notification_ids[1] 

426 

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

428 

429 with api_session(token4) as api: 

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

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

432 fr_id4 = res.sent[0].friend_request_id 

433 

434 # Moderator approves the friend request so notification is sent 

435 moderator.approve_friend_request(fr_id4) 

436 

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

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

439 notifications.MarkAllNotificationsSeen( 

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

441 ) 

442 

443 # last one is still unseen 

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

445 

446 # mark the first one unseen 

447 notifications.MarkNotificationSeen( 

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

449 ) 

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

451 

452 # mark the last one seen 

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

454 assert len(res.notifications) == 3 

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

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

457 

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

459 

460 notifications.MarkNotificationSeen( 

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

462 ) 

463 

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

465 assert len(res.notifications) == 3 

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

467 

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

469 

470 

471def test_unseen_notification_count_excludes_ums_hidden(db, moderator): 

472 user1, token1 = generate_user() 

473 user2, token2 = generate_user() 

474 

475 with api_session(token2) as api: 

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

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

478 fr_id = res.sent[0].friend_request_id 

479 

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

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

482 with api_session(token1) as api: 

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

484 

485 moderator.approve_friend_request(fr_id) 

486 

487 with api_session(token1) as api: 

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

489 

490 

491def test_GetVapidPublicKey(db): 

492 _, token = generate_user() 

493 

494 with notifications_session(token) as notifications: 

495 assert ( 

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

497 == "BApMo2tGuon07jv-pEaAKZmVo6E_d4HfcdDeV6wx2k9wV8EovJ0ve00bdLzZm9fizDrGZXRYJFqCcRJUfBcgA0A" 

498 ) 

499 

500 

501def test_RegisterPushNotificationSubscription(db): 

502 _, token = generate_user() 

503 

504 subscription_info = { 

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

506 "expirationTime": None, 

507 "keys": { 

508 "auth": "TnuEJ1OdfEkf6HKcUovl0Q", 

509 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0", 

510 }, 

511 } 

512 

513 with notifications_session(token) as notifications: 

514 res = notifications.RegisterPushNotificationSubscription( 

515 notifications_pb2.RegisterPushNotificationSubscriptionReq( 

516 full_subscription_json=json.dumps(subscription_info), 

517 ) 

518 ) 

519 

520 

521def test_RegisterPushNotificationSubscription_invalid_endpoint(db): 

522 _, token = generate_user() 

523 

524 subscription_info = { 

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

526 "expirationTime": None, 

527 "keys": { 

528 "auth": "TnuEJ1OdfEkf6HKcUovl0Q", 

529 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0", 

530 }, 

531 } 

532 

533 with notifications_session(token) as notifications: 

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

535 notifications.RegisterPushNotificationSubscription( 

536 notifications_pb2.RegisterPushNotificationSubscriptionReq( 

537 full_subscription_json=json.dumps(subscription_info), 

538 ) 

539 ) 

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

541 

542 

543def test_SendTestPushNotification(db, push_collector: PushCollector): 

544 user, token = generate_user() 

545 

546 with notifications_session(token) as notifications: 

547 notifications.SendTestPushNotification(empty_pb2.Empty()) 

548 

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

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

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

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

553 

554 

555def test_SendBlogPostNotification(db, push_collector: PushCollector): 

556 super_user, super_token = generate_user(is_superuser=True) 

557 

558 user1, user1_token = generate_user() 

559 # enabled email 

560 user2, user2_token = generate_user() 

561 # disabled push 

562 user3, user3_token = generate_user() 

563 

564 topic_action = NotificationTopicAction.general__new_blog_post 

565 

566 with notifications_session(user2_token) as notifications: 

567 notifications.SetNotificationSettings( 

568 notifications_pb2.SetNotificationSettingsReq( 

569 preferences=[ 

570 notifications_pb2.SingleNotificationPreference( 

571 topic=topic_action.topic, 

572 action=topic_action.action, 

573 delivery_method="email", 

574 enabled=True, 

575 ) 

576 ], 

577 ) 

578 ) 

579 

580 with notifications_session(user3_token) as notifications: 

581 notifications.SetNotificationSettings( 

582 notifications_pb2.SetNotificationSettingsReq( 

583 preferences=[ 

584 notifications_pb2.SingleNotificationPreference( 

585 topic=topic_action.topic, 

586 action=topic_action.action, 

587 delivery_method="push", 

588 enabled=False, 

589 ) 

590 ], 

591 ) 

592 ) 

593 

594 with mock_notification_email() as mock: 

595 with real_editor_session(super_token) as editor_api: 

596 editor_api.SendBlogPostNotification( 

597 editor_pb2.SendBlogPostNotificationReq( 

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

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

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

601 ) 

602 ) 

603 

604 process_jobs() 

605 

606 assert mock.call_count == 1 

607 assert email_fields(mock).recipient == user2.email 

608 assert "Couchers.org v0.9.9 Release Notes" in email_fields(mock).html 

609 assert "Couchers.org v0.9.9 Release Notes" in email_fields(mock).plain 

610 assert "Read about last major updates before v1!" in email_fields(mock).html 

611 assert "Read about last major updates before v1!" in email_fields(mock).plain 

612 assert "https://couchers.org/blog/2025/05/11/v0.9.9-release" in email_fields(mock).html 

613 assert "https://couchers.org/blog/2025/05/11/v0.9.9-release" in email_fields(mock).plain 

614 

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

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

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

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

619 

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

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

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

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

624 

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

626 

627 

628def test_get_topic_actions_by_delivery_type(db): 

629 user, token = generate_user() 

630 

631 # these are enabled by default 

632 assert NotificationDeliveryType.push in NotificationTopicAction.reference__receive_friend.defaults 

633 assert NotificationDeliveryType.push in NotificationTopicAction.host_request__accept.defaults 

634 

635 # these are disabled by default 

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

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

638 

639 with notifications_session(token) as notifications: 

640 notifications.SetNotificationSettings( 

641 notifications_pb2.SetNotificationSettingsReq( 

642 preferences=[ 

643 notifications_pb2.SingleNotificationPreference( 

644 topic=NotificationTopicAction.reference__receive_friend.topic, 

645 action=NotificationTopicAction.reference__receive_friend.action, 

646 delivery_method="push", 

647 enabled=False, 

648 ), 

649 notifications_pb2.SingleNotificationPreference( 

650 topic=NotificationTopicAction.event__create_any.topic, 

651 action=NotificationTopicAction.event__create_any.action, 

652 delivery_method="push", 

653 enabled=True, 

654 ), 

655 ], 

656 ) 

657 ) 

658 

659 with session_scope() as session: 

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

661 assert NotificationTopicAction.reference__receive_friend not in deliver 

662 assert NotificationTopicAction.host_request__accept in deliver 

663 assert NotificationTopicAction.event__create_any in deliver 

664 assert NotificationTopicAction.discussion__create not in deliver 

665 assert NotificationTopicAction.account_deletion__start in deliver 

666 

667 

668def test_event_reminder_email_sent(db): 

669 user, token = generate_user() 

670 title = "Board Game Night" 

671 start_event_time = timestamp_pb2.Timestamp(seconds=1751690400) 

672 

673 expected_time_str = LocalizationContext.from_user(user).localize_datetime(start_event_time) 

674 

675 with mock_notification_email() as mock: 

676 with session_scope() as session: 

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

678 

679 notify( 

680 session, 

681 user_id=user.id, 

682 topic_action=NotificationTopicAction.event__reminder, 

683 key="", 

684 data=notification_data_pb2.EventReminder( 

685 event=events_pb2.Event( 

686 event_id=1, 

687 slug="board-game-night", 

688 title=title, 

689 start_time=start_event_time, 

690 ), 

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

692 ), 

693 ) 

694 

695 assert mock.call_count == 1 

696 assert email_fields(mock).recipient == user.email 

697 assert title in email_fields(mock).html 

698 assert title in email_fields(mock).plain 

699 assert expected_time_str in email_fields(mock).html 

700 assert expected_time_str in email_fields(mock).plain 

701 

702 

703def test_RegisterMobilePushNotificationSubscription(db): 

704 user, token = generate_user() 

705 

706 with notifications_session(token) as notifications: 

707 notifications.RegisterMobilePushNotificationSubscription( 

708 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

709 token="ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", 

710 device_name="My iPhone", 

711 device_type="ios", 

712 ) 

713 ) 

714 

715 # Check subscription was created 

716 with session_scope() as session: 

717 sub = session.execute( 

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

719 ).scalar_one() 

720 assert sub.platform == PushNotificationPlatform.expo 

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

722 assert sub.device_name == "My iPhone" 

723 assert sub.device_type == DeviceType.ios 

724 assert sub.disabled_at == DATETIME_INFINITY 

725 

726 

727def test_RegisterMobilePushNotificationSubscription_android(db): 

728 user, token = generate_user() 

729 

730 with notifications_session(token) as notifications: 

731 notifications.RegisterMobilePushNotificationSubscription( 

732 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

733 token="ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]", 

734 device_name="My Android", 

735 device_type="android", 

736 ) 

737 ) 

738 

739 with session_scope() as session: 

740 sub = session.execute( 

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

742 ).scalar_one() 

743 assert sub.platform == PushNotificationPlatform.expo 

744 assert sub.device_type == DeviceType.android 

745 

746 

747def test_RegisterMobilePushNotificationSubscription_no_device_type(db): 

748 user, token = generate_user() 

749 

750 with notifications_session(token) as notifications: 

751 notifications.RegisterMobilePushNotificationSubscription( 

752 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

753 token="ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]", 

754 ) 

755 ) 

756 

757 with session_scope() as session: 

758 sub = session.execute( 

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

760 ).scalar_one() 

761 assert sub.platform == PushNotificationPlatform.expo 

762 assert sub.device_name is None 

763 assert sub.device_type is None 

764 

765 

766def test_RegisterMobilePushNotificationSubscription_re_enable(db): 

767 user, token = generate_user() 

768 

769 # Create a disabled subscription directly in the DB 

770 with session_scope() as session: 

771 sub = PushNotificationSubscription( 

772 user_id=user.id, 

773 platform=PushNotificationPlatform.expo, 

774 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

775 device_name="Old Device", 

776 device_type=DeviceType.ios, 

777 ) 

778 sub.disabled_at = now() 

779 session.add(sub) 

780 session.flush() 

781 sub_id = sub.id 

782 

783 # Re-register with the same token 

784 with notifications_session(token) as notifications: 

785 notifications.RegisterMobilePushNotificationSubscription( 

786 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

787 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

788 device_name="New Device Name", 

789 device_type="android", 

790 ) 

791 ) 

792 

793 # Check subscription was re-enabled and updated 

794 with session_scope() as session: 

795 sub = session.execute( 

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

797 ).scalar_one() 

798 assert sub.disabled_at == DATETIME_INFINITY 

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

800 assert sub.device_type == DeviceType.android 

801 

802 

803def test_RegisterMobilePushNotificationSubscription_already_exists(db): 

804 user, token = generate_user() 

805 

806 # Create an active subscription directly in the DB 

807 with session_scope() as session: 

808 sub = PushNotificationSubscription( 

809 user_id=user.id, 

810 platform=PushNotificationPlatform.expo, 

811 token="ExponentPushToken[existingtoken]", 

812 device_name="Existing Device", 

813 device_type=DeviceType.ios, 

814 ) 

815 session.add(sub) 

816 

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

818 with notifications_session(token) as notifications: 

819 notifications.RegisterMobilePushNotificationSubscription( 

820 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

821 token="ExponentPushToken[existingtoken]", 

822 device_name="Different Name", 

823 ) 

824 ) 

825 

826 # Check subscription was NOT modified (already active) 

827 with session_scope() as session: 

828 sub = session.execute( 

829 select(PushNotificationSubscription).where( 

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

831 ) 

832 ).scalar_one() 

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

834 

835 

836def test_SendTestMobilePushNotification(db, push_collector: PushCollector): 

837 user, token = generate_user() 

838 

839 with notifications_session(token) as notifications: 

840 notifications.SendTestMobilePushNotification(empty_pb2.Empty()) 

841 

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

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

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

845 

846 

847def test_get_expo_push_receipts(db): 

848 mock_response = Mock() 

849 mock_response.status_code = 200 

850 mock_response.json.return_value = { 

851 "data": { 

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

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

854 } 

855 } 

856 

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

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

859 

860 mock_post.assert_called_once() 

861 call_args = mock_post.call_args 

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

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

864 

865 assert result == { 

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

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

868 } 

869 

870 

871def test_get_expo_push_receipts_empty(db): 

872 result = get_expo_push_receipts([]) 

873 assert result == {} 

874 

875 

876def test_check_expo_push_receipts_success(db): 

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

878 user, token = generate_user() 

879 

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

881 with session_scope() as session: 

882 sub = PushNotificationSubscription( 

883 user_id=user.id, 

884 platform=PushNotificationPlatform.expo, 

885 token="ExponentPushToken[testtoken123]", 

886 device_name="Test Device", 

887 device_type=DeviceType.ios, 

888 ) 

889 session.add(sub) 

890 session.flush() 

891 

892 attempt = PushNotificationDeliveryAttempt( 

893 push_notification_subscription_id=sub.id, 

894 outcome=PushNotificationDeliveryOutcome.success, 

895 status_code=200, 

896 expo_ticket_id="test-ticket-id", 

897 ) 

898 session.add(attempt) 

899 session.flush() 

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

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

902 attempt_id = attempt.id 

903 sub_id = sub.id 

904 

905 # Mock the receipt API call 

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

907 mock_post.return_value.status_code = 200 

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

909 

910 check_expo_push_receipts(empty_pb2.Empty()) 

911 

912 # Verify the attempt was updated 

913 with session_scope() as session: 

914 attempt = session.execute( 

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

916 ).scalar_one() 

917 assert attempt.receipt_checked_at is not None 

918 assert attempt.receipt_status == "ok" 

919 assert attempt.receipt_error_code is None 

920 

921 # Subscription should still be enabled 

922 sub = session.execute( 

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

924 ).scalar_one() 

925 assert sub.disabled_at == DATETIME_INFINITY 

926 

927 

928def test_check_expo_push_receipts_device_not_registered(db): 

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

930 user, token = generate_user() 

931 

932 # Create a push subscription and delivery attempt 

933 with session_scope() as session: 

934 sub = PushNotificationSubscription( 

935 user_id=user.id, 

936 platform=PushNotificationPlatform.expo, 

937 token="ExponentPushToken[devicegone]", 

938 device_name="Test Device", 

939 device_type=DeviceType.android, 

940 ) 

941 session.add(sub) 

942 session.flush() 

943 

944 attempt = PushNotificationDeliveryAttempt( 

945 push_notification_subscription_id=sub.id, 

946 outcome=PushNotificationDeliveryOutcome.success, 

947 status_code=200, 

948 expo_ticket_id="ticket-device-gone", 

949 ) 

950 session.add(attempt) 

951 session.flush() 

952 # Make the attempt old enough to be checked 

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

954 attempt_id = attempt.id 

955 sub_id = sub.id 

956 

957 # Mock the receipt API call with DeviceNotRegistered error 

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

959 mock_post.return_value.status_code = 200 

960 mock_post.return_value.json.return_value = { 

961 "data": { 

962 "ticket-device-gone": { 

963 "status": "error", 

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

965 } 

966 } 

967 } 

968 

969 check_expo_push_receipts(empty_pb2.Empty()) 

970 

971 # Verify the attempt was updated and subscription disabled 

972 with session_scope() as session: 

973 attempt = session.execute( 

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

975 ).scalar_one() 

976 assert attempt.receipt_checked_at is not None 

977 assert attempt.receipt_status == "error" 

978 assert attempt.receipt_error_code == "DeviceNotRegistered" 

979 

980 # Subscription should be disabled 

981 sub = session.execute( 

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

983 ).scalar_one() 

984 assert sub.disabled_at <= now() 

985 

986 

987def test_check_expo_push_receipts_not_found(db): 

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

989 user, token = generate_user() 

990 

991 with session_scope() as session: 

992 sub = PushNotificationSubscription( 

993 user_id=user.id, 

994 platform=PushNotificationPlatform.expo, 

995 token="ExponentPushToken[notfound]", 

996 ) 

997 session.add(sub) 

998 session.flush() 

999 

1000 attempt = PushNotificationDeliveryAttempt( 

1001 push_notification_subscription_id=sub.id, 

1002 outcome=PushNotificationDeliveryOutcome.success, 

1003 status_code=200, 

1004 expo_ticket_id="unknown-ticket", 

1005 ) 

1006 session.add(attempt) 

1007 session.flush() 

1008 # Make the attempt old enough to be checked 

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

1010 attempt_id = attempt.id 

1011 sub_id = sub.id 

1012 

1013 # Mock empty receipt response (ticket not found) 

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

1015 mock_post.return_value.status_code = 200 

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

1017 

1018 check_expo_push_receipts(empty_pb2.Empty()) 

1019 

1020 with session_scope() as session: 

1021 attempt = session.execute( 

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

1023 ).scalar_one() 

1024 assert attempt.receipt_checked_at is not None 

1025 assert attempt.receipt_status == "not_found" 

1026 

1027 # Subscription should still be enabled 

1028 sub = session.execute( 

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

1030 ).scalar_one() 

1031 assert sub.disabled_at == DATETIME_INFINITY 

1032 

1033 

1034def test_check_expo_push_receipts_skips_already_checked(db): 

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

1036 user, token = generate_user() 

1037 

1038 # Create an attempt that was already checked 

1039 with session_scope() as session: 

1040 sub = PushNotificationSubscription( 

1041 user_id=user.id, 

1042 platform=PushNotificationPlatform.expo, 

1043 token="ExponentPushToken[alreadychecked]", 

1044 ) 

1045 session.add(sub) 

1046 session.flush() 

1047 

1048 attempt = PushNotificationDeliveryAttempt( 

1049 push_notification_subscription_id=sub.id, 

1050 outcome=PushNotificationDeliveryOutcome.success, 

1051 status_code=200, 

1052 expo_ticket_id="already-checked-ticket", 

1053 receipt_checked_at=now(), 

1054 receipt_status="ok", 

1055 ) 

1056 session.add(attempt) 

1057 session.flush() 

1058 # Make the attempt old enough 

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

1060 

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

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

1063 check_expo_push_receipts(empty_pb2.Empty()) 

1064 mock_post.assert_not_called() 

1065 

1066 

1067def test_SendDevPushNotification_success(db, push_collector: PushCollector): 

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

1069 user, token = generate_user() 

1070 

1071 # Enable dev APIs for this test 

1072 config["ENABLE_DEV_APIS"] = True 

1073 

1074 with notifications_session(token) as notifications: 

1075 notifications.SendDevPushNotification( 

1076 notifications_pb2.SendDevPushNotificationReq( 

1077 title="Test Dev Title", 

1078 body="Test dev notification body", 

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

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

1081 key="test-key", 

1082 ttl=3600, 

1083 ) 

1084 ) 

1085 

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

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

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

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

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

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

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

1093 assert push.ttl == 3600 

1094 

1095 

1096def test_SendDevPushNotification_minimal(db, push_collector: PushCollector): 

1097 """Test SendDevPushNotification with minimal parameters.""" 

1098 user, token = generate_user() 

1099 

1100 config["ENABLE_DEV_APIS"] = True 

1101 

1102 with notifications_session(token) as notifications: 

1103 notifications.SendDevPushNotification( 

1104 notifications_pb2.SendDevPushNotificationReq( 

1105 title="Minimal Title", 

1106 body="Minimal body", 

1107 ) 

1108 ) 

1109 

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

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

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

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

1114 

1115 

1116def test_SendDevPushNotification_disabled(db, push_collector: PushCollector): 

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

1118 user, token = generate_user() 

1119 

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

1121 config["ENABLE_DEV_APIS"] = False 

1122 

1123 with notifications_session(token) as notifications: 

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

1125 notifications.SendDevPushNotification( 

1126 notifications_pb2.SendDevPushNotificationReq( 

1127 title="Should Fail", 

1128 body="This should not be sent", 

1129 ) 

1130 ) 

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

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

1133 

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

1135 

1136 

1137def test_SendDevPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1139 user, token = generate_user() 

1140 

1141 config["ENABLE_DEV_APIS"] = True 

1142 config["PUSH_NOTIFICATIONS_ENABLED"] = False 

1143 

1144 with notifications_session(token) as notifications: 

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

1146 notifications.SendDevPushNotification( 

1147 notifications_pb2.SendDevPushNotificationReq( 

1148 title="Should Fail", 

1149 body="This should not be sent", 

1150 ) 

1151 ) 

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

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

1154 

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

1156 

1157 

1158def test_check_expo_push_receipts_skips_too_recent(db): 

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

1160 user, token = generate_user() 

1161 

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

1163 with session_scope() as session: 

1164 sub = PushNotificationSubscription( 

1165 user_id=user.id, 

1166 platform=PushNotificationPlatform.expo, 

1167 token="ExponentPushToken[recent]", 

1168 ) 

1169 session.add(sub) 

1170 session.flush() 

1171 

1172 attempt = PushNotificationDeliveryAttempt( 

1173 push_notification_subscription_id=sub.id, 

1174 outcome=PushNotificationDeliveryOutcome.success, 

1175 status_code=200, 

1176 expo_ticket_id="recent-ticket", 

1177 ) 

1178 session.add(attempt) 

1179 session.flush() 

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

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

1182 

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

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

1185 check_expo_push_receipts(empty_pb2.Empty()) 

1186 mock_post.assert_not_called() 

1187 

1188 

1189def test_check_expo_push_receipts_batch(db): 

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

1191 user, token = generate_user() 

1192 

1193 # Create multiple delivery attempts 

1194 attempt_ids = [] 

1195 with session_scope() as session: 

1196 sub = PushNotificationSubscription( 

1197 user_id=user.id, 

1198 platform=PushNotificationPlatform.expo, 

1199 token="ExponentPushToken[batch]", 

1200 ) 

1201 session.add(sub) 

1202 session.flush() 

1203 

1204 for i in range(3): 

1205 attempt = PushNotificationDeliveryAttempt( 

1206 push_notification_subscription_id=sub.id, 

1207 outcome=PushNotificationDeliveryOutcome.success, 

1208 status_code=200, 

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

1210 ) 

1211 session.add(attempt) 

1212 session.flush() 

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

1214 attempt_ids.append(attempt.id) 

1215 

1216 # Mock the batch receipt API call 

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

1218 mock_post.return_value.status_code = 200 

1219 mock_post.return_value.json.return_value = { 

1220 "data": { 

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

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

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

1224 } 

1225 } 

1226 

1227 check_expo_push_receipts(empty_pb2.Empty()) 

1228 

1229 # Should only call the API once for all tickets 

1230 assert mock_post.call_count == 1 

1231 

1232 # Verify all attempts were updated 

1233 with session_scope() as session: 

1234 for attempt_id in attempt_ids: 

1235 attempt = session.execute( 

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

1237 ).scalar_one() 

1238 assert attempt.receipt_checked_at is not None 

1239 assert attempt.receipt_status == "ok" 

1240 

1241 

1242def test_DebugRedeliverPushNotification_success(db, push_collector: PushCollector): 

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

1244 user, token = generate_user() 

1245 

1246 config["ENABLE_DEV_APIS"] = True 

1247 

1248 # Create a notification for the user 

1249 with session_scope() as session: 

1250 notify( 

1251 session, 

1252 user_id=user.id, 

1253 topic_action=NotificationTopicAction.badge__add, 

1254 key="test-badge", 

1255 data=notification_data_pb2.BadgeAdd( 

1256 badge_id="volunteer", 

1257 badge_name="Active Volunteer", 

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

1259 ), 

1260 ) 

1261 

1262 process_job() 

1263 

1264 # Pop the initial push notification 

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

1266 

1267 # Get the notification_id 

1268 with session_scope() as session: 

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

1270 notification_id = notification.id 

1271 

1272 # Redeliver the notification 

1273 with notifications_session(token) as notifications: 

1274 notifications.DebugRedeliverPushNotification( 

1275 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1276 ) 

1277 

1278 # Verify a new push was sent 

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

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

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

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

1283 

1284 

1285def test_DebugRedeliverPushNotification_not_found(db, push_collector: PushCollector): 

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

1287 user, token = generate_user() 

1288 

1289 config["ENABLE_DEV_APIS"] = True 

1290 

1291 with notifications_session(token) as notifications: 

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

1293 notifications.DebugRedeliverPushNotification( 

1294 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=999999) 

1295 ) 

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

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

1298 

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

1300 

1301 

1302def test_DebugRedeliverPushNotification_wrong_user(db, push_collector: PushCollector): 

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

1304 user1, token1 = generate_user() 

1305 user2, token2 = generate_user() 

1306 

1307 config["ENABLE_DEV_APIS"] = True 

1308 

1309 # Create a notification for user1 

1310 with session_scope() as session: 

1311 notify( 

1312 session, 

1313 user_id=user1.id, 

1314 topic_action=NotificationTopicAction.badge__add, 

1315 key="test-badge", 

1316 data=notification_data_pb2.BadgeAdd( 

1317 badge_id="volunteer", 

1318 badge_name="Active Volunteer", 

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

1320 ), 

1321 ) 

1322 

1323 process_job() 

1324 

1325 # Get the notification_id 

1326 with session_scope() as session: 

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

1328 notification_id = notification.id 

1329 

1330 # user2 tries to redeliver user1's notification 

1331 with notifications_session(token2) as notifications: 

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

1333 notifications.DebugRedeliverPushNotification( 

1334 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1335 ) 

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

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

1338 

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

1340 

1341 

1342def test_DebugRedeliverPushNotification_disabled(db, push_collector: PushCollector): 

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

1344 user, token = generate_user() 

1345 

1346 config["ENABLE_DEV_APIS"] = False 

1347 

1348 with notifications_session(token) as notifications: 

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

1350 notifications.DebugRedeliverPushNotification( 

1351 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1352 ) 

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

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

1355 

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

1357 

1358 

1359def test_DebugRedeliverPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1361 user, token = generate_user() 

1362 

1363 config["ENABLE_DEV_APIS"] = True 

1364 config["PUSH_NOTIFICATIONS_ENABLED"] = False 

1365 

1366 with notifications_session(token) as notifications: 

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

1368 notifications.DebugRedeliverPushNotification( 

1369 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1370 ) 

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

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

1373 

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

1375 

1376 

1377def test_handle_notification_email_delivery(db): 

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

1379 user, token = generate_user() 

1380 

1381 topic_action = NotificationTopicAction.badge__add 

1382 

1383 # Enable email notifications for this topic 

1384 with notifications_session(token) as notifications: 

1385 notifications.SetNotificationSettings( 

1386 notifications_pb2.SetNotificationSettingsReq( 

1387 preferences=[ 

1388 notifications_pb2.SingleNotificationPreference( 

1389 topic=topic_action.topic, 

1390 action=topic_action.action, 

1391 delivery_method="email", 

1392 enabled=True, 

1393 ) 

1394 ], 

1395 ) 

1396 ) 

1397 

1398 with mock_notification_email() as mock: 

1399 with session_scope() as session: 

1400 notify( 

1401 session, 

1402 user_id=user.id, 

1403 topic_action=topic_action, 

1404 key="test-badge", 

1405 data=notification_data_pb2.BadgeAdd( 

1406 badge_id="volunteer", 

1407 badge_name="Active Volunteer", 

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

1409 ), 

1410 ) 

1411 

1412 assert mock.call_count == 1 

1413 assert email_fields(mock).recipient == user.email 

1414 

1415 with session_scope() as session: 

1416 delivery = session.execute( 

1417 select(NotificationDelivery) 

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

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

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

1421 ).scalar_one() 

1422 assert delivery.delivered is not None 

1423 

1424 

1425def test_handle_notification_push_delivery(db, push_collector: PushCollector): 

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

1427 user, token = generate_user() 

1428 

1429 topic_action = NotificationTopicAction.badge__add 

1430 

1431 with session_scope() as session: 

1432 notify( 

1433 session, 

1434 user_id=user.id, 

1435 topic_action=topic_action, 

1436 key="test-badge", 

1437 data=notification_data_pb2.BadgeAdd( 

1438 badge_id="volunteer", 

1439 badge_name="Active Volunteer", 

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

1441 ), 

1442 ) 

1443 

1444 process_job() 

1445 

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

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

1448 

1449 with session_scope() as session: 

1450 delivery = session.execute( 

1451 select(NotificationDelivery) 

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

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

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

1455 ).scalar_one() 

1456 assert delivery.delivered is not None 

1457 

1458 

1459def test_handle_notification_digest_delivery(db): 

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

1461 user, token = generate_user() 

1462 

1463 topic_action = NotificationTopicAction.badge__add 

1464 

1465 # Enable only digest notifications for this topic 

1466 with notifications_session(token) as notifications: 

1467 notifications.SetNotificationSettings( 

1468 notifications_pb2.SetNotificationSettingsReq( 

1469 preferences=[ 

1470 notifications_pb2.SingleNotificationPreference( 

1471 topic=topic_action.topic, 

1472 action=topic_action.action, 

1473 delivery_method="push", 

1474 enabled=False, 

1475 ), 

1476 notifications_pb2.SingleNotificationPreference( 

1477 topic=topic_action.topic, 

1478 action=topic_action.action, 

1479 delivery_method="digest", 

1480 enabled=True, 

1481 ), 

1482 ], 

1483 ) 

1484 ) 

1485 

1486 with session_scope() as session: 

1487 notify( 

1488 session, 

1489 user_id=user.id, 

1490 topic_action=topic_action, 

1491 key="test-badge", 

1492 data=notification_data_pb2.BadgeAdd( 

1493 badge_id="volunteer", 

1494 badge_name="Active Volunteer", 

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

1496 ), 

1497 ) 

1498 

1499 process_job() 

1500 

1501 # Verify digest NotificationDelivery was created WITHOUT delivered timestamp 

1502 with session_scope() as session: 

1503 delivery = session.execute( 

1504 select(NotificationDelivery) 

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

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

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

1508 ).scalar_one() 

1509 assert delivery.delivered is None 

1510 

1511 

1512def test_handle_notification_banned_user_no_email(db): 

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

1514 user, token = generate_user() 

1515 

1516 topic_action = NotificationTopicAction.badge__add 

1517 

1518 # Enable email notifications 

1519 with notifications_session(token) as notifications: 

1520 notifications.SetNotificationSettings( 

1521 notifications_pb2.SetNotificationSettingsReq( 

1522 preferences=[ 

1523 notifications_pb2.SingleNotificationPreference( 

1524 topic=topic_action.topic, 

1525 action=topic_action.action, 

1526 delivery_method="email", 

1527 enabled=True, 

1528 ) 

1529 ], 

1530 ) 

1531 ) 

1532 

1533 # Ban the user 

1534 with session_scope() as session: 

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

1536 

1537 with mock_notification_email() as mock: 

1538 with session_scope() as session: 

1539 notify( 

1540 session, 

1541 user_id=user.id, 

1542 topic_action=topic_action, 

1543 key="test-badge", 

1544 data=notification_data_pb2.BadgeAdd( 

1545 badge_id="volunteer", 

1546 badge_name="Active Volunteer", 

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

1548 ), 

1549 ) 

1550 

1551 # Email should not be sent to the banned user 

1552 assert mock.call_count == 0 

1553 

1554 

1555def test_handle_notification_deleted_user_no_regular_email(db): 

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

1557 user, token = generate_user() 

1558 

1559 topic_action = NotificationTopicAction.badge__add 

1560 

1561 # Enable email notifications 

1562 with notifications_session(token) as notifications: 

1563 notifications.SetNotificationSettings( 

1564 notifications_pb2.SetNotificationSettingsReq( 

1565 preferences=[ 

1566 notifications_pb2.SingleNotificationPreference( 

1567 topic=topic_action.topic, 

1568 action=topic_action.action, 

1569 delivery_method="email", 

1570 enabled=True, 

1571 ) 

1572 ], 

1573 ) 

1574 ) 

1575 

1576 # Delete the user 

1577 with session_scope() as session: 

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

1579 

1580 with mock_notification_email() as mock: 

1581 with session_scope() as session: 

1582 notify( 

1583 session, 

1584 user_id=user.id, 

1585 topic_action=topic_action, 

1586 key="test-badge", 

1587 data=notification_data_pb2.BadgeAdd( 

1588 badge_id="volunteer", 

1589 badge_name="Active Volunteer", 

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

1591 ), 

1592 ) 

1593 

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

1595 assert mock.call_count == 0 

1596 

1597 

1598def test_handle_notification_deleted_user_receives_account_deletion_email(db): 

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

1600 user, token = generate_user() 

1601 

1602 topic_action = NotificationTopicAction.account_deletion__complete 

1603 

1604 # Delete the user 

1605 with session_scope() as session: 

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

1607 

1608 with mock_notification_email() as mock: 

1609 with session_scope() as session: 

1610 notify( 

1611 session, 

1612 user_id=user.id, 

1613 topic_action=topic_action, 

1614 key="", 

1615 data=notification_data_pb2.AccountDeletionComplete( 

1616 undelete_token="test-token", 

1617 undelete_days=7, 

1618 ), 

1619 ) 

1620 

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

1622 assert mock.call_count == 1 

1623 assert email_fields(mock).recipient == user.email 

1624 

1625 

1626def test_handle_notification_do_not_email_respected(db): 

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

1628 user, token = generate_user() 

1629 

1630 topic_action = NotificationTopicAction.badge__add 

1631 

1632 # Enable email notifications 

1633 with notifications_session(token) as notifications: 

1634 notifications.SetNotificationSettings( 

1635 notifications_pb2.SetNotificationSettingsReq( 

1636 preferences=[ 

1637 notifications_pb2.SingleNotificationPreference( 

1638 topic=topic_action.topic, 

1639 action=topic_action.action, 

1640 delivery_method="email", 

1641 enabled=True, 

1642 ) 

1643 ], 

1644 ) 

1645 ) 

1646 

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

1648 with session_scope() as session: 

1649 session.execute( 

1650 update(User) 

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

1652 .values( 

1653 hosting_status=HostingStatus.cant_host, 

1654 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1655 do_not_email=True, 

1656 ) 

1657 ) 

1658 

1659 with mock_notification_email() as mock: 

1660 with session_scope() as session: 

1661 notify( 

1662 session, 

1663 user_id=user.id, 

1664 topic_action=topic_action, 

1665 key="test-badge", 

1666 data=notification_data_pb2.BadgeAdd( 

1667 badge_id="volunteer", 

1668 badge_name="Active Volunteer", 

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

1670 ), 

1671 ) 

1672 

1673 # Email should not be sent when do_not_email is True 

1674 assert mock.call_count == 0 

1675 

1676 

1677def test_handle_notification_critical_bypasses_do_not_email(db): 

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

1679 user, token = generate_user() 

1680 

1681 topic_action = NotificationTopicAction.password__change 

1682 

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

1684 with session_scope() as session: 

1685 session.execute( 

1686 update(User) 

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

1688 .values( 

1689 hosting_status=HostingStatus.cant_host, 

1690 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1691 do_not_email=True, 

1692 ) 

1693 ) 

1694 

1695 with mock_notification_email() as mock: 

1696 with session_scope() as session: 

1697 notify( 

1698 session, 

1699 user_id=user.id, 

1700 topic_action=topic_action, 

1701 key="", 

1702 data=None, 

1703 ) 

1704 

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

1706 assert mock.call_count == 1 

1707 assert email_fields(mock).recipient == user.email 

1708 

1709 

1710def test_handle_notification_duplicate_delivery_skipped(db, push_collector: PushCollector): 

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

1712 user, token = generate_user() 

1713 

1714 topic_action = NotificationTopicAction.badge__add 

1715 

1716 # Create notification manually 

1717 with session_scope() as session: 

1718 notification = Notification( 

1719 user_id=user.id, 

1720 topic_action=topic_action, 

1721 key="test-badge", 

1722 data=notification_data_pb2.BadgeAdd( 

1723 badge_id="volunteer", 

1724 badge_name="Active Volunteer", 

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

1726 ).SerializeToString(), 

1727 ) 

1728 session.add(notification) 

1729 session.flush() 

1730 notification_id = notification.id 

1731 

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

1733 session.add( 

1734 NotificationDelivery( 

1735 notification_id=notification_id, 

1736 delivery_type=NotificationDeliveryType.push, 

1737 delivered=now(), 

1738 ) 

1739 ) 

1740 

1741 # Try to handle the notification again 

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

1743 

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

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

1746 

1747 # Verify only one delivery exists 

1748 with session_scope() as session: 

1749 delivery_count = len( 

1750 session.execute( 

1751 select(NotificationDelivery) 

1752 .where(NotificationDelivery.notification_id == notification_id) 

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

1754 ) 

1755 .scalars() 

1756 .all() 

1757 ) 

1758 assert delivery_count == 1 

1759 

1760 

1761def test_handle_notification_deferred_when_content_not_visible(db, moderator): 

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

1763 user1, token1 = generate_user(complete_profile=True) 

1764 user2, token2 = generate_user(complete_profile=True) 

1765 

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

1767 # This also queues a notification via SendFriendRequest 

1768 with api_session(token2) as api: 

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

1770 

1771 # Process the queued job (handle_notification) 

1772 process_job() 

1773 

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

1775 with session_scope() as session: 

1776 notification = session.execute( 

1777 select(Notification) 

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

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

1780 ).scalar_one() 

1781 

1782 deliveries = ( 

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

1784 .scalars() 

1785 .all() 

1786 ) 

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

1788 assert len(deliveries) == 0 

1789 

1790 

1791def test_handle_notification_delivered_when_content_visible(db, moderator): 

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

1793 user1, token1 = generate_user(complete_profile=True) 

1794 user2, token2 = generate_user(complete_profile=True) 

1795 

1796 # Create a friend request 

1797 with api_session(token2) as api: 

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

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

1800 fr_id = res.sent[0].friend_request_id 

1801 

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

1803 process_job() 

1804 

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

1806 moderator.approve_friend_request(fr_id) 

1807 

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

1809 process_jobs() 

1810 

1811 # Notification should have been delivered 

1812 with session_scope() as session: 

1813 notification = session.execute( 

1814 select(Notification) 

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

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

1817 ).scalar_one() 

1818 

1819 deliveries = ( 

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

1821 .scalars() 

1822 .all() 

1823 ) 

1824 # At least one delivery should exist 

1825 assert len(deliveries) > 0 

1826 

1827 

1828def test_handle_notification_multiple_delivery_types(db, push_collector: PushCollector): 

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

1830 user, token = generate_user() 

1831 

1832 topic_action = NotificationTopicAction.badge__add 

1833 

1834 # Enable both email and push notifications 

1835 with notifications_session(token) as notifications: 

1836 notifications.SetNotificationSettings( 

1837 notifications_pb2.SetNotificationSettingsReq( 

1838 preferences=[ 

1839 notifications_pb2.SingleNotificationPreference( 

1840 topic=topic_action.topic, 

1841 action=topic_action.action, 

1842 delivery_method="email", 

1843 enabled=True, 

1844 ), 

1845 notifications_pb2.SingleNotificationPreference( 

1846 topic=topic_action.topic, 

1847 action=topic_action.action, 

1848 delivery_method="push", 

1849 enabled=True, 

1850 ), 

1851 notifications_pb2.SingleNotificationPreference( 

1852 topic=topic_action.topic, 

1853 action=topic_action.action, 

1854 delivery_method="digest", 

1855 enabled=True, 

1856 ), 

1857 ], 

1858 ) 

1859 ) 

1860 

1861 with mock_notification_email() as mock: 

1862 with session_scope() as session: 

1863 notify( 

1864 session, 

1865 user_id=user.id, 

1866 topic_action=topic_action, 

1867 key="test-badge", 

1868 data=notification_data_pb2.BadgeAdd( 

1869 badge_id="volunteer", 

1870 badge_name="Active Volunteer", 

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

1872 ), 

1873 ) 

1874 

1875 # Email should be sent 

1876 assert mock.call_count == 1 

1877 

1878 # Push should be sent 

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

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

1881 

1882 # All three delivery types should have deliveries 

1883 with session_scope() as session: 

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

1885 

1886 deliveries = ( 

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

1888 .scalars() 

1889 .all() 

1890 ) 

1891 

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

1893 assert NotificationDeliveryType.email in delivery_types 

1894 assert NotificationDeliveryType.push in delivery_types 

1895 assert NotificationDeliveryType.digest in delivery_types 

1896 

1897 # Email and push should have delivered timestamps 

1898 for delivery in deliveries: 

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

1900 assert delivery.delivered is not None 

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

1902 assert delivery.delivered is None