Coverage for app / backend / src / tests / test_notifications.py: 99%
782 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-05 09:44 +0000
1import html
2import json
3import re
4from datetime import timedelta
5from unittest.mock import Mock, patch
6from urllib.parse import parse_qs, urlparse
8import grpc
9import pytest
10from google.protobuf import empty_pb2, timestamp_pb2
11from sqlalchemy import select, update
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)
62@pytest.fixture(autouse=True)
63def _(testconfig):
64 pass
67@pytest.mark.parametrize("enabled", [True, False])
68def test_SetNotificationSettings_preferences_respected_editable(db, enabled):
69 user, token = generate_user()
71 # enable a notification type and check it gets delivered
72 topic_action = NotificationTopicAction.badge__add
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 )
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 )
101 process_job()
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()
112 if enabled:
113 assert deliv is not None
114 else:
115 assert deliv is None
118def test_SetNotificationSettings_preferences_not_editable(db):
119 user, token = generate_user()
121 # enable a notification type and check it gets delivered
122 topic_action = NotificationTopicAction.password_reset__start
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."
142def test_unsubscribe(db):
143 # this is the ugliest test i've written
145 user, token = generate_user()
147 topic_action = NotificationTopicAction.badge__add
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 )
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 )
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")
206 with notifications_session(token) as notifications:
207 res = notifications.GetNotificationSettings(notifications_pb2.GetNotificationSettingsReq())
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
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 )
229 assert mock.call_count == 0
232def test_unsubscribe_do_not_email(db, moderator):
233 user, token = generate_user()
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
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)
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")
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
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)
282 assert mock.call_count == 0
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
289def test_get_do_not_email(db):
290 _, token = generate_user()
292 with session_scope() as session:
293 user = session.execute(select(User)).scalar_one()
294 user.do_not_email = False
296 with notifications_session(token) as notifications:
297 res = notifications.GetNotificationSettings(notifications_pb2.GetNotificationSettingsReq())
298 assert not res.do_not_email_enabled
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
306 with notifications_session(token) as notifications:
307 res = notifications.GetNotificationSettings(notifications_pb2.GetNotificationSettingsReq())
308 assert res.do_not_email_enabled
311def test_set_do_not_email(db):
312 _, token = generate_user()
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
320 with notifications_session(token) as notifications:
321 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=False))
323 with session_scope() as session:
324 user = session.execute(select(User)).scalar_one()
325 assert not user.do_not_email
327 with notifications_session(token) as notifications:
328 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=True))
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
336 with notifications_session(token) as notifications:
337 notifications.SetNotificationSettings(notifications_pb2.SetNotificationSettingsReq(enable_do_not_email=False))
339 with session_scope() as session:
340 user = session.execute(select(User)).scalar_one()
341 assert not user.do_not_email
344def test_list_notifications(db, push_collector: PushCollector, moderator):
345 user1, token1 = generate_user()
346 user2, token2 = generate_user()
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
353 # Moderator approves the friend request so the notification is sent
354 moderator.approve_friend_request(fr_id)
356 with notifications_session(token1) as notifications:
357 res = notifications.ListNotifications(notifications_pb2.ListNotificationsReq())
358 assert len(res.notifications) == 1
360 n = res.notifications[0]
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/"
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}"))
377 process_jobs()
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
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]
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()
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
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
415 # Moderator approves the friend requests so notifications are sent
416 moderator.approve_friend_request(fr_id2)
417 moderator.approve_friend_request(fr_id3)
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]
427 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 2
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
434 # Moderator approves the friend request so notification is sent
435 moderator.approve_friend_request(fr_id4)
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 )
443 # last one is still unseen
444 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 1
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
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]
458 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 2
460 notifications.MarkNotificationSeen(
461 notifications_pb2.MarkNotificationSeenReq(notification_id=notification_ids2[0], set_seen=True)
462 )
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]
468 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 1
471def test_unseen_notification_count_excludes_ums_hidden(db, moderator):
472 user1, token1 = generate_user()
473 user2, token2 = generate_user()
475 with api_session(token2) as api:
476 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id))
477 res = api.ListFriendRequests(empty_pb2.Empty())
478 fr_id = res.sent[0].friend_request_id
480 # Before moderation the friend request is shadowed, so the resulting notification
481 # is not visible to the recipient and must not contribute to their unseen count.
482 with api_session(token1) as api:
483 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 0
485 moderator.approve_friend_request(fr_id)
487 with api_session(token1) as api:
488 assert api.Ping(api_pb2.PingReq()).unseen_notification_count == 1
491def test_GetVapidPublicKey(db):
492 _, token = generate_user()
494 with notifications_session(token) as notifications:
495 assert (
496 notifications.GetVapidPublicKey(empty_pb2.Empty()).vapid_public_key
497 == "BApMo2tGuon07jv-pEaAKZmVo6E_d4HfcdDeV6wx2k9wV8EovJ0ve00bdLzZm9fizDrGZXRYJFqCcRJUfBcgA0A"
498 )
501def test_RegisterPushNotificationSubscription(db):
502 _, token = generate_user()
504 subscription_info = {
505 "endpoint": "https://updates.push.services.mozilla.com/wpush/v2/gAAAAABmW2_iYKVyZRJPhAhktbkXd6Bc8zjIUvtVi5diYL7ZYn8FHka94kIdF46Mp8DwCDWlACnbKOEo97ikaa7JYowGLiGz3qsWL7Vo19LaV4I71mUDUOIKxWIsfp_kM77MlRJQKDUddv-sYyiffOyg63d1lnc_BMIyLXt69T5SEpfnfWTNb6I",
506 "expirationTime": None,
507 "keys": {
508 "auth": "TnuEJ1OdfEkf6HKcUovl0Q",
509 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0",
510 },
511 }
513 with notifications_session(token) as notifications:
514 res = notifications.RegisterPushNotificationSubscription(
515 notifications_pb2.RegisterPushNotificationSubscriptionReq(
516 full_subscription_json=json.dumps(subscription_info),
517 )
518 )
521def test_RegisterPushNotificationSubscription_invalid_endpoint(db):
522 _, token = generate_user()
524 subscription_info = {
525 "endpoint": "https://permanently-removed.invalid/some-id",
526 "expirationTime": None,
527 "keys": {
528 "auth": "TnuEJ1OdfEkf6HKcUovl0Q",
529 "p256dh": "BK7Rp8og3eFJPqm0ofR8F-l2mtNCCCWYo6f_5kSs8jPEFiKetnZHNOglvC6IrgU9vHmgFHlG7gHGtB1HM599sy0",
530 },
531 }
533 with notifications_session(token) as notifications:
534 with pytest.raises(grpc.RpcError) as e:
535 notifications.RegisterPushNotificationSubscription(
536 notifications_pb2.RegisterPushNotificationSubscriptionReq(
537 full_subscription_json=json.dumps(subscription_info),
538 )
539 )
540 assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT
543def test_SendTestPushNotification(db, push_collector: PushCollector):
544 user, token = generate_user()
546 with notifications_session(token) as notifications:
547 notifications.SendTestPushNotification(empty_pb2.Empty())
549 assert push_collector.count_for_user(user.id) == 1
550 push = push_collector.pop_for_user(user.id, last=True)
551 assert push.content.title == "Push notifications test"
552 assert push.content.body == "If you see this, then it's working :)"
555def test_SendBlogPostNotification(db, push_collector: PushCollector):
556 super_user, super_token = generate_user(is_superuser=True)
558 user1, user1_token = generate_user()
559 # enabled email
560 user2, user2_token = generate_user()
561 # disabled push
562 user3, user3_token = generate_user()
564 topic_action = NotificationTopicAction.general__new_blog_post
566 with notifications_session(user2_token) as notifications:
567 notifications.SetNotificationSettings(
568 notifications_pb2.SetNotificationSettingsReq(
569 preferences=[
570 notifications_pb2.SingleNotificationPreference(
571 topic=topic_action.topic,
572 action=topic_action.action,
573 delivery_method="email",
574 enabled=True,
575 )
576 ],
577 )
578 )
580 with notifications_session(user3_token) as notifications:
581 notifications.SetNotificationSettings(
582 notifications_pb2.SetNotificationSettingsReq(
583 preferences=[
584 notifications_pb2.SingleNotificationPreference(
585 topic=topic_action.topic,
586 action=topic_action.action,
587 delivery_method="push",
588 enabled=False,
589 )
590 ],
591 )
592 )
594 with mock_notification_email() as mock:
595 with real_editor_session(super_token) as editor_api:
596 editor_api.SendBlogPostNotification(
597 editor_pb2.SendBlogPostNotificationReq(
598 title="Couchers.org v0.9.9 Release Notes",
599 blurb="Read about last major updates before v1!",
600 url="https://couchers.org/blog/2025/05/11/v0.9.9-release",
601 )
602 )
604 process_jobs()
606 assert mock.call_count == 1
607 assert email_fields(mock).recipient == user2.email
608 assert "Couchers.org v0.9.9 Release Notes" in email_fields(mock).html
609 assert "Couchers.org v0.9.9 Release Notes" in email_fields(mock).plain
610 assert "Read about last major updates before v1!" in email_fields(mock).html
611 assert "Read about last major updates before v1!" in email_fields(mock).plain
612 assert "https://couchers.org/blog/2025/05/11/v0.9.9-release" in email_fields(mock).html
613 assert "https://couchers.org/blog/2025/05/11/v0.9.9-release" in email_fields(mock).plain
615 push = push_collector.pop_for_user(user1.id, last=True)
616 assert push.content.title == "New blog post: Couchers.org v0.9.9 Release Notes"
617 assert push.content.body == "Read about last major updates before v1!"
618 assert push.content.action_url == "https://couchers.org/blog/2025/05/11/v0.9.9-release"
620 push = push_collector.pop_for_user(user2.id, last=True)
621 assert push.content.title == "New blog post: Couchers.org v0.9.9 Release Notes"
622 assert push.content.body == "Read about last major updates before v1!"
623 assert push.content.action_url == "https://couchers.org/blog/2025/05/11/v0.9.9-release"
625 assert push_collector.count_for_user(user3.id) == 0
628def test_get_topic_actions_by_delivery_type(db):
629 user, token = generate_user()
631 # these are enabled by default
632 assert NotificationDeliveryType.push in NotificationTopicAction.reference__receive_friend.defaults
633 assert NotificationDeliveryType.push in NotificationTopicAction.host_request__accept.defaults
635 # these are disabled by default
636 assert NotificationDeliveryType.push not in NotificationTopicAction.event__create_any.defaults
637 assert NotificationDeliveryType.push not in NotificationTopicAction.discussion__create.defaults
639 with notifications_session(token) as notifications:
640 notifications.SetNotificationSettings(
641 notifications_pb2.SetNotificationSettingsReq(
642 preferences=[
643 notifications_pb2.SingleNotificationPreference(
644 topic=NotificationTopicAction.reference__receive_friend.topic,
645 action=NotificationTopicAction.reference__receive_friend.action,
646 delivery_method="push",
647 enabled=False,
648 ),
649 notifications_pb2.SingleNotificationPreference(
650 topic=NotificationTopicAction.event__create_any.topic,
651 action=NotificationTopicAction.event__create_any.action,
652 delivery_method="push",
653 enabled=True,
654 ),
655 ],
656 )
657 )
659 with session_scope() as session:
660 deliver = get_topic_actions_by_delivery_type(session, user.id, NotificationDeliveryType.push)
661 assert NotificationTopicAction.reference__receive_friend not in deliver
662 assert NotificationTopicAction.host_request__accept in deliver
663 assert NotificationTopicAction.event__create_any in deliver
664 assert NotificationTopicAction.discussion__create not in deliver
665 assert NotificationTopicAction.account_deletion__start in deliver
668def test_event_reminder_email_sent(db):
669 user, token = generate_user()
670 title = "Board Game Night"
671 start_event_time = timestamp_pb2.Timestamp(seconds=1751690400)
673 expected_time_str = LocalizationContext.from_user(user).localize_datetime(start_event_time)
675 with mock_notification_email() as mock:
676 with session_scope() as session:
677 user_in_session = session.get_one(User, user.id)
679 notify(
680 session,
681 user_id=user.id,
682 topic_action=NotificationTopicAction.event__reminder,
683 key="",
684 data=notification_data_pb2.EventReminder(
685 event=events_pb2.Event(
686 event_id=1,
687 slug="board-game-night",
688 title=title,
689 start_time=start_event_time,
690 ),
691 user=user_model_to_pb(user_in_session, session, make_background_user_context(user_id=user.id)),
692 ),
693 )
695 assert mock.call_count == 1
696 assert email_fields(mock).recipient == user.email
697 assert title in email_fields(mock).html
698 assert title in email_fields(mock).plain
699 assert expected_time_str in email_fields(mock).html
700 assert expected_time_str in email_fields(mock).plain
703def test_RegisterMobilePushNotificationSubscription(db):
704 user, token = generate_user()
706 with notifications_session(token) as notifications:
707 notifications.RegisterMobilePushNotificationSubscription(
708 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq(
709 token="ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
710 device_name="My iPhone",
711 device_type="ios",
712 )
713 )
715 # Check subscription was created
716 with session_scope() as session:
717 sub = session.execute(
718 select(PushNotificationSubscription).where(PushNotificationSubscription.user_id == user.id)
719 ).scalar_one()
720 assert sub.platform == PushNotificationPlatform.expo
721 assert sub.token == "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]"
722 assert sub.device_name == "My iPhone"
723 assert sub.device_type == DeviceType.ios
724 assert sub.disabled_at == DATETIME_INFINITY
727def test_RegisterMobilePushNotificationSubscription_android(db):
728 user, token = generate_user()
730 with notifications_session(token) as notifications:
731 notifications.RegisterMobilePushNotificationSubscription(
732 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq(
733 token="ExponentPushToken[yyyyyyyyyyyyyyyyyyyyyy]",
734 device_name="My Android",
735 device_type="android",
736 )
737 )
739 with session_scope() as session:
740 sub = session.execute(
741 select(PushNotificationSubscription).where(PushNotificationSubscription.user_id == user.id)
742 ).scalar_one()
743 assert sub.platform == PushNotificationPlatform.expo
744 assert sub.device_type == DeviceType.android
747def test_RegisterMobilePushNotificationSubscription_no_device_type(db):
748 user, token = generate_user()
750 with notifications_session(token) as notifications:
751 notifications.RegisterMobilePushNotificationSubscription(
752 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq(
753 token="ExponentPushToken[zzzzzzzzzzzzzzzzzzzzzz]",
754 )
755 )
757 with session_scope() as session:
758 sub = session.execute(
759 select(PushNotificationSubscription).where(PushNotificationSubscription.user_id == user.id)
760 ).scalar_one()
761 assert sub.platform == PushNotificationPlatform.expo
762 assert sub.device_name is None
763 assert sub.device_type is None
766def test_RegisterMobilePushNotificationSubscription_re_enable(db):
767 user, token = generate_user()
769 # Create a disabled subscription directly in the DB
770 with session_scope() as session:
771 sub = PushNotificationSubscription(
772 user_id=user.id,
773 platform=PushNotificationPlatform.expo,
774 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]",
775 device_name="Old Device",
776 device_type=DeviceType.ios,
777 )
778 sub.disabled_at = now()
779 session.add(sub)
780 session.flush()
781 sub_id = sub.id
783 # Re-register with the same token
784 with notifications_session(token) as notifications:
785 notifications.RegisterMobilePushNotificationSubscription(
786 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq(
787 token="ExponentPushToken[reeeeeeeeeeeeeeeeeeeee]",
788 device_name="New Device Name",
789 device_type="android",
790 )
791 )
793 # Check subscription was re-enabled and updated
794 with session_scope() as session:
795 sub = session.execute(
796 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id)
797 ).scalar_one()
798 assert sub.disabled_at == DATETIME_INFINITY
799 assert sub.device_name == "New Device Name"
800 assert sub.device_type == DeviceType.android
803def test_RegisterMobilePushNotificationSubscription_already_exists(db):
804 user, token = generate_user()
806 # Create an active subscription directly in the DB
807 with session_scope() as session:
808 sub = PushNotificationSubscription(
809 user_id=user.id,
810 platform=PushNotificationPlatform.expo,
811 token="ExponentPushToken[existingtoken]",
812 device_name="Existing Device",
813 device_type=DeviceType.ios,
814 )
815 session.add(sub)
817 # Try to register with the same token - should just return without error
818 with notifications_session(token) as notifications:
819 notifications.RegisterMobilePushNotificationSubscription(
820 notifications_pb2.RegisterMobilePushNotificationSubscriptionReq(
821 token="ExponentPushToken[existingtoken]",
822 device_name="Different Name",
823 )
824 )
826 # Check subscription was NOT modified (already active)
827 with session_scope() as session:
828 sub = session.execute(
829 select(PushNotificationSubscription).where(
830 PushNotificationSubscription.token == "ExponentPushToken[existingtoken]"
831 )
832 ).scalar_one()
833 assert sub.device_name == "Existing Device" # unchanged
836def test_SendTestMobilePushNotification(db, push_collector: PushCollector):
837 user, token = generate_user()
839 with notifications_session(token) as notifications:
840 notifications.SendTestMobilePushNotification(empty_pb2.Empty())
842 push = push_collector.pop_for_user(user.id, last=True)
843 assert push.content.title == "Mobile notifications test"
844 assert push.content.body == "If you see this on your phone, everything is wired up correctly 🎉"
847def test_get_expo_push_receipts(db):
848 mock_response = Mock()
849 mock_response.status_code = 200
850 mock_response.json.return_value = {
851 "data": {
852 "ticket-1": {"status": "ok"},
853 "ticket-2": {"status": "error", "details": {"error": "DeviceNotRegistered"}},
854 }
855 }
857 with patch("couchers.notifications.expo_api.requests.post", return_value=mock_response) as mock_post:
858 result = get_expo_push_receipts(["ticket-1", "ticket-2"])
860 mock_post.assert_called_once()
861 call_args = mock_post.call_args
862 assert call_args[0][0] == "https://exp.host/--/api/v2/push/getReceipts"
863 assert call_args[1]["json"] == {"ids": ["ticket-1", "ticket-2"]}
865 assert result == {
866 "ticket-1": {"status": "ok"},
867 "ticket-2": {"status": "error", "details": {"error": "DeviceNotRegistered"}},
868 }
871def test_get_expo_push_receipts_empty(db):
872 result = get_expo_push_receipts([])
873 assert result == {}
876def test_check_expo_push_receipts_success(db):
877 """Test batch receipt checking with successful delivery."""
878 user, token = generate_user()
880 # Create a push subscription and delivery attempt (old enough to be checked)
881 with session_scope() as session:
882 sub = PushNotificationSubscription(
883 user_id=user.id,
884 platform=PushNotificationPlatform.expo,
885 token="ExponentPushToken[testtoken123]",
886 device_name="Test Device",
887 device_type=DeviceType.ios,
888 )
889 session.add(sub)
890 session.flush()
892 attempt = PushNotificationDeliveryAttempt(
893 push_notification_subscription_id=sub.id,
894 outcome=PushNotificationDeliveryOutcome.success,
895 status_code=200,
896 expo_ticket_id="test-ticket-id",
897 )
898 session.add(attempt)
899 session.flush()
900 # Make the attempt old enough to be checked (>15 min)
901 attempt.time = now() - timedelta(minutes=20)
902 attempt_id = attempt.id
903 sub_id = sub.id
905 # Mock the receipt API call
906 with patch("couchers.notifications.expo_api.requests.post") as mock_post:
907 mock_post.return_value.status_code = 200
908 mock_post.return_value.json.return_value = {"data": {"test-ticket-id": {"status": "ok"}}}
910 check_expo_push_receipts(empty_pb2.Empty())
912 # Verify the attempt was updated
913 with session_scope() as session:
914 attempt = session.execute(
915 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id)
916 ).scalar_one()
917 assert attempt.receipt_checked_at is not None
918 assert attempt.receipt_status == "ok"
919 assert attempt.receipt_error_code is None
921 # Subscription should still be enabled
922 sub = session.execute(
923 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id)
924 ).scalar_one()
925 assert sub.disabled_at == DATETIME_INFINITY
928def test_check_expo_push_receipts_device_not_registered(db):
929 """Test batch receipt checking with DeviceNotRegistered error disables subscription."""
930 user, token = generate_user()
932 # Create a push subscription and delivery attempt
933 with session_scope() as session:
934 sub = PushNotificationSubscription(
935 user_id=user.id,
936 platform=PushNotificationPlatform.expo,
937 token="ExponentPushToken[devicegone]",
938 device_name="Test Device",
939 device_type=DeviceType.android,
940 )
941 session.add(sub)
942 session.flush()
944 attempt = PushNotificationDeliveryAttempt(
945 push_notification_subscription_id=sub.id,
946 outcome=PushNotificationDeliveryOutcome.success,
947 status_code=200,
948 expo_ticket_id="ticket-device-gone",
949 )
950 session.add(attempt)
951 session.flush()
952 # Make the attempt old enough to be checked
953 attempt.time = now() - timedelta(minutes=15)
954 attempt_id = attempt.id
955 sub_id = sub.id
957 # Mock the receipt API call with DeviceNotRegistered error
958 with patch("couchers.notifications.expo_api.requests.post") as mock_post:
959 mock_post.return_value.status_code = 200
960 mock_post.return_value.json.return_value = {
961 "data": {
962 "ticket-device-gone": {
963 "status": "error",
964 "details": {"error": "DeviceNotRegistered"},
965 }
966 }
967 }
969 check_expo_push_receipts(empty_pb2.Empty())
971 # Verify the attempt was updated and subscription disabled
972 with session_scope() as session:
973 attempt = session.execute(
974 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id)
975 ).scalar_one()
976 assert attempt.receipt_checked_at is not None
977 assert attempt.receipt_status == "error"
978 assert attempt.receipt_error_code == "DeviceNotRegistered"
980 # Subscription should be disabled
981 sub = session.execute(
982 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id)
983 ).scalar_one()
984 assert sub.disabled_at <= now()
987def test_check_expo_push_receipts_not_found(db):
988 """Test batch receipt checking when ticket not found (expired)."""
989 user, token = generate_user()
991 with session_scope() as session:
992 sub = PushNotificationSubscription(
993 user_id=user.id,
994 platform=PushNotificationPlatform.expo,
995 token="ExponentPushToken[notfound]",
996 )
997 session.add(sub)
998 session.flush()
1000 attempt = PushNotificationDeliveryAttempt(
1001 push_notification_subscription_id=sub.id,
1002 outcome=PushNotificationDeliveryOutcome.success,
1003 status_code=200,
1004 expo_ticket_id="unknown-ticket",
1005 )
1006 session.add(attempt)
1007 session.flush()
1008 # Make the attempt old enough to be checked
1009 attempt.time = now() - timedelta(minutes=15)
1010 attempt_id = attempt.id
1011 sub_id = sub.id
1013 # Mock empty receipt response (ticket not found)
1014 with patch("couchers.notifications.expo_api.requests.post") as mock_post:
1015 mock_post.return_value.status_code = 200
1016 mock_post.return_value.json.return_value = {"data": {}}
1018 check_expo_push_receipts(empty_pb2.Empty())
1020 with session_scope() as session:
1021 attempt = session.execute(
1022 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id)
1023 ).scalar_one()
1024 assert attempt.receipt_checked_at is not None
1025 assert attempt.receipt_status == "not_found"
1027 # Subscription should still be enabled
1028 sub = session.execute(
1029 select(PushNotificationSubscription).where(PushNotificationSubscription.id == sub_id)
1030 ).scalar_one()
1031 assert sub.disabled_at == DATETIME_INFINITY
1034def test_check_expo_push_receipts_skips_already_checked(db):
1035 """Test that already-checked receipts are not re-checked."""
1036 user, token = generate_user()
1038 # Create an attempt that was already checked
1039 with session_scope() as session:
1040 sub = PushNotificationSubscription(
1041 user_id=user.id,
1042 platform=PushNotificationPlatform.expo,
1043 token="ExponentPushToken[alreadychecked]",
1044 )
1045 session.add(sub)
1046 session.flush()
1048 attempt = PushNotificationDeliveryAttempt(
1049 push_notification_subscription_id=sub.id,
1050 outcome=PushNotificationDeliveryOutcome.success,
1051 status_code=200,
1052 expo_ticket_id="already-checked-ticket",
1053 receipt_checked_at=now(),
1054 receipt_status="ok",
1055 )
1056 session.add(attempt)
1057 session.flush()
1058 # Make the attempt old enough
1059 attempt.time = now() - timedelta(minutes=15)
1061 # Should not call the API since the only attempt is already checked
1062 with patch("couchers.notifications.expo_api.requests.post") as mock_post:
1063 check_expo_push_receipts(empty_pb2.Empty())
1064 mock_post.assert_not_called()
1067def test_SendDevPushNotification_success(db, push_collector: PushCollector):
1068 """Test SendDevPushNotification sends push with all specified parameters."""
1069 user, token = generate_user()
1071 # Enable dev APIs for this test
1072 config["ENABLE_DEV_APIS"] = True
1074 with notifications_session(token) as notifications:
1075 notifications.SendDevPushNotification(
1076 notifications_pb2.SendDevPushNotificationReq(
1077 title="Test Dev Title",
1078 body="Test dev notification body",
1079 icon="https://example.com/icon.png",
1080 url="https://example.com/action",
1081 key="test-key",
1082 ttl=3600,
1083 )
1084 )
1086 push = push_collector.pop_for_user(user.id, last=True)
1087 assert push.content.title == "Test Dev Title"
1088 assert push.content.body == "Test dev notification body"
1089 assert push.content.action_url == "https://example.com/action"
1090 assert push.content.icon_url == "https://example.com/icon.png"
1091 assert push.topic_action == "adhoc:testing"
1092 assert push.key == "test-key"
1093 assert push.ttl == 3600
1096def test_SendDevPushNotification_minimal(db, push_collector: PushCollector):
1097 """Test SendDevPushNotification with minimal parameters."""
1098 user, token = generate_user()
1100 config["ENABLE_DEV_APIS"] = True
1102 with notifications_session(token) as notifications:
1103 notifications.SendDevPushNotification(
1104 notifications_pb2.SendDevPushNotificationReq(
1105 title="Minimal Title",
1106 body="Minimal body",
1107 )
1108 )
1110 push = push_collector.pop_for_user(user.id, last=True)
1111 assert push.content.title == "Minimal Title"
1112 assert push.content.body == "Minimal body"
1113 assert push.topic_action == "adhoc:testing"
1116def test_SendDevPushNotification_disabled(db, push_collector: PushCollector):
1117 """Test SendDevPushNotification fails when ENABLE_DEV_APIS is disabled."""
1118 user, token = generate_user()
1120 # Ensure dev APIs are disabled (default in tests)
1121 config["ENABLE_DEV_APIS"] = False
1123 with notifications_session(token) as notifications:
1124 with pytest.raises(grpc.RpcError) as e:
1125 notifications.SendDevPushNotification(
1126 notifications_pb2.SendDevPushNotificationReq(
1127 title="Should Fail",
1128 body="This should not be sent",
1129 )
1130 )
1131 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
1132 assert "Development APIs are not enabled" in not_none(e.value.details())
1134 assert push_collector.count_for_user(user.id) == 0
1137def test_SendDevPushNotification_push_notifications_disabled(db, push_collector: PushCollector):
1138 """Test SendDevPushNotification fails when push notifications are disabled."""
1139 user, token = generate_user()
1141 config["ENABLE_DEV_APIS"] = True
1142 config["PUSH_NOTIFICATIONS_ENABLED"] = False
1144 with notifications_session(token) as notifications:
1145 with pytest.raises(grpc.RpcError) as e:
1146 notifications.SendDevPushNotification(
1147 notifications_pb2.SendDevPushNotificationReq(
1148 title="Should Fail",
1149 body="This should not be sent",
1150 )
1151 )
1152 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
1153 assert "Push notifications are currently disabled" in not_none(e.value.details())
1155 assert push_collector.count_for_user(user.id) == 0
1158def test_check_expo_push_receipts_skips_too_recent(db):
1159 """Test that too-recent receipts (<15 min) are not checked."""
1160 user, token = generate_user()
1162 # Create a recent attempt (not old enough to check)
1163 with session_scope() as session:
1164 sub = PushNotificationSubscription(
1165 user_id=user.id,
1166 platform=PushNotificationPlatform.expo,
1167 token="ExponentPushToken[recent]",
1168 )
1169 session.add(sub)
1170 session.flush()
1172 attempt = PushNotificationDeliveryAttempt(
1173 push_notification_subscription_id=sub.id,
1174 outcome=PushNotificationDeliveryOutcome.success,
1175 status_code=200,
1176 expo_ticket_id="recent-ticket",
1177 )
1178 session.add(attempt)
1179 session.flush()
1180 # Make the attempt only 5 minutes old (too recent)
1181 attempt.time = now() - timedelta(minutes=5)
1183 # Should not call the API since the attempt is too recent
1184 with patch("couchers.notifications.expo_api.requests.post") as mock_post:
1185 check_expo_push_receipts(empty_pb2.Empty())
1186 mock_post.assert_not_called()
1189def test_check_expo_push_receipts_batch(db):
1190 """Test that multiple receipts are checked in a single batch."""
1191 user, token = generate_user()
1193 # Create multiple delivery attempts
1194 attempt_ids = []
1195 with session_scope() as session:
1196 sub = PushNotificationSubscription(
1197 user_id=user.id,
1198 platform=PushNotificationPlatform.expo,
1199 token="ExponentPushToken[batch]",
1200 )
1201 session.add(sub)
1202 session.flush()
1204 for i in range(3):
1205 attempt = PushNotificationDeliveryAttempt(
1206 push_notification_subscription_id=sub.id,
1207 outcome=PushNotificationDeliveryOutcome.success,
1208 status_code=200,
1209 expo_ticket_id=f"batch-ticket-{i}",
1210 )
1211 session.add(attempt)
1212 session.flush()
1213 attempt.time = now() - timedelta(minutes=20)
1214 attempt_ids.append(attempt.id)
1216 # Mock the batch receipt API call
1217 with patch("couchers.notifications.expo_api.requests.post") as mock_post:
1218 mock_post.return_value.status_code = 200
1219 mock_post.return_value.json.return_value = {
1220 "data": {
1221 "batch-ticket-0": {"status": "ok"},
1222 "batch-ticket-1": {"status": "ok"},
1223 "batch-ticket-2": {"status": "ok"},
1224 }
1225 }
1227 check_expo_push_receipts(empty_pb2.Empty())
1229 # Should only call the API once for all tickets
1230 assert mock_post.call_count == 1
1232 # Verify all attempts were updated
1233 with session_scope() as session:
1234 for attempt_id in attempt_ids:
1235 attempt = session.execute(
1236 select(PushNotificationDeliveryAttempt).where(PushNotificationDeliveryAttempt.id == attempt_id)
1237 ).scalar_one()
1238 assert attempt.receipt_checked_at is not None
1239 assert attempt.receipt_status == "ok"
1242def test_DebugRedeliverPushNotification_success(db, push_collector: PushCollector):
1243 """Test DebugRedeliverPushNotification redelivers an existing notification."""
1244 user, token = generate_user()
1246 config["ENABLE_DEV_APIS"] = True
1248 # Create a notification for the user
1249 with session_scope() as session:
1250 notify(
1251 session,
1252 user_id=user.id,
1253 topic_action=NotificationTopicAction.badge__add,
1254 key="test-badge",
1255 data=notification_data_pb2.BadgeAdd(
1256 badge_id="volunteer",
1257 badge_name="Active Volunteer",
1258 badge_description="This user is an active volunteer for Couchers.org",
1259 ),
1260 )
1262 process_job()
1264 # Pop the initial push notification
1265 push_collector.pop_for_user(user.id, last=True)
1267 # Get the notification_id
1268 with session_scope() as session:
1269 notification = session.execute(select(Notification).where(Notification.user_id == user.id)).scalar_one()
1270 notification_id = notification.id
1272 # Redeliver the notification
1273 with notifications_session(token) as notifications:
1274 notifications.DebugRedeliverPushNotification(
1275 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id)
1276 )
1278 # Verify a new push was sent
1279 push = push_collector.pop_for_user(user.id, last=True)
1280 assert "Active Volunteer" in push.content.title
1281 assert push.topic_action == "badge:add"
1282 assert push.key == "test-badge"
1285def test_DebugRedeliverPushNotification_not_found(db, push_collector: PushCollector):
1286 """Test DebugRedeliverPushNotification fails when notification doesn't exist."""
1287 user, token = generate_user()
1289 config["ENABLE_DEV_APIS"] = True
1291 with notifications_session(token) as notifications:
1292 with pytest.raises(grpc.RpcError) as e:
1293 notifications.DebugRedeliverPushNotification(
1294 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=999999)
1295 )
1296 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1297 assert "notification not found" in not_none(e.value.details()).lower()
1299 assert push_collector.count_for_user(user.id) == 0
1302def test_DebugRedeliverPushNotification_wrong_user(db, push_collector: PushCollector):
1303 """Test DebugRedeliverPushNotification fails when notification belongs to another user."""
1304 user1, token1 = generate_user()
1305 user2, token2 = generate_user()
1307 config["ENABLE_DEV_APIS"] = True
1309 # Create a notification for user1
1310 with session_scope() as session:
1311 notify(
1312 session,
1313 user_id=user1.id,
1314 topic_action=NotificationTopicAction.badge__add,
1315 key="test-badge",
1316 data=notification_data_pb2.BadgeAdd(
1317 badge_id="volunteer",
1318 badge_name="Active Volunteer",
1319 badge_description="This user is an active volunteer for Couchers.org",
1320 ),
1321 )
1323 process_job()
1325 # Get the notification_id
1326 with session_scope() as session:
1327 notification = session.execute(select(Notification).where(Notification.user_id == user1.id)).scalar_one()
1328 notification_id = notification.id
1330 # user2 tries to redeliver user1's notification
1331 with notifications_session(token2) as notifications:
1332 with pytest.raises(grpc.RpcError) as e:
1333 notifications.DebugRedeliverPushNotification(
1334 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=notification_id)
1335 )
1336 assert e.value.code() == grpc.StatusCode.NOT_FOUND
1337 assert "notification not found" in not_none(e.value.details()).lower()
1339 assert push_collector.count_for_user(user2.id) == 0
1342def test_DebugRedeliverPushNotification_disabled(db, push_collector: PushCollector):
1343 """Test DebugRedeliverPushNotification fails when ENABLE_DEV_APIS is disabled."""
1344 user, token = generate_user()
1346 config["ENABLE_DEV_APIS"] = False
1348 with notifications_session(token) as notifications:
1349 with pytest.raises(grpc.RpcError) as e:
1350 notifications.DebugRedeliverPushNotification(
1351 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1)
1352 )
1353 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
1354 assert "Development APIs are not enabled" in not_none(e.value.details())
1356 assert push_collector.count_for_user(user.id) == 0
1359def test_DebugRedeliverPushNotification_push_notifications_disabled(db, push_collector: PushCollector):
1360 """Test DebugRedeliverPushNotification fails when push notifications are disabled."""
1361 user, token = generate_user()
1363 config["ENABLE_DEV_APIS"] = True
1364 config["PUSH_NOTIFICATIONS_ENABLED"] = False
1366 with notifications_session(token) as notifications:
1367 with pytest.raises(grpc.RpcError) as e:
1368 notifications.DebugRedeliverPushNotification(
1369 notifications_pb2.DebugRedeliverPushNotificationReq(notification_id=1)
1370 )
1371 assert e.value.code() == grpc.StatusCode.UNAVAILABLE
1372 assert "Push notifications are currently disabled" in not_none(e.value.details())
1374 assert push_collector.count_for_user(user.id) == 0
1377def test_handle_notification_email_delivery(db):
1378 """Test that email notifications are delivered when email preference is enabled."""
1379 user, token = generate_user()
1381 topic_action = NotificationTopicAction.badge__add
1383 # Enable email notifications for this topic
1384 with notifications_session(token) as notifications:
1385 notifications.SetNotificationSettings(
1386 notifications_pb2.SetNotificationSettingsReq(
1387 preferences=[
1388 notifications_pb2.SingleNotificationPreference(
1389 topic=topic_action.topic,
1390 action=topic_action.action,
1391 delivery_method="email",
1392 enabled=True,
1393 )
1394 ],
1395 )
1396 )
1398 with mock_notification_email() as mock:
1399 with session_scope() as session:
1400 notify(
1401 session,
1402 user_id=user.id,
1403 topic_action=topic_action,
1404 key="test-badge",
1405 data=notification_data_pb2.BadgeAdd(
1406 badge_id="volunteer",
1407 badge_name="Active Volunteer",
1408 badge_description="This user is an active volunteer",
1409 ),
1410 )
1412 assert mock.call_count == 1
1413 assert email_fields(mock).recipient == user.email
1415 with session_scope() as session:
1416 delivery = session.execute(
1417 select(NotificationDelivery)
1418 .join(Notification, Notification.id == NotificationDelivery.notification_id)
1419 .where(Notification.user_id == user.id)
1420 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.email)
1421 ).scalar_one()
1422 assert delivery.delivered is not None
1425def test_handle_notification_push_delivery(db, push_collector: PushCollector):
1426 """Test that push notifications are delivered immediately when push preference is enabled."""
1427 user, token = generate_user()
1429 topic_action = NotificationTopicAction.badge__add
1431 with session_scope() as session:
1432 notify(
1433 session,
1434 user_id=user.id,
1435 topic_action=topic_action,
1436 key="test-badge",
1437 data=notification_data_pb2.BadgeAdd(
1438 badge_id="volunteer",
1439 badge_name="Active Volunteer",
1440 badge_description="This user is an active volunteer",
1441 ),
1442 )
1444 process_job()
1446 push = push_collector.pop_for_user(user.id, last=True)
1447 assert "Active Volunteer" in push.content.title
1449 with session_scope() as session:
1450 delivery = session.execute(
1451 select(NotificationDelivery)
1452 .join(Notification, Notification.id == NotificationDelivery.notification_id)
1453 .where(Notification.user_id == user.id)
1454 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.push)
1455 ).scalar_one()
1456 assert delivery.delivered is not None
1459def test_handle_notification_digest_delivery(db):
1460 """Test that digest notifications are queued without a delivered timestamp."""
1461 user, token = generate_user()
1463 topic_action = NotificationTopicAction.badge__add
1465 # Enable only digest notifications for this topic
1466 with notifications_session(token) as notifications:
1467 notifications.SetNotificationSettings(
1468 notifications_pb2.SetNotificationSettingsReq(
1469 preferences=[
1470 notifications_pb2.SingleNotificationPreference(
1471 topic=topic_action.topic,
1472 action=topic_action.action,
1473 delivery_method="push",
1474 enabled=False,
1475 ),
1476 notifications_pb2.SingleNotificationPreference(
1477 topic=topic_action.topic,
1478 action=topic_action.action,
1479 delivery_method="digest",
1480 enabled=True,
1481 ),
1482 ],
1483 )
1484 )
1486 with session_scope() as session:
1487 notify(
1488 session,
1489 user_id=user.id,
1490 topic_action=topic_action,
1491 key="test-badge",
1492 data=notification_data_pb2.BadgeAdd(
1493 badge_id="volunteer",
1494 badge_name="Active Volunteer",
1495 badge_description="This user is an active volunteer",
1496 ),
1497 )
1499 process_job()
1501 # Verify digest NotificationDelivery was created WITHOUT delivered timestamp
1502 with session_scope() as session:
1503 delivery = session.execute(
1504 select(NotificationDelivery)
1505 .join(Notification, Notification.id == NotificationDelivery.notification_id)
1506 .where(Notification.user_id == user.id)
1507 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.digest)
1508 ).scalar_one()
1509 assert delivery.delivered is None
1512def test_handle_notification_banned_user_no_email(db):
1513 """Test that banned users don't receive email notifications."""
1514 user, token = generate_user()
1516 topic_action = NotificationTopicAction.badge__add
1518 # Enable email notifications
1519 with notifications_session(token) as notifications:
1520 notifications.SetNotificationSettings(
1521 notifications_pb2.SetNotificationSettingsReq(
1522 preferences=[
1523 notifications_pb2.SingleNotificationPreference(
1524 topic=topic_action.topic,
1525 action=topic_action.action,
1526 delivery_method="email",
1527 enabled=True,
1528 )
1529 ],
1530 )
1531 )
1533 # Ban the user
1534 with session_scope() as session:
1535 session.execute(update(User).where(User.id == user.id).values(banned_at=now()))
1537 with mock_notification_email() as mock:
1538 with session_scope() as session:
1539 notify(
1540 session,
1541 user_id=user.id,
1542 topic_action=topic_action,
1543 key="test-badge",
1544 data=notification_data_pb2.BadgeAdd(
1545 badge_id="volunteer",
1546 badge_name="Active Volunteer",
1547 badge_description="This user is an active volunteer",
1548 ),
1549 )
1551 # Email should not be sent to the banned user
1552 assert mock.call_count == 0
1555def test_handle_notification_deleted_user_no_regular_email(db):
1556 """Test that deleted users don't receive non-account-deletion email notifications."""
1557 user, token = generate_user()
1559 topic_action = NotificationTopicAction.badge__add
1561 # Enable email notifications
1562 with notifications_session(token) as notifications:
1563 notifications.SetNotificationSettings(
1564 notifications_pb2.SetNotificationSettingsReq(
1565 preferences=[
1566 notifications_pb2.SingleNotificationPreference(
1567 topic=topic_action.topic,
1568 action=topic_action.action,
1569 delivery_method="email",
1570 enabled=True,
1571 )
1572 ],
1573 )
1574 )
1576 # Delete the user
1577 with session_scope() as session:
1578 session.execute(update(User).where(User.id == user.id).values(deleted_at=now()))
1580 with mock_notification_email() as mock:
1581 with session_scope() as session:
1582 notify(
1583 session,
1584 user_id=user.id,
1585 topic_action=topic_action,
1586 key="test-badge",
1587 data=notification_data_pb2.BadgeAdd(
1588 badge_id="volunteer",
1589 badge_name="Active Volunteer",
1590 badge_description="This user is an active volunteer",
1591 ),
1592 )
1594 # Email should not be sent to deleted user for non-account-deletion notification
1595 assert mock.call_count == 0
1598def test_handle_notification_deleted_user_receives_account_deletion_email(db):
1599 """Test that deleted users CAN receive account deletion notifications."""
1600 user, token = generate_user()
1602 topic_action = NotificationTopicAction.account_deletion__complete
1604 # Delete the user
1605 with session_scope() as session:
1606 session.execute(update(User).where(User.id == user.id).values(deleted_at=now()))
1608 with mock_notification_email() as mock:
1609 with session_scope() as session:
1610 notify(
1611 session,
1612 user_id=user.id,
1613 topic_action=topic_action,
1614 key="",
1615 data=notification_data_pb2.AccountDeletionComplete(
1616 undelete_token="test-token",
1617 undelete_days=7,
1618 ),
1619 )
1621 # Email SHOULD be sent to deleted user for account deletion notification
1622 assert mock.call_count == 1
1623 assert email_fields(mock).recipient == user.email
1626def test_handle_notification_do_not_email_respected(db):
1627 """Test that users with do_not_email set don't receive non-critical emails."""
1628 user, token = generate_user()
1630 topic_action = NotificationTopicAction.badge__add
1632 # Enable email notifications
1633 with notifications_session(token) as notifications:
1634 notifications.SetNotificationSettings(
1635 notifications_pb2.SetNotificationSettingsReq(
1636 preferences=[
1637 notifications_pb2.SingleNotificationPreference(
1638 topic=topic_action.topic,
1639 action=topic_action.action,
1640 delivery_method="email",
1641 enabled=True,
1642 )
1643 ],
1644 )
1645 )
1647 # Set do_not_email (requires hosting/meetup status to be set due to DB constraint)
1648 with session_scope() as session:
1649 session.execute(
1650 update(User)
1651 .where(User.id == user.id)
1652 .values(
1653 hosting_status=HostingStatus.cant_host,
1654 meetup_status=MeetupStatus.does_not_want_to_meetup,
1655 do_not_email=True,
1656 )
1657 )
1659 with mock_notification_email() as mock:
1660 with session_scope() as session:
1661 notify(
1662 session,
1663 user_id=user.id,
1664 topic_action=topic_action,
1665 key="test-badge",
1666 data=notification_data_pb2.BadgeAdd(
1667 badge_id="volunteer",
1668 badge_name="Active Volunteer",
1669 badge_description="This user is an active volunteer",
1670 ),
1671 )
1673 # Email should not be sent when do_not_email is True
1674 assert mock.call_count == 0
1677def test_handle_notification_critical_bypasses_do_not_email(db):
1678 """Test that critical notifications bypass do_not_email setting."""
1679 user, token = generate_user()
1681 topic_action = NotificationTopicAction.password__change
1683 # Set do_not_email (requires hosting/meetup status to be set due to DB constraint)
1684 with session_scope() as session:
1685 session.execute(
1686 update(User)
1687 .where(User.id == user.id)
1688 .values(
1689 hosting_status=HostingStatus.cant_host,
1690 meetup_status=MeetupStatus.does_not_want_to_meetup,
1691 do_not_email=True,
1692 )
1693 )
1695 with mock_notification_email() as mock:
1696 with session_scope() as session:
1697 notify(
1698 session,
1699 user_id=user.id,
1700 topic_action=topic_action,
1701 key="",
1702 data=None,
1703 )
1705 # Critical email SHOULD be sent even with do_not_email=True
1706 assert mock.call_count == 1
1707 assert email_fields(mock).recipient == user.email
1710def test_handle_notification_duplicate_delivery_skipped(db, push_collector: PushCollector):
1711 """Test that duplicate deliveries are skipped when NotificationDelivery already exists."""
1712 user, token = generate_user()
1714 topic_action = NotificationTopicAction.badge__add
1716 # Create notification manually
1717 with session_scope() as session:
1718 notification = Notification(
1719 user_id=user.id,
1720 topic_action=topic_action,
1721 key="test-badge",
1722 data=notification_data_pb2.BadgeAdd(
1723 badge_id="volunteer",
1724 badge_name="Active Volunteer",
1725 badge_description="This user is an active volunteer",
1726 ).SerializeToString(),
1727 )
1728 session.add(notification)
1729 session.flush()
1730 notification_id = notification.id
1732 # Manually create a push delivery (simulating it was already delivered)
1733 session.add(
1734 NotificationDelivery(
1735 notification_id=notification_id,
1736 delivery_type=NotificationDeliveryType.push,
1737 delivered=now(),
1738 )
1739 )
1741 # Try to handle the notification again
1742 handle_notification(jobs_pb2.HandleNotificationPayload(notification_id=notification_id))
1744 # No new push should be sent since delivery already exists
1745 assert push_collector.count_for_user(user.id) == 0
1747 # Verify only one delivery exists
1748 with session_scope() as session:
1749 delivery_count = len(
1750 session.execute(
1751 select(NotificationDelivery)
1752 .where(NotificationDelivery.notification_id == notification_id)
1753 .where(NotificationDelivery.delivery_type == NotificationDeliveryType.push)
1754 )
1755 .scalars()
1756 .all()
1757 )
1758 assert delivery_count == 1
1761def test_handle_notification_deferred_when_content_not_visible(db, moderator):
1762 """Test that notifications linked to non-visible moderated content are deferred."""
1763 user1, token1 = generate_user(complete_profile=True)
1764 user2, token2 = generate_user(complete_profile=True)
1766 # Create a friend request (which creates a moderation state)
1767 # This also queues a notification via SendFriendRequest
1768 with api_session(token2) as api:
1769 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id))
1771 # Process the queued job (handle_notification)
1772 process_job()
1774 # The notification should exist but have no deliveries because content is shadowed
1775 with session_scope() as session:
1776 notification = session.execute(
1777 select(Notification)
1778 .where(Notification.user_id == user1.id)
1779 .where(Notification.topic_action == NotificationTopicAction.friend_request__create)
1780 ).scalar_one()
1782 deliveries = (
1783 session.execute(select(NotificationDelivery).where(NotificationDelivery.notification_id == notification.id))
1784 .scalars()
1785 .all()
1786 )
1787 # No deliveries because content is not yet visible (shadowed)
1788 assert len(deliveries) == 0
1791def test_handle_notification_delivered_when_content_visible(db, moderator):
1792 """Test that notifications linked to visible moderated content are delivered."""
1793 user1, token1 = generate_user(complete_profile=True)
1794 user2, token2 = generate_user(complete_profile=True)
1796 # Create a friend request
1797 with api_session(token2) as api:
1798 api.SendFriendRequest(api_pb2.SendFriendRequestReq(user_id=user1.id))
1799 res = api.ListFriendRequests(empty_pb2.Empty())
1800 fr_id = res.sent[0].friend_request_id
1802 # Process initial job (which is deferred because content is shadowed)
1803 process_job()
1805 # Approve the friend request so it becomes visible (this queues the notification job again)
1806 moderator.approve_friend_request(fr_id)
1808 # Process the notification job that was re-queued after approval
1809 process_jobs()
1811 # Notification should have been delivered
1812 with session_scope() as session:
1813 notification = session.execute(
1814 select(Notification)
1815 .where(Notification.user_id == user1.id)
1816 .where(Notification.topic_action == NotificationTopicAction.friend_request__create)
1817 ).scalar_one()
1819 deliveries = (
1820 session.execute(select(NotificationDelivery).where(NotificationDelivery.notification_id == notification.id))
1821 .scalars()
1822 .all()
1823 )
1824 # At least one delivery should exist
1825 assert len(deliveries) > 0
1828def test_handle_notification_multiple_delivery_types(db, push_collector: PushCollector):
1829 """Test that multiple delivery types are processed for a single notification."""
1830 user, token = generate_user()
1832 topic_action = NotificationTopicAction.badge__add
1834 # Enable both email and push notifications
1835 with notifications_session(token) as notifications:
1836 notifications.SetNotificationSettings(
1837 notifications_pb2.SetNotificationSettingsReq(
1838 preferences=[
1839 notifications_pb2.SingleNotificationPreference(
1840 topic=topic_action.topic,
1841 action=topic_action.action,
1842 delivery_method="email",
1843 enabled=True,
1844 ),
1845 notifications_pb2.SingleNotificationPreference(
1846 topic=topic_action.topic,
1847 action=topic_action.action,
1848 delivery_method="push",
1849 enabled=True,
1850 ),
1851 notifications_pb2.SingleNotificationPreference(
1852 topic=topic_action.topic,
1853 action=topic_action.action,
1854 delivery_method="digest",
1855 enabled=True,
1856 ),
1857 ],
1858 )
1859 )
1861 with mock_notification_email() as mock:
1862 with session_scope() as session:
1863 notify(
1864 session,
1865 user_id=user.id,
1866 topic_action=topic_action,
1867 key="test-badge",
1868 data=notification_data_pb2.BadgeAdd(
1869 badge_id="volunteer",
1870 badge_name="Active Volunteer",
1871 badge_description="This user is an active volunteer",
1872 ),
1873 )
1875 # Email should be sent
1876 assert mock.call_count == 1
1878 # Push should be sent
1879 push = push_collector.pop_for_user(user.id, last=True)
1880 assert "Active Volunteer" in push.content.title
1882 # All three delivery types should have deliveries
1883 with session_scope() as session:
1884 notification = session.execute(select(Notification).where(Notification.user_id == user.id)).scalar_one()
1886 deliveries = (
1887 session.execute(select(NotificationDelivery).where(NotificationDelivery.notification_id == notification.id))
1888 .scalars()
1889 .all()
1890 )
1892 delivery_types = {d.delivery_type for d in deliveries}
1893 assert NotificationDeliveryType.email in delivery_types
1894 assert NotificationDeliveryType.push in delivery_types
1895 assert NotificationDeliveryType.digest in delivery_types
1897 # Email and push should have delivered timestamps
1898 for delivery in deliveries:
1899 if delivery.delivery_type in [NotificationDeliveryType.email, NotificationDeliveryType.push]:
1900 assert delivery.delivered is not None
1901 elif delivery.delivery_type == NotificationDeliveryType.digest: 1901 ↛ 1898line 1901 didn't jump to line 1898 because the condition on line 1901 was always true
1902 assert delivery.delivered is None