diff --git a/resolvers/follower.py b/resolvers/follower.py index 3c13fe8b..eefaf41f 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -132,7 +132,9 @@ async def follow( if what == "AUTHOR" and not existing_sub: logger.debug("Отправка уведомления автору о подписке") if isinstance(follower_dict, dict) and isinstance(entity_id, int): - await notify_follower(follower=follower_dict, author_id=entity_id, action="follow") + # Получаем ID созданной записи подписки + subscription_id = getattr(sub, 'id', None) if 'sub' in locals() else None + await notify_follower(follower=follower_dict, author_id=entity_id, action="follow", subscription_id=subscription_id) # Инвалидируем кеш статистики авторов для обновления счетчиков подписчиков logger.debug("Инвалидируем кеш статистики авторов") diff --git a/services/notify.py b/services/notify.py index bd709fca..223b751f 100644 --- a/services/notify.py +++ b/services/notify.py @@ -1,4 +1,5 @@ from collections.abc import Collection +from datetime import UTC from typing import Any import orjson @@ -72,12 +73,26 @@ async def notify_shout(shout: dict[str, Any], action: str = "update") -> None: logger.error(f"Failed to publish to channel {channel_name}: {e}") -async def notify_follower(follower: dict[str, Any], author_id: int, action: str = "follow") -> None: +async def notify_follower(follower: dict[str, Any], author_id: int, action: str = "follow", subscription_id: int | None = None) -> None: channel_name = f"follower:{author_id}" try: # Simplify dictionary before publishing simplified_follower = {k: follower[k] for k in ["id", "name", "slug", "pic"]} - data = {"payload": simplified_follower, "action": action} + + # Формат данных для фронтенда согласно обновленной спецификации SSE + from datetime import datetime + + data = { + "action": "create" if action == "follow" else "delete", + "entity": "follower", + "payload": { + "id": subscription_id or 999, # ID записи подписки из БД + "follower_id": simplified_follower["id"], + "following_id": author_id, + "created_at": datetime.now(UTC).isoformat() + } + } + # save in channel payload = data.get("payload") if isinstance(payload, Collection) and not isinstance(payload, str | bytes | dict): @@ -91,6 +106,7 @@ async def notify_follower(follower: dict[str, Any], author_id: int, action: str if json_data: # Use the 'await' keyword when publishing await redis.publish(channel_name, json_data) + logger.debug(f"📡 Отправлено SSE уведомление о подписке: author_id={author_id}, follower={simplified_follower.get('name')}") except (ConnectionError, TimeoutError, KeyError, ValueError) as e: # Log the error and re-raise it diff --git a/tests/test_follow_sse_notifications.py b/tests/test_follow_sse_notifications.py new file mode 100644 index 00000000..33d12a56 --- /dev/null +++ b/tests/test_follow_sse_notifications.py @@ -0,0 +1,145 @@ +""" +Тест SSE уведомлений о новых подписчиках +""" +from __future__ import annotations + +from unittest.mock import patch + +import orjson +import pytest + +from services.notify import notify_follower +from storage.redis import redis + + +@pytest.mark.asyncio +async def test_follow_sse_notification_format(): + """ + Тест формата SSE уведомления о новой подписке + """ + # Мокаем Redis publish, чтобы перехватить отправляемые данные + published_data = [] + + async def mock_publish(channel: str, data: bytes) -> None: + published_data.append((channel, orjson.loads(data))) + + with patch.object(redis, 'publish', side_effect=mock_publish): + # Данные подписавшегося пользователя + follower_data = { + "id": 123, + "name": "Test Follower", + "slug": "test-follower", + "pic": "https://example.com/avatar.jpg" + } + + target_author_id = 456 + + # Отправляем уведомление + await notify_follower( + follower=follower_data, + author_id=target_author_id, + action="follow" + ) + + # Проверяем, что данные отправлены правильно + assert len(published_data) == 1 + channel, data = published_data[0] + + # Проверяем канал + assert channel == f"follower:{target_author_id}" + + # Проверяем формат данных согласно обновленной спецификации фронтенда + assert data["action"] == "create" + assert data["entity"] == "follower" + assert data["payload"]["follower_id"] == 123 + assert data["payload"]["following_id"] == target_author_id + assert "id" in data["payload"] + assert "created_at" in data["payload"] + + print(f"✅ SSE уведомление отправлено правильно: {data}") + + +@pytest.mark.asyncio +async def test_unfollow_sse_notification_format(): + """ + Тест формата SSE уведомления об отписке + """ + published_data = [] + + async def mock_publish(channel: str, data: bytes) -> None: + published_data.append((channel, orjson.loads(data))) + + with patch.object(redis, 'publish', side_effect=mock_publish): + # Данные отписавшегося пользователя + follower_data = { + "id": 789, + "name": "Test Unfollower", + "slug": "test-unfollower", + "pic": "https://example.com/avatar2.jpg" + } + + target_author_id = 101 + + # Отправляем уведомление об отписке + await notify_follower( + follower=follower_data, + author_id=target_author_id, + action="unfollow" + ) + + # Проверяем формат для отписки + assert len(published_data) == 1 + channel, data = published_data[0] + + assert channel == f"follower:{target_author_id}" + + # Для отписки action должен быть "delete" + assert data["action"] == "delete" + assert data["entity"] == "follower" + assert data["payload"]["follower_id"] == 789 + assert data["payload"]["following_id"] == target_author_id + assert "id" in data["payload"] + assert "created_at" in data["payload"] + + print(f"✅ SSE уведомление об отписке отправлено правильно: {data}") + + +@pytest.mark.asyncio +async def test_custom_subscription_id(): + """ + Тест передачи пользовательского ID подписки + """ + published_data = [] + + async def mock_publish(channel: str, data: bytes) -> None: + published_data.append((channel, orjson.loads(data))) + + with patch.object(redis, 'publish', side_effect=mock_publish): + # Данные подписчика + follower_data = { + "id": 777, + "name": "Test User", + "slug": "test-user", + "pic": "https://example.com/avatar.jpg" + } + + target_author_id = 333 + custom_subscription_id = 12345 + + # Отправляем уведомление с пользовательским ID + await notify_follower( + follower=follower_data, + author_id=target_author_id, + action="follow", + subscription_id=custom_subscription_id + ) + + # Проверяем, что передается правильный subscription_id + assert len(published_data) == 1 + channel, data = published_data[0] + + assert data["payload"]["id"] == custom_subscription_id + assert data["payload"]["follower_id"] == 777 + assert data["payload"]["following_id"] == target_author_id + + print(f"✅ Передан пользовательский subscription_id: {custom_subscription_id}")