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

763 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +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_GetVapidPublicKey(db): 

472 _, token = generate_user() 

473 

474 with notifications_session(token) as notifications: 

475 assert ( 

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

477 == "BApMo2tGuon07jv-pEaAKZmVo6E_d4HfcdDeV6wx2k9wV8EovJ0ve00bdLzZm9fizDrGZXRYJFqCcRJUfBcgA0A" 

478 ) 

479 

480 

481def test_RegisterPushNotificationSubscription(db): 

482 _, token = generate_user() 

483 

484 subscription_info = { 

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

486 "expirationTime": None, 

487 "keys": { 

488 "auth": "TnuEJ1OdfEkf6HKcUovl0Q", 

489 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0", 

490 }, 

491 } 

492 

493 with notifications_session(token) as notifications: 

494 res = notifications.RegisterPushNotificationSubscription( 

495 notifications_pb2.RegisterPushNotificationSubscriptionReq( 

496 full_subscription_json=json.dumps(subscription_info), 

497 ) 

498 ) 

499 

500 

501def test_SendTestPushNotification(db, push_collector: PushCollector): 

502 user, token = generate_user() 

503 

504 with notifications_session(token) as notifications: 

505 notifications.SendTestPushNotification(empty_pb2.Empty()) 

506 

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

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

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

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

511 

512 

513def test_SendBlogPostNotification(db, push_collector: PushCollector): 

514 super_user, super_token = generate_user(is_superuser=True) 

515 

516 user1, user1_token = generate_user() 

517 # enabled email 

518 user2, user2_token = generate_user() 

519 # disabled push 

520 user3, user3_token = generate_user() 

521 

522 topic_action = NotificationTopicAction.general__new_blog_post 

523 

524 with notifications_session(user2_token) as notifications: 

525 notifications.SetNotificationSettings( 

526 notifications_pb2.SetNotificationSettingsReq( 

527 preferences=[ 

528 notifications_pb2.SingleNotificationPreference( 

529 topic=topic_action.topic, 

530 action=topic_action.action, 

531 delivery_method="email", 

532 enabled=True, 

533 ) 

534 ], 

535 ) 

536 ) 

537 

538 with notifications_session(user3_token) as notifications: 

539 notifications.SetNotificationSettings( 

540 notifications_pb2.SetNotificationSettingsReq( 

541 preferences=[ 

542 notifications_pb2.SingleNotificationPreference( 

543 topic=topic_action.topic, 

544 action=topic_action.action, 

545 delivery_method="push", 

546 enabled=False, 

547 ) 

548 ], 

549 ) 

550 ) 

551 

552 with mock_notification_email() as mock: 

553 with real_editor_session(super_token) as editor_api: 

554 editor_api.SendBlogPostNotification( 

555 editor_pb2.SendBlogPostNotificationReq( 

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

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

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

559 ) 

560 ) 

561 

562 process_jobs() 

563 

564 assert mock.call_count == 1 

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

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

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

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

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

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

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

572 

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

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

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

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

577 

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

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

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

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

582 

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

584 

585 

586def test_get_topic_actions_by_delivery_type(db): 

587 user, token = generate_user() 

588 

589 # these are enabled by default 

590 assert NotificationDeliveryType.push in NotificationTopicAction.reference__receive_friend.defaults 

591 assert NotificationDeliveryType.push in NotificationTopicAction.host_request__accept.defaults 

592 

593 # these are disabled by default 

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

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

596 

597 with notifications_session(token) as notifications: 

598 notifications.SetNotificationSettings( 

599 notifications_pb2.SetNotificationSettingsReq( 

600 preferences=[ 

601 notifications_pb2.SingleNotificationPreference( 

602 topic=NotificationTopicAction.reference__receive_friend.topic, 

603 action=NotificationTopicAction.reference__receive_friend.action, 

604 delivery_method="push", 

605 enabled=False, 

606 ), 

607 notifications_pb2.SingleNotificationPreference( 

608 topic=NotificationTopicAction.event__create_any.topic, 

609 action=NotificationTopicAction.event__create_any.action, 

610 delivery_method="push", 

611 enabled=True, 

612 ), 

613 ], 

614 ) 

615 ) 

616 

617 with session_scope() as session: 

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

619 assert NotificationTopicAction.reference__receive_friend not in deliver 

620 assert NotificationTopicAction.host_request__accept in deliver 

621 assert NotificationTopicAction.event__create_any in deliver 

622 assert NotificationTopicAction.discussion__create not in deliver 

623 assert NotificationTopicAction.account_deletion__start in deliver 

624 

625 

626def test_event_reminder_email_sent(db): 

627 user, token = generate_user() 

628 title = "Board Game Night" 

629 start_event_time = timestamp_pb2.Timestamp(seconds=1751690400) 

630 

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

632 

633 with mock_notification_email() as mock: 

634 with session_scope() as session: 

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

636 

