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

624 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-02-03 06:18 +0000

1import html 

2import json 

3import re 

4from urllib.parse import parse_qs, urlparse 

5 

6import grpc 

7import pytest 

8from google.protobuf import empty_pb2, timestamp_pb2 

9from sqlalchemy import select 

10 

11from couchers.config import config 

12from couchers.constants import DATETIME_INFINITY 

13from couchers.context import make_background_user_context 

14from couchers.crypto import b64decode 

15from couchers.db import session_scope 

16from couchers.i18n import LocalizationContext 

17from couchers.jobs.worker import process_job 

18from couchers.models import ( 

19 DeviceType, 

20 HostingStatus, 

21 MeetupStatus, 

22 Notification, 

23 NotificationDelivery, 

24 NotificationDeliveryType, 

25 NotificationTopicAction, 

26 PushNotificationPlatform, 

27 PushNotificationSubscription, 

28 User, 

29) 

30from couchers.notifications.notify import notify 

31from couchers.notifications.settings import get_topic_actions_by_delivery_type 

32from couchers.proto import ( 

33 api_pb2, 

34 auth_pb2, 

35 conversations_pb2, 

36 editor_pb2, 

37 events_pb2, 

38 notification_data_pb2, 

39 notifications_pb2, 

40) 

41from couchers.proto.internal import unsubscribe_pb2 

42from couchers.servicers.api import user_model_to_pb 

43from couchers.utils import not_none, now 

44from tests.fixtures.db import generate_user 

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

46from tests.fixtures.sessions import ( 

47 api_session, 

48 auth_api_session, 

49 conversations_session, 

50 notifications_session, 

51 real_editor_session, 

52) 

53 

54 

55@pytest.fixture(autouse=True) 

56def _(testconfig): 

57 pass 

58 

59 

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

61def test_SetNotificationSettings_preferences_respected_editable(db, enabled): 

62 user, token = generate_user() 

63 

64 # enable a notification type and check it gets delivered 

65 topic_action = NotificationTopicAction.badge__add 

66 

67 with notifications_session(token) as notifications: 

68 notifications.SetNotificationSettings( 

69 notifications_pb2.SetNotificationSettingsReq( 

70 preferences=[ 

71 notifications_pb2.SingleNotificationPreference( 

72 topic=topic_action.topic, 

73 action=topic_action.action, 

74 delivery_method="push", 

75 enabled=enabled, 

76 ) 

77 ], 

78 ) 

79 ) 

80 

81 with session_scope() as session: 

82 notify( 

83 session, 

84 user_id=user.id, 

85 topic_action=topic_action, 

86 key="", 

87 data=notification_data_pb2.BadgeAdd( 

88 badge_id="volunteer", 

89 badge_name="Active Volunteer", 

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

91 ), 

92 ) 

93 

94 process_job() 

95 

96 with session_scope() as session: 

97 deliv = session.execute( 

98 select(NotificationDelivery) 

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

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

101 .where(Notification.topic_action == topic_action) 

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

103 ).scalar_one_or_none() 

104 

105 if enabled: 

106 assert deliv is not None 

107 else: 

108 assert deliv is None 

109 

110 

111def test_SetNotificationSettings_preferences_not_editable(db): 

112 user, token = generate_user() 

113 

114 # enable a notification type and check it gets delivered 

115 topic_action = NotificationTopicAction.password_reset__start 

116 

117 with notifications_session(token) as notifications: 

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

119 notifications.SetNotificationSettings( 

120 notifications_pb2.SetNotificationSettingsReq( 

121 preferences=[ 

122 notifications_pb2.SingleNotificationPreference( 

123 topic=topic_action.topic, 

124 action=topic_action.action, 

125 delivery_method="push", 

126 enabled=False, 

127 ) 

128 ], 

129 ) 

130 ) 

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

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

133 

134 

135def test_unsubscribe(db): 

136 # this is the ugliest test i've written 

137 

138 user, token = generate_user() 

139 

140 topic_action = NotificationTopicAction.badge__add 

141 

142 # first enable email notifs 

143 with notifications_session(token) as notifications: 

144 notifications.SetNotificationSettings( 

145 notifications_pb2.SetNotificationSettingsReq( 

146 preferences=[ 

147 notifications_pb2.SingleNotificationPreference( 

148 topic=topic_action.topic, 

149 action=topic_action.action, 

150 delivery_method=method, 

151 enabled=enabled, 

152 ) 

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

154 ], 

155 ) 

156 ) 

157 

158 with mock_notification_email() as mock: 

159 with session_scope() as session: 

