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

799 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +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, reset_preference 

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_reset_preference(db): 

661 user, token = generate_user() 

662 

663 topic_action = NotificationTopicAction.event__create_any 

664 # neither delivery type is on by default, so enabling both leaves two overrides to tell apart 

665 assert NotificationDeliveryType.push not in topic_action.defaults 

666 assert NotificationDeliveryType.email not in topic_action.defaults 

667 

668 def get_setting() -> notifications_pb2.NotificationItem: 

669 with notifications_session(token) as notifications: 

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

671 items: list[notifications_pb2.NotificationItem] = [ 

672 item 

673 for group in res.groups 

674 for topic in group.topics 

675 if topic.topic == topic_action.topic 

676 for item in topic.items 

677 if item.action == topic_action.action 

678 ] 

679 (item,) = items 

680 return item 

681 

682 with notifications_session(token) as notifications: 

683 notifications.SetNotificationSettings( 

684 notifications_pb2.SetNotificationSettingsReq( 

685 preferences=[ 

686 notifications_pb2.SingleNotificationPreference( 

687 topic=topic_action.topic, 

688 action=topic_action.action, 

689 delivery_method=delivery_method, 

690 enabled=True, 

691 ) 

692 for delivery_method in ["push", "email"] 

693 ] 

694 ) 

695 ) 

696 

697 assert get_setting().push 

698 assert get_setting().email 

699 

700 # there is no API for clearing an override back to its default, it's only reachable internally 

701 with session_scope() as session: 

702 reset_preference(session, user.id, topic_action, NotificationDeliveryType.push) 

703 

704 # only the push override should go, leaving the email one in place 

705 assert not get_setting().push 

706 assert get_setting().email 

707 

708 

709def test_event_reminder_email_sent(db, email_collector: EmailCollector): 

710 user, token = generate_user() 

711 title = "Board Game Night" 

712 

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

714 start_time = timestamp_pb2.Timestamp(seconds=1751690400) 

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

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

717 

718 with session_scope() as session: 

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

720 

721 notify( 

722 session, 

723 user_id=user.id, 

724 topic_action=NotificationTopicAction.event__reminder, 

725 key="", 

726 data=notification_data_pb2.EventReminder( 

727 event=events_pb2.Event( 

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

729 ), 

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

731 ), 

732 ) 

733 

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

735 assert email.recipient == user.email 

736 assert title in email.html 

737 assert title in email.plain 

738 assert expected_time_str in email.html 

739 assert expected_time_str in email.plain 

740 

741 

742def test_RegisterMobilePushNotificationSubscription(db): 

743 user, token = generate_user() 

744 

745 with notifications_session(token) as notifications: 

746 notifications.RegisterMobilePushNotificationSubscription( 

747 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

748 token="ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", 

749 device_name="My iPhone", 

750 device_type="ios", 

751 ) 

752 ) 

753 

754 # Check subscription was created 

755 with session_scope() as session: 

756 sub = session.execute( 

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

758 ).scalar_one() 

759 assert sub.platform == PushNotificationPlatform.expo 

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

761 assert sub.device_name == "My iPhone" 

762 assert sub.device_type == DeviceType.ios 

763 assert sub.disabled_at == DATETIME_INFINITY 

764 

765 

766def test_RegisterMobilePushNotificationSubscription_android(db): 

767 user, token = generate_user() 

768 

769 with notifications_session(token) as notifications: 

770 notifications.RegisterMobilePushNotificationSubscription( 

771 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

772 token="ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]", 

773 device_name="My Android", 

774 device_type="android", 

775 ) 

776 ) 

777 

778 with session_scope() as session: 

779 sub = session.execute( 

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

781 ).scalar_one() 

782 assert sub.platform == PushNotificationPlatform.expo 

783 assert sub.device_type == DeviceType.android 

784 

785 

786def test_RegisterMobilePushNotificationSubscription_no_device_type(db): 

787 user, token = generate_user() 

788 

789 with notifications_session(token) as notifications: 

790 notifications.RegisterMobilePushNotificationSubscription( 

791 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

792 token="ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]", 

793 ) 

794 ) 

795 

796 with session_scope() as session: 

797 sub = session.execute( 

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

799 ).scalar_one() 

800 assert sub.platform == PushNotificationPlatform.expo 

801 assert sub.device_name is None 

802 assert sub.device_type is None 

803 

804 

805def test_RegisterMobilePushNotificationSubscription_re_enable(db): 

806 user, token = generate_user() 

807 

808 # Create a disabled subscription directly in the DB 

809 with session_scope() as session: 