637 notify( 

638 session, 

639 user_id=user.id, 

640 topic_action=NotificationTopicAction.event__reminder, 

641 key="", 

642 data=notification_data_pb2.EventReminder( 

643 event=events_pb2.Event( 

644 event_id=1, 

645 slug="board-game-night", 

646 title=title, 

647 start_time=start_event_time, 

648 ), 

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

650 ), 

651 ) 

652 

653 assert mock.call_count == 1 

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

655 assert title in email_fields(mock).html 

656 assert title in email_fields(mock).plain 

657 assert expected_time_str in email_fields(mock).html 

658 assert expected_time_str in email_fields(mock).plain 

659 

660 

661def test_RegisterMobilePushNotificationSubscription(db): 

662 user, token = generate_user() 

663 

664 with notifications_session(token) as notifications: 

665 notifications.RegisterMobilePushNotificationSubscription( 

666 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

667 token="ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", 

668 device_name="My iPhone", 

669 device_type="ios", 

670 ) 

671 ) 

672 

673 # Check subscription was created 

674 with session_scope() as session: 

675 sub = session.execute( 

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

677 ).scalar_one() 

678 assert sub.platform == PushNotificationPlatform.expo 

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

680 assert sub.device_name == "My iPhone" 

681 assert sub.device_type == DeviceType.ios 

682 assert sub.disabled_at == DATETIME_INFINITY 

683 

684 

685def test_RegisterMobilePushNotificationSubscription_android(db): 

686 user, token = generate_user() 

687 

688 with notifications_session(token) as notifications: 

689 notifications.RegisterMobilePushNotificationSubscription( 

690 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

691 token="ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]", 

692 device_name="My Android", 

693 device_type="android", 

694 ) 

695 ) 

696 

697 with session_scope() as session: 

698 sub = session.execute( 

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

700 ).scalar_one() 

701 assert sub.platform == PushNotificationPlatform.expo 

702 assert sub.device_type == DeviceType.android 

703 

704 

705def test_RegisterMobilePushNotificationSubscription_no_device_type(db): 

706 user, token = generate_user() 

707 

708 with notifications_session(token) as notifications: 

709 notifications.RegisterMobilePushNotificationSubscription( 

710 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

711 token="ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]", 

712 ) 

713 ) 

714 

715 with session_scope() as session: 

716 sub = session.execute( 

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

718 ).scalar_one() 

719 assert sub.platform == PushNotificationPlatform.expo 

720 assert sub.device_name is None 

721 assert sub.device_type is None 

722 

723 

724def test_RegisterMobilePushNotificationSubscription_re_enable(db): 

725 user, token = generate_user() 

726 

727 # Create a disabled subscription directly in the DB 

728 with session_scope() as session: 

729 sub = PushNotificationSubscription( 

730 user_id=user.id, 

731 platform=PushNotificationPlatform.expo, 

732 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

733 device_name="Old Device", 

734 device_type=DeviceType.ios, 

735 ) 

736 sub.disabled_at = now() 

737 session.add(sub) 

738 session.flush() 

739 sub_id = sub.id 

740 

741 # Re-register with the same token 

742 with notifications_session(token) as notifications: 

743 notifications.RegisterMobilePushNotificationSubscription( 

744 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

745 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

746 device_name="New Device Name", 

747 device_type="android", 

748 ) 

749 ) 

750 

751 # Check subscription was re-enabled and updated 

752 with session_scope() as session: 

753 sub = session.execute( 

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

755 ).scalar_one() 

756 assert sub.disabled_at == DATETIME_INFINITY 

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

758 assert sub.device_type == DeviceType.android 

759 

760 

761def test_RegisterMobilePushNotificationSubscription_already_exists(db): 

762 user, token = generate_user() 

763 

764 # Create an active subscription directly in the DB 

765 with session_scope() as session: 

766 sub = PushNotificationSubscription( 

767 user_id=user.id, 

768 platform=PushNotificationPlatform.expo, 

769 token="ExponentPushToken[existingtoken]", 

770 device_name="Existing Device", 

771 device_type=DeviceType.ios, 

772 ) 

773 session.add(sub) 

774 

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

776 with notifications_session(token) as notifications: 

777 notifications.RegisterMobilePushNotificationSubscription( 

778 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

779 token="ExponentPushToken[existingtoken]", 

780 device_name="Different Name", 

781 ) 

782 ) 

783 

784 # Check subscription was NOT modified (already active) 

785 with session_scope() as session: 

786 sub = session.execute( 

787 select(PushNotificationSubscription).where( 

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

789 ) 

790 ).scalar_one() 

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

792 

793 

794def test_SendTestMobilePushNotification(db, push_collector: PushCollector): 

795 user, token = generate_user() 

796 

797 with notifications_session(token) as notifications: 

798 notifications.SendTestMobilePushNotification(empty_pb2.Empty()) 

799 

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

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

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

803 

804 

805def test_get_expo_push_receipts(db): 