160 notify( 

161 session, 

162 user_id=user.id, 

163 topic_action=topic_action, 

164 key="", 

165 data=notification_data_pb2.BadgeAdd( 

166 badge_id="volunteer", 

167 badge_name="Active Volunteer", 

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

169 ), 

170 ) 

171 

172 assert mock.call_count == 1 

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

174 # very ugly 

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

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

177 if "payload" not in link: 

178 continue 

179 print(link) 

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

181 params = parse_qs(url_parts.query) 

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

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

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

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

186 assert ( 

187 auth_api.Unsubscribe( 

188 auth_pb2.UnsubscribeReq( 

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

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

191 ) 

192 ).response 

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

194 ) 

195 break 

196 else: 

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

198 

199 with notifications_session(token) as notifications: 

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

201 

202 for group in res.groups: 

203 for topic in group.topics: 

204 for item in topic.items: 

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

206 assert not item.email 

207 

208 with mock_notification_email() as mock: 

209 with session_scope() as session: 

210 notify( 

211 session, 

212 user_id=user.id, 

213 topic_action=topic_action, 

214 key="", 

215 data=notification_data_pb2.BadgeAdd( 

216 badge_id="volunteer", 

217 badge_name="Active Volunteer", 

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

219 ), 

220 ) 

221 

222 assert mock.call_count == 0 

223 

224 

225def test_unsubscribe_do_not_email(db): 

226 user, token = generate_user() 

227 

228 _, token2 = generate_user(complete_profile=True) 

229 with mock_notification_email() as mock: 

230 with api_session(token2) as api: 

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

232 

233 assert mock.call_count == 1 

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

235 # very ugly 

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

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

238 if "payload" not in link: 

239 continue 

240 print(link) 

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

242 params = parse_qs(url_parts.query) 

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

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

245 if payload.HasField("do_not_email"): 

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

247 assert ( 

248 auth_api.Unsubscribe( 

249 auth_pb2.UnsubscribeReq( 

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

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

252 ) 

253 ).response 

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

255 ) 

256 break 

257 else: 

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

259 

260 _, token3 = generate_user(complete_profile=True) 

261 with mock_notification_email() as mock: 

262 with api_session(token3) as api: 

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

264 

265 assert mock.call_count == 0 

266 

267 with session_scope() as session: 

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

269 assert user_.do_not_email 

270 

271 

272def test_get_do_not_email(db): 

273 _, token = generate_user() 

274 

275 with session_scope() as session: 

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

277 user.do_not_email = False 

278 

279 with notifications_session(token) as notifications: 

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

281 assert not res.do_not_email_enabled 

282 

283 with session_scope() as session: 

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

285 user.do_not_email = True 

286 user.hosting_status = HostingStatus.cant_host 

287 user.meetup_status = MeetupStatus.does_not_want_to_meetup 

288 

289 with notifications_session(token) as notifications: 

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

291 assert res.do_not_email_enabled 

292 

293 

294def test_set_do_not_email(db): 

295 _, token = generate_user() 

296 

297 with session_scope() as session: 

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

299 user.do_not_email = False 

300 user.hosting_status = HostingStatus.can_host 

301 user.meetup_status = MeetupStatus.wants_to_meetup 

302 

303 with notifications_session(token) as notifications: 

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

305 

306 with session_scope() as session: 

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

308 assert not user.do_not_email 

309 

310 with notifications_session(token) as notifications: 

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

312 

313 with session_scope() as session: 

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

315 assert user.do_not_email 

316 assert user.hosting_status == HostingStatus.cant_host 

317 assert user.meetup_status == MeetupStatus.does_not_want_to_meetup 

318 

319 with notifications_session(token) as notifications: 

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

321 

322 with session_scope() as session: 

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

324 assert not user.do_not_email 

325 

326 

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

328 user1, token1 = generate_user() 

329 user2, token2 = generate_user() 

330 

331 with api_session(token2) as api: 

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

333 

334 with notifications_session(token1) as notifications: 

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

336 assert len(res.notifications) == 1 

337 

338 n = res.notifications[0] 

339 

340 assert n.topic == "friend_request" 

341 assert n.action == "create" 

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

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

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

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

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

347 

348 with conversations_session(token2) as c: 

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

350 group_chat_id = res.group_chat_id 

351 moderator.approve_group_chat(group_chat_id) 

352 for i in range(17): 

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

354 

355 process_jobs() 

356 

357 all_notifs = [] 

358 with notifications_session(token1) as notifications: 

359 page_token = None 

360 for _ in range(100): 360 ↛ 373line 360 didn't jump to line 373

361 res = notifications.ListNotifications( 

362 notifications_pb2.ListNotificationsReq( 

363 page_size=5, 

364 page_token=page_token, 

365 ) 

366 ) 

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