810 sub = PushNotificationSubscription( 

811 user_id=user.id, 

812 platform=PushNotificationPlatform.expo, 

813 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

814 device_name="Old Device", 

815 device_type=DeviceType.ios, 

816 ) 

817 sub.disabled_at = now() 

818 session.add(sub) 

819 session.flush() 

820 sub_id = sub.id 

821 

822 # Re-register with the same token 

823 with notifications_session(token) as notifications: 

824 notifications.RegisterMobilePushNotificationSubscription( 

825 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

826 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

827 device_name="New Device Name", 

828 device_type="android", 

829 ) 

830 ) 

831 

832 # Check subscription was re-enabled and updated 

833 with session_scope() as session: 

834 sub = session.execute( 

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

836 ).scalar_one() 

837 assert sub.disabled_at == DATETIME_INFINITY 

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

839 assert sub.device_type == DeviceType.android 

840 

841 

842def test_RegisterMobilePushNotificationSubscription_already_exists(db): 

843 user, token = generate_user() 

844 

845 # Create an active subscription directly in the DB 

846 with session_scope() as session: 

847 sub = PushNotificationSubscription( 

848 user_id=user.id, 

849 platform=PushNotificationPlatform.expo, 

850 token="ExponentPushToken[existingtoken]", 

851 device_name="Existing Device", 

852 device_type=DeviceType.ios, 

853 ) 

854 session.add(sub) 

855 

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

857 with notifications_session(token) as notifications: 

858 notifications.RegisterMobilePushNotificationSubscription( 

859 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

860 token="ExponentPushToken[existingtoken]", 

861 device_name="Different Name", 

862 ) 

863 ) 

864 

865 # Check subscription was NOT modified (already active) 

866 with session_scope() as session: 

867 sub = session.execute( 

868 select(PushNotificationSubscription).where( 

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

870 ) 

871 ).scalar_one() 

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

873 

874 

875def test_SendTestMobilePushNotification(db, push_collector: PushCollector): 

876 user, token = generate_user() 

877 

878 with notifications_session(token) as notifications: 

879 notifications.SendTestMobilePushNotification(empty_pb2.Empty()) 

880 

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

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

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

884 

885 

886def test_get_expo_push_receipts(db): 

887 mock_response = Mock() 

888 mock_response.status_code = 200 

889 mock_response.json.return_value = { 

890 "data": { 

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

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

893 } 

894 } 

895 

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

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

898 

899 mock_post.assert_called_once() 

900 call_args = mock_post.call_args 

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

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

903 

904 assert result == { 

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

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

907 } 

908 

909 

910def test_get_expo_push_receipts_empty(db): 

911 result = get_expo_push_receipts([]) 

912 assert result == {} 

913 

914 

915def test_check_expo_push_receipts_success(db): 

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

917 user, token = generate_user() 

918 

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

920 with session_scope() as session: 

921 sub = PushNotificationSubscription( 

922 user_id=user.id, 

923 platform=PushNotificationPlatform.expo, 

924 token="ExponentPushToken[testtoken123]", 

925 device_name="Test Device", 

926 device_type=DeviceType.ios, 

927 ) 

928 session.add(sub) 

929 session.flush() 

930 

931 attempt = PushNotificationDeliveryAttempt( 

932 push_notification_subscription_id=sub.id, 

933 outcome=PushNotificationDeliveryOutcome.success, 

934 status_code=200, 

935 expo_ticket_id="test-ticket-id", 

936 ) 

937 session.add(attempt) 

938 session.flush() 

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

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

941 attempt_id = attempt.id 

942 sub_id = sub.id 

943 

944 # Mock the receipt API call 

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

946 mock_post.return_value.status_code = 200 

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

948 

949 check_expo_push_receipts(empty_pb2.Empty()) 

950 

951 # Verify the attempt was updated 

952 with session_scope() as session: 

953 attempt = session.execute( 

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

955 ).scalar_one() 

956 assert attempt.receipt_checked_at is not None 

957 assert attempt.receipt_status == "ok" 

958 assert attempt.receipt_error_code is None 

959 

960 # Subscription should still be enabled 

961 sub = session.execute( 

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

963 ).scalar_one() 

964 assert sub.disabled_at == DATETIME_INFINITY 

965 

966 

967def test_check_expo_push_receipts_device_not_registered(db): 

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

969 user, token = generate_user() 

970 

971 # Create a push subscription and delivery attempt 

972 with session_scope() as session: 

973 sub = PushNotificationSubscription( 

974 user_id=user.id, 

975 platform=PushNotificationPlatform.expo, 

976 token="ExponentPushToken[devicegone]", 

977 device_name="Test Device", 

978 device_type=DeviceType.android, 

979 ) 