806 mock_response = Mock() 

807 mock_response.status_code = 200 

808 mock_response.json.return_value = { 

809 "data": { 

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

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

812 } 

813 } 

814 

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

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

817 

818 mock_post.assert_called_once() 

819 call_args = mock_post.call_args 

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

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

822 

823 assert result == { 

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

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

826 } 

827 

828 

829def test_get_expo_push_receipts_empty(db): 

830 result = get_expo_push_receipts([]) 

831 assert result == {} 

832 

833 

834def test_check_expo_push_receipts_success(db): 

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

836 user, token = generate_user() 

837 

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

839 with session_scope() as session: 

840 sub = PushNotificationSubscription( 

841 user_id=user.id, 

842 platform=PushNotificationPlatform.expo, 

843 token="ExponentPushToken[testtoken123]", 

844 device_name="Test Device", 

845 device_type=DeviceType.ios, 

846 ) 

847 session.add(sub) 

848 session.flush() 

849 

850 attempt = PushNotificationDeliveryAttempt( 

851 push_notification_subscription_id=sub.id, 

852 outcome=PushNotificationDeliveryOutcome.success, 

853 status_code=200, 

854 expo_ticket_id="test-ticket-id", 

855 ) 

856 session.add(attempt) 

857 session.flush() 

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

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

860 attempt_id = attempt.id 

861 sub_id = sub.id 

862 

863 # Mock the receipt API call 

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

865 mock_post.return_value.status_code = 200 

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

867 

868 check_expo_push_receipts(empty_pb2.Empty()) 

869 

870 # Verify the attempt was updated 

871 with session_scope() as session: 

872 attempt = session.execute( 

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

874 ).scalar_one() 

875 assert attempt.receipt_checked_at is not None 

876 assert attempt.receipt_status == "ok" 

877 assert attempt.receipt_error_code is None 

878 

879 # Subscription should still be enabled 

880 sub = session.execute( 

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

882 ).scalar_one() 

883 assert sub.disabled_at == DATETIME_INFINITY 

884 

885 

886def test_check_expo_push_receipts_device_not_registered(db): 

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

888 user, token = generate_user() 

889 

890 # Create a push subscription and delivery attempt 

891 with session_scope() as session: 

892 sub = PushNotificationSubscription( 

893 user_id=user.id, 

894 platform=PushNotificationPlatform.expo, 

895 token="ExponentPushToken[devicegone]", 

896 device_name="Test Device", 

897 device_type=DeviceType.android, 

898 ) 

899 session.add(sub) 

900 session.flush() 

901 

902 attempt = PushNotificationDeliveryAttempt( 

903 push_notification_subscription_id=sub.id, 

904 outcome=PushNotificationDeliveryOutcome.success, 

905 status_code=200, 

906 expo_ticket_id="ticket-device-gone", 

907 ) 

908 session.add(attempt) 

909 session.flush() 

910 # Make the attempt old enough to be checked 

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

912 attempt_id = attempt.id 

913 sub_id = sub.id 

914 

915 # Mock the receipt API call with DeviceNotRegistered error 

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

917 mock_post.return_value.status_code = 200 

918 mock_post.return_value.json.return_value = { 

919 "data": { 

920 "ticket-device-gone": { 

921 "status": "error", 

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

923 } 

924 } 

925 } 

926 

927 check_expo_push_receipts(empty_pb2.Empty()) 

928 

929 # Verify the attempt was updated and subscription disabled 

930 with session_scope() as session: 

931 attempt = session.execute( 

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

933 ).scalar_one() 

934 assert attempt.receipt_checked_at is not None 

935 assert attempt.receipt_status == "error" 

936 assert attempt.receipt_error_code == "DeviceNotRegistered" 

937 

938 # Subscription should be disabled 

939 sub = session.execute( 

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

941 ).scalar_one() 

942 assert sub.disabled_at <= now() 

943 

944 

945def test_check_expo_push_receipts_not_found(db): 

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

947 user, token = generate_user() 

948 

949 with session_scope() as session: 

950 sub = PushNotificationSubscription( 

951 user_id=user.id, 

952 platform=PushNotificationPlatform.expo, 

953 token="ExponentPushToken[notfound]", 

954 ) 

955 session.add(sub) 

956 session.flush() 

957 

958 attempt = PushNotificationDeliveryAttempt( 

959 push_notification_subscription_id=sub.id, 

960 outcome=PushNotificationDeliveryOutcome.success, 

961 status_code=200, 

962 expo_ticket_id="unknown-ticket", 

963 ) 

964 session.add(attempt) 

965 session.flush() 

966 # Make the attempt old enough to be checked 

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

968 attempt_id = attempt.id 

969 sub_id = sub.id 

970 

971 # Mock empty receipt response (ticket not found) 

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

973 mock_post.return_value.status_code = 200 

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

975 

976 check_expo_push_receipts(empty_pb2.Empty()) 