368 all_notifs += res.notifications 

369 page_token = res.next_page_token 

370 if not page_token: 

371 break 

372 

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

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

375 

376 

377def test_notifications_seen(db, push_collector: PushCollector): 

378 user1, token1 = generate_user() 

379 user2, token2 = generate_user() 

380 user3, token3 = generate_user() 

381 user4, token4 = generate_user() 

382 

383 with api_session(token2) as api: 

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

385 

386 with api_session(token3) as api: 

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

388 

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

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

391 assert len(res.notifications) == 2 

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

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

394 # should be listed desc time 

395 assert notification_ids[0] > notification_ids[1] 

396 

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

398 

399 with api_session(token4) as api: 

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

401 

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

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

404 notifications.MarkAllNotificationsSeen( 

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

406 ) 

407 

408 # last one is still unseen 

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

410 

411 # mark the first one unseen 

412 notifications.MarkNotificationSeen( 

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

414 ) 

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

416 

417 # mark the last one seen 

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

419 assert len(res.notifications) == 3 

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

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

422 

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

424 

425 notifications.MarkNotificationSeen( 

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

427 ) 

428 

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

430 assert len(res.notifications) == 3 

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

432 

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

434 

435 

436def test_GetVapidPublicKey(db): 

437 _, token = generate_user() 

438 

439 with notifications_session(token) as notifications: 

440 assert ( 

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

442 == "BApMo2tGuon07jv-pEaAKZmVo6E_d4HfcdDeV6wx2k9wV8EovJ0ve00bdLzZm9fizDrGZXRYJFqCcRJUfBcgA0A" 

443 ) 

444 

445 

446def test_RegisterPushNotificationSubscription(db): 

447 _, token = generate_user() 

448 

449 subscription_info = { 

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

451 "expirationTime": None, 

452 "keys": { 

453 "auth": "TnuEJ1OdfEkf6HKcUovl0Q", 

454 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0", 

455 }, 

456 } 

457 

458 with notifications_session(token) as notifications: 

459 res = notifications.RegisterPushNotificationSubscription( 

460 notifications_pb2.RegisterPushNotificationSubscriptionReq( 

461 full_subscription_json=json.dumps(subscription_info), 

462 ) 

463 ) 

464 

465 

466def test_SendTestPushNotification(db, push_collector: PushCollector): 

467 user, token = generate_user() 

468 

469 with notifications_session(token) as notifications: 

470 notifications.SendTestPushNotification(empty_pb2.Empty()) 

471 

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

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

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

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

476 

477 

478def test_SendBlogPostNotification(db, push_collector: PushCollector): 

479 super_user, super_token = generate_user(is_superuser=True) 

480 

481 user1, user1_token = generate_user() 

482 # enabled email 

483 user2, user2_token = generate_user() 

484 # disabled push 

485 user3, user3_token = generate_user() 

486 

487 topic_action = NotificationTopicAction.general__new_blog_post 

488 

489 with notifications_session(user2_token) as notifications: 

490 notifications.SetNotificationSettings( 

491 notifications_pb2.SetNotificationSettingsReq( 

492 preferences=[ 

493 notifications_pb2.SingleNotificationPreference( 

494 topic=topic_action.topic, 

495 action=topic_action.action, 

496 delivery_method="email", 

497 enabled=True, 

498 ) 

499 ], 

500 ) 

501 ) 

502 

503 with notifications_session(user3_token) as notifications: 

504 notifications.SetNotificationSettings( 

505 notifications_pb2.SetNotificationSettingsReq( 

506 preferences=[ 

507 notifications_pb2.SingleNotificationPreference( 

508 topic=topic_action.topic, 

509 action=topic_action.action, 

510 delivery_method="push", 

511 enabled=False, 

512 ) 

513 ], 

514 ) 

515 ) 

516 

517 with mock_notification_email() as mock: 

518 with real_editor_session(super_token) as editor_api: 

519 editor_api.SendBlogPostNotification( 

520 editor_pb2.SendBlogPostNotificationReq( 

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

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

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

524 ) 

525 ) 

526 

527 process_jobs() 

528 

529 assert mock.call_count == 1 

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

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

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

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

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

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

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

537 

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

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

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

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

542 

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

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

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

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

547 

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

549 

550 

551def test_get_topic_actions_by_delivery_type(db): 

552 user, token = generate_user() 

553 

554 # these are enabled by default 

555 assert NotificationDeliveryType.push in NotificationTopicAction.reference__receive_friend.defaults 

556 assert NotificationDeliveryType.push in NotificationTopicAction.host_request__accept.defaults 

557 

558 # these are disabled by default 

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

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

561 