980 session.add(sub) 

981 session.flush() 

982 

983 attempt = PushNotificationDeliveryAttempt( 

984 push_notification_subscription_id=sub.id, 

985 outcome=PushNotificationDeliveryOutcome.success, 

986 status_code=200, 

987 expo_ticket_id="ticket-device-gone", 

988 ) 

989 session.add(attempt) 

990 session.flush() 

991 # Make the attempt old enough to be checked 

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

993 attempt_id = attempt.id 

994 sub_id = sub.id 

995 

996 # Mock the receipt API call with DeviceNotRegistered error 

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

998 mock_post.return_value.status_code = 200 

999 mock_post.return_value.json.return_value = { 

1000 "data": { 

1001 "ticket-device-gone": { 

1002 "status": "error", 

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

1004 } 

1005 } 

1006 } 

1007 

1008 check_expo_push_receipts(empty_pb2.Empty()) 

1009 

1010 # Verify the attempt was updated and subscription disabled 

1011 with session_scope() as session: 

1012 attempt = session.execute( 

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

1014 ).scalar_one() 

1015 assert attempt.receipt_checked_at is not None 

1016 assert attempt.receipt_status == "error" 

1017 assert attempt.receipt_error_code == "DeviceNotRegistered" 

1018 

1019 # Subscription should be disabled 

1020 sub = session.execute( 

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

1022 ).scalar_one() 

1023 assert sub.disabled_at <= now() 

1024 

1025 

1026def test_check_expo_push_receipts_not_found(db): 

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

1028 user, token = generate_user() 

1029 

1030 with session_scope() as session: 

1031 sub = PushNotificationSubscription( 

1032 user_id=user.id, 

1033 platform=PushNotificationPlatform.expo, 

1034 token="ExponentPushToken[notfound]", 

1035 ) 

1036 session.add(sub) 

1037 session.flush() 

1038 

1039 attempt = PushNotificationDeliveryAttempt( 

1040 push_notification_subscription_id=sub.id, 

1041 outcome=PushNotificationDeliveryOutcome.success, 

1042 status_code=200, 

1043 expo_ticket_id="unknown-ticket", 

1044 ) 

1045 session.add(attempt) 

1046 session.flush() 

1047 # Make the attempt old enough to be checked 

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

1049 attempt_id = attempt.id 

1050 sub_id = sub.id 

1051 

1052 # Mock empty receipt response (ticket not found) 

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

1054 mock_post.return_value.status_code = 200 

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

1056 

1057 check_expo_push_receipts(empty_pb2.Empty()) 

1058 

1059 with session_scope() as session: 

1060 attempt = session.execute( 

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

1062 ).scalar_one() 

1063 assert attempt.receipt_checked_at is not None 

1064 assert attempt.receipt_status == "not_found" 

1065 

1066 # Subscription should still be enabled 

1067 sub = session.execute( 

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

1069 ).scalar_one() 

1070 assert sub.disabled_at == DATETIME_INFINITY 

1071 

1072 

1073def test_check_expo_push_receipts_skips_already_checked(db): 

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

1075 user, token = generate_user() 

1076 

1077 # Create an attempt that was already checked 

1078 with session_scope() as session: 

1079 sub = PushNotificationSubscription( 

1080 user_id=user.id, 

1081 platform=PushNotificationPlatform.expo, 

1082 token="ExponentPushToken[alreadychecked]", 

1083 ) 

1084 session.add(sub) 

1085 session.flush() 

1086 

1087 attempt = PushNotificationDeliveryAttempt( 

1088 push_notification_subscription_id=sub.id, 

1089 outcome=PushNotificationDeliveryOutcome.success, 

1090 status_code=200, 

1091 expo_ticket_id="already-checked-ticket", 

1092 receipt_checked_at=now(), 

1093 receipt_status="ok", 

1094 ) 

1095 session.add(attempt) 

1096 session.flush() 

1097 # Make the attempt old enough 

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

1099 

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

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

1102 check_expo_push_receipts(empty_pb2.Empty()) 

1103 mock_post.assert_not_called() 

1104 

1105 

1106def test_SendDevPushNotification_success(db, push_collector: PushCollector): 

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

1108 user, token = generate_user() 

1109 

1110 # Enable dev APIs for this test 

1111 config.ENABLE_DEV_APIS = True 

1112 

1113 with notifications_session(token) as notifications: 

