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.
171 lines
10 KiB
Python
171 lines
10 KiB
Python
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)
|