977 

978 with session_scope() as session: 

979 attempt = session.execute( 

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

981 ).scalar_one() 

982 assert attempt.receipt_checked_at is not None 

983 assert attempt.receipt_status == "not_found" 

984 

985 # Subscription should still be enabled 

986 sub = session.execute( 

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

988 ).scalar_one() 

989 assert sub.disabled_at == DATETIME_INFINITY 

990 

991 

992def test_check_expo_push_receipts_skips_already_checked(db): 

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

994 user, token = generate_user() 

995 

996 # Create an attempt that was already checked 

997 with session_scope() as session: 

998 sub = PushNotificationSubscription( 

999 user_id=user.id, 

1000 platform=PushNotificationPlatform.expo, 

1001 token="ExponentPushToken[alreadychecked]", 

1002 ) 

1003 session.add(sub) 

1004 session.flush() 

1005 

1006 attempt = PushNotificationDeliveryAttempt( 

1007 push_notification_subscription_id=sub.id, 

1008 outcome=PushNotificationDeliveryOutcome.success, 

1009 status_code=200, 

1010 expo_ticket_id="already-checked-ticket", 

1011 receipt_checked_at=now(), 

1012 receipt_status="ok", 

1013 ) 

1014 session.add(attempt) 

1015 session.flush() 

1016 # Make the attempt old enough 

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

1018 

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

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

1021 check_expo_push_receipts(empty_pb2.Empty()) 

1022 mock_post.assert_not_called() 

1023 

1024 

1025def test_SendDevPushNotification_success(db, push_collector: PushCollector): 

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

1027 user, token = generate_user() 

1028 

1029 # Enable dev APIs for this test 

1030 config["ENABLE_DEV_APIS"] = True 

1031 

1032 with notifications_session(token) as notifications: 

1033 notifications.SendDevPushNotification( 

1034 notifications_pb2.SendDevPushNotificationReq( 

1035 title="Test Dev Title", 

1036 body="Test dev notification body", 

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

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

1039 key="test-key", 

1040 ttl=3600, 

1041 ) 

1042 ) 

1043 

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

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

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

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

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

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

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

1051 assert push.ttl == 3600 

1052 

1053 

1054def test_SendDevPushNotification_minimal(db, push_collector: PushCollector): 

1055 """Test SendDevPushNotification with minimal parameters.""" 

1056 user, token = generate_user() 

1057 

1058 config["ENABLE_DEV_APIS"] = True 

1059 

1060 with notifications_session(token) as notifications: 

1061 notifications.SendDevPushNotification( 

1062 notifications_pb2.SendDevPushNotificationReq( 

1063 title="Minimal Title", 

1064 body="Minimal body", 

1065 ) 

1066 ) 

1067 

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

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

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

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

1072 

1073 

1074def test_SendDevPushNotification_disabled(db, push_collector: PushCollector): 

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

1076 user, token = generate_user() 

1077 

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

1079 config["ENABLE_DEV_APIS"] = False 

1080 

1081 with notifications_session(token) as notifications: 

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

1083 notifications.SendDevPushNotification( 

1084 notifications_pb2.SendDevPushNotificationReq( 

1085 title="Should Fail", 

1086 body="This should not be sent", 

1087 ) 

1088 ) 

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

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

1091 

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

1093 

1094 

1095def test_SendDevPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1097 user, token = generate_user() 

1098 

1099 config["ENABLE_DEV_APIS"] = True 

1100 config["PUSH_NOTIFICATIONS_ENABLED"] = False 

1101 

1102 with notifications_session(token) as notifications: 

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

1104 notifications.SendDevPushNotification( 

1105 notifications_pb2.SendDevPushNotificationReq( 

1106 title="Should Fail", 

1107 body="This should not be sent", 

1108 ) 

1109 ) 

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

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

1112 

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

1114 

1115 

1116def test_check_expo_push_receipts_skips_too_recent(db): 

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

1118 user, token = generate_user() 

1119 

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

1121 with session_scope() as session: 

1122 sub = PushNotificationSubscription( 

1123 user_id=user.id, 

1124 platform=PushNotificationPlatform.expo, 

1125 token="ExponentPushToken[recent]", 

1126 ) 

1127 session.add(sub) 

1128 session.flush() 

1129 

1130 attempt = PushNotificationDeliveryAttempt( 

1131 push_notification_subscription_id=sub.id, 

1132 outcome=PushNotificationDeliveryOutcome.success, 

1133 status_code=200, 

1134 expo_ticket_id="recent-ticket", 

1135 ) 

1136 session.add(attempt) 

1137 session.flush() 

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

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

1140 

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

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

1143 check_expo_push_receipts(empty_pb2.Empty()) 

1144 mock_post.assert_not_called() 

1145 

1146 

1147def test_check_expo_push_receipts_batch(db): 

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

1149 user, token = generate_user() 

1150 

1151 # Create multiple delivery attempts 

