From f891b736083ea8276a4838f4cb5a530cfed7a466 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 30 Aug 2025 18:23:15 +0300 Subject: [PATCH] following-debug --- cache/cache.py | 12 +- resolvers/follower.py | 3 + tests/test_cache_logic_only.py | 174 +++++++++++++++++++ tests/test_follow_cache_consistency.py | 221 +++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 tests/test_cache_logic_only.py create mode 100644 tests/test_follow_cache_consistency.py diff --git a/cache/cache.py b/cache/cache.py index 08e942f5..d1f6b279 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -308,11 +308,16 @@ async def get_cached_author_followers(author_id: int): # Get cached follower authors async def get_cached_follower_authors(author_id: int): + from utils.logger import root_logger as logger + # Attempt to retrieve authors from cache - cached = await redis.execute("GET", f"author:follows-authors:{author_id}") + cache_key = f"author:follows-authors:{author_id}" + cached = await redis.execute("GET", cache_key) if cached: authors_ids = orjson.loads(cached) + logger.debug(f"[get_cached_follower_authors] Cache HIT for {cache_key}: {len(authors_ids)} authors") else: + logger.debug(f"[get_cached_follower_authors] Cache MISS for {cache_key}, querying DB") # Query authors from database with local_session() as session: authors_ids = [ @@ -323,7 +328,10 @@ async def get_cached_follower_authors(author_id: int): .where(AuthorFollower.follower == author_id) ).all() ] - await redis.execute("SET", f"author:follows-authors:{author_id}", fast_json_dumps(authors_ids)) + await redis.execute("SET", cache_key, fast_json_dumps(authors_ids)) + logger.debug( + f"[get_cached_follower_authors] DB query result for user {author_id}: {len(authors_ids)} authors, IDs: {authors_ids}" + ) return await get_cached_authors_by_ids(authors_ids) diff --git a/resolvers/follower.py b/resolvers/follower.py index 21d30857..9b94e415 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -137,6 +137,9 @@ async def follow( if get_cached_follows_method and isinstance(follower_id, int): logger.debug("Получение актуального списка подписок из кэша") existing_follows = await get_cached_follows_method(follower_id) + logger.debug( + f"Получено подписок: {len(existing_follows)}, содержит target={entity_id in [f.get('id') for f in existing_follows] if existing_follows else False}" + ) # Если это авторы, получаем безопасную версию if what == "AUTHOR": diff --git a/tests/test_cache_logic_only.py b/tests/test_cache_logic_only.py new file mode 100644 index 00000000..6be633ed --- /dev/null +++ b/tests/test_cache_logic_only.py @@ -0,0 +1,174 @@ +""" +Простой тест кеша подписок без GraphQL и авторизации +""" +from __future__ import annotations + +import asyncio +import time +import pytest + +from cache.cache import get_cached_follower_authors +from orm.author import Author, AuthorFollower +from storage.db import local_session +from storage.redis import redis + + +@pytest.mark.asyncio +async def test_cache_invalidation_logic(): + """ + Тест логики инвалидации кеша при прямых операциях с БД + """ + # Создаем тестовых пользователей + with local_session() as session: + follower = Author( + name="Cache Test Follower", + slug=f"cache-test-follower-{int(time.time())}", + email=f"cache-follower-{int(time.time())}@test.com" + ) + session.add(follower) + + target_author = Author( + name="Cache Test Target", + slug=f"cache-test-target-{int(time.time())}", + email=f"cache-target-{int(time.time())}@test.com" + ) + session.add(target_author) + session.commit() + + follower_id = follower.id + target_author_id = target_author.id + + try: + # 1. Очищаем кеш + cache_key = f"author:follows-authors:{follower_id}" + await redis.execute("DEL", cache_key) + + # 2. Проверяем начальное состояние (пустой список) + initial_follows = await get_cached_follower_authors(follower_id) + assert len(initial_follows) == 0, "Изначально подписок быть не должно" + + # 3. Создаем подписку в БД напрямую + with local_session() as session: + subscription = AuthorFollower( + follower=follower_id, + following=target_author_id + ) + session.add(subscription) + session.commit() + + # 4. Инвалидируем кеш (имитируя логику follow) + await redis.execute("DEL", cache_key) + + # 5. Проверяем, что кеш обновился + after_follow_follows = await get_cached_follower_authors(follower_id) + assert len(after_follow_follows) == 1, "После подписки должна быть 1 запись" + assert after_follow_follows[0]["id"] == target_author_id, "ID должен совпадать" + + # 6. Проверяем, что второй запрос берется из кеша + cached_follows = await get_cached_follower_authors(follower_id) + assert len(cached_follows) == 1, "Кеш должен содержать 1 запись" + assert cached_follows[0]["id"] == target_author_id, "ID в кеше должен совпадать" + + # 7. Удаляем подписку из БД + with local_session() as session: + session.query(AuthorFollower).filter( + AuthorFollower.follower == follower_id, + AuthorFollower.following == target_author_id + ).delete() + session.commit() + + # 8. Инвалидируем кеш (имитируя логику unfollow) + await redis.execute("DEL", cache_key) + + # 9. Проверяем, что кеш снова пустой + after_unfollow_follows = await get_cached_follower_authors(follower_id) + assert len(after_unfollow_follows) == 0, "После отписки кеш должен быть пустым" + + print("✅ Тест логики кеширования прошел успешно!") + + finally: + # Очистка + with local_session() as session: + session.query(AuthorFollower).filter( + AuthorFollower.follower == follower_id + ).delete() + session.query(Author).filter(Author.id.in_([follower_id, target_author_id])).delete() + session.commit() + + await redis.execute("DEL", cache_key) + + +@pytest.mark.asyncio +async def test_cache_miss_behavior(): + """ + Тест поведения при промахе кеша - данные должны браться из БД + """ + with local_session() as session: + follower = Author( + name="Cache Miss Test", + slug=f"cache-miss-{int(time.time())}", + email=f"cache-miss-{int(time.time())}@test.com" + ) + session.add(follower) + + target1 = Author( + name="Target 1", + slug=f"target-1-{int(time.time())}", + email=f"target-1-{int(time.time())}@test.com" + ) + session.add(target1) + + target2 = Author( + name="Target 2", + slug=f"target-2-{int(time.time())}", + email=f"target-2-{int(time.time())}@test.com" + ) + session.add(target2) + session.commit() + + follower_id = follower.id + target1_id = target1.id + target2_id = target2.id + + # Создаем подписки в БД + sub1 = AuthorFollower(follower=follower_id, following=target1_id) + sub2 = AuthorFollower(follower=follower_id, following=target2_id) + session.add_all([sub1, sub2]) + session.commit() + + try: + cache_key = f"author:follows-authors:{follower_id}" + + # Убеждаемся, что кеша нет + await redis.execute("DEL", cache_key) + + # Запрашиваем данные (должно произойти cache miss и загрузка из БД) + follows = await get_cached_follower_authors(follower_id) + + assert len(follows) == 2, "Должно быть 2 подписки" + follow_ids = {f["id"] for f in follows} + assert target1_id in follow_ids, "Должна быть подписка на target1" + assert target2_id in follow_ids, "Должна быть подписка на target2" + + # Второй запрос должен брать из кеша + cached_follows = await get_cached_follower_authors(follower_id) + assert len(cached_follows) == 2, "Кеш должен содержать 2 записи" + + print("✅ Тест cache miss поведения прошел успешно!") + + finally: + # Очистка + with local_session() as session: + session.query(AuthorFollower).filter( + AuthorFollower.follower == follower_id + ).delete() + session.query(Author).filter(Author.id.in_([follower_id, target1_id, target2_id])).delete() + session.commit() + + await redis.execute("DEL", cache_key) + + +if __name__ == "__main__": + asyncio.run(test_cache_invalidation_logic()) + asyncio.run(test_cache_miss_behavior()) + print("🎯 Все тесты кеша прошли успешно!") diff --git a/tests/test_follow_cache_consistency.py b/tests/test_follow_cache_consistency.py new file mode 100644 index 00000000..6220bb4f --- /dev/null +++ b/tests/test_follow_cache_consistency.py @@ -0,0 +1,221 @@ +""" +Тест консистентности кеша подписок после операций follow/unfollow +""" +from __future__ import annotations + +import asyncio +import pytest +import time +from unittest.mock import patch + +from cache.cache import get_cached_follower_authors +from orm.author import Author, AuthorFollower +from resolvers.follower import follow, unfollow +from storage.db import local_session +from storage.redis import redis + + +class MockRequest: + """Mock объект для HTTP request""" + def __init__(self): + self.method = "POST" + self.url = type('MockURL', (), {"path": "/graphql"})() + + +class MockGraphQLResolveInfo: + """Mock объект для GraphQL resolve info""" + def __init__(self, author_id: int): + self.context = { + "author": {"id": author_id}, + "request": MockRequest() + } + + +@pytest.mark.asyncio +async def test_follow_cache_consistency(): + """ + Тест консистентности кеша после операции подписки: + 1. Подписываемся на автора + 2. Проверяем, что кеш корректно инвалидирован и обновлен + 3. Проверяем, что следующий запрос возвращает актуальные данные + """ + # Создаем тестовых пользователей + with local_session() as session: + # Создаем подписчика + follower = Author( + name="Test Follower", + slug=f"test-follower-{int(time.time())}", + email=f"follower-{int(time.time())}@test.com" + ) + session.add(follower) + + # Создаем автора для подписки + target_author = Author( + name="Target Author", + slug=f"target-author-{int(time.time())}", + email=f"target-{int(time.time())}@test.com" + ) + session.add(target_author) + session.commit() + + follower_id = follower.id + target_author_id = target_author.id + target_author_slug = target_author.slug + + try: + # Очищаем кеш перед тестом + cache_key = f"author:follows-authors:{follower_id}" + await redis.execute("DEL", cache_key) + + # 1. Проверяем начальное состояние (пустой список подписок) + initial_follows = await get_cached_follower_authors(follower_id) + assert len(initial_follows) == 0, "Изначально подписок быть не должно" + + # 2. Выполняем подписку (обходим авторизацию) + mock_info = MockGraphQLResolveInfo(follower_id) + + # Патчим декоратор авторизации + with patch('resolvers.follower.login_required', lambda func: func): + result = await follow( + None, + mock_info, + what="AUTHOR", + slug=target_author_slug + ) + + # 3. Проверяем результат операции + assert result.get("error") is None, f"Операция подписки завершилась с ошибкой: {result.get('error')}" + returned_authors = result.get("authors", []) + assert len(returned_authors) == 1, "Должен вернуться 1 автор в списке подписок" + assert any( + author.get("id") == target_author_id for author in returned_authors + ), "Целевой автор должен быть в списке подписок" + + # 4. Проверяем кеш напрямую (должен быть обновлен) + fresh_follows = await get_cached_follower_authors(follower_id) + assert len(fresh_follows) == 1, "Кеш должен содержать 1 подписку" + assert fresh_follows[0]["id"] == target_author_id, "ID автора в кеше должен совпадать" + + # 5. Проверяем консистентность с БД + with local_session() as session: + db_follows = session.query(AuthorFollower).filter( + AuthorFollower.follower == follower_id, + AuthorFollower.following == target_author_id + ).all() + assert len(db_follows) == 1, "В БД должна быть запись о подписке" + + # 6. Тестируем отписку (обходим авторизацию) + with patch('resolvers.follower.login_required', lambda func: func): + unfollow_result = await unfollow( + None, + mock_info, + what="AUTHOR", + slug=target_author_slug + ) + + assert unfollow_result.get("error") is None, f"Операция отписки завершилась с ошибкой: {unfollow_result.get('error')}" + + # 7. Проверяем кеш после отписки + after_unfollow_follows = await get_cached_follower_authors(follower_id) + assert len(after_unfollow_follows) == 0, "После отписки кеш должен быть пустым" + + # 8. Проверяем БД после отписки + with local_session() as session: + db_follows_after = session.query(AuthorFollower).filter( + AuthorFollower.follower == follower_id, + AuthorFollower.following == target_author_id + ).all() + assert len(db_follows_after) == 0, "В БД не должно быть записи о подписке после отписки" + + finally: + # Очистка тестовых данных + with local_session() as session: + # Удаляем подписки + session.query(AuthorFollower).filter( + AuthorFollower.follower == follower_id + ).delete() + + # Удаляем авторов + session.query(Author).filter(Author.id.in_([follower_id, target_author_id])).delete() + session.commit() + + # Очищаем кеш + await redis.execute("DEL", f"author:follows-authors:{follower_id}") + + +@pytest.mark.asyncio +async def test_follow_already_following(): + """ + Тест попытки повторной подписки на того же автора + """ + # Создаем тестовых пользователей + with local_session() as session: + follower = Author( + name="Test Follower 2", + slug=f"test-follower-2-{int(time.time())}", + email=f"follower-2-{int(time.time())}@test.com" + ) + session.add(follower) + + target_author = Author( + name="Target Author 2", + slug=f"target-author-2-{int(time.time())}", + email=f"target-2-{int(time.time())}@test.com" + ) + session.add(target_author) + session.commit() + + follower_id = follower.id + target_author_id = target_author.id + target_author_slug = target_author.slug + + # Создаем изначальную подписку + subscription = AuthorFollower( + follower=follower_id, + following=target_author_id + ) + session.add(subscription) + session.commit() + + try: + # Очищаем кеш + cache_key = f"author:follows-authors:{follower_id}" + await redis.execute("DEL", cache_key) + + # Пытаемся подписаться повторно (обходим авторизацию) + mock_info = MockGraphQLResolveInfo(follower_id) + with patch('resolvers.follower.login_required', lambda func: func): + result = await follow( + None, + mock_info, + what="AUTHOR", + slug=target_author_slug + ) + + # Должна вернуться ошибка "already following" + assert result.get("error") == "already following", "Должна быть ошибка повторной подписки" + + # Но список авторов должен содержать целевого автора + returned_authors = result.get("authors", []) + assert len(returned_authors) == 1, "Должен вернуться список с 1 автором" + assert any( + author.get("id") == target_author_id for author in returned_authors + ), "Целевой автор должен быть в списке" + + finally: + # Очистка + with local_session() as session: + session.query(AuthorFollower).filter( + AuthorFollower.follower == follower_id + ).delete() + session.query(Author).filter(Author.id.in_([follower_id, target_author_id])).delete() + session.commit() + + await redis.execute("DEL", f"author:follows-authors:{follower_id}") + + +if __name__ == "__main__": + # Запуск тестов напрямую + asyncio.run(test_follow_cache_consistency()) + asyncio.run(test_follow_already_following()) + print("✅ Все тесты консистентности кеша прошли успешно!")