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)