1152 attempt_ids = [] 

1153 with session_scope() as session: 

1154 sub = PushNotificationSubscription( 

1155 user_id=user.id, 

1156 platform=PushNotificationPlatform.expo, 

1157 token="ExponentPushToken[batch]", 

1158 ) 

1159 session.add(sub) 

1160 session.flush() 

1161 

1162 for i in range(3): 

1163 attempt = PushNotificationDeliveryAttempt( 

1164 push_notification_subscription_id=sub.id, 

1165 outcome=PushNotificationDeliveryOutcome.success, 

1166 status_code=200, 

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

1168 ) 

1169 session.add(attempt) 

1170 session.flush() 

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

1172 attempt_ids.append(attempt.id) 

1173 

1174 # Mock the batch receipt API call 

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

1176 mock_post.return_value.status_code = 200 

1177 mock_post.return_value.json.return_value = { 

1178 "data": { 

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

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

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

1182 } 

1183 } 

1184 

1185 check_expo_push_receipts(empty_pb2.Empty()) 

1186 

1187 # Should only call the API once for all tickets 

1188 assert mock_post.call_count == 1 

1189 

1190 # Verify all attempts were updated 

1191 with session_scope() as session: 

1192 for attempt_id in attempt_ids: 

1193 attempt = session.execute( 

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

1195 ).scalar_one() 

1196 assert attempt.receipt_checked_at is not None 

1197 assert attempt.receipt_status == "ok" 

1198 

1199 

1200def test_DebugRedeliverPushNotification_success(db, push_collector: PushCollector): 

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

1202 user, token = generate_user() 

1203 

1204 config["ENABLE_DEV_APIS"] = True 

1205 

1206 # Create a notification for the user 

1207 with session_scope() as session: 

1208 notify( 

1209 session, 

1210 user_id=user.id, 

1211 topic_action=NotificationTopicAction.badge__add, 

1212 key="test-badge", 

1213 data=notification_data_pb2.BadgeAdd( 

1214 badge_id="volunteer", 

1215 badge_name="Active Volunteer", 

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

1217 ), 

1218 ) 

1219 

1220 process_job() 

1221 

1222 # Pop the initial push notification 

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

1224 

1225 # Get the notification_id 

1226 with session_scope() as session: 

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

1228 notification_id = notification.id 

1229 

1230 # Redeliver the notification 

1231 with notifications_session(token) as notifications: 

1232 notifications.DebugRedeliverPushNotification( 

1233 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1234 ) 

1235 

1236 # Verify a new push was sent 

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

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

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

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

1241 

1242 

1243def test_DebugRedeliverPushNotification_not_found(db, push_collector: PushCollector): 

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

1245 user, token = generate_user() 

1246 

1247 config["ENABLE_DEV_APIS"] = True 

1248 

1249 with notifications_session(token) as notifications: 

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

1251 notifications.DebugRedeliverPushNotification( 

1252 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=999999) 

1253 ) 

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

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

1256 

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

1258 

1259 

1260def test_DebugRedeliverPushNotification_wrong_user(db, push_collector: PushCollector): 

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

1262 user1, token1 = generate_user() 

1263 user2, token2 = generate_user() 

1264 

1265 config["ENABLE_DEV_APIS"] = True 

1266 

1267 # Create a notification for user1 

1268 with session_scope() as session: 

1269 notify( 

1270 session, 

1271 user_id=user1.id, 

1272 topic_action=NotificationTopicAction.badge__add, 

1273 key="test-badge", 

1274 data=notification_data_pb2.BadgeAdd( 

1275 badge_id="volunteer", 

1276 badge_name="Active Volunteer", 

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

1278 ), 

1279 ) 

1280 

1281 process_job() 

1282 

1283 # Get the notification_id 

1284 with session_scope() as session: 

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

1286 notification_id = notification.id 

1287 

1288 # user2 tries to redeliver user1's notification 

1289 with notifications_session(token2) as notifications: 

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

1291 notifications.DebugRedeliverPushNotification( 

1292 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1293 ) 

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

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

1296 

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

1298 

1299 

1300def test_DebugRedeliverPushNotification_disabled(db, push_collector: PushCollector): 

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

1302 user, token = generate_user() 

1303 

1304 config["ENABLE_DEV_APIS"] = False 

1305 

1306 with notifications_session(token) as notifications: 

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

1308 notifications.DebugRedeliverPushNotification( 

1309 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1310 ) 

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

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

1313 

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

1315 

1316 

1317def test_DebugRedeliverPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1319 user, token = generate_user() 

1320 

1321 config["ENABLE_DEV_APIS"] = True 

1322 config["PUSH_NOTIFICATIONS_ENABLED"] = False 

1323 

1324 with notifications_session(token) as notifications: 

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

1326 notifications.DebugRedeliverPushNotification( 

1327 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1328 ) 

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

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

1331 

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

1333 

1334 