562 with notifications_session(token) as notifications: 

563 notifications.SetNotificationSettings( 

564 notifications_pb2.SetNotificationSettingsReq( 

565 preferences=[ 

566 notifications_pb2.SingleNotificationPreference( 

567 topic=NotificationTopicAction.reference__receive_friend.topic, 

568 action=NotificationTopicAction.reference__receive_friend.action, 

569 delivery_method="push", 

570 enabled=False, 

571 ), 

572 notifications_pb2.SingleNotificationPreference( 

573 topic=NotificationTopicAction.event__create_any.topic, 

574 action=NotificationTopicAction.event__create_any.action, 

575 delivery_method="push", 

576 enabled=True, 

577 ), 

578 ], 

579 ) 

580 ) 

581 

582 with session_scope() as session: 

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

584 assert NotificationTopicAction.reference__receive_friend not in deliver 

585 assert NotificationTopicAction.host_request__accept in deliver 

586 assert NotificationTopicAction.event__create_any in deliver 

587 assert NotificationTopicAction.discussion__create not in deliver 

588 assert NotificationTopicAction.account_deletion__start in deliver 

589 

590 

591def test_event_reminder_email_sent(db): 

592 user, token = generate_user() 

593 title = "Board Game Night" 

594 start_event_time = timestamp_pb2.Timestamp(seconds=1751690400) 

595 

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

597 

598 with mock_notification_email() as mock: 

599 with session_scope() as session: 

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

601 

602 notify( 

603 session, 

604 user_id=user.id, 

605 topic_action=NotificationTopicAction.event__reminder, 

606 key="", 

607 data=notification_data_pb2.EventReminder( 

608 event=events_pb2.Event( 

609 event_id=1, 

610 slug="board-game-night", 

611 title=title, 

612 start_time=start_event_time, 

613 ), 

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

615 ), 

616 ) 

617 

618 assert mock.call_count == 1 

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

620 assert title in email_fields(mock).html 

621 assert title in email_fields(mock).plain 

622 assert expected_time_str in email_fields(mock).html 

623 assert expected_time_str in email_fields(mock).plain 

624 

625 

626def test_RegisterMobilePushNotificationSubscription(db): 

627 user, token = generate_user() 

628 

629 with notifications_session(token) as notifications: 

630 notifications.RegisterMobilePushNotificationSubscription( 

631 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

632 token="ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", 

633 device_name="My iPhone", 

634 device_type="ios", 

635 ) 

636 ) 

637 

638 # Check subscription was created 

639 with session_scope() as session: 

640 sub = session.execute( 

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

642 ).scalar_one() 

643 assert sub.platform == PushNotificationPlatform.expo 

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

645 assert sub.device_name == "My iPhone" 

646 assert sub.device_type == DeviceType.ios 

647 assert sub.disabled_at == DATETIME_INFINITY 

648 

649 

650def test_RegisterMobilePushNotificationSubscription_android(db): 

651 user, token = generate_user() 

652 

653 with notifications_session(token) as notifications: 

654 notifications.RegisterMobilePushNotificationSubscription( 

655 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

656 token="ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]", 

657 device_name="My Android", 

658 device_type="android", 

659 ) 

660 ) 

661 

662 with session_scope() as session: 

663 sub = session.execute( 

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

665 ).scalar_one() 

666 assert sub.platform == PushNotificationPlatform.expo 

667 assert sub.device_type == DeviceType.android 

668 

669 

670def test_RegisterMobilePushNotificationSubscription_no_device_type(db): 

671 user, token = generate_user() 

672 

673 with notifications_session(token) as notifications: 

674 notifications.RegisterMobilePushNotificationSubscription( 

675 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

676 token="ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]", 

677 ) 

678 ) 

679 

680 with session_scope() as session: 

681 sub = session.execute( 

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

683 ).scalar_one() 

684 assert sub.platform == PushNotificationPlatform.expo 

685 assert sub.device_name is None 

686 assert sub.device_type is None 

687 

688 

689def test_RegisterMobilePushNotificationSubscription_re_enable(db): 

690 user, token = generate_user() 

691 

692 # Create a disabled subscription directly in the DB 

693 with session_scope() as session: 

694 sub = PushNotificationSubscription( 

695 user_id=user.id, 

696 platform=PushNotificationPlatform.expo, 

697 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

698 device_name="Old Device", 

699 device_type=DeviceType.ios, 

700 ) 

701 sub.disabled_at = now() 

702 session.add(sub) 

703 session.flush() 

704 sub_id = sub.id 

705 

706 # Re-register with the same token 

707 with notifications_session(token) as notifications: 

708 notifications.RegisterMobilePushNotificationSubscription( 

709 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

710 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]", 

