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:
2026-09-26 16:50:29 +03:30
parent 3ec28638d1
commit ff99130938
76 changed files with 3271 additions and 1357 deletions

View 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()

View File

@@ -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

View 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