1335def test_handle_notification_email_delivery(db): 

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

1337 user, token = generate_user() 

1338 

1339 topic_action = NotificationTopicAction.badge__add 

1340 

1341 # Enable email notifications for this topic 

1342 with notifications_session(token) as notifications: 

1343 notifications.SetNotificationSettings( 

1344 notifications_pb2.SetNotificationSettingsReq( 

1345 preferences=[ 

1346 notifications_pb2.SingleNotificationPreference( 

1347 topic=topic_action.topic, 

1348 action=topic_action.action, 

1349 delivery_method="email", 

1350 enabled=True, 

1351 ) 

1352 ], 

1353 ) 

1354 ) 

1355 

1356 with mock_notification_email() as mock: 

1357 with session_scope() as session: 

1358 notify( 

1359 session, 

1360 user_id=user.id, 

1361 topic_action=topic_action, 

1362 key="test-badge", 

1363 data=notification_data_pb2.BadgeAdd( 

1364 badge_id="volunteer", 

1365 badge_name="Active Volunteer", 

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

1367 ), 

1368 ) 

1369 

1370 assert mock.call_count == 1 

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

1372 

1373 with session_scope() as session: 

1374 delivery = session.execute( 

1375 select(NotificationDelivery) 

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

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

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

1379 ).scalar_one() 

1380 assert delivery.delivered is not None 

1381 

1382 

1383def test_handle_notification_push_delivery(db, push_collector: PushCollector): 

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

1385 user, token = generate_user() 

1386 

1387 topic_action = NotificationTopicAction.badge__add 

1388 

1389 with session_scope() as session: 

1390 notify( 

1391 session, 

1392 user_id=user.id, 

1393 topic_action=topic_action, 

1394 key="test-badge", 

1395 data=notification_data_pb2.BadgeAdd( 

1396 badge_id="volunteer", 

1397 badge_name="Active Volunteer", 

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

1399 ), 

1400 ) 

1401 

1402 process_job() 

1403 

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

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

1406 

1407 with session_scope() as session: 

1408 delivery = session.execute( 

1409 select(NotificationDelivery) 

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

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

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

1413 ).scalar_one() 

1414 assert delivery.delivered is not None 

1415 

1416 

1417def test_handle_notification_digest_delivery(db): 

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

1419 user, token = generate_user() 

1420 

1421 topic_action = NotificationTopicAction.badge__add 

1422 

1423 # Enable only digest notifications for this topic 

1424 with notifications_session(token) as notifications: 

1425 notifications.SetNotificationSettings( 

1426 notifications_pb2.SetNotificationSettingsReq( 

1427 preferences=[ 

1428 notifications_pb2.SingleNotificationPreference( 

1429 topic=topic_action.topic, 

1430 action=topic_action.action, 

1431 delivery_method="push", 

1432 enabled=False, 

1433 ), 

1434 notifications_pb2.SingleNotificationPreference( 

1435 topic=topic_action.topic, 

1436 action=topic_action.action, 

1437 delivery_method="digest", 

1438 enabled=True, 

1439 ), 

1440 ], 

1441 ) 

1442 ) 

1443 

1444 with session_scope() as session: 

1445 notify( 

1446 session, 

1447 user_id=user.id, 

1448 topic_action=topic_action, 

1449 key="test-badge", 

1450 data=notification_data_pb2.BadgeAdd( 

1451 badge_id="volunteer", 

1452 badge_name="Active Volunteer", 

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

1454 ), 

1455 ) 

1456 

1457 process_job() 

1458 

1459 # Verify digest NotificationDelivery was created WITHOUT delivered timestamp 

1460 with session_scope() as session: 

1461 delivery = session.execute( 

1462 select(NotificationDelivery) 

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

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

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

1466 ).scalar_one() 

1467 assert delivery.delivered is None 

1468 

1469 

1470def test_handle_notification_banned_user_no_email(db): 

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

1472 user, token = generate_user() 

1473 

1474 topic_action = NotificationTopicAction.badge__add 

1475 

1476 # Enable email notifications 

1477 with notifications_session(token) as notifications: 

1478 notifications.SetNotificationSettings( 

1479 notifications_pb2.SetNotificationSettingsReq( 

1480 preferences=[ 

1481 notifications_pb2.SingleNotificationPreference( 

1482 topic=topic_action.topic, 

1483 action=topic_action.action, 

1484 delivery_method="email", 

1485 enabled=True, 

1486 ) 

1487 ], 

1488 ) 

1489 ) 

1490 

1491 # Ban the user 

1492 with session_scope() as session: 

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

1494 

1495 with mock_notification_email() as mock: 

1496 with session_scope() as session: 

1497 notify( 

1498 session, 

1499 user_id=user.id, 

1500 topic_action=topic_action, 

1501 key="test-badge", 

1502 data=notification_data_pb2.BadgeAdd( 

1503 badge_id="volunteer", 

1504 badge_name="Active Volunteer", 

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

1506 ), 

1507 ) 