711 device_name="New Device Name", 

712 device_type="android", 

713 ) 

714 ) 

715 

716 # Check subscription was re-enabled and updated 

717 with session_scope() as session: 

718 sub = session.execute( 

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

720 ).scalar_one() 

721 assert sub.disabled_at == DATETIME_INFINITY 

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

723 assert sub.device_type == DeviceType.android 

724 

725 

726def test_RegisterMobilePushNotificationSubscription_already_exists(db): 

727 user, token = generate_user() 

728 

729 # Create an active subscription directly in the DB 

730 with session_scope() as session: 

731 sub = PushNotificationSubscription( 

732 user_id=user.id, 

733 platform=PushNotificationPlatform.expo, 

734 token="ExponentPushToken[existingtoken]", 

735 device_name="Existing Device", 

736 device_type=DeviceType.ios, 

737 ) 

738 session.add(sub) 

739 

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

741 with notifications_session(token) as notifications: 

742 notifications.RegisterMobilePushNotificationSubscription( 

743 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq( 

744 token="ExponentPushToken[existingtoken]", 

745 device_name="Different Name", 

746 ) 

747 ) 

748 

749 # Check subscription was NOT modified (already active) 

750 with session_scope() as session: 

751 sub = session.execute( 

752 select(PushNotificationSubscription).where( 

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

754 ) 

755 ).scalar_one() 

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

757 

758 

759def test_SendTestMobilePushNotification(db, push_collector: PushCollector): 

760 user, token = generate_user() 

761 

762 with notifications_session(token) as notifications: 

763 notifications.SendTestMobilePushNotification(empty_pb2.Empty()) 

764 

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

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

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

768 

769 

770def test_get_expo_push_receipts(db): 

771 from unittest.mock import Mock, patch 

772 

773 from couchers.notifications.expo_api import get_expo_push_receipts 

774 

775 mock_response = Mock() 

776 mock_response.status_code = 200 

777 mock_response.json.return_value = { 

778 "data": { 

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

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

781 } 

782 } 

783 

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

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

786 

787 mock_post.assert_called_once() 

788 call_args = mock_post.call_args 

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

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

791 

792 assert result == { 

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

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

795 } 

796 

797 

798def test_get_expo_push_receipts_empty(db): 

799 from couchers.notifications.expo_api import get_expo_push_receipts 

800 

801 result = get_expo_push_receipts([]) 

802 assert result == {} 

803 

804 

805def test_check_expo_push_receipts_success(db): 

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

807 from datetime import timedelta 

808 from unittest.mock import patch 

809 

810 from google.protobuf import empty_pb2 

811 

812 from couchers.jobs.handlers import check_expo_push_receipts 

813 from couchers.models import PushNotificationDeliveryAttempt, PushNotificationDeliveryOutcome 

814 

815 user, token = generate_user() 

816 

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

818 with session_scope() as session: 

819 sub = PushNotificationSubscription( 

820 user_id=user.id, 

821 platform=PushNotificationPlatform.expo, 

822 token="ExponentPushToken[testtoken123]", 

823 device_name="Test Device", 

824 device_type=DeviceType.ios, 

825 ) 

826 session.add(sub) 

827 session.flush() 

828 

829 attempt = PushNotificationDeliveryAttempt( 

830 push_notification_subscription_id=sub.id, 

831 outcome=PushNotificationDeliveryOutcome.success, 

832 status_code=200, 

833 expo_ticket_id="test-ticket-id", 

834 ) 

835 session.add(attempt) 

836 session.flush() 

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

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

839 attempt_id = attempt.id 

840 sub_id = sub.id 

841 

842 # Mock the receipt API call 

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

844 mock_post.return_value.status_code = 200 

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

846 

847 check_expo_push_receipts(empty_pb2.Empty()) 

848 

849 # Verify the attempt was updated 

850 with session_scope() as session: 

851 attempt = session.execute( 

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

853 ).scalar_one() 

854 assert attempt.receipt_checked_at is not None 

855 assert attempt.receipt_status == "ok" 

856 assert attempt.receipt_error_code is None 

857 

858 # Subscription should still be enabled 

859 sub = session.execute( 

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

861 ).scalar_one() 

862 assert sub.disabled_at == DATETIME_INFINITY 

863 

864 

865def test_check_expo_push_receipts_device_not_registered(db): 

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

867 from datetime import timedelta 

868 from unittest.mock import patch 

869 

870 from google.protobuf import empty_pb2 

871 

872 from couchers.jobs.handlers import check_expo_push_receipts 

873 from couchers.models import PushNotificationDeliveryAttempt, PushNotificationDeliveryOutcome 

