import json import time from datetime import timedelta import pytest from django.utils import timezone from rest_framework.test import APIClient from apps.notifications import services, views from apps.notifications.services import RedisNotificationStore from apps.notifications.tests.test_services import FakePubSub, FakeRedis from apps.users.models import User @pytest.fixture() def fake_redis(monkeypatch): redis = FakeRedis() monkeypatch.setattr(services, "redis_client", redis) return redis @pytest.fixture() def user(db): return User.objects.create_user(mobile="09121111111", password="secret123") @pytest.fixture() def second_user(db): return User.objects.create_user(mobile="09122222222", password="secret123") def _read_sse_chunks(response, count): iterator = iter(response.streaming_content) chunks = [] for _ in range(count): chunk = next(iterator) if isinstance(chunk, bytes): chunk = chunk.decode("utf-8") chunks.append(chunk) response.close() return chunks def _parse_sse_data(chunk: str) -> dict: for line in chunk.splitlines(): if line.startswith("data: "): return json.loads(line.removeprefix("data: ")) raise AssertionError("SSE payload did not include data") def test_stream_token_endpoint_returns_short_lived_token(user): client = APIClient() client.force_authenticate(user=user) response = client.post("/api/notifications/stream-token/") assert response.status_code == 200 assert response.data["token"] assert response.data["expires_in"] > 0 def test_stream_endpoint_rejects_missing_and_expired_token(user, settings): client = APIClient() missing = client.get("/api/notifications/stream/") assert missing.status_code == 401 settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = 1 token = views._issue_stream_token_for_user(str(user.id)) time.sleep(1.1) expired = client.get(f"/api/notifications/stream/?token={token}") assert expired.status_code == 401 def test_stream_endpoint_sends_only_current_users_notifications( fake_redis, user, second_user, monkeypatch ): RedisNotificationStore.add(str(user.id), {"title": "For current user"}) RedisNotificationStore.add(str(second_user.id), {"title": "For another user"}) pubsub = FakePubSub() monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub)) token = views._issue_stream_token_for_user(str(user.id)) client = APIClient() response = client.get( f"/api/notifications/stream/?token={token}", HTTP_ACCEPT="text/event-stream", ) retry_line, connected_chunk = _read_sse_chunks(response, 2) assert response.status_code == 200 assert retry_line.startswith("retry:") connected = _parse_sse_data(connected_chunk) assert connected["unread_count"] == 1 assert [item["title"] for item in connected["notifications"]] == ["For current user"] def test_stream_endpoint_emits_heartbeat(fake_redis, user, settings, monkeypatch): pubsub = FakePubSub() monkeypatch.setattr(RedisNotificationStore, "get_pubsub", classmethod(lambda cls: pubsub)) settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS = 1 first_now = timezone.now() tick_values = iter( [ first_now, first_now, first_now + timedelta(seconds=2), first_now + timedelta(seconds=2), first_now + timedelta(seconds=2), first_now + timedelta(seconds=2), ] ) last_tick = first_now + timedelta(seconds=2) def fake_now(): return next(tick_values, last_tick) monkeypatch.setattr(views.timezone, "now", fake_now) view = views.NotificationStreamView() stream = view._build_stream(str(user.id)) chunks = [next(stream) for _ in range(4)] stream.close() assert "event: ping" in chunks[3] def test_notification_list_and_seen_endpoints_work(fake_redis, user): notification = RedisNotificationStore.add( str(user.id), {"title": "Deploy succeeded", "type": "deploy"}, ) client = APIClient() client.force_authenticate(user=user) list_response = client.get("/api/notifications/list/?type=deploy") assert list_response.status_code == 200 assert list_response.data["count"] == 1 assert list_response.data["unread_count"] == 1 assert list_response.data["notifications"][0]["title"] == "Deploy succeeded" seen_response = client.post("/api/notifications/seen/", {"id": notification["id"]}, format="json") assert seen_response.status_code == 200 assert seen_response.data["marked_read"] is True assert seen_response.data["notification"]["is_seen"] is True def test_notification_delete_endpoint_removes_notification(fake_redis, user): notification = RedisNotificationStore.add( str(user.id), {"title": "Delete me", "type": "deploy"}, ) client = APIClient() client.force_authenticate(user=user) response = client.delete(f"/api/notifications/{notification['id']}/") assert response.status_code == 200 assert response.data["deleted"] is True assert response.data["notification_id"] == notification["id"] assert RedisNotificationStore.get(str(user.id), notification["id"]) is None