1508 

1509 # Email should not be sent to the banned user 

1510 assert mock.call_count == 0 

1511 

1512 

1513def test_handle_notification_deleted_user_no_regular_email(db): 

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

1515 user, token = generate_user() 

1516 

1517 topic_action = NotificationTopicAction.badge__add 

1518 

1519 # Enable email notifications 

1520 with notifications_session(token) as notifications: 

1521 notifications.SetNotificationSettings( 

1522 notifications_pb2.SetNotificationSettingsReq( 

1523 preferences=[ 

1524 notifications_pb2.SingleNotificationPreference( 

1525 topic=topic_action.topic, 

1526 action=topic_action.action, 

1527 delivery_method="email", 

1528 enabled=True, 

1529 ) 

1530 ], 

1531 ) 

1532 ) 

1533 

1534 # Delete the user 

1535 with session_scope() as session: 

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

1537 

1538 with mock_notification_email() as mock: 

1539 with session_scope() as session: 

1540 notify( 

1541 session, 

1542 user_id=user.id, 

1543 topic_action=topic_action, 

1544 key="test-badge", 

1545 data=notification_data_pb2.BadgeAdd( 

1546 badge_id="volunteer", 

1547 badge_name="Active Volunteer", 

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

1549 ), 

1550 ) 

1551 

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

1553 assert mock.call_count == 0 

1554 

1555 

1556def test_handle_notification_deleted_user_receives_account_deletion_email(db): 

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

1558 user, token = generate_user() 

1559 

1560 topic_action = NotificationTopicAction.account_deletion__complete 

1561 

1562 # Delete the user 

1563 with session_scope() as session: 

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

1565 

1566 with mock_notification_email() as mock: 

1567 with session_scope() as session: 

1568 notify( 

1569 session, 

1570 user_id=user.id, 

1571 topic_action=topic_action, 

1572 key="", 

1573 data=notification_data_pb2.AccountDeletionComplete( 

1574 undelete_token="test-token", 

1575 undelete_days=7, 

1576 ), 

1577 ) 

1578 

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

1580 assert mock.call_count == 1 

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

1582 

1583 

1584def test_handle_notification_do_not_email_respected(db): 

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

1586 user, token = generate_user() 

1587 

1588 topic_action = NotificationTopicAction.badge__add 

1589 

1590 # Enable email notifications 

1591 with notifications_session(token) as notifications: 

1592 notifications.SetNotificationSettings( 

1593 notifications_pb2.SetNotificationSettingsReq( 

1594 preferences=[ 

1595 notifications_pb2.SingleNotificationPreference( 

1596 topic=topic_action.topic, 

1597 action=topic_action.action, 

1598 delivery_method="email", 

1599 enabled=True, 

1600 ) 

1601 ], 

1602 ) 

1603 ) 

1604 

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

1606 with session_scope() as session: 

1607 session.execute( 

1608 update(User) 

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

1610 .values( 

1611 hosting_status=HostingStatus.cant_host, 

1612 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1613 do_not_email=True, 

1614 ) 

1615 ) 

1616 

1617 with mock_notification_email() as mock: 

1618 with session_scope() as session: 

1619 notify( 

1620 session, 

1621 user_id=user.id, 

1622 topic_action=topic_action, 

1623 key="test-badge", 

1624 data=notification_data_pb2.BadgeAdd( 

1625 badge_id="volunteer", 

1626 badge_name="Active Volunteer", 

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

1628 ), 

1629 ) 

1630 

1631 # Email should not be sent when do_not_email is True 

1632 assert mock.call_count == 0 

1633 

1634 

1635def test_handle_notification_critical_bypasses_do_not_email(db): 

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

1637 user, token = generate_user() 

1638 

1639 topic_action = NotificationTopicAction.password__change 

1640 

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

1642 with session_scope() as session: 

1643 session.execute( 

1644 update(User) 

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

1646 .values( 

1647 hosting_status=HostingStatus.cant_host, 

1648 meetup_status=MeetupStatus.does_not_want_to_meetup, 

1649 do_not_email=True, 

1650 ) 

1651 ) 

1652 

1653 with mock_notification_email() as mock: 

1654 with session_scope() as session: 

1655 notify( 

1656 session, 

1657 user_id=user.id, 

1658 topic_action=topic_action, 

1659 key="", 

1660 data=None, 

1661 ) 

1662 

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

1664 assert mock.call_count == 1 

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

1666 

1667 

1668def test_handle_notification_duplicate_delivery_skipped(db, push_collector: PushCollector): 

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

1670 user, token = generate_user() 

1671 

1672 topic_action = NotificationTopicAction.badge__add 

1673 

1674 # Create notification manually 

1675 with session_scope() as session: 

