test: migrate backend suites to pytest with module coverage gate
Split backend tests into unit and integration suites, add isolated fixtures and coverage enforcement, and update CI and test documentation. Cover current authentication, content, notification, payment, media, and admin flows; fix behaviors revealed by the new tests. Validate both suites and the per-module gate against local PostgreSQL.
This commit is contained in:
51
apps/notifications/tests/integration/test_api_flows.py
Normal file
51
apps/notifications/tests/integration/test_api_flows.py
Normal file
@@ -0,0 +1,51 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.notifications import services
|
||||
|
||||
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.django_db]
|
||||
|
||||
|
||||
def test_notification_api_lifecycle_uses_authenticated_redis_namespace(client, user_factory, auth_headers,
|
||||
fake_notification_redis, monkeypatch):
|
||||
monkeypatch.setattr(services, "redis_client", fake_notification_redis)
|
||||
owner = user_factory()
|
||||
other = user_factory()
|
||||
item = services.RedisNotificationStore.add(str(owner.pk), {"type": "event", "title": "Workshop"})
|
||||
path = "/api/notifications/"
|
||||
assert client.get(path).status_code == 401
|
||||
assert client.get(path, **auth_headers(other)).json()["notifications"] == []
|
||||
listing = client.get(path, {"status": "unread", "limit": 1}, **auth_headers(owner))
|
||||
assert listing.status_code == 200 and listing.json()["unread_count"] == 1
|
||||
assert listing.json()["notifications"][0]["id"] == item["id"]
|
||||
assert client.get(path, {"status": "invalid"}, **auth_headers(owner)).status_code == 400
|
||||
assert client.post(path + "mark-seen", data=json.dumps({"id": "unknown"}),
|
||||
content_type="application/json", **auth_headers(owner)).status_code == 404
|
||||
seen = client.post(path + "mark-seen", data=json.dumps({"id": item["id"]}),
|
||||
content_type="application/json", **auth_headers(owner))
|
||||
assert seen.status_code == 200 and seen.json()["unread_count"] == 0
|
||||
assert client.get(path, {"status": "read"}, **auth_headers(owner)).json()["count"] == 1
|
||||
assert client.post(path + "mark-all-read", **auth_headers(owner)).json()["marked_read"] == 1
|
||||
assert services.RedisNotificationStore.unread_count(str(owner.pk)) == 0
|
||||
assert client.delete(path + item["id"], **auth_headers(owner)).json()["deleted"]
|
||||
assert services.RedisNotificationStore.get(str(owner.pk), item["id"]) is None
|
||||
|
||||
|
||||
def test_stream_token_endpoint_and_sse_auth(client, user_factory, auth_headers, settings):
|
||||
user = user_factory()
|
||||
headers = auth_headers(user)
|
||||
settings.NOTIFICATIONS_ENABLED = False
|
||||
assert client.post("/api/notifications/stream-token", **headers).status_code == 503
|
||||
assert client.get("/api/notifications/stream/").status_code == 503
|
||||
settings.NOTIFICATIONS_ENABLED = True
|
||||
issued = client.post("/api/notifications/stream-token", **headers)
|
||||
assert issued.status_code == 200, issued.content
|
||||
token = issued.json()["token"]
|
||||
assert client.get("/api/notifications/stream/").status_code == 401
|
||||
assert client.get("/api/notifications/stream/", {"token": "bad"}).status_code == 401
|
||||
response = client.get("/api/notifications/stream/", {"token": token})
|
||||
assert response.status_code == 200
|
||||
assert response["Content-Type"] == "text/event-stream"
|
||||
response.close()
|
||||
@@ -0,0 +1,111 @@
|
||||
import pytest
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from django.contrib import admin
|
||||
from django.test import RequestFactory
|
||||
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
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.integration
|
||||
class TestManualNotificationTests:
|
||||
|
||||
def test_form_requires_title_and_plain_digest_but_allows_optional_body(self):
|
||||
assert ManualNotificationForm({'title': 'Title', 'digest': 'Intro'}).is_valid()
|
||||
assert not ManualNotificationForm({'title': '', 'digest': 'Intro'}).is_valid()
|
||||
|
||||
def test_task_sends_only_to_selected_active_users(self, mocker):
|
||||
notify_user = mocker.patch('apps.notifications.tasks.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')
|
||||
assert sent == 1
|
||||
notify_user.assert_called_once_with(active.pk, {'type': 'manual', 'title': 'Title', 'message': 'Digest', 'body_markdown': '# Body', 'level': 'info'})
|
||||
|
||||
def test_admin_action_queues_valid_composer_for_selected_users(self, mocker):
|
||||
delay = mocker.patch('apps.users.admin.send_manual_notification.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)
|
||||
mocker.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')
|
||||
assert not user.is_email_verified
|
||||
user.email = 'person@example.com'
|
||||
user.save(update_fields=['email'])
|
||||
user.refresh_from_db()
|
||||
assert user.is_email_verified
|
||||
|
||||
def test_admin_changelist_uses_the_composer_before_queuing(self, mocker):
|
||||
delay = mocker.patch('apps.users.admin.send_manual_notification.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)
|
||||
assert preview.status_code == 200
|
||||
assert 'توضیح کوتاه' in preview.content.decode()
|
||||
assert not delay.called
|
||||
result = self.client.post(path, {**action_data, 'confirm_send': '1', 'title': 'Title', 'digest': 'Digest', 'body_markdown': '# Body'})
|
||||
assert result.status_code in (200, 302)
|
||||
delay.assert_called_once_with([recipient.pk], 'Title', 'Digest', '# Body')
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pytest_client(self, client):
|
||||
self.client = client
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.integration
|
||||
class TestNotificationAPIIntegrationTests:
|
||||
|
||||
def test_detail_requires_auth_and_returns_body_only_to_recipient(self, mocker):
|
||||
get = mocker.patch('apps.notifications.api.views.RedisNotificationStore.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'
|
||||
assert self.client.get(path).status_code == 401
|
||||
owner_response = self.client.get(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(owner)}')
|
||||
assert owner_response.status_code == 200
|
||||
assert owner_response.json()['body_markdown'] == '# Private'
|
||||
assert owner_response.json()['action_url'] == '/events/workshop'
|
||||
other_response = self.client.get(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(other)}')
|
||||
assert other_response.status_code == 404
|
||||
|
||||
def test_list_excludes_full_body_and_uses_read_filter(self, mocker):
|
||||
list_notifications_store = mocker.patch('apps.notifications.api.views.RedisNotificationStore.list')
|
||||
_count = mocker.patch('apps.notifications.api.views.RedisNotificationStore.unread_count', return_value=1)
|
||||
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)}')
|
||||
assert response.status_code == 200
|
||||
item = response.json()['notifications'][0]
|
||||
assert item['has_body']
|
||||
assert 'body_markdown' not in item
|
||||
assert list_notifications_store.call_args.kwargs['read_filter'] == 'unread'
|
||||
|
||||
def test_delete_only_removes_the_authenticated_users_notification(self, mocker):
|
||||
delete = mocker.patch('apps.notifications.api.views.RedisNotificationStore.delete')
|
||||
_count = mocker.patch('apps.notifications.api.views.RedisNotificationStore.unread_count', return_value=0)
|
||||
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'
|
||||
assert self.client.delete(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(other)}').status_code == 404
|
||||
assert self.client.delete(path, HTTP_AUTHORIZATION=f'Bearer {create_jwt_token(owner)}').status_code == 200
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pytest_client(self, client):
|
||||
self.client = client
|
||||
38
apps/notifications/tests/integration/test_redis_store.py
Normal file
38
apps/notifications/tests/integration/test_redis_store.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.notifications import services
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
def test_fake_redis_store_add_filter_mark_and_delete(fake_notification_redis, monkeypatch):
|
||||
monkeypatch.setattr(services, "redis_client", fake_notification_redis)
|
||||
first = services.RedisNotificationStore.add("user-1", {"type": "news", "title": "One", "body_markdown": "# Long body"})
|
||||
second = services.RedisNotificationStore.add("user-1", {"type": "event", "title": "Two"})
|
||||
assert services.RedisNotificationStore.unread_count("user-1") == 2
|
||||
assert services.RedisNotificationStore.list("user-1", type_filter="news")[1] == 1
|
||||
assert services.RedisNotificationStore.get("user-2", first["id"]) is None
|
||||
marked = services.RedisNotificationStore.mark_seen("user-1", first["id"])
|
||||
assert marked["unread_count"] == 1
|
||||
assert services.RedisNotificationStore.list("user-1", read_filter="read")[1] == 1
|
||||
assert services.RedisNotificationStore.mark_all_seen("user-1") == 2
|
||||
assert services.RedisNotificationStore.unread_count("user-1") == 0
|
||||
assert services.RedisNotificationStore.delete("user-1", second["id"])
|
||||
assert not services.RedisNotificationStore.delete("user-1", second["id"])
|
||||
assert services.RedisNotificationStore.get("user-1", second["id"]) is None
|
||||
|
||||
|
||||
def test_fake_redis_store_cleans_expired_and_corrupt_records(fake_notification_redis, monkeypatch):
|
||||
monkeypatch.setattr(services, "redis_client", fake_notification_redis)
|
||||
old = (timezone.now() - timedelta(days=90)).isoformat()
|
||||
expired = services.RedisNotificationStore.add("user-1", {"created_at": old, "title": "Old"})
|
||||
recent = services.RedisNotificationStore.add("user-1", {"title": "Recent"})
|
||||
assert services.RedisNotificationStore.cleanup_expired(retention_days=30) == 1
|
||||
assert services.RedisNotificationStore.get("user-1", expired["id"]) is None
|
||||
fake_notification_redis.hset(services.RedisNotificationStore._data_key("user-1"), recent["id"], "broken json")
|
||||
assert services.RedisNotificationStore.list("user-1") == ([], 0)
|
||||
assert services.RedisNotificationStore.get("user-1", recent["id"]) is None
|
||||
@@ -1,170 +0,0 @@
|
||||
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)
|
||||
64
apps/notifications/tests/unit/test_store.py
Normal file
64
apps/notifications/tests/unit/test_store.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from django.contrib import admin
|
||||
from django.test import RequestFactory
|
||||
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
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNotificationStoreTests:
|
||||
|
||||
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)
|
||||
assert summary['has_body']
|
||||
assert 'body_markdown' not in summary
|
||||
assert notification['body_markdown'] == '# Full body'
|
||||
|
||||
def test_read_filter_applies_before_pagination(self, mocker):
|
||||
redis_client = mocker.patch('apps.notifications.services.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')
|
||||
assert count == 2
|
||||
assert [item['id'] for item in unread] == ['three']
|
||||
read, count = RedisNotificationStore.list('user', read_filter='read')
|
||||
assert count == 1
|
||||
assert read[0]['id'] == 'two'
|
||||
|
||||
def test_detail_is_scoped_to_authenticated_recipient(self, mocker):
|
||||
store = mocker.patch('apps.notifications.api.views.RedisNotificationStore')
|
||||
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))
|
||||
assert get_notification(owner, 'one')[0] == 200
|
||||
assert get_notification(other, 'one')[0] == 404
|
||||
store.get.assert_any_call('2', 'one')
|
||||
|
||||
def test_list_returns_summaries_and_rejects_unknown_status(self, mocker):
|
||||
store = mocker.patch('apps.notifications.api.views.RedisNotificationStore')
|
||||
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')
|
||||
assert 'body_markdown' not in result['notifications'][0]
|
||||
assert store.list.call_args.kwargs['read_filter'] == 'unread'
|
||||
assert list_notifications(request, status='invalid').status_code == 400
|
||||
|
||||
def test_marking_old_ephemeral_notification_read_keeps_it_visible(self, mocker):
|
||||
redis_client = mocker.patch('apps.notifications.services.redis_client')
|
||||
get = mocker.patch.object(RedisNotificationStore, 'get')
|
||||
_count = mocker.patch.object(RedisNotificationStore, 'unread_count', return_value=0)
|
||||
_publish = mocker.patch.object(RedisNotificationStore, '_publish_event')
|
||||
get.return_value = {'id': 'one', 'is_seen': False, 'delete_on_seen': True, 'body_markdown': '# Body'}
|
||||
result = RedisNotificationStore.mark_seen('user', 'one')
|
||||
assert not result['deleted']
|
||||
assert result['notification']['is_seen']
|
||||
redis_client.hset.assert_called_once()
|
||||
redis_client.pipeline.assert_not_called()
|
||||
28
apps/notifications/tests/unit/test_stream.py
Normal file
28
apps/notifications/tests/unit/test_stream.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.notifications.api.views import NotificationStreamView
|
||||
from apps.notifications.services import RedisNotificationStore
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_sse_stream_sends_snapshot_update_and_closes_pubsub(mocker, settings):
|
||||
settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 60
|
||||
pubsub = mocker.Mock()
|
||||
pubsub.get_message.side_effect = [{"type": "message", "data": json.dumps({"event": "notification", "data": {"id": "one"}})}]
|
||||
mocker.patch("apps.notifications.api.views.RedisNotificationStore.get_pubsub", return_value=pubsub)
|
||||
mocker.patch("apps.notifications.api.views.RedisNotificationStore.list", return_value=([{"id": "one"}], 1))
|
||||
mocker.patch("apps.notifications.api.views.RedisNotificationStore.unread_count", return_value=1)
|
||||
mocker.patch("apps.notifications.api.views.RedisNotificationStore.summary", return_value={"id": "one"})
|
||||
stream = NotificationStreamView()._build_stream("42")
|
||||
assert next(stream).startswith("retry:")
|
||||
assert "connected" in next(stream)
|
||||
assert "unread_count" in next(stream)
|
||||
assert '"id": "one"' in next(stream)
|
||||
stream.close()
|
||||
pubsub.subscribe.assert_called_once_with(RedisNotificationStore._channel_key("42"))
|
||||
pubsub.unsubscribe.assert_called_once()
|
||||
pubsub.close.assert_called_once()
|
||||
22
apps/notifications/tests/unit/test_stream_token.py
Normal file
22
apps/notifications/tests/unit/test_stream_token.py
Normal file
@@ -0,0 +1,22 @@
|
||||
import pytest
|
||||
from django.core import signing
|
||||
|
||||
from apps.notifications.api.views import _format_sse_event, _issue_stream_token_for_user, _safe_int, _validate_stream_token
|
||||
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_stream_token_round_trip_and_invalid_token():
|
||||
token = _issue_stream_token_for_user("42")
|
||||
assert _validate_stream_token(token) == "42"
|
||||
with pytest.raises(signing.BadSignature):
|
||||
_validate_stream_token("wrong")
|
||||
with pytest.raises(signing.BadSignature):
|
||||
_validate_stream_token(None)
|
||||
|
||||
|
||||
def test_sse_formatter_and_safe_integer_limits():
|
||||
assert _safe_int("-5", 10) == 0
|
||||
assert _safe_int("oops", 10) == 10
|
||||
assert 'event: connected\ndata: {"ready": true}\n\n' == _format_sse_event("connected", {"ready": True})
|
||||
Reference in New Issue
Block a user