1114 notifications.SendDevPushNotification( 

1115 notifications_pb2.SendDevPushNotificationReq( 

1116 title="Test Dev Title", 

1117 body="Test dev notification body", 

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

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

1120 key="test-key", 

1121 ttl=3600, 

1122 ) 

1123 ) 

1124 

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

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

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

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

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

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

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

1132 assert push.ttl == 3600 

1133 

1134 

1135def test_SendDevPushNotification_minimal(db, push_collector: PushCollector): 

1136 """Test SendDevPushNotification with minimal parameters.""" 

1137 user, token = generate_user() 

1138 

1139 config.ENABLE_DEV_APIS = True 

1140 

1141 with notifications_session(token) as notifications: 

1142 notifications.SendDevPushNotification( 

1143 notifications_pb2.SendDevPushNotificationReq( 

1144 title="Minimal Title", 

1145 body="Minimal body", 

1146 ) 

1147 ) 

1148 

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

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

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

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

1153 

1154 

1155def test_SendDevPushNotification_disabled(db, push_collector: PushCollector): 

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

1157 user, token = generate_user() 

1158 

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

1160 config.ENABLE_DEV_APIS = False 

1161 

1162 with notifications_session(token) as notifications: 

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

1164 notifications.SendDevPushNotification( 

1165 notifications_pb2.SendDevPushNotificationReq( 

1166 title="Should Fail", 

1167 body="This should not be sent", 

1168 ) 

1169 ) 

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

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

1172 

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

1174 

1175 

1176def test_SendDevPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1178 user, token = generate_user() 

1179 

1180 config.ENABLE_DEV_APIS = True 

1181 config.PUSH_NOTIFICATIONS_ENABLED = False 

1182 

1183 with notifications_session(token) as notifications: 

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

1185 notifications.SendDevPushNotification( 

1186 notifications_pb2.SendDevPushNotificationReq( 

1187 title="Should Fail", 

1188 body="This should not be sent", 

1189 ) 

1190 ) 

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

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

1193 

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

1195 

1196 

1197def test_check_expo_push_receipts_skips_too_recent(db): 

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

1199 user, token = generate_user() 

1200 

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

1202 with session_scope() as session: 

1203 sub = PushNotificationSubscription( 

1204 user_id=user.id, 

1205 platform=PushNotificationPlatform.expo, 

1206 token="ExponentPushToken[recent]", 

1207 ) 

1208 session.add(sub) 

1209 session.flush() 

1210 

1211 attempt = PushNotificationDeliveryAttempt( 

1212 push_notification_subscription_id=sub.id, 

1213 outcome=PushNotificationDeliveryOutcome.success, 

1214 status_code=200, 

1215 expo_ticket_id="recent-ticket", 

1216 ) 

1217 session.add(attempt) 

1218 session.flush() 

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

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

1221 

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

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

1224 check_expo_push_receipts(empty_pb2.Empty()) 

1225 mock_post.assert_not_called() 

1226 

1227 

1228def test_check_expo_push_receipts_batch(db): 

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

1230 user, token = generate_user() 

1231 

1232 # Create multiple delivery attempts 

1233 attempt_ids = [] 

1234 with session_scope() as session: 

1235 sub = PushNotificationSubscription( 

1236 user_id=user.id, 

1237 platform=PushNotificationPlatform.expo, 

1238 token="ExponentPushToken[batch]", 

1239 ) 

1240 session.add(sub) 

1241 session.flush() 

1242 

1243 for i in range(3): 

1244 attempt = PushNotificationDeliveryAttempt( 

1245 push_notification_subscription_id=sub.id, 

1246 outcome=PushNotificationDeliveryOutcome.success, 

1247 status_code=200, 

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

1249 ) 

1250 session.add(attempt) 

1251 session.flush() 

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

1253 attempt_ids.append(attempt.id) 

1254 

1255 # Mock the batch receipt API call 

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

1257 mock_post.return_value.status_code = 200 

1258 mock_post.return_value.json.return_value = { 

1259 "data": { 

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

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

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

1263 } 

1264 } 

1265 

1266 check_expo_push_receipts(empty_pb2.Empty()) 

1267 

1268 # Should only call the API once for all tickets 

1269 assert mock_post.call_count == 1 

1270 

1271 # Verify all attempts were updated 

1272 with session_scope() as session: 

1273 for attempt_id in attempt_ids: 

1274 attempt = session.execute( 

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

1276 ).scalar_one() 

1277 assert attempt.receipt_checked_at is not None 

1278 assert attempt.receipt_status == "ok" 

1279 

1280 

1281def test_DebugRedeliverPushNotification_success(db, push_collector: PushCollector): 

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

