feat: replace email flows with in-app notifications
Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
This commit is contained in:
0
apps/notifications/tests/__init__.py
Normal file
0
apps/notifications/tests/__init__.py
Normal file
170
apps/notifications/tests/test_notifications.py
Normal file
170
apps/notifications/tests/test_notifications.py
Normal file
@@ -0,0 +1,170 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib import admin
|
||||
from django.test import RequestFactory, SimpleTestCase, TestCase
|
||||
|
||||
from apps.notifications.api.views import get_notification, list_notifications
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
from apps.notifications.tasks import send_manual_notification
|
||||
from apps.users.admin import ManualNotificationForm, UserAdmin
|
||||
from apps.users.models import User
|
||||
from core.authentication import create_jwt_token
|
||||
|
||||
|
||||
class NotificationStoreTests(SimpleTestCase):
|
||||
def test_summary_hides_full_body_and_advertises_it(self):
|
||||
notification = RedisNotificationStore._normalize_notification({
|
||||
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Full body",
|
||||
})
|
||||
summary = RedisNotificationStore.summary(notification)
|
||||
self.assertTrue(summary["has_body"])
|
||||
self.assertNotIn("body_markdown", summary)
|
||||
self.assertEqual(notification["body_markdown"], "# Full body")
|
||||
|
||||
@mock.patch("apps.notifications.services.redis_client")
|
||||
def test_read_filter_applies_before_pagination(self, redis_client):
|
||||
redis_client.zrevrange.return_value = ["one", "two", "three"]
|
||||
redis_client.pipeline.return_value.execute.return_value = [
|
||||
json.dumps({"id": "one", "is_seen": False}),
|
||||
json.dumps({"id": "two", "is_seen": True}),
|
||||
json.dumps({"id": "three", "is_seen": False}),
|
||||
]
|
||||
unread, count = RedisNotificationStore.list("user", limit=1, offset=1, read_filter="unread")
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual([item["id"] for item in unread], ["three"])
|
||||
read, count = RedisNotificationStore.list("user", read_filter="read")
|
||||
self.assertEqual(count, 1)
|
||||
self.assertEqual(read[0]["id"], "two")
|
||||
|
||||
@mock.patch("apps.notifications.api.views.RedisNotificationStore")
|
||||
def test_detail_is_scoped_to_authenticated_recipient(self, store):
|
||||
store.get.side_effect = lambda user_id, _: {"id": "one", "body_markdown": "# Secret"} if user_id == "1" else None
|
||||
owner = SimpleNamespace(auth=SimpleNamespace(id=1))
|
||||
other = SimpleNamespace(auth=SimpleNamespace(id=2))
|
||||
self.assertEqual(get_notification(owner, "one")[0], 200)
|
||||
self.assertEqual(get_notification(other, "one")[0], 404)
|
||||
store.get.assert_any_call("2", "one")
|
||||
|
||||
@mock.patch("apps.notifications.api.views.RedisNotificationStore")
|
||||
def test_list_returns_summaries_and_rejects_unknown_status(self, store):
|
||||
request = SimpleNamespace(auth=SimpleNamespace(id=1))
|
||||
store.list.return_value = ([{"id": "one", "body_markdown": "# Secret"}], 1)
|
||||
store.summary.side_effect = RedisNotificationStore.summary
|
||||
store.unread_count.return_value = 1
|
||||
result = list_notifications(request, status="unread")
|
||||
self.assertNotIn("body_markdown", result["notifications"][0])
|
||||
self.assertEqual(store.list.call_args.kwargs["read_filter"], "unread")
|
||||
self.assertEqual(list_notifications(request, status="invalid").status_code, 400)
|
||||
|
||||
@mock.patch.object(RedisNotificationStore, "_publish_event")
|
||||
@mock.patch.object(RedisNotificationStore, "unread_count", return_value=0)
|
||||
@mock.patch.object(RedisNotificationStore, "get")
|
||||
@mock.patch("apps.notifications.services.redis_client")
|
||||
def test_marking_old_ephemeral_notification_read_keeps_it_visible(self, redis_client, get, _count, _publish):
|
||||
get.return_value = {"id": "one", "is_seen": False, "delete_on_seen": True, "body_markdown": "# Body"}
|
||||
result = RedisNotificationStore.mark_seen("user", "one")
|
||||
self.assertFalse(result["deleted"])
|
||||
self.assertTrue(result["notification"]["is_seen"])
|
||||
redis_client.hset.assert_called_once()
|
||||
redis_client.pipeline.assert_not_called()
|
||||
|
||||
|
||||
class ManualNotificationTests(TestCase):
|
||||
def test_form_requires_title_and_plain_digest_but_allows_optional_body(self):
|
||||
self.assertTrue(ManualNotificationForm({"title": "Title", "digest": "Intro"}).is_valid())
|
||||
self.assertFalse(ManualNotificationForm({"title": "", "digest": "Intro"}).is_valid())
|
||||
|
||||
@mock.patch("apps.notifications.tasks.notify_user")
|
||||
def test_task_sends_only_to_selected_active_users(self, notify_user):
|
||||
active = User.objects.create_user(username="active", mobile="09190000001", password="password")
|
||||
inactive = User.objects.create_user(username="inactive", mobile="09190000002", password="password", is_active=False)
|
||||
sent = send_manual_notification.run([active.pk, inactive.pk], "Title", "Digest", "# Body")
|
||||
self.assertEqual(sent, 1)
|
||||
notify_user.assert_called_once_with(active.pk, {
|
||||
"type": "manual", "title": "Title", "message": "Digest", "body_markdown": "# Body", "level": "info",
|
||||
})
|
||||
|
||||
@mock.patch("apps.users.admin.send_manual_notification.delay")
|
||||
def test_admin_action_queues_valid_composer_for_selected_users(self, delay):
|
||||
staff = User.objects.create_superuser(username="staff", mobile="09190000003", password="password")
|
||||
recipient = User.objects.create_user(username="recipient", mobile="09190000004", password="password")
|
||||
request = RequestFactory().post("/admin/users/user/", {
|
||||
"confirm_send": "1", "title": "Title", "digest": "Digest", "body_markdown": "**Body**",
|
||||
})
|
||||
request.user = staff
|
||||
model_admin = UserAdmin(User, admin.site)
|
||||
with mock.patch.object(model_admin, "message_user"):
|
||||
model_admin.send_notification(request, User.objects.filter(pk=recipient.pk))
|
||||
delay.assert_called_once_with([recipient.pk], "Title", "Digest", "**Body**")
|
||||
|
||||
def test_account_email_identity_is_true_when_an_email_is_supplied(self):
|
||||
user = User.objects.create_user(username="emailuser", mobile="09190000005", password="password")
|
||||
self.assertFalse(user.is_email_verified)
|
||||
user.email = "person@example.com"
|
||||
user.save(update_fields=["email"])
|
||||
user.refresh_from_db()
|
||||
self.assertTrue(user.is_email_verified)
|
||||
|
||||
@mock.patch("apps.users.admin.send_manual_notification.delay")
|
||||
def test_admin_changelist_uses_the_composer_before_queuing(self, delay):
|
||||
staff = User.objects.create_superuser(username="adminstaff", mobile="09190000008", password="password")
|
||||
recipient = User.objects.create_user(username="target", mobile="09190000009", password="password")
|
||||
self.client.force_login(staff)
|
||||
action_data = {"action": "send_notification", "_selected_action": str(recipient.pk), "index": "0"}
|
||||
path = "/admin/users/user/"
|
||||
preview = self.client.post(path, action_data)
|
||||
self.assertEqual(preview.status_code, 200)
|
||||
self.assertContains(preview, "توضیح کوتاه")
|
||||
self.assertFalse(delay.called)
|
||||
result = self.client.post(path, {
|
||||
**action_data, "confirm_send": "1", "title": "Title", "digest": "Digest", "body_markdown": "# Body",
|
||||
})
|
||||
self.assertIn(result.status_code, (200, 302))
|
||||
delay.assert_called_once_with([recipient.pk], "Title", "Digest", "# Body")
|
||||
|
||||
|
||||
class NotificationAPIIntegrationTests(TestCase):
|
||||
@mock.patch("apps.notifications.api.views.RedisNotificationStore.get")
|
||||
def test_detail_requires_auth_and_returns_body_only_to_recipient(self, get):
|
||||
owner = User.objects.create_user(username="owner", mobile="09190000006", password="password")
|
||||
other = User.objects.create_user(username="other", mobile="09190000007", password="password")
|
||||
detail = RedisNotificationStore._normalize_notification({
|
||||
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Private",
|
||||
"action_url": "/events/workshop",
|
||||
})
|
||||
get.side_effect = lambda user_id, _: detail if user_id == str(owner.pk) else None
|
||||
path = "/api/notifications/one"
|
||||
self.assertEqual(self.client.get(path).status_code, 401)
|
||||
owner_response = self.client.get(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(owner)}")
|
||||
self.assertEqual(owner_response.status_code, 200)
|
||||
self.assertEqual(owner_response.json()["body_markdown"], "# Private")
|
||||
self.assertEqual(owner_response.json()["action_url"], "/events/workshop")
|
||||
other_response = self.client.get(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(other)}")
|
||||
self.assertEqual(other_response.status_code, 404)
|
||||
|
||||
@mock.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=1)
|
||||
@mock.patch("apps.notifications.api.views.RedisNotificationStore.list")
|
||||
def test_list_excludes_full_body_and_uses_read_filter(self, list_notifications_store, _count):
|
||||
user = User.objects.create_user(username="reader", mobile="09190000010", password="password")
|
||||
full = RedisNotificationStore._normalize_notification({
|
||||
"id": "one", "title": "Title", "message": "Digest", "body_markdown": "# Private",
|
||||
})
|
||||
list_notifications_store.return_value = ([full], 1)
|
||||
response = self.client.get("/api/notifications/?status=unread", HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(user)}")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
item = response.json()["notifications"][0]
|
||||
self.assertTrue(item["has_body"])
|
||||
self.assertNotIn("body_markdown", item)
|
||||
self.assertEqual(list_notifications_store.call_args.kwargs["read_filter"], "unread")
|
||||
|
||||
@mock.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=0)
|
||||
@mock.patch("apps.notifications.api.views.RedisNotificationStore.delete")
|
||||
def test_delete_only_removes_the_authenticated_users_notification(self, delete, _count):
|
||||
owner = User.objects.create_user(username="delete_owner", mobile="09190000011", password="password")
|
||||
other = User.objects.create_user(username="delete_other", mobile="09190000012", password="password")
|
||||
delete.side_effect = lambda user_id, _: user_id == str(owner.pk)
|
||||
path = "/api/notifications/one"
|
||||
self.assertEqual(self.client.delete(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(other)}").status_code, 404)
|
||||
self.assertEqual(self.client.delete(path, HTTP_AUTHORIZATION=f"Bearer {create_jwt_token(owner)}").status_code, 200)
|
||||
Reference in New Issue
Block a user