diff --git a/resolvers/follower.py b/resolvers/follower.py index 9b94e415..3c13fe8b 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -15,6 +15,7 @@ from orm.author import Author, AuthorFollower from orm.community import Community, CommunityFollower from orm.shout import Shout, ShoutReactionsFollower from orm.topic import Topic, TopicFollower +from resolvers.author import invalidate_authors_cache from services.auth import login_required from services.notify import notify_follower from storage.db import local_session @@ -133,6 +134,10 @@ async def follow( if isinstance(follower_dict, dict) and isinstance(entity_id, int): await notify_follower(follower=follower_dict, author_id=entity_id, action="follow") + # Инвалидируем кеш статистики авторов для обновления счетчиков подписчиков + logger.debug("Инвалидируем кеш статистики авторов") + await invalidate_authors_cache(entity_id) + # Всегда получаем актуальный список подписок для возврата клиенту if get_cached_follows_method and isinstance(follower_id, int): logger.debug("Получение актуального списка подписок из кэша") @@ -260,6 +265,10 @@ async def unfollow( if what == "AUTHOR" and isinstance(follower_dict, dict): await notify_follower(follower=follower_dict, author_id=entity_id, action="unfollow") + # Инвалидируем кеш статистики авторов для обновления счетчиков подписчиков + logger.debug("Инвалидируем кеш статистики авторов после отписки") + await invalidate_authors_cache(entity_id) + return {f"{entity_type}s": follows, "error": None} except Exception as exc: diff --git a/tests/test_follower_counters.py b/tests/test_follower_counters.py new file mode 100644 index 00000000..ca4b18ad --- /dev/null +++ b/tests/test_follower_counters.py @@ -0,0 +1,162 @@ +""" +Тест обновления счетчиков подписчиков в статистике авторов +""" +from __future__ import annotations + +import asyncio +import time +import pytest + +from resolvers.author import get_authors_with_stats, invalidate_authors_cache +from resolvers.stat import get_followers_count +from orm.author import Author, AuthorFollower +from storage.db import local_session +from storage.redis import redis + + +@pytest.mark.asyncio +async def test_follower_counters_update(): + """ + Тест обновления счетчиков подписчиков после инвалидации кеша + """ + # Создаем тестовых пользователей + with local_session() as session: + # Создаем автора, у которого будут подписчики + target_author = Author( + name="Popular Author", + slug=f"popular-author-{int(time.time())}", + email=f"popular-{int(time.time())}@test.com" + ) + session.add(target_author) + + # Создаем подписчиков + follower1 = Author( + name="Follower 1", + slug=f"follower-1-{int(time.time())}", + email=f"follower-1-{int(time.time())}@test.com" + ) + session.add(follower1) + + follower2 = Author( + name="Follower 2", + slug=f"follower-2-{int(time.time())}", + email=f"follower-2-{int(time.time())}@test.com" + ) + session.add(follower2) + + session.commit() + + target_author_id = target_author.id + follower1_id = follower1.id + follower2_id = follower2.id + + try: + # 1. Очищаем кеш авторов + await invalidate_authors_cache() + + # 2. Получаем начальную статистику (должно быть 0 подписчиков) + initial_stats = await get_authors_with_stats(limit=100, offset=0) + target_stats = next((author for author in initial_stats if author["id"] == target_author_id), None) + + assert target_stats is not None, "Автор должен быть найден в статистике" + assert target_stats["stat"]["followers"] == 0, "Изначально подписчиков быть не должно" + + # 3. Добавляем подписчика в БД + with local_session() as session: + subscription1 = AuthorFollower( + follower=follower1_id, + following=target_author_id + ) + session.add(subscription1) + session.commit() + + # 4. Проверяем, что прямой запрос к БД возвращает 1 подписчика + direct_count = get_followers_count("author", target_author_id) + assert direct_count == 1, "Прямой запрос должен вернуть 1 подписчика" + + # 5. Инвалидируем кеш (имитируя логику follow) + await invalidate_authors_cache(target_author_id) + + # 6. Получаем обновленную статистику + updated_stats = await get_authors_with_stats(limit=100, offset=0) + updated_target_stats = next((author for author in updated_stats if author["id"] == target_author_id), None) + + assert updated_target_stats is not None, "Автор должен быть найден после обновления" + assert updated_target_stats["stat"]["followers"] == 1, "После инвалидации кеша должен быть 1 подписчик" + + # 7. Добавляем второго подписчика + with local_session() as session: + subscription2 = AuthorFollower( + follower=follower2_id, + following=target_author_id + ) + session.add(subscription2) + session.commit() + + # 8. Инвалидируем кеш снова + await invalidate_authors_cache(target_author_id) + + # 9. Проверяем финальную статистику + final_stats = await get_authors_with_stats(limit=100, offset=0) + final_target_stats = next((author for author in final_stats if author["id"] == target_author_id), None) + + assert final_target_stats is not None, "Автор должен быть найден в финальной статистике" + assert final_target_stats["stat"]["followers"] == 2, "В финальной статистике должно быть 2 подписчика" + + print("✅ Тест обновления счетчиков подписчиков прошел успешно!") + + finally: + # Очистка тестовых данных + with local_session() as session: + session.query(AuthorFollower).filter( + AuthorFollower.following == target_author_id + ).delete() + session.query(Author).filter(Author.id.in_([target_author_id, follower1_id, follower2_id])).delete() + session.commit() + + # Очищаем кеш + await invalidate_authors_cache() + + +@pytest.mark.asyncio +async def test_follower_counter_edge_cases(): + """ + Тест крайних случаев для счетчиков подписчиков + """ + with local_session() as session: + author = Author( + name="Edge Case Author", + slug=f"edge-case-{int(time.time())}", + email=f"edge-case-{int(time.time())}@test.com" + ) + session.add(author) + session.commit() + author_id = author.id + + try: + # Тест 1: Автор без подписчиков + await invalidate_authors_cache() + stats = await get_authors_with_stats(limit=100, offset=0) + author_stats = next((a for a in stats if a["id"] == author_id), None) + + if author_stats: # Автор может не попасть в топ-100, это нормально + assert author_stats["stat"]["followers"] == 0, "Автор без подписчиков должен иметь 0 в счетчике" + + # Тест 2: Проверяем прямой запрос для несуществующего автора + nonexistent_count = get_followers_count("author", 999999) + assert nonexistent_count == 0, "Несуществующий автор должен иметь 0 подписчиков" + + print("✅ Тест крайних случаев прошел успешно!") + + finally: + with local_session() as session: + session.query(Author).filter(Author.id == author_id).delete() + session.commit() + + await invalidate_authors_cache() + + +if __name__ == "__main__": + asyncio.run(test_follower_counters_update()) + asyncio.run(test_follower_counter_edge_cases()) + print("🎯 Все тесты счетчиков подписчиков прошли успешно!")