1283 user, token = generate_user() 

1284 

1285 config.ENABLE_DEV_APIS = True 

1286 

1287 # Create a notification for the user 

1288 with session_scope() as session: 

1289 notify( 

1290 session, 

1291 user_id=user.id, 

1292 topic_action=NotificationTopicAction.badge__add, 

1293 key="test-badge", 

1294 data=notification_data_pb2.BadgeAdd( 

1295 badge_id="volunteer", 

1296 badge_name="Active Volunteer", 

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

1298 ), 

1299 ) 

1300 

1301 process_job() 

1302 

1303 # Pop the initial push notification 

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

1305 

1306 # Get the notification_id 

1307 with session_scope() as session: 

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

1309 notification_id = notification.id 

1310 

1311 # Redeliver the notification 

1312 with notifications_session(token) as notifications: 

1313 notifications.DebugRedeliverPushNotification( 

1314 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1315 ) 

1316 

1317 # Verify a new push was sent 

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

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

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

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

1322 

1323 

1324def test_DebugRedeliverPushNotification_not_found(db, push_collector: PushCollector): 

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

1326 user, token = generate_user() 

1327 

1328 config.ENABLE_DEV_APIS = True 

1329 

1330 with notifications_session(token) as notifications: 

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

1332 notifications.DebugRedeliverPushNotification( 

1333 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=999999) 

1334 ) 

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

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

1337 

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

1339 

1340 

1341def test_DebugRedeliverPushNotification_wrong_user(db, push_collector: PushCollector): 

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

1343 user1, token1 = generate_user() 

1344 user2, token2 = generate_user() 

1345 

1346 config.ENABLE_DEV_APIS = True 

1347 

1348 # Create a notification for user1 

1349 with session_scope() as session: 

1350 notify( 

1351 session, 

1352 user_id=user1.id, 

1353 topic_action=NotificationTopicAction.badge__add, 

1354 key="test-badge", 

1355 data=notification_data_pb2.BadgeAdd( 

1356 badge_id="volunteer", 

1357 badge_name="Active Volunteer", 

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

1359 ), 

1360 ) 

1361 

1362 process_job() 

1363 

1364 # Get the notification_id 

1365 with session_scope() as session: 

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

1367 notification_id = notification.id 

1368 

1369 # user2 tries to redeliver user1's notification 

1370 with notifications_session(token2) as notifications: 

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

1372 notifications.DebugRedeliverPushNotification( 

1373 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1374 ) 

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

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

1377 

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

1379 

1380 

1381def test_DebugRedeliverPushNotification_disabled(db, push_collector: PushCollector): 

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

1383 user, token = generate_user() 

1384 

1385 config.ENABLE_DEV_APIS = False 

1386 

1387 with notifications_session(token) as notifications: 

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

1389 notifications.DebugRedeliverPushNotification( 

1390 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1391 ) 

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

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

1394 

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

1396 

1397 

1398def test_DebugRedeliverPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1400 user, token = generate_user() 

1401 

1402 config.ENABLE_DEV_APIS = True 

1403 config.PUSH_NOTIFICATIONS_ENABLED = False 

1404 

1405 with notifications_session(token) as notifications: 

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

1407 notifications.DebugRedeliverPushNotification( 

1408 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1409 ) 

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

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

1412 

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

1414 

1415 

1416def test_handle_notification_email_delivery(db, email_collector: EmailCollector): 

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

1418 user, token = generate_user() 

1419 

1420 topic_action = NotificationTopicAction.badge__add 

1421 

1422 # Enable email notifications for this topic 

1423 with notifications_session(token) as notifications: 

1424 notifications.SetNotificationSettings( 

1425 notifications_pb2.SetNotificationSettingsReq( 

1426 preferences=[ 

1427 notifications_pb2.SingleNotificationPreference( 

1428 topic=topic_action.topic, 

1429 action=topic_action.action, 

1430 delivery_method="email", 

1431 enabled=True, 

1432 ) 

1433 ], 

1434 ) 

1435 ) 

1436 

1437 with session_scope() as session: 

1438 notify( 

1439 session, 

1440 user_id=user.id, 

1441 topic_action=topic_action, 

1442 key="test-badge", 

1443 data=notification_data_pb2.BadgeAdd( 

1444 badge_id="volunteer", 

1445 badge_name="Active Volunteer", 

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

1447 ), 

1448 ) 

1449 

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

1451 assert email.recipient == user.email 

1452 

1453 with session_scope() as session: 

1454 delivery = session.execute( 

1455 select(NotificationDelivery) 

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

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

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

1459 ).scalar_one() 