874 

875 user, token = generate_user() 

876 

877 # Create a push subscription and delivery attempt 

878 with session_scope() as session: 

879 sub = PushNotificationSubscription( 

880 user_id=user.id, 

881 platform=PushNotificationPlatform.expo, 

882 token="ExponentPushToken[devicegone]", 

883 device_name="Test Device", 

884 device_type=DeviceType.android, 

885 ) 

886 session.add(sub) 

887 session.flush() 

888 

889 attempt = PushNotificationDeliveryAttempt( 

890 push_notification_subscription_id=sub.id, 

891 outcome=PushNotificationDeliveryOutcome.success, 

892 status_code=200, 

893 expo_ticket_id="ticket-device-gone", 

894 ) 

895 session.add(attempt) 

896 session.flush() 

897 # Make the attempt old enough to be checked 

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

899 attempt_id = attempt.id 

900 sub_id = sub.id 

901 

902 # Mock the receipt API call with DeviceNotRegistered error 

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

904 mock_post.return_value.status_code = 200 

905 mock_post.return_value.json.return_value = { 

906 "data": { 

907 "ticket-device-gone": { 

908 "status": "error", 

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

910 } 

911 } 

912 } 

913 

914 check_expo_push_receipts(empty_pb2.Empty()) 

915 

916 # Verify the attempt was updated and subscription disabled 

917 with session_scope() as session: 

918 attempt = session.execute( 

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

920 ).scalar_one() 

921 assert attempt.receipt_checked_at is not None 

922 assert attempt.receipt_status == "error" 

923 assert attempt.receipt_error_code == "DeviceNotRegistered" 

924 

925 # Subscription should be disabled 

926 sub = session.execute( 

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

928 ).scalar_one() 

929 assert sub.disabled_at <= now() 

930 

931 

932def test_check_expo_push_receipts_not_found(db): 

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

934 from datetime import timedelta 

935 from unittest.mock import patch 

936 

937 from google.protobuf import empty_pb2 

938 

939 from couchers.jobs.handlers import check_expo_push_receipts 

940 from couchers.models import PushNotificationDeliveryAttempt, PushNotificationDeliveryOutcome 

941 

942 user, token = generate_user() 

943 

944 with session_scope() as session: 

945 sub = PushNotificationSubscription( 

946 user_id=user.id, 

947 platform=PushNotificationPlatform.expo, 

948 token="ExponentPushToken[notfound]", 

949 ) 

950 session.add(sub) 

951 session.flush() 

952 

953 attempt = PushNotificationDeliveryAttempt( 

954 push_notification_subscription_id=sub.id, 

955 outcome=PushNotificationDeliveryOutcome.success, 

956 status_code=200, 

957 expo_ticket_id="unknown-ticket", 

958 ) 

959 session.add(attempt) 

960 session.flush() 

961 # Make the attempt old enough to be checked 

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

963 attempt_id = attempt.id 

964 sub_id = sub.id 

965 

966 # Mock empty receipt response (ticket not found) 

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

968 mock_post.return_value.status_code = 200 

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

970 

971 check_expo_push_receipts(empty_pb2.Empty()) 

972 

973 with session_scope() as session: 

974 attempt = session.execute( 

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

976 ).scalar_one() 

977 assert attempt.receipt_checked_at is not None 

978 assert attempt.receipt_status == "not_found" 

979 

980 # Subscription should still be enabled 

981 sub = session.execute( 

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

983 ).scalar_one() 

984 assert sub.disabled_at == DATETIME_INFINITY 

985 

986 

987def test_check_expo_push_receipts_skips_already_checked(db): 

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

989 from datetime import timedelta 

990 from unittest.mock import patch 

991 

992 from google.protobuf import empty_pb2 

993 

994 from couchers.jobs.handlers import check_expo_push_receipts 

995 from couchers.models import PushNotificationDeliveryAttempt, PushNotificationDeliveryOutcome 

996 

997 user, token = generate_user() 

998 

999 # Create an attempt that was already checked 

1000 with session_scope() as session: 

1001 sub = PushNotificationSubscription( 

1002 user_id=user.id, 

1003 platform=PushNotificationPlatform.expo, 

1004 token="ExponentPushToken[alreadychecked]", 

1005 ) 

1006 session.add(sub) 

1007 session.flush() 

1008 

1009 attempt = PushNotificationDeliveryAttempt( 

1010 push_notification_subscription_id=sub.id, 

1011 outcome=PushNotificationDeliveryOutcome.success, 

1012 status_code=200, 

1013 expo_ticket_id="already-checked-ticket", 

1014 receipt_checked_at=now(), 

1015 receipt_status="ok", 

1016 ) 