1676 notification = Notification( 

1677 user_id=user.id, 

1678 topic_action=topic_action, 

1679 key="test-badge", 

1680 data=notification_data_pb2.BadgeAdd( 

1681 badge_id="volunteer", 

1682 badge_name="Active Volunteer", 

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

1684 ).SerializeToString(), 

1685 ) 

1686 session.add(notification) 

1687 session.flush() 

1688 notification_id = notification.id 

1689 

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

1691 session.add( 

1692 NotificationDelivery( 

1693 notification_id=notification_id, 

1694 delivery_type=NotificationDeliveryType.push, 

1695 delivered=now(), 

1696 ) 

1697 ) 

1698 

1699 # Try to handle the notification again 

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

1701 

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

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

1704 

1705 # Verify only one delivery exists 

1706 with session_scope() as session: 

1707 delivery_count = len( 

1708 session.execute( 

1709 select(NotificationDelivery) 

1710 .where(NotificationDelivery.notification_id == notification_id) 

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

1712 ) 

1713 .scalars() 

1714 .all() 

1715 ) 

1716 assert delivery_count == 1 

1717 

1718 

1719def test_handle_notification_deferred_when_content_not_visible(db, moderator): 

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

1721 user1, token1 = generate_user(complete_profile=True) 

1722 user2, token2 = generate_user(complete_profile=True) 

1723 

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

1725 # This also queues a notification via SendFriendRequest 

1726 with api_session(token2) as api: 

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

1728 

1729 # Process the queued job (handle_notification) 

1730 process_job() 

1731 

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

1733 with session_scope() as session: 

1734 notification = session.execute( 

1735 select(Notification) 

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

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

1738 ).scalar_one() 

1739 

1740 deliveries = ( 

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

1742 .scalars() 

1743 .all() 

1744 ) 

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

1746 assert len(deliveries) == 0 

1747 

1748 

1749def test_handle_notification_delivered_when_content_visible(db, moderator): 

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

1751 user1, token1 = generate_user(complete_profile=True) 

1752 user2, token2 = generate_user(complete_profile=True) 

1753 

1754 # Create a friend request 

1755 with api_session(token2) as api: 

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

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

1758 fr_id = res.sent[0].friend_request_id 

1759 

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

1761 process_job() 

1762 

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

1764 moderator.approve_friend_request(fr_id) 

1765 

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

1767 process_jobs() 

1768 

1769 # Notification should have been delivered 

1770 with session_scope() as session: 

1771 notification = session.execute( 

1772 select(Notification) 

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

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

1775 ).scalar_one() 

1776 

1777 deliveries = ( 

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

1779 .scalars() 

1780 .all() 

1781 ) 

1782 # At least one delivery should exist 

1783 assert len(deliveries) > 0 

1784 

1785 

1786def test_handle_notification_multiple_delivery_types(db, push_collector: PushCollector): 

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

1788 user, token = generate_user() 

1789 

1790 topic_action = NotificationTopicAction.badge__add 

1791 

1792 # Enable both email and push notifications 

1793 with notifications_session(token) as notifications: 

1794 notifications.SetNotificationSettings( 

1795 notifications_pb2.SetNotificationSettingsReq( 

1796 preferences=[ 

1797 notifications_pb2.SingleNotificationPreference( 

1798 topic=topic_action.topic, 

1799 action=topic_action.action, 

1800 delivery_method="email", 

1801 enabled=True, 

1802 ), 

1803 notifications_pb2.SingleNotificationPreference( 

1804 topic=topic_action.topic, 

1805 action=topic_action.action, 

1806 delivery_method="push", 

1807 enabled=True, 

1808 ), 

1809 notifications_pb2.SingleNotificationPreference( 

1810 topic=topic_action.topic, 

1811 action=topic_action.action, 

1812 delivery_method="digest", 

1813 enabled=True, 

1814 ), 

1815 ], 

1816 ) 

1817 ) 

1818 

1819 with mock_notification_email() as mock: 

1820 with session_scope() as session: 

1821 notify( 

1822 session, 

1823 user_id=user.id, 

1824 topic_action=topic_action, 

1825 key="test-badge", 

1826 data=notification_data_pb2.BadgeAdd( 

1827 badge_id="volunteer", 

1828 badge_name="Active Volunteer", 

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

1830 ), 

1831 ) 

1832 

1833 # Email should be sent 

1834 assert mock.call_count == 1 

1835 

1836 # Push should be sent 

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

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

1839 

1840 # All three delivery types should have deliveries 

1841 with session_scope() as session: 

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

1843 

1844 deliveries = ( 

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

1846 .scalars() 

1847 .all() 

1848 ) 

1849 

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

1851 assert NotificationDeliveryType.email in delivery_types 

1852 assert NotificationDeliveryType.push in delivery_types 

1853 assert NotificationDeliveryType.digest in delivery_types 

1854 

1855 # Email and push should have delivered timestamps 

1856 for delivery in deliveries: 

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

1858 assert delivery.delivered is not None 

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

1860 assert delivery.delivered is None