1460 assert delivery.delivered is not None 

1461 

1462 

1463def test_handle_notification_push_delivery(db, push_collector: PushCollector): 

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

1465 user, token = generate_user() 

1466 

1467 topic_action = NotificationTopicAction.badge__add 

1468 

1469 with session_scope() as session: 

1470 notify( 

1471 session, 

1472 user_id=user.id, 

1473 topic_action=topic_action, 

1474 key="test-badge", 

1475 data=notification_data_pb2.BadgeAdd( 

1476 badge_id="volunteer", 

1477 badge_name="Active Volunteer", 

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

1479 ), 

1480 ) 

1481 

1482 process_job() 

1483 

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

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

1486 

1487 with session_scope() as session: 

1488 delivery = session.execute( 

1489 select(NotificationDelivery) 

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

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

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

1493 ).scalar_one() 

1494 assert delivery.delivered is not None 

1495 

1496 

1497def test_handle_notification_digest_delivery(db): 

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

1499 user, token = generate_user() 

1500 

1501 topic_action = NotificationTopicAction.badge__add 

1502 

1503 # Enable only digest notifications for this topic 

1504 with notifications_session(token) as notifications: 

1505 notifications.SetNotificationSettings( 

1506 notifications_pb2.SetNotificationSettingsReq( 

1507 preferences=[ 

1508 notifications_pb2.SingleNotificationPreference( 

1509 topic=topic_action.topic, 

1510 action=topic_action.action, 

1511 delivery_method="push", 

1512 enabled=False, 

1513 ), 

1514 notifications_pb2.SingleNotificationPreference( 

1515 topic=topic_action.topic, 

1516 action=topic_action.action, 

1517 delivery_method="digest", 

1518 enabled=True, 

1519 ), 

1520 ], 

1521 ) 

1522 ) 

1523 

1524 with session_scope() as session: 

1525 notify( 

1526 session, 

1527 user_id=user.id, 

1528 topic_action=topic_action, 

1529 key="test-badge", 

1530 data=notification_data_pb2.BadgeAdd( 

1531 badge_id="volunteer", 

1532 badge_name="Active Volunteer", 

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

1534 ), 

1535 ) 

1536 

1537 process_job() 

1538 

1539 # Verify digest NotificationDelivery was created WITHOUT delivered timestamp 

1540 with session_scope() as session: 

1541 delivery = session.execute( 

1542 select(NotificationDelivery) 

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

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

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

1546 ).scalar_one() 

1547 assert delivery.delivered is None 

1548 

1549 

1550def test_handle_notification_banned_user_no_email(db, email_collector: EmailCollector): 

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

1552 user, token = generate_user() 

1553 

1554 topic_action = NotificationTopicAction.badge__add 

1555 

1556 # Enable email notifications 

1557 with notifications_session(token) as notifications: 

1558 notifications.SetNotificationSettings( 

1559 notifications_pb2.SetNotificationSettingsReq( 

1560 preferences=[ 

1561 notifications_pb2.SingleNotificationPreference( 

1562 topic=topic_action.topic, 

1563 action=topic_action.action, 

1564 delivery_method="email", 

1565 enabled=True, 

1566 ) 

1567 ], 

1568 ) 

1569 ) 

1570 

1571 # Ban the user 

1572 with session_scope() as session: 

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

1574 

1575 with session_scope() as session: 

1576 notify( 

1577 session, 

1578 user_id=user.id, 

1579 topic_action=topic_action, 

1580 key="test-badge", 

1581 data=notification_data_pb2.BadgeAdd( 

1582 badge_id="volunteer", 

1583 badge_name="Active Volunteer", 

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

1585 ), 

1586 ) 

1587 

1588 # Email should not be sent to the banned user 

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

1590 

1591 

1592def test_handle_notification_deleted_user_no_regular_email(db, email_collector: EmailCollector): 

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

1594 user, token = generate_user() 

1595 

1596 topic_action = NotificationTopicAction.badge__add 

1597 

1598 # Enable email notifications 

1599 with notifications_session(token) as notifications: 

1600 notifications.SetNotificationSettings( 

1601 notifications_pb2.SetNotificationSettingsReq( 

1602 preferences=[ 

1603 notifications_pb2.SingleNotificationPreference( 

1604 topic=topic_action.topic, 

1605 action=topic_action.action, 

1606 delivery_method="email", 

1607 enabled=True, 

1608 ) 

1609 ], 

1610 ) 

1611 ) 

1612 

1613 # Delete the user 

1614 with session_scope() as session: 

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

1616 

1617 with session_scope() as session: 