1017 session.add(attempt) 

1018 session.flush() 

1019 # Make the attempt old enough 

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

1021 

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

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

1024 check_expo_push_receipts(empty_pb2.Empty()) 

1025 mock_post.assert_not_called() 

1026 

1027 

1028def test_SendDevPushNotification_success(db, push_collector: PushCollector): 

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

1030 user, token = generate_user() 

1031 

1032 # Enable dev APIs for this test 

1033 config["ENABLE_DEV_APIS"] = True 

1034 

1035 with notifications_session(token) as notifications: 

1036 notifications.SendDevPushNotification( 

1037 notifications_pb2.SendDevPushNotificationReq( 

1038 title="Test Dev Title", 

1039 body="Test dev notification body", 

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

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

1042 key="test-key", 

1043 ttl=3600, 

1044 ) 

1045 ) 

1046 

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

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

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

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

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

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

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

1054 assert push.ttl == 3600 

1055 

1056 

1057def test_SendDevPushNotification_minimal(db, push_collector: PushCollector): 

1058 """Test SendDevPushNotification with minimal parameters.""" 

1059 user, token = generate_user() 

1060 

1061 config["ENABLE_DEV_APIS"] = True 

1062 

1063 with notifications_session(token) as notifications: 

1064 notifications.SendDevPushNotification( 

1065 notifications_pb2.SendDevPushNotificationReq( 

1066 title="Minimal Title", 

1067 body="Minimal body", 

1068 ) 

1069 ) 

1070 

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

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

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

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

1075 

1076 

1077def test_SendDevPushNotification_disabled(db, push_collector: PushCollector): 

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

1079 user, token = generate_user() 

1080 

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

1082 config["ENABLE_DEV_APIS"] = False 

1083 

1084 with notifications_session(token) as notifications: 

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

1086 notifications.SendDevPushNotification( 

1087 notifications_pb2.SendDevPushNotificationReq( 

1088 title="Should Fail", 

1089 body="This should not be sent", 

1090 ) 

1091 ) 

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

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

1094 

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

1096 

1097 

1098def test_SendDevPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1100 user, token = generate_user() 

1101 

1102 config["ENABLE_DEV_APIS"] = True 

1103 config["PUSH_NOTIFICATIONS_ENABLED"] = False 

1104 

1105 with notifications_session(token) as notifications: 

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

1107 notifications.SendDevPushNotification( 

1108 notifications_pb2.SendDevPushNotificationReq( 

1109 title="Should Fail", 

1110 body="This should not be sent", 

1111 ) 

1112 ) 

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

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

1115 

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

1117 

1118 

1119def test_check_expo_push_receipts_skips_too_recent(db): 

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

1121 from datetime import timedelta 

1122 from unittest.mock import patch 

1123 

1124 from google.protobuf import empty_pb2 

1125 

1126 from couchers.jobs.handlers import check_expo_push_receipts 

1127 from couchers.models import PushNotificationDeliveryAttempt, PushNotificationDeliveryOutcome 

1128 

1129 user, token = generate_user() 

1130 

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

1132 with session_scope() as session: 

1133 sub = PushNotificationSubscription( 

1134 user_id=user.id, 

1135 platform=PushNotificationPlatform.expo, 

1136 token="ExponentPushToken[recent]", 

1137 ) 

1138 session.add(sub) 

1139 session.flush() 

1140 

1141 attempt = PushNotificationDeliveryAttempt( 

1142 push_notification_subscription_id=sub.id, 

1143 outcome=PushNotificationDeliveryOutcome.success, 

1144 status_code=200, 

1145 expo_ticket_id="recent-ticket", 

1146 ) 

1147 session.add(attempt) 

1148 session.flush() 

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

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

1151 

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

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

1154 check_expo_push_receipts(empty_pb2.Empty()) 

1155 mock_post.assert_not_called() 

1156 

1157 

1158def test_check_expo_push_receipts_batch(db): 

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

1160 from datetime import timedelta 

1161 from unittest.mock import patch 

1162 

1163 from google.protobuf import empty_pb2 

1164 

1165 from couchers.jobs.handlers import check_expo_push_receipts 

1166 from couchers.models import PushNotificationDeliveryAttempt, PushNotificationDeliveryOutcome 

1167 

1168 user, token = generate_user() 

1169 

1170 # Create multiple delivery attempts 

1171 attempt_ids = [] 

1172 with session_scope() as session: 

1173 sub = PushNotificationSubscription( 

1174 user_id=user.id, 

1175 platform=PushNotificationPlatform.expo, 

1176 token="ExponentPushToken[batch]", 

1177 ) 

1178 session.add(sub) 

