diff --git a/README.md b/README.md index a3c482b4..2db7b9da 100644 --- a/README.md +++ b/README.md @@ -68,10 +68,8 @@ uv run pytest --cov=services,utils,orm,resolvers ```bash # Run ruff linter -uv run ruff check . - -# Run ruff formatter -uv run ruff format . +uv run ruff check . --select I +uv run ruff format --line-length=120 # Run mypy type checker uv run mypy . diff --git a/auth/decorators.py b/auth/decorators.py index 1df7aae6..9a11604d 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -95,9 +95,10 @@ async def get_auth_token(request: Any) -> Optional[str]: # 2. Проверяем наличие auth_token в scope (приоритет) if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth_token" in request.scope: token = request.scope.get("auth_token") - token_len = len(token) if hasattr(token, "__len__") else "unknown" - logger.debug(f"[decorators] Токен получен из request.scope['auth_token']: {token_len}") - return token + if token is not None: + token_len = len(token) if hasattr(token, "__len__") else "unknown" + logger.debug(f"[decorators] Токен получен из request.scope['auth_token']: {token_len}") + return token logger.debug("[decorators] request.scope['auth_token'] НЕ найден") # Стандартная система сессий уже обрабатывает кэширование @@ -147,10 +148,11 @@ async def get_auth_token(request: Any) -> Optional[str]: if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope: auth_info = request.scope.get("auth", {}) if isinstance(auth_info, dict) and "token" in auth_info: - token = auth_info["token"] - token_len = len(token) if hasattr(token, "__len__") else "unknown" - logger.debug(f"[decorators] Токен получен из request.scope['auth']: {token_len}") - return token + token = auth_info.get("token") + if token is not None: + token_len = len(token) if hasattr(token, "__len__") else "unknown" + logger.debug(f"[decorators] Токен получен из request.scope['auth']: {token_len}") + return token # 4. Проверяем заголовок Authorization headers = get_safe_headers(request) @@ -164,18 +166,20 @@ async def get_auth_token(request: Any) -> Optional[str]: logger.debug(f"[decorators] Токен получен из заголовка {SESSION_TOKEN_HEADER}: {token_len}") return token token = auth_header.strip() - token_len = len(token) if hasattr(token, "__len__") else "unknown" - logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {token_len}") - return token + if token: + token_len = len(token) if hasattr(token, "__len__") else "unknown" + logger.debug(f"[decorators] Прямой токен получен из заголовка {SESSION_TOKEN_HEADER}: {token_len}") + return token # Затем проверяем стандартный заголовок Authorization, если основной не определен if SESSION_TOKEN_HEADER.lower() != "authorization": auth_header = headers.get("authorization", "") if auth_header and auth_header.startswith("Bearer "): token = auth_header[7:].strip() - token_len = len(token) if hasattr(token, "__len__") else "unknown" - logger.debug(f"[decorators] Токен получен из заголовка Authorization: {token_len}") - return token + if token: + token_len = len(token) if hasattr(token, "__len__") else "unknown" + logger.debug(f"[decorators] Токен получен из заголовка Authorization: {token_len}") + return token # 5. Проверяем cookie if hasattr(request, "cookies") and request.cookies: @@ -232,14 +236,15 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None: return # Если аутентификации нет в request.auth, пробуем получить ее из scope + token: Optional[str] = None if hasattr(request, "scope") and "auth" in request.scope: auth_cred = request.scope.get("auth") if isinstance(auth_cred, AuthCredentials) and getattr(auth_cred, "logged_in", False): logger.debug(f"[validate_graphql_context] Пользователь авторизован через scope: {auth_cred.author_id}") return - # Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен - token = await get_auth_token(request) + # Если авторизации нет ни в auth, ни в scope, пробуем получить и проверить токен + token = await get_auth_token(request) if not token: # Если токен не найден, логируем как предупреждение, но не бросаем GraphQLError client_info = { @@ -261,7 +266,8 @@ async def validate_graphql_context(info: GraphQLResolveInfo) -> None: return # Логируем информацию о найденном токене - logger.debug(f"[validate_graphql_context] Токен найден, длина: {len(token)}") + token_len = len(token) if hasattr(token, "__len__") else 0 + logger.debug(f"[validate_graphql_context] Токен найден, длина: {token_len}") # Используем единый механизм проверки токена из auth.internal auth_state = await authenticate(request) diff --git a/auth/handler.py b/auth/handler.py index a665b846..ff488fd4 100644 --- a/auth/handler.py +++ b/auth/handler.py @@ -1,3 +1,5 @@ +from typing import Any + from ariadne.asgi.handlers import GraphQLHTTPHandler from starlette.requests import Request from starlette.responses import JSONResponse @@ -62,20 +64,22 @@ class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler): # Добавляем данные авторизации только если они доступны # Проверяем наличие данных авторизации в scope if hasattr(request, "scope") and isinstance(request.scope, dict) and "auth" in request.scope: - auth_cred = request.scope.get("auth") + auth_cred: Any | None = request.scope.get("auth") context["auth"] = auth_cred # Безопасно логируем информацию о типе объекта auth logger.debug(f"[graphql] Добавлены данные авторизации в контекст из scope: {type(auth_cred).__name__}") # Проверяем, есть ли токен в auth_cred - if hasattr(auth_cred, "token") and auth_cred.token: - logger.debug(f"[graphql] Токен найден в auth_cred: {len(auth_cred.token)}") + if auth_cred is not None and hasattr(auth_cred, "token") and getattr(auth_cred, "token"): + token_val = auth_cred.token + token_len = len(token_val) if hasattr(token_val, "__len__") else 0 + logger.debug(f"[graphql] Токен найден в auth_cred: {token_len}") else: logger.debug("[graphql] Токен НЕ найден в auth_cred") # Добавляем author_id в контекст для RBAC author_id = None - if hasattr(auth_cred, "author_id") and auth_cred.author_id: + if auth_cred is not None and hasattr(auth_cred, "author_id") and getattr(auth_cred, "author_id"): author_id = auth_cred.author_id elif isinstance(auth_cred, dict) and "author_id" in auth_cred: author_id = auth_cred["author_id"] diff --git a/auth/middleware.py b/auth/middleware.py index bf4bdf0b..d48ff2b2 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -135,7 +135,7 @@ class AuthMiddleware: # Роли пользователя будут определяться в контексте конкретной операции # через RBAC систему, а не здесь - roles = [] + roles: list[str] = [] # Обновляем last_seen author.last_seen = int(time.time()) diff --git a/auth/tokens/batch.py b/auth/tokens/batch.py index 419a9402..c70662b0 100644 --- a/auth/tokens/batch.py +++ b/auth/tokens/batch.py @@ -55,12 +55,16 @@ class BatchTokenOperations(BaseTokenManager): valid_tokens = [] for token, payload in zip(token_batch, decoded_payloads): - if isinstance(payload, Exception) or not payload: + if isinstance(payload, Exception) or payload is None: results[token] = False continue # payload может быть словарем или объектом, обрабатываем оба случая - user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id") + user_id = ( + payload.user_id + if hasattr(payload, "user_id") + else (payload.get("user_id") if isinstance(payload, dict) else None) + ) if not user_id: results[token] = False continue @@ -119,10 +123,18 @@ class BatchTokenOperations(BaseTokenManager): # Декодируем токены и подготавливаем операции for token in token_batch: payload = await self._safe_decode_token(token) - if payload: + if payload is not None: # payload может быть словарем или объектом, обрабатываем оба случая - user_id = payload.user_id if hasattr(payload, "user_id") else payload.get("user_id") - username = payload.username if hasattr(payload, "username") else payload.get("username") + user_id = ( + payload.user_id + if hasattr(payload, "user_id") + else (payload.get("user_id") if isinstance(payload, dict) else None) + ) + username = ( + payload.username + if hasattr(payload, "username") + else (payload.get("username") if isinstance(payload, dict) else None) + ) if not user_id: continue diff --git a/orm/community.py b/orm/community.py index ad5a925c..a4473daf 100644 --- a/orm/community.py +++ b/orm/community.py @@ -652,26 +652,12 @@ class CommunityAuthor(BaseModel): Returns: Словарь со статистикой ролей """ + # Загружаем список авторов сообщества (одним способом вне зависимости от сессии) if session is None: with local_session() as s: community_authors = s.query(cls).where(cls.community_id == community_id).all() - - role_counts: dict[str, int] = {} - total_members = len(community_authors) - - for ca in community_authors: - for role in ca.role_list: - role_counts[role] = role_counts.get(role, 0) + 1 - - return { - "total_members": total_members, - "role_counts": role_counts, - "roles_distribution": { - role: count / total_members if total_members > 0 else 0 for role, count in role_counts.items() - }, - } - - community_authors = session.query(cls).where(cls.community_id == community_id).all() + else: + community_authors = session.query(cls).where(cls.community_id == community_id).all() role_counts: dict[str, int] = {} total_members = len(community_authors) diff --git a/tests/conftest.py b/tests/conftest.py index fcd67bf0..eeaaf76e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from sqlalchemy.pool import StaticPool import time import uuid from starlette.testclient import TestClient +import requests from services.redis import redis from orm.base import BaseModel as Base @@ -27,6 +28,22 @@ def get_test_client(): return app return TestClient(_import_app()) +@pytest.fixture(autouse=True, scope="session") +def _set_requests_default_timeout(): + """Глобально задаем таймаут по умолчанию для requests в тестах, чтобы исключить зависания. + + 🪓 Упрощение: мокаем методы requests, добавляя timeout=10, если он не указан. + """ + original_request = requests.sessions.Session.request + + def request_with_default_timeout(self, method, url, **kwargs): # type: ignore[override] + if "timeout" not in kwargs: + kwargs["timeout"] = 10 + return original_request(self, method, url, **kwargs) + + requests.sessions.Session.request = request_with_default_timeout # type: ignore[assignment] + yield + requests.sessions.Session.request = original_request # type: ignore[assignment] @pytest.fixture(scope="session") diff --git a/tests/test_community_delete_e2e_browser.py b/tests/test_community_delete_e2e_browser.py index 410532de..0682ca84 100644 --- a/tests/test_community_delete_e2e_browser.py +++ b/tests/test_community_delete_e2e_browser.py @@ -60,8 +60,8 @@ class TestCommunityDeleteE2EBrowser: # В CI/CD используем uv run python backend_process = subprocess.Popen( ["uv", "run", "python", "dev.py"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) @@ -80,11 +80,7 @@ class TestCommunityDeleteE2EBrowser: # Если сервер не запустился, выводим логи и завершаем тест print("❌ Бэкенд сервер не запустился за 20 секунд") - # Получаем логи процесса - if backend_process: - stdout, stderr = backend_process.communicate() - print(f"📋 STDOUT: {stdout.decode()}") - print(f"📋 STDERR: {stderr.decode()}") + # Логи процесса не собираем, чтобы не блокировать выполнение raise Exception("Бэкенд сервер не запустился за 20 секунд") @@ -128,8 +124,8 @@ class TestCommunityDeleteE2EBrowser: try: frontend_process = subprocess.Popen( ["npm", "run", "dev"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) @@ -149,11 +145,7 @@ class TestCommunityDeleteE2EBrowser: # Если фронтенд не запустился, выводим логи print("❌ Фронтенд сервер не запустился за 15 секунд") - # Получаем логи процесса - if frontend_process: - stdout, stderr = frontend_process.communicate() - print(f"📋 STDOUT: {stdout.decode()}") - print(f"📋 STDERR: {stderr.decode()}") + # Логи процесса не собираем, чтобы не блокировать выполнение print("⚠️ Продолжаем тест без фронтенда (только API тесты)") frontend_process = None