1618 notify( 

1619 session, 

1620 user_id=user.id, 

1621 topic_action=topic_action, 

1622 key="test-badge", 

1623 data=notification_data_pb2.BadgeAdd( 

1624 badge_id="volunteer", 

1625 badge_name="Active Volunteer", 

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

1627 ), 

1628 ) 

1629 

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

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

1632 

1633 

1634def test_handle_notification_deleted_user_receives_account_deletion_email(db, email_collector: EmailCollector): 

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

1636 user, token = generate_user() 

1637 

1638 topic_action = NotificationTopicAction.account_deletion__complete 

1639 

1640 # Delete the user 

1641 with session_scope() as session: 

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

1643 

1644 with session_scope() as session: 

1645 notify( 

1646 session, 

1647 user_id=user.id, 

1648 topic_action=topic_action, 

1649 key="", 

1650 data=notification_data_pb2.AccountDeletionComplete( 

1651 undelete_token="test-token", 

1652 undelete_days=7, 

1653 ), 

1654 ) 

1655 

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

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

1658 assert email.recipient == user.email 

1659 

1660 

1661def test_handle_notification_do_not_email_respected(db, email_collector: EmailCollector): 

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

1663 user, token = generate_user() 

1664 

1665 topic_action = NotificationTopicAction.badge__add 

1666 

1667 # Enable email notifications 

1668 with notifications_session(token) as notifications: 

1669 notifications.SetNotificationSettings( 

1670 notifications_pb2.SetNotificationSettingsReq( 

1671 preferences=[ 

1672 notifications_pb2.SingleNotificationPreference( 

1673 topic=topic_action.topic, 

1674 action=topic_action.action, 

1675 delivery_method="email", 

1676 enabled=True, 

1677 ) 

1678 ], 

1679 ) 

1680 ) 

1681 

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

1683 with session_scope() as session: 

1684 session.execute( 

1685 update(User) 

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

1687 .values( 

1688 hosting_status=HostingStatus.cant_host, 

1689 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1690 do_not_email=True, 

1691 ) 

1692 ) 

1693 

1694 with session_scope() as session: 

1695 notify( 

1696 session, 

1697 user_id=user.id, 

1698 topic_action=topic_action, 

1699 key="test-badge", 

1700 data=notification_data_pb2.BadgeAdd( 

1701 badge_id="volunteer", 

1702 badge_name="Active Volunteer", 

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

1704 ), 

1705 ) 

1706 

1707 # Email should not be sent when do_not_email is True 

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

1709 

1710 

1711def test_handle_notification_critical_bypasses_do_not_email(db, email_collector: EmailCollector): 

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

1713 user, token = generate_user() 

1714 

1715 topic_action = NotificationTopicAction.password__change 

1716 

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

1718 with session_scope() as session: 

1719 session.execute( 

1720 update(User) 

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

1722 .values( 

1723 hosting_status=HostingStatus.cant_host, 

1724 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1725 do_not_email=True, 

1726 ) 

1727 ) 

1728 

1729 with session_scope() as session: 

1730 notify( 

1731 session, 

1732 user_id=user.id, 

1733 topic_action=topic_action, 

1734 key="", 

1735 data=None, 

1736 ) 

1737 

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

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

1740 assert email.recipient == user.email 

1741 

1742 

1743def test_handle_notification_duplicate_delivery_skipped(db, push_collector: PushCollector): 

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

1745 user, token = generate_user() 

1746 

1747 topic_action = NotificationTopicAction.badge__add 

1748 

1749 # Create notification manually 

1750 with session_scope() as session: 

1751 notification = Notification( 

1752 user_id=user.id, 

1753 topic_action=topic_action, 

1754 key="test-badge", 

1755 data=notification_data_pb2.BadgeAdd( 

1756 badge_id="volunteer", 

1757 badge_name="Active Volunteer", 

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

1759 ).SerializeToString(), 

1760 ) 

1761 session.add(notification) 

1762 session.flush() 

1763 notification_id = notification.id 

1764 

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

1766 session.add( 

1767 NotificationDelivery( 

1768 notification_id=notification_id, 

1769 delivery_type=NotificationDeliveryType.push, 

1770 delivered=now(), 

1771 ) 

1772 ) 

1773 

1774 # Try to handle the notification again 

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

1776 

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

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

1779 

1780 # Verify only one delivery exists 

1781 with session_scope() as session: 

1782 delivery_count = len( 

1783 session.execute( 

1784 select(NotificationDelivery) 

1785 .where(NotificationDelivery.notification_id == notification_id) 

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

1787 ) 

1788 .scalars() 

1789 .all() 

1790 ) 