1179 session.flush() 

1180 

1181 for i in range(3): 

1182 attempt = PushNotificationDeliveryAttempt( 

1183 push_notification_subscription_id=sub.id, 

1184 outcome=PushNotificationDeliveryOutcome.success, 

1185 status_code=200, 

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

1187 ) 

1188 session.add(attempt) 

1189 session.flush() 

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

1191 attempt_ids.append(attempt.id) 

1192 

1193 # Mock the batch receipt API call 

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

1195 mock_post.return_value.status_code = 200 

1196 mock_post.return_value.json.return_value = { 

1197 "data": { 

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

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

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

1201 } 

1202 } 

1203 

1204 check_expo_push_receipts(empty_pb2.Empty()) 

1205 

1206 # Should only call the API once for all tickets 

1207 assert mock_post.call_count == 1 

1208 

1209 # Verify all attempts were updated 

1210 with session_scope() as session: 

1211 for attempt_id in attempt_ids: 

1212 attempt = session.execute( 

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

1214 ).scalar_one() 

1215 assert attempt.receipt_checked_at is not None 

1216 assert attempt.receipt_status == "ok" 

1217 

1218 

1219def test_DebugRedeliverPushNotification_success(db, push_collector: PushCollector): 

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

1221 user, token = generate_user() 

1222 

1223 config["ENABLE_DEV_APIS"] = True 

1224 

1225 # Create a notification for the user 

1226 with session_scope() as session: 

1227 notify( 

1228 session, 

1229 user_id=user.id, 

1230 topic_action=NotificationTopicAction.badge__add, 

1231 key="test-badge", 

1232 data=notification_data_pb2.BadgeAdd( 

1233 badge_id="volunteer", 

1234 badge_name="Active Volunteer", 

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

1236 ), 

1237 ) 

1238 

1239 process_job() 

1240 

1241 # Pop the initial push notification 

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

1243 

1244 # Get the notification_id 

1245 with session_scope() as session: 

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

1247 notification_id = notification.id 

1248 

1249 # Redeliver the notification 

1250 with notifications_session(token) as notifications: 

1251 notifications.DebugRedeliverPushNotification( 

1252 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1253 ) 

1254 

1255 # Verify a new push was sent 

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

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

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

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

1260 

1261 

1262def test_DebugRedeliverPushNotification_not_found(db, push_collector: PushCollector): 

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

1264 user, token = generate_user() 

1265 

1266 config["ENABLE_DEV_APIS"] = True 

1267 

1268 with notifications_session(token) as notifications: 

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

1270 notifications.DebugRedeliverPushNotification( 

1271 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=999999) 

1272 ) 

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

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

1275 

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

1277 

1278 

1279def test_DebugRedeliverPushNotification_wrong_user(db, push_collector: PushCollector): 

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

1281 user1, token1 = generate_user() 

1282 user2, token2 = generate_user() 

1283 

1284 config["ENABLE_DEV_APIS"] = True 

1285 

1286 # Create a notification for user1 

1287 with session_scope() as session: 

1288 notify( 

1289 session, 

1290 user_id=user1.id, 

1291 topic_action=NotificationTopicAction.badge__add, 

1292 key="test-badge", 

1293 data=notification_data_pb2.BadgeAdd( 

1294 badge_id="volunteer", 

1295 badge_name="Active Volunteer", 

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

1297 ), 

1298 ) 

1299 

1300 process_job() 

1301 

1302 # Get the notification_id 

1303 with session_scope() as session: 

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

1305 notification_id = notification.id 

1306 

1307 # user2 tries to redeliver user1's notification 

1308 with notifications_session(token2) as notifications: 

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

1310 notifications.DebugRedeliverPushNotification( 

1311 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id) 

1312 ) 

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

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

1315 

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

1317 

1318 

1319def test_DebugRedeliverPushNotification_disabled(db, push_collector: PushCollector): 

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

1321 user, token = generate_user() 

1322 

1323 config["ENABLE_DEV_APIS"] = False 

1324 

1325 with notifications_session(token) as notifications: 

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

1327 notifications.DebugRedeliverPushNotification( 

1328 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1329 ) 

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

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

1332 

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

1334 

1335 

1336def test_DebugRedeliverPushNotification_push_notifications_disabled(db, push_collector: PushCollector): 

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

1338 user, token = generate_user() 

1339 

1340 config["ENABLE_DEV_APIS"] = True 

1341 config["PUSH_NOTIFICATIONS_ENABLED"] = False 

1342 

1343 with notifications_session(token) as notifications: 

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

1345 notifications.DebugRedeliverPushNotification( 

1346 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1) 

1347 ) 

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

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

1350 

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