1791 assert delivery_count == 1 

1792 

1793 

1794def test_handle_notification_deferred_when_content_not_visible(db, moderator): 

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

1796 user1, token1 = generate_user(complete_profile=True) 

1797 user2, token2 = generate_user(complete_profile=True) 

1798 

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

1800 # This also queues a notification via SendFriendRequest 

1801 with api_session(token2) as api: 

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

1803 

1804 # Process the queued job (handle_notification) 

1805 process_job() 

1806 

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

1808 with session_scope() as session: 

1809 notification = session.execute( 

1810 select(Notification) 

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

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

1813 ).scalar_one() 

1814 

1815 deliveries = ( 

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

1817 .scalars() 

1818 .all() 

1819 ) 

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

1821 assert len(deliveries) == 0 

1822 

1823 

1824def test_handle_notification_delivered_when_content_visible(db, moderator): 

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

1826 user1, token1 = generate_user(complete_profile=True) 

1827 user2, token2 = generate_user(complete_profile=True) 

1828 

1829 # Create a friend request 

1830 with api_session(token2) as api: 

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

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

1833 fr_id = res.sent[0].friend_request_id 

1834 

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

1836 process_job() 

1837 

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

1839 moderator.approve_friend_request(fr_id) 

1840 

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

1842 process_jobs() 

1843 

1844 # Notification should have been delivered 

1845 with session_scope() as session: 

1846 notification = session.execute( 

1847 select(Notification) 

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

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

1850 ).scalar_one() 

1851 

1852 deliveries = ( 

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

1854 .scalars() 

1855 .all() 

1856 ) 

1857 # At least one delivery should exist 

1858 assert len(deliveries) > 0 

1859 

1860 

1861def test_notification_serializes_shadowed_actor(db, moderator): 

1862 recipient, _ = generate_user(complete_profile=True) 

1863 sender, sender_token = generate_user(complete_profile=True) 

1864 

1865 with session_scope() as session: 

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

1867 

1868 with api_session(sender_token) as api: 

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

1870 

1871 process_job() 

1872 

1873 with session_scope() as session: 

1874 notification = session.execute( 

1875 select(Notification) 

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

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

1878 ).scalar_one() 

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

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

1881 assert not data.other_user.is_ghost 

1882 

1883 

1884def test_handle_notification_multiple_delivery_types( 

1885 db, email_collector: EmailCollector, push_collector: PushCollector 

1886): 

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

1888 user, token = generate_user() 

1889 

1890 topic_action = NotificationTopicAction.badge__add 

1891 

1892 # Enable both email and push notifications 

1893 with notifications_session(token) as notifications: 

1894 notifications.SetNotificationSettings( 

1895 notifications_pb2.SetNotificationSettingsReq( 

1896 preferences=[ 

1897 notifications_pb2.SingleNotificationPreference( 

1898 topic=topic_action.topic, 

1899 action=topic_action.action, 

1900 delivery_method="email", 

1901 enabled=True, 

1902 ), 

1903 notifications_pb2.SingleNotificationPreference( 

1904 topic=topic_action.topic, 

1905 action=topic_action.action, 

1906 delivery_method="push", 

1907 enabled=True, 

1908 ), 

1909 notifications_pb2.SingleNotificationPreference( 

1910 topic=topic_action.topic, 

1911 action=topic_action.action, 

1912 delivery_method="digest", 

1913 enabled=True, 

1914 ), 

1915 ], 

1916 ) 

1917 ) 

1918 

1919 with session_scope() as session: 

1920 notify( 

1921 session, 

1922 user_id=user.id, 

1923 topic_action=topic_action, 

1924 key="test-badge", 

1925 data=notification_data_pb2.BadgeAdd( 

1926 badge_id="volunteer", 

1927 badge_name="Active Volunteer", 

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

1929 ), 

1930 ) 

1931 

1932 # Email should be sent 

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

1934 

1935 # Push should be sent 

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

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

1938 

1939 # All three delivery types should have deliveries 

1940 with session_scope() as session: 

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

1942 

1943 deliveries = ( 

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

1945 .scalars() 

1946 .all() 

1947 ) 

1948 

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

1950 assert NotificationDeliveryType.email in delivery_types 

1951 assert NotificationDeliveryType.push in delivery_types 

1952 assert NotificationDeliveryType.digest in delivery_types 

1953 

1954 # Email and push should have delivered timestamps 

1955 for delivery in deliveries: 

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

1957 assert delivery.delivered is not None 

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

1959 assert delivery.delivered is None