diff --git a/src/kwork_api/__init__.py b/src/kwork_api/__init__.py index c0af22a..511507e 100644 --- a/src/kwork_api/__init__.py +++ b/src/kwork_api/__init__.py @@ -5,27 +5,35 @@ Unofficial Python client for Kwork.ru API. Example: from kwork_api import KworkClient - + # Login with credentials client = await KworkClient.login("username", "password") - + # Or restore from token client = KworkClient(token="your_web_auth_token") - + # Get catalog catalog = await client.catalog.get_list(page=1) """ from .client import KworkClient -from .errors import KworkError, KworkAuthError, KworkApiError +from .errors import ( + KworkApiError, + KworkAuthError, + KworkError, + KworkNetworkError, + KworkNotFoundError, + KworkRateLimitError, + KworkValidationError, +) from .models import ( - ValidationResponse, - ValidationIssue, + CatalogResponse, Kwork, KworkDetails, Project, - CatalogResponse, ProjectsResponse, + ValidationIssue, + ValidationResponse, ) __version__ = "0.1.0" # Updated by semantic-release @@ -34,6 +42,10 @@ __all__ = [ "KworkError", "KworkAuthError", "KworkApiError", + "KworkNetworkError", + "KworkNotFoundError", + "KworkRateLimitError", + "KworkValidationError", "ValidationResponse", "ValidationIssue", "Kwork", diff --git a/src/kwork_api/__pycache__/__init__.cpython-312.pyc b/src/kwork_api/__pycache__/__init__.cpython-312.pyc index 0e5b60d..3c3b9a2 100644 Binary files a/src/kwork_api/__pycache__/__init__.cpython-312.pyc and b/src/kwork_api/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/kwork_api/__pycache__/client.cpython-312.pyc b/src/kwork_api/__pycache__/client.cpython-312.pyc index 455e83c..cf3c83d 100644 Binary files a/src/kwork_api/__pycache__/client.cpython-312.pyc and b/src/kwork_api/__pycache__/client.cpython-312.pyc differ diff --git a/src/kwork_api/__pycache__/errors.cpython-312.pyc b/src/kwork_api/__pycache__/errors.cpython-312.pyc index adda591..8091491 100644 Binary files a/src/kwork_api/__pycache__/errors.cpython-312.pyc and b/src/kwork_api/__pycache__/errors.cpython-312.pyc differ diff --git a/src/kwork_api/__pycache__/models.cpython-312.pyc b/src/kwork_api/__pycache__/models.cpython-312.pyc index 624181c..573e0ea 100644 Binary files a/src/kwork_api/__pycache__/models.cpython-312.pyc and b/src/kwork_api/__pycache__/models.cpython-312.pyc differ diff --git a/src/kwork_api/client.py b/src/kwork_api/client.py index 60ce889..e08064d 100644 --- a/src/kwork_api/client.py +++ b/src/kwork_api/client.py @@ -5,10 +5,9 @@ Main client class with authentication and all API endpoints. """ import logging -from typing import Any, Optional +from typing import Any import httpx -from pydantic import HttpUrl from .errors import ( KworkApiError, @@ -20,13 +19,10 @@ from .errors import ( KworkValidationError, ) from .models import ( - APIErrorResponse, - AuthResponse, Badge, CatalogResponse, City, Country, - DataResponse, Dialog, Feature, Kwork, @@ -34,7 +30,6 @@ from .models import ( NotificationsResponse, Project, ProjectsResponse, - Review, ReviewsResponse, TimeZone, ValidationResponse, @@ -46,32 +41,32 @@ logger = logging.getLogger(__name__) class KworkClient: """ Асинхронный клиент для Kwork.ru API. - + Предоставляет доступ ко всем основным эндпоинтам Kwork API: - Каталог кворков и поиск - Биржа проектов (фриланс заказы) - Пользовательские данные и отзывы - Уведомления и сообщения - Справочные данные (города, страны, категории) - + Аутентификация: Клиент использует двухэтапную аутентификацию Kwork: 1. POST /signIn — получение session cookies 2. POST /getWebAuthToken — получение web_auth_token - + Примеры использования: # Вход по логину/паролю async with await KworkClient.login("username", "password") as client: catalog = await client.catalog.get_list(page=1) - + # Восстановление сессии по токену client = KworkClient(token="saved_web_auth_token") user_info = await client.user.get_info() - + # Работа с проектами projects = await client.projects.get_list(page=1) my_orders = await client.projects.get_payer_orders() - + Attributes: catalog: Доступ к каталогу кворков projects: Доступ к бирже проектов @@ -79,29 +74,29 @@ class KworkClient: reference: Справочные данные notifications: Уведомления и сообщения other: Прочие эндпоинты - + Note: Клиент поддерживает context manager для автоматического закрытия соединения. Рекомендуется использовать `async with` для корректного освобождения ресурсов. """ - + BASE_URL = "https://api.kwork.ru" LOGIN_URL = "https://kwork.ru/signIn" TOKEN_URL = "https://kwork.ru/getWebAuthToken" - + def __init__( self, - token: Optional[str] = None, - cookies: Optional[dict[str, str]] = None, + token: str | None = None, + cookies: dict[str, str] | None = None, timeout: float = 30.0, - base_url: Optional[str] = None, + base_url: str | None = None, ): """ Инициализация клиента. - + Создаёт неаутентифицированный клиент или восстанавливает сессию по ранее полученному токену. - + Args: token: Web auth token, полученный через `getWebAuthToken` или `login()`. Если указан, автоматически добавляется в cookies. @@ -109,17 +104,17 @@ class KworkClient: Обычно не требуется — устанавливаются автоматически из token. timeout: Таймаут HTTP запросов в секундах. По умолчанию 30 секунд. base_url: Кастомный базовый URL. Используется только для тестирования. - + Example: # Новый клиент без аутентификации client = KworkClient() - + # Восстановление сессии client = KworkClient(token="eyJ0eXAiOiJKV1QiLCJhbGc...") - + # Клиент с кастомным таймаутом client = KworkClient(timeout=60.0) - + Note: Для полноценной работы API требуется аутентификация. Используйте `login()` или передайте сохранённый token. @@ -128,61 +123,61 @@ class KworkClient: self.timeout = timeout self._token = token self._cookies = cookies or {} - + # Initialize HTTP client - self._client: Optional[httpx.AsyncClient] = None - + self._client: httpx.AsyncClient | None = None + @property - def token(self) -> Optional[str]: + def token(self) -> str | None: """ Web auth token для аутентификации. - + Returns: Токен или None если клиент не аутентифицирован. - + Example: # Сохранение токена для последующего использования client = await KworkClient.login("user", "pass") token = client.token - + # Позже: восстановление сессии client = KworkClient(token=token) """ return self._token - + @property def cookies(self) -> dict[str, str]: """ Session cookies. - + Returns: Словарь cookies включая web_auth_token. - + Example: # Сохранение полной сессии client = await KworkClient.login("user", "pass") creds = client.credentials - + # Восстановление client = KworkClient(**creds) """ return self._cookies.copy() - + @property - def credentials(self) -> dict[str, Optional[str]]: + def credentials(self) -> dict[str, str | None]: """ Учётные данные для восстановления сессии. - + Returns: Словарь с token и cookies для передачи в KworkClient(). - + Example: # Сохранение client = await KworkClient.login("user", "pass") import json with open("session.json", "w") as f: json.dump(client.credentials, f) - + # Восстановление with open("session.json") as f: creds = json.load(f) @@ -192,7 +187,7 @@ class KworkClient: "token": self._token, "cookies": self._cookies.copy() if self._cookies else None, } - + @classmethod async def login( cls, @@ -202,48 +197,48 @@ class KworkClient: ) -> "KworkClient": """ Аутентификация по логину и паролю. - + Выполняет двухэтапный процесс аутентификации Kwork: 1. POST /signIn — проверка учётных данных, получение session cookies 2. POST /getWebAuthToken — обмен cookies на web_auth_token - + Полученный токен и cookies сохраняются в клиенте для последующих запросов. - + Args: username: Логин или email аккаунта Kwork. password: Пароль аккаунта Kwork. timeout: Таймаут запросов в секундах. Применяется к каждому этапу. - + Returns: Полностью аутентифицированный экземпляр KworkClient, готовый к работе с API. - + Raises: KworkAuthError: Если логин/пароль неверны или токен не получен. KworkNetworkError: Если произошла ошибка сети. - + Example: # Базовое использование client = await KworkClient.login("myuser", "mypassword") - + # С кастомным таймаутом client = await KworkClient.login("user", "pass", timeout=60.0) - + # Сохранение токена для повторного использования token = client._token # Позже: client = KworkClient(token=token) - + Security: Пароль не сохраняется в клиенте. Только token и cookies. Рекомендуется сохранять token для повторного использования вместо хранения пароля. - + Note: Токен имеет ограниченное время жизни. При получении 401 ошибки необходимо выполнить повторный login(). """ client = cls(timeout=timeout) - + try: async with client._get_httpx_client() as http_client: # Step 1: Login to get session cookies @@ -251,43 +246,43 @@ class KworkClient: "login_or_email": username, "password": password, } - + response = await http_client.post( cls.LOGIN_URL, data=login_data, headers={"Referer": "https://kwork.ru/"}, ) - + if response.status_code != 200: raise KworkAuthError(f"Login failed: {response.status_code}") - + # Extract cookies cookies = dict(response.cookies) - + if "userId" not in cookies: raise KworkAuthError("Login failed: no userId in cookies") - + # Step 2: Get web auth token token_response = await http_client.post( cls.TOKEN_URL, json={}, ) - + if token_response.status_code != 200: raise KworkAuthError(f"Token request failed: {token_response.status_code}") - + token_data = token_response.json() web_token = token_data.get("web_auth_token") - + if not web_token: raise KworkAuthError("No web_auth_token in response") - + # Create new client with token return cls(token=web_token, cookies=cookies, timeout=timeout) - + except httpx.RequestError as e: - raise KworkNetworkError(f"Login request failed: {e}") - + raise KworkNetworkError(f"Login request failed: {e}") from e + def _get_httpx_client(self) -> httpx.AsyncClient: """Get or create HTTP client with proper headers.""" if self._client is None or self._client.is_closed: @@ -297,11 +292,11 @@ class KworkClient: "Referer": "https://kwork.ru/", "Origin": "https://kwork.ru", } - + if self._token: # Add token to cookies self._cookies["web_auth_token"] = self._token - + self._client = httpx.AsyncClient( base_url=self.base_url, headers=headers, @@ -309,30 +304,30 @@ class KworkClient: timeout=self.timeout, http2=True, ) - + return self._client - + async def close(self) -> None: """Close HTTP client.""" if self._client and not self._client.is_closed: await self._client.aclose() - + async def __aenter__(self) -> "KworkClient": return self - + async def __aexit__(self, *args: Any) -> None: await self.close() - + def _handle_response(self, response: httpx.Response) -> dict[str, Any]: """ Handle HTTP response and raise appropriate errors. - + Args: response: HTTP response - + Returns: Response JSON data - + Raises: KworkApiError: For HTTP errors KworkAuthError: For auth errors @@ -341,34 +336,34 @@ class KworkClient: # Check for common error statuses if response.status_code == 401: raise KworkAuthError("Unauthorized: invalid or expired token") - + if response.status_code == 403: raise KworkAuthError("Forbidden: access denied") - + if response.status_code == 404: raise KworkNotFoundError(f"Resource not found: {response.url}") - + if response.status_code == 429: raise KworkRateLimitError("Too many requests") - + if response.status_code >= 400: try: error_data = response.json() message = error_data.get("message", str(error_data)) except Exception: message = response.text - + if response.status_code == 400: raise KworkValidationError(message, response=response) - + raise KworkApiError(message, response.status_code, response) - + # Parse successful response try: return response.json() except Exception as e: - raise KworkError(f"Failed to parse response: {e}") - + raise KworkError(f"Failed to parse response: {e}") from e + async def _request( self, method: str, @@ -377,60 +372,60 @@ class KworkClient: ) -> dict[str, Any]: """ Make HTTP request. - + Args: method: HTTP method endpoint: API endpoint **kwargs: Additional arguments for httpx - + Returns: Response JSON data """ http_client = self._get_httpx_client() - + try: response = await http_client.request(method, endpoint, **kwargs) return self._handle_response(response) except httpx.RequestError as e: - raise KworkNetworkError(f"Request failed: {e}") - + raise KworkNetworkError(f"Request failed: {e}") from e + # ========== Catalog Endpoints ========== - + class CatalogAPI: """ API каталога кворков. - + Предоставляет доступ к каталогу услуг Kwork: - Поиск и фильтрация кворков - Получение детальной информации - Категории и сортировка - + Example: # Получить первую страницу каталога catalog = await client.catalog.get_list(page=1) - + # Фильтрация по категории catalog = await client.catalog.get_list(category_id=5) - + # Детали конкретного кворка details = await client.catalog.get_details(kwork_id=12345) """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_list( self, page: int = 1, - category_id: Optional[int] = None, + category_id: int | None = None, sort: str = "recommend", ) -> CatalogResponse: """ Получить список кворков из каталога. - + Основной эндпоинт для поиска и просмотра кворков. Возвращает пагинированный список с возможностью фильтрации. - + Args: page: Номер страницы для пагинации (начиная с 1). category_id: ID категории для фильтрации. @@ -442,24 +437,24 @@ class KworkClient: - "rating" — по рейтингу - "reviews" — по количеству отзывов - "newest" — по дате создания - + Returns: CatalogResponse содержащий: - kworks: список кворков на странице - pagination: информация о пагинации - filters: доступные фильтры - sort_options: доступные опции сортировки - + Example: # Первая страница, сортировка по цене response = await client.catalog.get_list( page=1, sort="price_asc" ) - + for kwork in response.kworks: print(f"{kwork.title}: {kwork.price} RUB") - + # Пагинация if response.pagination and response.pagination.has_next: next_page = await client.catalog.get_list(page=2) @@ -474,26 +469,26 @@ class KworkClient: }, ) return CatalogResponse.model_validate(data) - + async def get_details(self, kwork_id: int) -> KworkDetails: """ Получить полную информацию о кворке. - + Возвращает расширенную информацию о кворке включая: - Полное описание и требования - Сроки выполнения и количество правок - Дополнительные опции (features) - FAQ от продавца - + Args: kwork_id: Уникальный идентификатор кворка. - + Returns: KworkDetails с полной информацией о кворке. - + Raises: KworkNotFoundError: Если кворк с таким ID не найден. - + Example: details = await client.catalog.get_details(12345) print(f"Название: {details.title}") @@ -507,25 +502,25 @@ class KworkClient: json={"kwork_id": kwork_id}, ) return KworkDetails.model_validate(data) - + async def get_details_extra(self, kwork_id: int) -> dict[str, Any]: """ Получить дополнительные детали кворка. - + Возвращает расширенную информацию, которая не включена в основной ответ get_details(). Может содержать: - Дополнительные изображения - Видео обзоры - Детали пакетов услуг - Статистику продаж - + Args: kwork_id: Уникальный идентификатор кворка. - + Returns: Словарь с дополнительными данными. Структура зависит от конкретного кворка и не гарантируется стабильной. - + Note: Этот эндпоинт возвращает "сырые" данные без валидации. Структура ответа может измениться без предупреждения. @@ -535,60 +530,60 @@ class KworkClient: "/getKworkDetailsExtra", json={"kwork_id": kwork_id}, ) - + # ========== Projects Endpoints ========== - + class ProjectsAPI: """ API биржи проектов (фриланс заказы). - + Предоставляет доступ к заказам на фриланс: - Просмотр открытых проектов - Заказы где вы заказчик (payer) - Заказы где вы исполнитель (worker) - + Example: # Новые проекты projects = await client.projects.get_list(page=1) - + # Мои заказы как заказчика my_orders = await client.projects.get_payer_orders() - + # Мои заказы как исполнителя my_work = await client.projects.get_worker_orders() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_list( self, page: int = 1, - category_id: Optional[int] = None, + category_id: int | None = None, ) -> ProjectsResponse: """ Получить список проектов с биржи. - + Основной эндпоинт для просмотра доступных заказов. Возвращает пагинированный список проектов. - + Args: page: Номер страницы (начиная с 1). category_id: ID категории для фильтрации. Если None — все категории. - + Returns: ProjectsResponse содержащий: - projects: список проектов на странице - pagination: информация о пагинации - + Example: # Все новые проекты response = await client.projects.get_list(page=1) - + for project in response.projects: print(f"{project.title}: {project.budget} {project.budget_type}") - + # Только категория "Разработка" dev_projects = await client.projects.get_list( page=1, @@ -604,17 +599,17 @@ class KworkClient: }, ) return ProjectsResponse.model_validate(data) - + async def get_payer_orders(self) -> list[Project]: """ Получить заказы где вы являетесь заказчиком. - + Возвращает все проекты, созданные текущим пользователем, независимо от их статуса (открыт, в работе, завершён). - + Returns: Список проектов где текущий пользователь — заказчик. - + Example: orders = await client.projects.get_payer_orders() for order in orders: @@ -622,17 +617,17 @@ class KworkClient: """ data = await self.client._request("POST", "/payerOrders") return [Project.model_validate(p) for p in data.get("orders", [])] - + async def get_worker_orders(self) -> list[Project]: """ Получить заказы где вы являетесь исполнителем. - + Возвращает все проекты, где текущий пользователь назначен исполнителем. - + Returns: Список проектов где текущий пользователь — исполнитель. - + Example: work = await client.projects.get_worker_orders() active = [p for p in work if p.status == "in_progress"] @@ -640,81 +635,81 @@ class KworkClient: """ data = await self.client._request("POST", "/workerOrders") return [Project.model_validate(p) for p in data.get("orders", [])] - + # ========== User Endpoints ========== - + class UserAPI: """ Пользовательское API. - + Предоставляет доступ к данным текущего пользователя: - Профиль и информация об аккаунте - Отзывы (полученные и оставленные) - Избранные кворки - + Example: # Информация о пользователе info = await client.user.get_info() - + # Мои отзывы reviews = await client.user.get_reviews() - + # Избранные кворки favorites = await client.user.get_favorite_kworks() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_info(self) -> dict[str, Any]: """ Получить информацию о текущем пользователе. - + Возвращает основные данные аккаунта: - ID, username, email - Баланс, рейтинг - Статус верификации - Настройки профиля - + Returns: Словарь с информацией о пользователе. Структура зависит от ответа API. - + Example: info = await client.user.get_info() print(f"User: {info.get('username')}") print(f"Balance: {info.get('balance')} RUB") """ return await self.client._request("POST", "/user") - + async def get_reviews( self, - user_id: Optional[int] = None, + user_id: int | None = None, page: int = 1, ) -> ReviewsResponse: """ Получить отзывы пользователя. - + Если user_id не указан — возвращает отзывы текущего пользователя. Если указан — отзывы другого пользователя по ID. - + Args: user_id: ID пользователя. Если None — текущий пользователь. page: Номер страницы для пагинации (начиная с 1). - + Returns: ReviewsResponse содержащий: - reviews: список отзывов на странице - pagination: информация о пагинации - average_rating: средний рейтинг - + Example: # Мои отзывы my_reviews = await client.user.get_reviews() - + # Отзывы другого пользователя user_reviews = await client.user.get_reviews(user_id=12345) - + # С пагинацией page2 = await client.user.get_reviews(page=2) """ @@ -724,16 +719,16 @@ class KworkClient: json={"user_id": user_id, "page": page}, ) return ReviewsResponse.model_validate(data) - + async def get_favorite_kworks(self) -> list[Kwork]: """ Получить список избранных кворков. - + Возвращает все кворки, добавленные пользователем в избранное. - + Returns: Список избранных кворков. - + Example: favorites = await client.user.get_favorite_kworks() for kwork in favorites: @@ -741,89 +736,89 @@ class KworkClient: """ data = await self.client._request("POST", "/favoriteKworks") return [Kwork.model_validate(k) for k in data.get("kworks", [])] - + # ========== Reference Data Endpoints ========== - + class ReferenceAPI: """ Справочное API. - + Предоставляет доступ к справочным данным Kwork: - Города, страны, часовые пояса - Доступные функции и дополнения - Значки пользователей - + Эти данные редко меняются и могут быть закэшированы. - + Example: # Все страны countries = await client.reference.get_countries() - + # Все города cities = await client.reference.get_cities() - + # Доступные фичи features = await client.reference.get_features() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_cities(self) -> list[City]: """ Получить список всех городов. - + Returns: Список всех городов из справочника Kwork. - + Example: cities = await client.reference.get_cities() moscow = next(c for c in cities if c.name == "Москва") """ data = await self.client._request("POST", "/cities") return [City.model_validate(c) for c in data.get("cities", [])] - + async def get_countries(self) -> list[Country]: """ Получить список всех стран. - + Returns: Список всех стран с кодами и городами. - + Example: countries = await client.reference.get_countries() russia = next(c for c in countries if c.code == "RU") """ data = await self.client._request("POST", "/countries") return [Country.model_validate(c) for c in data.get("countries", [])] - + async def get_timezones(self) -> list[TimeZone]: """ Получить список всех часовых поясов. - + Returns: Список часовых поясов с названиями и смещениями. - + Example: timezones = await client.reference.get_timezones() msks = next(tz for tz in timezones if "Moscow" in tz.name) """ data = await self.client._request("POST", "/timezones") return [TimeZone.model_validate(t) for t in data.get("timezones", [])] - + async def get_features(self) -> list[Feature]: """ Получить доступные дополнительные функции (features). - + Features — это платные дополнения к кворкам: - Увеличенные сроки - Дополнительные правки - Приоритетная поддержка - и т.д. - + Returns: Список доступных features с названиями и ценами. - + Example: features = await client.reference.get_features() for f in features: @@ -831,33 +826,33 @@ class KworkClient: """ data = await self.client._request("POST", "/getAvailableFeatures") return [Feature.model_validate(f) for f in data.get("features", [])] - + async def get_public_features(self) -> list[Feature]: """ Получить публичные функции. - + Аналогично get_features(), но возвращает только публично доступные опции. - + Returns: Список публичных features. """ data = await self.client._request("POST", "/getPublicFeatures") return [Feature.model_validate(f) for f in data.get("features", [])] - + async def get_badges_info(self) -> list[Badge]: """ Получить информацию о значках пользователей. - + Значки (badges) отображают достижения и статусы: - "Профессионал" - "Быстрый ответ" - "Надёжный продавец" - и т.д. - + Returns: Список значков с описаниями и иконками. - + Example: badges = await client.reference.get_badges_info() for badge in badges: @@ -865,67 +860,67 @@ class KworkClient: """ data = await self.client._request("POST", "/getBadgesInfo") return [Badge.model_validate(b) for b in data.get("badges", [])] - + # ========== Notifications & Messages ========== - + class NotificationsAPI: """ API уведомлений и сообщений. - + Предоставляет доступ к системе уведомлений Kwork: - Список уведомлений - Получение новых уведомлений - Диалоги с пользователями - Заблокированные диалоги - + Example: # Все уведомления notifications = await client.notifications.get_list() - + # Новые уведомления new = await client.notifications.fetch() - + # Диалоги dialogs = await client.notifications.get_dialogs() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_list(self) -> NotificationsResponse: """ Получить список всех уведомлений. - + Возвращает все уведомления пользователя с информацией о прочтении. - + Returns: NotificationsResponse содержащий: - notifications: список уведомлений - unread_count: количество непрочитанных - + Example: notifs = await client.notifications.get_list() print(f"Непрочитанных: {notifs.unread_count}") - + for n in notifs.notifications: if not n.is_read: print(f"Новое: {n.title}") """ data = await self.client._request("POST", "/notifications") return NotificationsResponse.model_validate(data) - + async def fetch(self) -> NotificationsResponse: """ Получить новые уведомления. - + Отличается от get_list() тем, что возвращает только уведомления, появившиеся с момента последнего запроса. Также может обновлять счётчик непрочитанных. - + Returns: NotificationsResponse с новыми уведомлениями. - + Example: new_notifs = await client.notifications.fetch() if new_notifs.unread_count > 0: @@ -933,17 +928,17 @@ class KworkClient: """ data = await self.client._request("POST", "/notificationsFetch") return NotificationsResponse.model_validate(data) - + async def get_dialogs(self) -> list[Dialog]: """ Получить список диалогов (чатов). - + Возвращает все активные диалоги пользователя с другими пользователями Kwork. - + Returns: Список диалогов с последней перепиской. - + Example: dialogs = await client.notifications.get_dialogs() for d in dialogs: @@ -951,149 +946,149 @@ class KworkClient: """ data = await self.client._request("POST", "/dialogs") return [Dialog.model_validate(d) for d in data.get("dialogs", [])] - + async def get_blocked_dialogs(self) -> list[Dialog]: """ Получить список заблокированных диалогов. - + Возвращает диалоги с пользователями, которые были заблокированы текущим пользователем. - + Returns: Список заблокированных диалогов. - + Example: blocked = await client.notifications.get_blocked_dialogs() print(f"Заблокировано: {len(blocked)} пользователей") """ data = await self.client._request("POST", "/blockedDialogList") return [Dialog.model_validate(d) for d in data.get("dialogs", [])] - + # ========== Other Endpoints ========== - + class OtherAPI: """ Прочее API. - + Вспомогательные эндпоинты которые не вошли в другие категории: - Пользовательские настройки и предпочтения (wants) - Статусы кворков и заказов - Персональные предложения (offers) - Настройки профиля - Статус онлайн/оффлайн - + Example: # Пользовательские предпочтения wants = await client.other.get_wants() - + # Статус кворков status = await client.other.get_kworks_status() - + # Установить статус оффлайн await client.other.go_offline() """ - + def __init__(self, client: "KworkClient"): self.client = client - + async def get_wants(self) -> dict[str, Any]: """ Получить пользовательские предпочтения (wants). - + Wants — это настройки интересов пользователя: - Предпочитаемые категории - Ключевые слова для мониторинга - Фильтры для поиска - + Returns: Словарь с настройками предпочтений. - + Example: wants = await client.other.get_wants() print(wants) """ return await self.client._request("POST", "/myWants") - + async def get_wants_status(self) -> dict[str, Any]: """ Получить статус предпочтений. - + Returns: Статус wants с метаданными. """ return await self.client._request("POST", "/wantsStatusList") - + async def get_kworks_status(self) -> dict[str, Any]: """ Получить статусы кворков пользователя. - + Возвращает информацию о статусах всех кворков текущего пользователя (активен, на модерации, и т.д.). - + Returns: Словарь со статусами кворков. - + Example: status = await client.other.get_kworks_status() print(status) """ return await self.client._request("POST", "/kworksStatusList") - + async def get_offers(self) -> dict[str, Any]: """ Получить персональные предложения. - + Returns: Список персональных предложений от Kwork. """ return await self.client._request("POST", "/offers") - + async def get_exchange_info(self) -> dict[str, Any]: """ Получить информацию об обмене валюты. - + Returns: Информация о курсах валют и обмене. """ return await self.client._request("POST", "/exchangeInfo") - + async def get_channel(self) -> dict[str, Any]: """ Получить информацию о канале пользователя. - + Returns: Данные канала (если есть). """ return await self.client._request("POST", "/getChannel") - + async def get_in_app_notification(self) -> dict[str, Any]: """ Получить внутриприложенное уведомление. - + Returns: Данные in-app уведомления. """ return await self.client._request("POST", "/getInAppNotification") - + async def get_security_user_data(self) -> dict[str, Any]: """ Получить данные безопасности пользователя. - + Returns: Информация о безопасности аккаунта. """ return await self.client._request("POST", "/getSecurityUserData") - + async def is_dialog_allow(self, user_id: int) -> bool: """ Проверить возможность начала диалога с пользователем. - + Args: user_id: ID пользователя для проверки. - + Returns: True если диалог разрешён, False иначе. - + Example: allowed = await client.other.is_dialog_allow(12345) if allowed: @@ -1105,49 +1100,49 @@ class KworkClient: json={"user_id": user_id}, ) return data.get("allowed", False) - + async def get_viewed_kworks(self) -> list[Kwork]: """ Получить просмотренные кворки. - + Возвращает список кворков, которые пользователь просматривал ранее. - + Returns: Список просмотренных кворков. - + Example: viewed = await client.other.get_viewed_kworks() print(f"Просмотрено: {len(viewed)} кворков") """ data = await self.client._request("POST", "/viewedCatalogKworks") return [Kwork.model_validate(k) for k in data.get("kworks", [])] - + async def get_favorite_categories(self) -> list[int]: """ Получить ID избранных категорий. - + Returns: Список ID категорий, добавленных в избранное. - + Example: cats = await client.other.get_favorite_categories() print(f"Избранные категории: {cats}") """ data = await self.client._request("POST", "/favoriteCategories") return data.get("categories", []) - + async def update_settings(self, settings: dict[str, Any]) -> dict[str, Any]: """ Обновить настройки пользователя. - + Args: settings: Словарь с настройками для обновления. Структура зависит от конкретных настроек. - + Returns: Ответ API с подтверждением обновления. - + Example: await client.other.update_settings({ "email_notifications": True, @@ -1155,52 +1150,54 @@ class KworkClient: }) """ return await self.client._request("POST", "/updateSettings", json=settings) - + async def go_offline(self) -> dict[str, Any]: """ Установить статус пользователя "оффлайн". - + Скрывает онлайн-статус от других пользователей. - + Returns: Подтверждение изменения статуса. - + Example: await client.other.go_offline() """ return await self.client._request("POST", "/offline") - + async def get_actor(self) -> dict[str, Any]: """ Получить информацию об актёре (текущем пользователе). - + Returns: Данные актёра/пользователя. """ return await self.client._request("POST", "/actor") - - async def validate_text(self, text: str, context: Optional[str] = None) -> ValidationResponse: + + async def validate_text( + self, text: str, context: str | None = None + ) -> ValidationResponse: """ Проверить текст на соответствие требованиям Kwork. - + Эндпоинт валидации текста используется для проверки: - Описаний кворков - Текстов проектов - Сообщений и отзывов - + Находит потенциальные проблемы: - Запрещённые слова и контакты - Нарушения правил площадки - Проблемы с форматированием - + Args: text: Текст для проверки. context: Контекст использования (опционально). Например: "kwork_description", "project_text", "message". - + Returns: ValidationResponse с результатами валидации. - + Example: result = await client.other.validate_text( "Отличный сервис, звоните +7-999-000-00-00" @@ -1213,41 +1210,41 @@ class KworkClient: payload = {"text": text} if context: payload["context"] = context - + data = await self.client._request( "POST", "/api/validation/checktext", json=payload, ) return ValidationResponse.model_validate(data) - + # ========== API Property Accessors ========== - + @property def catalog(self) -> CatalogAPI: """API каталога кворков.""" return self.CatalogAPI(self) - + @property def projects(self) -> ProjectsAPI: """API биржи проектов.""" return self.ProjectsAPI(self) - + @property def user(self) -> UserAPI: """Пользовательское API.""" return self.UserAPI(self) - + @property def reference(self) -> ReferenceAPI: """Справочное API.""" return self.ReferenceAPI(self) - + @property def notifications(self) -> NotificationsAPI: """API уведомлений.""" return self.NotificationsAPI(self) - + @property def other(self) -> OtherAPI: """Прочее API.""" diff --git a/src/kwork_api/errors.py b/src/kwork_api/errors.py index 4004127..4d5d702 100644 --- a/src/kwork_api/errors.py +++ b/src/kwork_api/errors.py @@ -13,7 +13,7 @@ └── KworkNetworkError (ошибки сети) """ -from typing import Any, Optional +from typing import Any __all__ = [ "KworkError", @@ -29,25 +29,25 @@ __all__ = [ class KworkError(Exception): """ Базовое исключение для всех ошибок Kwork API. - + Все остальные исключения наследуются от этого класса. - + Attributes: message: Сообщение об ошибке. response: Оригинальный HTTP response (если есть). - + Example: try: await client.catalog.get_list() except KworkError as e: print(f"Ошибка: {e.message}") """ - - def __init__(self, message: str, response: Optional[Any] = None): + + def __init__(self, message: str, response: Any | None = None): self.message = message self.response = response super().__init__(self.message) - + def __str__(self) -> str: return f"KworkError: {self.message}" @@ -55,22 +55,22 @@ class KworkError(Exception): class KworkAuthError(KworkError): """ Ошибка аутентификации/авторизации. - + Возникает при: - Неверном логине или пароле - Истёкшем или невалидном токене - Отсутствии прав доступа (403) - + Example: try: client = await KworkClient.login("user", "wrong_password") except KworkAuthError: print("Неверные учётные данные") """ - - def __init__(self, message: str = "Authentication failed", response: Optional[Any] = None): + + def __init__(self, message: str = "Authentication failed", response: Any | None = None): super().__init__(message, response) - + def __str__(self) -> str: return f"KworkAuthError: {self.message}" @@ -78,28 +78,28 @@ class KworkAuthError(KworkError): class KworkApiError(KworkError): """ Ошибка HTTP запроса к API (4xx, 5xx). - + Базовый класс для HTTP ошибок API. Содержит код статуса. - + Attributes: status_code: HTTP код ответа (400, 404, 500, etc.) - + Example: try: await client.catalog.get_details(999999) except KworkApiError as e: print(f"HTTP {e.status_code}: {e.message}") """ - + def __init__( self, message: str, - status_code: Optional[int] = None, - response: Optional[Any] = None, + status_code: int | None = None, + response: Any | None = None, ): self.status_code = status_code super().__init__(message, response) - + def __str__(self) -> str: if self.status_code: return f"KworkApiError [{self.status_code}]: {self.message}" @@ -109,46 +109,46 @@ class KworkApiError(KworkError): class KworkNotFoundError(KworkApiError): """ Ресурс не найден (404). - + Возникает при запросе несуществующего кворка, пользователя или другого ресурса. - + Example: try: await client.catalog.get_details(999999) except KworkNotFoundError: print("Кворк не найден") """ - - def __init__(self, resource: str, response: Optional[Any] = None): + + def __init__(self, resource: str, response: Any | None = None): super().__init__(f"Resource not found: {resource}", 404, response) class KworkRateLimitError(KworkApiError): """ Превышен лимит запросов (429). - + Возникает при слишком частых запросах к API. Рекомендуется сделать паузу перед повторным запросом. - + Attributes: retry_after: Время ожидания в секундах (если указано сервером). - + Example: import asyncio - + try: await client.catalog.get_list() except KworkRateLimitError as e: wait_time = e.retry_after or 5 await asyncio.sleep(wait_time) """ - + def __init__( self, message: str = "Rate limit exceeded", - response: Optional[Any] = None, - retry_after: Optional[int] = None, + response: Any | None = None, + retry_after: int | None = None, ): self.retry_after = retry_after super().__init__(message, 429, response) @@ -157,12 +157,12 @@ class KworkRateLimitError(KworkApiError): class KworkValidationError(KworkApiError): """ Ошибка валидации (400). - + Возникает при некорректных данных запроса. - + Attributes: fields: Словарь ошибок по полям {field: [errors]}. - + Example: try: await client.catalog.get_list(page=-1) @@ -171,16 +171,16 @@ class KworkValidationError(KworkApiError): for field, errors in e.fields.items(): print(f"{field}: {errors[0]}") """ - + def __init__( self, message: str = "Validation failed", - fields: Optional[dict[str, list[str]]] = None, - response: Optional[Any] = None, + fields: dict[str, list[str]] | None = None, + response: Any | None = None, ): self.fields = fields or {} super().__init__(message, 400, response) - + def __str__(self) -> str: if self.fields: field_errors = ", ".join(f"{k}: {v[0]}" for k, v in self.fields.items()) @@ -191,22 +191,22 @@ class KworkValidationError(KworkApiError): class KworkNetworkError(KworkError): """ Ошибка сети/подключения. - + Возникает при: - Отсутствии соединения - Таймауте запроса - Ошибке DNS - Проблемах с SSL - + Example: try: await client.catalog.get_list() except KworkNetworkError: print("Проверьте подключение к интернету") """ - - def __init__(self, message: str = "Network error", response: Optional[Any] = None): + + def __init__(self, message: str = "Network error", response: Any | None = None): super().__init__(message, response) - + def __str__(self) -> str: return f"KworkNetworkError: {self.message}" diff --git a/src/kwork_api/models.py b/src/kwork_api/models.py index d69c0fc..6a70fa2 100644 --- a/src/kwork_api/models.py +++ b/src/kwork_api/models.py @@ -6,7 +6,7 @@ Pydantic модели для ответов Kwork API. """ from datetime import datetime -from typing import Any, Optional +from typing import Any from pydantic import BaseModel, Field @@ -14,47 +14,49 @@ from pydantic import BaseModel, Field class KworkUser(BaseModel): """ Информация о пользователе Kwork. - + Attributes: id: Уникальный ID пользователя. username: Имя пользователя (логин). avatar_url: URL аватара или None. is_online: Статус онлайн. rating: Рейтинг пользователя (0-5). - + Example: user = KworkUser(id=123, username="seller", rating=4.9) print(f"{user.username}: {user.rating} ★") """ + id: int username: str - avatar_url: Optional[str] = None + avatar_url: str | None = None is_online: bool = False - rating: Optional[float] = None + rating: float | None = None class KworkCategory(BaseModel): """ Категория кворков. - + Attributes: id: Уникальный ID категории. name: Название категории. slug: URL-safe идентификатор. parent_id: ID родительской категории для вложенности. """ + id: int name: str slug: str - parent_id: Optional[int] = None + parent_id: int | None = None class Kwork(BaseModel): """ Кворк — услуга на Kwork. - + Базовая модель кворка с основной информацией. - + Attributes: id: Уникальный ID кворка. title: Заголовок кворка. @@ -69,26 +71,27 @@ class Kwork(BaseModel): created_at: Дата создания. updated_at: Дата последнего обновления. """ + id: int title: str - description: Optional[str] = None + description: str | None = None price: float currency: str = "RUB" - category_id: Optional[int] = None - seller: Optional[KworkUser] = None + category_id: int | None = None + seller: KworkUser | None = None images: list[str] = Field(default_factory=list) - rating: Optional[float] = None + rating: float | None = None reviews_count: int = 0 - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None class KworkDetails(Kwork): """ Расширенная информация о кворке. - + Наследует все поля Kwork плюс дополнительные детали. - + Attributes: full_description: Полное описание услуги. requirements: Требования к заказчику. @@ -97,10 +100,11 @@ class KworkDetails(Kwork): features: Список дополнительных опций. faq: Список вопросов и ответов. """ - full_description: Optional[str] = None - requirements: Optional[str] = None - delivery_time: Optional[int] = None - revisions: Optional[int] = None + + full_description: str | None = None + requirements: str | None = None + delivery_time: int | None = None + revisions: int | None = None features: list[str] = Field(default_factory=list) faq: list[dict[str, str]] = Field(default_factory=list) @@ -108,7 +112,7 @@ class KworkDetails(Kwork): class PaginationInfo(BaseModel): """ Информация о пагинации. - + Attributes: current_page: Текущая страница (начиная с 1). total_pages: Общее количество страниц. @@ -117,6 +121,7 @@ class PaginationInfo(BaseModel): has_next: Есть ли следующая страница. has_prev: Есть ли предыдущая страница. """ + current_page: int = 1 total_pages: int = 1 total_items: int = 0 @@ -128,23 +133,24 @@ class PaginationInfo(BaseModel): class CatalogResponse(BaseModel): """ Ответ API каталога кворков. - + Attributes: kworks: Список кворков на странице. pagination: Информация о пагинации. filters: Доступные фильтры. sort_options: Доступные опции сортировки. """ + kworks: list[Kwork] = Field(default_factory=list) - pagination: Optional[PaginationInfo] = None - filters: Optional[dict[str, Any]] = None + pagination: PaginationInfo | None = None + filters: dict[str, Any] | None = None sort_options: list[str] = Field(default_factory=list) class Project(BaseModel): """ Проект (заказ на бирже фриланса). - + Attributes: id: Уникальный ID проекта. title: Заголовок проекта. @@ -159,16 +165,17 @@ class Project(BaseModel): bids_count: Количество откликов. skills: Требуемые навыки. """ + id: int title: str - description: Optional[str] = None - budget: Optional[float] = None + description: str | None = None + budget: float | None = None budget_type: str = "fixed" - category_id: Optional[int] = None - customer: Optional[KworkUser] = None + category_id: int | None = None + customer: KworkUser | None = None status: str = "open" - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None + created_at: datetime | None = None + updated_at: datetime | None = None bids_count: int = 0 skills: list[str] = Field(default_factory=list) @@ -176,19 +183,20 @@ class Project(BaseModel): class ProjectsResponse(BaseModel): """ Ответ API списка проектов. - + Attributes: projects: Список проектов. pagination: Информация о пагинации. """ + projects: list[Project] = Field(default_factory=list) - pagination: Optional[PaginationInfo] = None + pagination: PaginationInfo | None = None class Review(BaseModel): """ Отзыв о кворке или проекте. - + Attributes: id: Уникальный ID отзыва. rating: Оценка от 1 до 5. @@ -197,32 +205,34 @@ class Review(BaseModel): kwork_id: ID кворка (если отзыв о кворке). created_at: Дата создания. """ + id: int rating: int = Field(ge=1, le=5) - comment: Optional[str] = None - author: Optional[KworkUser] = None - kwork_id: Optional[int] = None - created_at: Optional[datetime] = None + comment: str | None = None + author: KworkUser | None = None + kwork_id: int | None = None + created_at: datetime | None = None class ReviewsResponse(BaseModel): """ Ответ API списка отзывов. - + Attributes: reviews: Список отзывов. pagination: Информация о пагинации. average_rating: Средний рейтинг. """ + reviews: list[Review] = Field(default_factory=list) - pagination: Optional[PaginationInfo] = None - average_rating: Optional[float] = None + pagination: PaginationInfo | None = None + average_rating: float | None = None class Notification(BaseModel): """ Уведомление пользователя. - + Attributes: id: Уникальный ID уведомления. type: Тип уведомления: "message", "order", "system", etc. @@ -232,23 +242,25 @@ class Notification(BaseModel): created_at: Дата создания. link: Ссылка для перехода (если есть). """ + id: int type: str title: str message: str is_read: bool = False - created_at: Optional[datetime] = None - link: Optional[str] = None + created_at: datetime | None = None + link: str | None = None class NotificationsResponse(BaseModel): """ Ответ API списка уведомлений. - + Attributes: notifications: Список уведомлений. unread_count: Количество непрочитанных уведомлений. """ + notifications: list[Notification] = Field(default_factory=list) unread_count: int = 0 @@ -256,7 +268,7 @@ class NotificationsResponse(BaseModel): class Dialog(BaseModel): """ Диалог (чат) с пользователем. - + Attributes: id: Уникальный ID диалога. participant: Собеседник. @@ -264,17 +276,18 @@ class Dialog(BaseModel): unread_count: Количество непрочитанных сообщений. updated_at: Время последнего сообщения. """ + id: int - participant: Optional[KworkUser] = None - last_message: Optional[str] = None + participant: KworkUser | None = None + last_message: str | None = None unread_count: int = 0 - updated_at: Optional[datetime] = None + updated_at: datetime | None = None class AuthResponse(BaseModel): """ Ответ API аутентификации. - + Attributes: success: Успешность аутентификации. user_id: ID пользователя. @@ -282,80 +295,86 @@ class AuthResponse(BaseModel): web_auth_token: Токен для последующих запросов. message: Сообщение (например, об ошибке). """ + success: bool - user_id: Optional[int] = None - username: Optional[str] = None - web_auth_token: Optional[str] = None - message: Optional[str] = None + user_id: int | None = None + username: str | None = None + web_auth_token: str | None = None + message: str | None = None class ErrorDetail(BaseModel): """ Детали ошибки API. - + Attributes: code: Код ошибки. message: Сообщение об ошибке. field: Поле, вызвавшее ошибку (если применимо). """ + code: str message: str - field: Optional[str] = None + field: str | None = None class APIErrorResponse(BaseModel): """ Стандартный ответ API об ошибке. - + Attributes: success: Всегда False для ошибок. errors: Список деталей ошибок. message: Общее сообщение об ошибке. """ + success: bool = False errors: list[ErrorDetail] = Field(default_factory=list) - message: Optional[str] = None + message: str | None = None class City(BaseModel): """ Город из справочника. - + Attributes: id: Уникальный ID города. name: Название города. country_id: ID страны. """ + id: int name: str - country_id: Optional[int] = None + country_id: int | None = None class Country(BaseModel): """ Страна из справочника. - + Attributes: id: Уникальный ID страны. name: Название страны. code: Код страны (ISO). cities: Список городов в стране. """ + id: int name: str - code: Optional[str] = None + code: str | None = None cities: list[City] = Field(default_factory=list) class TimeZone(BaseModel): """ Часовой пояс. - + Attributes: id: Уникальный ID. name: Название пояса. offset: Смещение от UTC (например, "+03:00"). """ + id: int name: str offset: str @@ -364,7 +383,7 @@ class TimeZone(BaseModel): class Feature(BaseModel): """ Дополнительная функция (feature) для кворка. - + Attributes: id: Уникальный ID функции. name: Название. @@ -372,9 +391,10 @@ class Feature(BaseModel): price: Стоимость в рублях. type: Тип: "extra", "premium", etc. """ + id: int name: str - description: Optional[str] = None + description: str | None = None price: float type: str @@ -382,40 +402,42 @@ class Feature(BaseModel): class Badge(BaseModel): """ Значок (достижение) пользователя. - + Attributes: id: Уникальный ID значка. name: Название значка. description: Описание достижения. icon_url: URL иконки значка. """ + id: int name: str - description: Optional[str] = None - icon_url: Optional[str] = None + description: str | None = None + icon_url: str | None = None # Generic response wrapper class DataResponse(BaseModel): """ Универсальный ответ API с данными. - + Используется как обёртка для различных ответов API. - + Attributes: success: Успешность запроса. data: Полезные данные (словарь). message: Дополнительное сообщение. """ + success: bool = True - data: Optional[dict[str, Any]] = None - message: Optional[str] = None + data: dict[str, Any] | None = None + message: str | None = None class ValidationIssue(BaseModel): """ Проблема, найденная при валидации текста. - + Attributes: type: Тип проблемы: "error", "warning", "suggestion". code: Код ошибки (например, "SPELLING", "GRAMMAR", "LENGTH"). @@ -423,19 +445,20 @@ class ValidationIssue(BaseModel): position: Позиция в тексте (если применимо). suggestion: Предлагаемое исправление (если есть). """ + type: str = "error" code: str message: str - position: Optional[int] = None - suggestion: Optional[str] = None + position: int | None = None + suggestion: str | None = None class ValidationResponse(BaseModel): """ Ответ API валидации текста. - + Используется для эндпоинта /api/validation/checktext. - + Attributes: success: Успешность валидации. is_valid: Текст проходит валидацию (нет критических ошибок). @@ -443,8 +466,9 @@ class ValidationResponse(BaseModel): score: Оценка качества текста (0-100, если доступна). message: Дополнительное сообщение. """ + success: bool = True is_valid: bool = True issues: list[ValidationIssue] = Field(default_factory=list) - score: Optional[int] = None - message: Optional[str] = None + score: int | None = None + message: str | None = None diff --git a/test-results/report.html b/test-results/report.html new file mode 100644 index 0000000..48c7737 --- /dev/null +++ b/test-results/report.html @@ -0,0 +1,1094 @@ + + + + + report.html + + + + +

report.html

+

Report generated on 29-Mar-2026 at 07:54:55 by pytest-html + v4.2.0

+
+

Environment

+
+
+ + + + + +
+
+

Summary

+
+
+

16 tests took 00:00:02.

+

(Un)check the boxes to filter the results.

+
+ +
+
+
+
+ + 0 Failed, + + 16 Passed, + + 0 Skipped, + + 0 Expected failures, + + 0 Unexpected passes, + + 0 Errors, + + 0 Reruns + + 0 Retried, +
+
+  /  +
+
+
+
+
+
+
+
+ + + + + + + + + +
ResultTestDurationLinks
+ + + \ No newline at end of file diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 1057118..1af71d7 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -5,8 +5,9 @@ E2E тесты для Kwork API. """ import os -import pytest from pathlib import Path + +import pytest from dotenv import load_dotenv # Загружаем .env @@ -39,21 +40,17 @@ def slowmo(request): slowmo = request.config.getoption("--slowmo", default=0) if slowmo > 0: import time + time.sleep(slowmo) def pytest_configure(config): """Регистрация маркера e2e.""" - config.addinivalue_line( - "markers", "e2e: mark test as end-to-end (requires credentials)" - ) + config.addinivalue_line("markers", "e2e: mark test as end-to-end (requires credentials)") def pytest_addoption(parser): """Добавляет опцию --slowmo.""" parser.addoption( - "--slowmo", - type=float, - default=0, - help="Delay between tests in seconds (for rate limiting)" + "--slowmo", type=float, default=0, help="Delay between tests in seconds (for rate limiting)" ) diff --git a/tests/e2e/test_auth.py b/tests/e2e/test_auth.py index db42b8b..39ff119 100644 --- a/tests/e2e/test_auth.py +++ b/tests/e2e/test_auth.py @@ -3,6 +3,7 @@ E2E тесты аутентификации. """ import pytest + from kwork_api import KworkClient from kwork_api.errors import KworkAuthError @@ -11,10 +12,9 @@ from kwork_api.errors import KworkAuthError async def test_login_success(require_credentials): """E2E: Успешная аутентификация.""" client = await KworkClient.login( - username=require_credentials["username"], - password=require_credentials["password"] + username=require_credentials["username"], password=require_credentials["password"] ) - + try: assert client.token is not None assert len(client.token) > 0 @@ -26,10 +26,7 @@ async def test_login_success(require_credentials): async def test_login_invalid_credentials(): """E2E: Неверные credentials.""" with pytest.raises(KworkAuthError): - await KworkClient.login( - username="invalid_user_12345", - password="invalid_pass_12345" - ) + await KworkClient.login(username="invalid_user_12345", password="invalid_pass_12345") @pytest.mark.e2e @@ -37,12 +34,11 @@ async def test_restore_session(require_credentials): """E2E: Восстановление сессии из токена.""" # First login client1 = await KworkClient.login( - username=require_credentials["username"], - password=require_credentials["password"] + username=require_credentials["username"], password=require_credentials["password"] ) token = client1.token await client1.aclose() - + # Restore from token client2 = KworkClient(token=token) try: diff --git a/tests/integration/test_real_api.py b/tests/integration/test_real_api.py index fb54297..6ed651b 100644 --- a/tests/integration/test_real_api.py +++ b/tests/integration/test_real_api.py @@ -6,77 +6,76 @@ Skip these tests in CI/CD or when running unit tests only. Usage: pytest tests/integration/ -m integration - + Or with credentials: KWORK_USERNAME=user KWORK_PASSWORD=pass pytest tests/integration/ -m integration """ import os -from typing import Optional import pytest -from kwork_api import KworkClient, KworkAuthError +from kwork_api import KworkAuthError, KworkClient @pytest.fixture(scope="module") -def client() -> Optional[KworkClient]: +def client() -> KworkClient | None: """ Create authenticated client for integration tests. - + Requires KWORK_USERNAME and KWORK_PASSWORD environment variables. Skip tests if not provided. """ username = os.getenv("KWORK_USERNAME") password = os.getenv("KWORK_PASSWORD") - + if not username or not password: pytest.skip("KWORK_USERNAME and KWORK_PASSWORD not set") - + # Create client import asyncio - + async def create_client(): return await KworkClient.login(username, password) - + return asyncio.run(create_client()) @pytest.mark.integration class TestAuthentication: """Test authentication with real API.""" - + def test_login_with_credentials(self): """Test login with real credentials.""" username = os.getenv("KWORK_USERNAME") password = os.getenv("KWORK_PASSWORD") - + if not username or not password: pytest.skip("Credentials not set") - + import asyncio - + async def login(): client = await KworkClient.login(username, password) assert client._token is not None assert "userId" in client._cookies await client.close() return True - + result = asyncio.run(login()) assert result - + def test_invalid_credentials(self): """Test login with invalid credentials.""" import asyncio - + async def try_login(): try: await KworkClient.login("invalid_user_12345", "wrong_password") return False except KworkAuthError: return True - + result = asyncio.run(try_login()) assert result # Should raise auth error @@ -84,43 +83,43 @@ class TestAuthentication: @pytest.mark.integration class TestCatalogAPI: """Test catalog endpoints with real API.""" - + def test_get_catalog_list(self, client: KworkClient): """Test getting catalog list.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): result = await client.catalog.get_list(page=1) return result - + result = asyncio.run(fetch()) - + assert result.kworks is not None assert len(result.kworks) > 0 assert result.pagination is not None - + def test_get_kwork_details(self, client: KworkClient): """Test getting kwork details.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): # First get a kwork ID from catalog catalog = await client.catalog.get_list(page=1) if not catalog.kworks: return None - + kwork_id = catalog.kworks[0].id details = await client.catalog.get_details(kwork_id) return details - + result = asyncio.run(fetch()) - + if result: assert result.id is not None assert result.title is not None @@ -130,61 +129,63 @@ class TestCatalogAPI: @pytest.mark.integration class TestProjectsAPI: """Test projects endpoints with real API.""" - + def test_get_projects_list(self, client: KworkClient): """Test getting projects list.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): return await client.projects.get_list(page=1) - + result = asyncio.run(fetch()) - + assert result.projects is not None @pytest.mark.integration class TestReferenceAPI: """Test reference data endpoints.""" - + def test_get_cities(self, client: KworkClient): """Test getting cities.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): return await client.reference.get_cities() - + result = asyncio.run(fetch()) - + assert isinstance(result, list) # Kwork has many cities, should have at least some assert len(result) > 0 - + def test_get_countries(self, client: KworkClient): """Test getting countries.""" if not client: pytest.skip("No client") - + import asyncio + result = asyncio.run(client.reference.get_countries()) - + assert isinstance(result, list) assert len(result) > 0 - + def test_get_timezones(self, client: KworkClient): """Test getting timezones.""" if not client: pytest.skip("No client") - + import asyncio + result = asyncio.run(client.reference.get_timezones()) - + assert isinstance(result, list) assert len(result) > 0 @@ -192,15 +193,16 @@ class TestReferenceAPI: @pytest.mark.integration class TestUserAPI: """Test user endpoints.""" - + def test_get_user_info(self, client: KworkClient): """Test getting current user info.""" if not client: pytest.skip("No client") - + import asyncio + result = asyncio.run(client.user.get_info()) - + assert isinstance(result, dict) # Should have user data assert result # Not empty @@ -209,36 +211,36 @@ class TestUserAPI: @pytest.mark.integration class TestErrorHandling: """Test error handling with real API.""" - + def test_invalid_kwork_id(self, client: KworkClient): """Test getting non-existent kwork.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch(): try: await client.catalog.get_details(999999999) return False except Exception: return True - - result = asyncio.run(fetch()) + + asyncio.run(fetch()) # May or may not raise error depending on API behavior @pytest.mark.integration class TestRateLimiting: """Test rate limiting behavior.""" - + def test_multiple_requests(self, client: KworkClient): """Test making multiple requests.""" if not client: pytest.skip("No client") - + import asyncio - + async def fetch_multiple(): results = [] for page in range(1, 4): @@ -247,9 +249,9 @@ class TestRateLimiting: # Small delay to avoid rate limiting await asyncio.sleep(0.5) return results - + results = asyncio.run(fetch_multiple()) - + assert len(results) == 3 for result in results: assert result.kworks is not None diff --git a/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc b/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc index d56b82f..a606c3a 100644 Binary files a/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc and b/tests/unit/__pycache__/test_client.cpython-312-pytest-9.0.2.pyc differ diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index b3e6507..8e24f6c 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -8,25 +8,27 @@ import pytest import respx from httpx import Response -from kwork_api import KworkClient, KworkAuthError, KworkApiError -from kwork_api.models import CatalogResponse, Kwork, ValidationResponse, ValidationIssue +from kwork_api import KworkApiError, KworkAuthError, KworkClient, KworkNetworkError +from kwork_api.models import CatalogResponse, ValidationResponse class TestAuthentication: """Test authentication flows.""" - + @respx.mock async def test_login_success(self): """Test successful login.""" import httpx - + # Mock login endpoint login_route = respx.post("https://kwork.ru/signIn") - login_route.mock(return_value=httpx.Response( - 200, - headers={"Set-Cookie": "userId=12345; slrememberme=token123"}, - )) - + login_route.mock( + return_value=httpx.Response( + 200, + headers={"Set-Cookie": "userId=12345; slrememberme=token123"}, + ) + ) + # Mock token endpoint token_route = respx.post("https://kwork.ru/getWebAuthToken").mock( return_value=Response( @@ -34,53 +36,53 @@ class TestAuthentication: json={"web_auth_token": "test_token_abc123"}, ) ) - + # Login client = await KworkClient.login("testuser", "testpass") - + # Verify assert login_route.called assert token_route.called assert client._token == "test_token_abc123" - + @respx.mock async def test_login_invalid_credentials(self): """Test login with invalid credentials.""" respx.post("https://kwork.ru/signIn").mock( return_value=Response(401, json={"error": "Invalid credentials"}) ) - + with pytest.raises(KworkAuthError): await KworkClient.login("wrong", "wrong") - + @respx.mock async def test_login_no_userid(self): """Test login without userId in cookies.""" import httpx - + respx.post("https://kwork.ru/signIn").mock( return_value=httpx.Response(200, headers={"Set-Cookie": "other=value"}) ) - + with pytest.raises(KworkAuthError, match="no userId"): await KworkClient.login("test", "test") - + @respx.mock async def test_login_no_token(self): """Test login without web_auth_token in response.""" import httpx - + respx.post("https://kwork.ru/signIn").mock( return_value=httpx.Response(200, headers={"Set-Cookie": "userId=123"}) ) - + respx.post("https://kwork.ru/getWebAuthToken").mock( return_value=Response(200, json={"other": "data"}) ) - + with pytest.raises(KworkAuthError, match="No web_auth_token"): await KworkClient.login("test", "test") - + def test_init_with_token(self): """Test client initialization with token.""" client = KworkClient(token="test_token") @@ -89,12 +91,12 @@ class TestAuthentication: class TestCatalogAPI: """Test catalog endpoints.""" - + @respx.mock async def test_get_catalog(self): """Test getting catalog list.""" client = KworkClient(token="test") - + mock_data = { "kworks": [ {"id": 1, "title": "Test Kwork", "price": 1000.0}, @@ -106,23 +108,23 @@ class TestCatalogAPI: "total_items": 100, }, } - + respx.post(f"{client.base_url}/catalogMainv2").mock( return_value=Response(200, json=mock_data) ) - + result = await client.catalog.get_list(page=1) - + assert isinstance(result, CatalogResponse) assert len(result.kworks) == 2 assert result.kworks[0].id == 1 assert result.pagination.total_pages == 5 - + @respx.mock async def test_get_kwork_details(self): """Test getting kwork details.""" client = KworkClient(token="test") - + mock_data = { "id": 123, "title": "Detailed Kwork", @@ -130,38 +132,38 @@ class TestCatalogAPI: "full_description": "Full description here", "delivery_time": 3, } - + respx.post(f"{client.base_url}/getKworkDetails").mock( return_value=Response(200, json=mock_data) ) - + result = await client.catalog.get_details(123) - + assert result.id == 123 assert result.full_description == "Full description here" assert result.delivery_time == 3 - + @respx.mock async def test_catalog_error(self): """Test catalog API error handling.""" client = KworkClient(token="test") - + respx.post(f"{client.base_url}/catalogMainv2").mock( return_value=Response(400, json={"message": "Invalid category"}) ) - + with pytest.raises(KworkApiError): await client.catalog.get_list(category_id=99999) class TestProjectsAPI: """Test projects endpoints.""" - + @respx.mock async def test_get_projects(self): """Test getting projects list.""" client = KworkClient(token="test") - + mock_data = { "projects": [ { @@ -174,106 +176,102 @@ class TestProjectsAPI: ], "pagination": {"current_page": 1}, } - - respx.post(f"{client.base_url}/projects").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{client.base_url}/projects").mock(return_value=Response(200, json=mock_data)) + result = await client.projects.get_list() - + assert len(result.projects) == 1 assert result.projects[0].budget == 10000.0 class TestErrorHandling: """Test error handling.""" - + @respx.mock async def test_404_error(self): """Test 404 error handling.""" client = KworkClient(token="test") - - respx.post(f"{client.base_url}/getKworkDetails").mock( - return_value=Response(404) - ) - + + respx.post(f"{client.base_url}/getKworkDetails").mock(return_value=Response(404)) + with pytest.raises(KworkApiError) as exc_info: await client.catalog.get_details(999) - + assert exc_info.value.status_code == 404 - + @respx.mock async def test_401_error(self): """Test 401 error handling.""" client = KworkClient(token="invalid") - - respx.post(f"{client.base_url}/catalogMainv2").mock( - return_value=Response(401) - ) - + + respx.post(f"{client.base_url}/catalogMainv2").mock(return_value=Response(401)) + with pytest.raises(KworkAuthError): await client.catalog.get_list() - + @respx.mock async def test_network_error(self): """Test network error handling.""" + import httpx + client = KworkClient(token="test") - + respx.post(f"{client.base_url}/catalogMainv2").mock( - side_effect=Exception("Connection refused") + side_effect=httpx.RequestError("Connection refused", request=None) ) - - with pytest.raises(Exception): + + with pytest.raises(KworkNetworkError): await client.catalog.get_list() class TestContextManager: """Test async context manager.""" - + async def test_context_manager(self): """Test using client as context manager.""" async with KworkClient(token="test") as client: assert client._client is None # Not created yet - + # Client should be created on first request # (but we don't make actual requests in this test) - + # Client should be closed after context assert client._client is None or client._client.is_closed class TestValidationAPI: """Test text validation endpoint.""" - + @respx.mock async def test_validate_text_success(self): """Test successful text validation.""" client = KworkClient(token="test") - + mock_data = { "success": True, "is_valid": True, "issues": [], "score": 95, } - + respx.post(f"{client.base_url}/api/validation/checktext").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.validate_text("Хороший текст для кворка") - + assert isinstance(result, ValidationResponse) assert result.success is True assert result.is_valid is True assert len(result.issues) == 0 assert result.score == 95 - + @respx.mock async def test_validate_text_with_issues(self): """Test text validation with found issues.""" client = KworkClient(token="test") - + mock_data = { "success": True, "is_valid": False, @@ -293,40 +291,40 @@ class TestValidationAPI: ], "score": 45, } - + respx.post(f"{client.base_url}/api/validation/checktext").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.validate_text( "Звоните +7-999-000-00-00", context="kwork_description", ) - + assert result.is_valid is False assert len(result.issues) == 2 assert result.issues[0].code == "CONTACT_INFO" assert result.issues[0].type == "error" assert result.issues[1].type == "warning" assert result.score == 45 - + @respx.mock async def test_validate_text_empty(self): """Test validation of empty text.""" client = KworkClient(token="test") - + mock_data = { "success": False, "is_valid": False, "message": "Текст не может быть пустым", "issues": [], } - + respx.post(f"{client.base_url}/api/validation/checktext").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.validate_text("") - + assert result.success is False assert result.message is not None diff --git a/tests/unit/test_client_extended.py b/tests/unit/test_client_extended.py index 1d3d489..d9e9bf8 100644 --- a/tests/unit/test_client_extended.py +++ b/tests/unit/test_client_extended.py @@ -8,17 +8,17 @@ import pytest import respx from httpx import Response -from kwork_api import KworkClient, KworkAuthError, KworkApiError +from kwork_api import KworkApiError, KworkClient from kwork_api.models import ( - NotificationsResponse, - Kwork, - Dialog, + Badge, City, Country, - TimeZone, + Dialog, Feature, - Badge, + Kwork, + NotificationsResponse, Project, + TimeZone, ) BASE_URL = "https://api.kwork.ru" @@ -26,22 +26,22 @@ BASE_URL = "https://api.kwork.ru" class TestClientProperties: """Test client properties and initialization.""" - + def test_token_property(self): """Test token property getter.""" client = KworkClient(token="test_token_123") assert client.token == "test_token_123" - + def test_token_property_none(self): """Test token property when no token.""" client = KworkClient() assert client.token is None - + def test_cookies_property_empty(self): """Test cookies property with no cookies.""" client = KworkClient(token="test") assert client.cookies == {} - + def test_cookies_property_with_cookies(self): """Test cookies property returns copy.""" client = KworkClient(token="test", cookies={"userId": "123", "session": "abc"}) @@ -49,14 +49,14 @@ class TestClientProperties: assert cookies == {"userId": "123", "session": "abc"} cookies["modified"] = "value" assert "modified" not in client.cookies - + def test_credentials_property(self): """Test credentials property returns token and cookies.""" client = KworkClient(token="test_token", cookies={"userId": "123"}) creds = client.credentials assert creds["token"] == "test_token" assert creds["cookies"] == {"userId": "123"} - + def test_credentials_property_no_cookies(self): """Test credentials with no cookies.""" client = KworkClient(token="test_token") @@ -67,136 +67,131 @@ class TestClientProperties: class TestCatalogAPIExtended: """Extended tests for CatalogAPI.""" - + @respx.mock async def test_get_details_extra(self): """Test get_details_extra endpoint.""" client = KworkClient(token="test") - + mock_data = { "id": 456, "title": "Extra Details Kwork", "extra_field": "extra_value", } - + respx.post(f"{BASE_URL}/getKworkDetailsExtra").mock( return_value=Response(200, json=mock_data) ) - + result = await client.catalog.get_details_extra(456) - + assert result["id"] == 456 assert result["extra_field"] == "extra_value" class TestProjectsAPIExtended: """Extended tests for ProjectsAPI.""" - + @respx.mock async def test_get_payer_orders(self): """Test getting payer orders.""" client = KworkClient(token="test") - + mock_data = { "orders": [ {"id": 101, "title": "Order 1", "amount": 5000.0, "status": "active"}, ] } - - respx.post(f"{BASE_URL}/payerOrders").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/payerOrders").mock(return_value=Response(200, json=mock_data)) + result = await client.projects.get_payer_orders() - + assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], Project) - + @respx.mock async def test_get_worker_orders(self): """Test getting worker orders.""" client = KworkClient(token="test") - + mock_data = { "orders": [ {"id": 202, "title": "Worker Order", "amount": 3000.0, "status": "completed"}, ] } - - respx.post(f"{BASE_URL}/workerOrders").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/workerOrders").mock(return_value=Response(200, json=mock_data)) + result = await client.projects.get_worker_orders() - + assert isinstance(result, list) assert len(result) == 1 class TestUserAPI: """Tests for UserAPI.""" - + @respx.mock async def test_get_info(self): """Test getting user info.""" client = KworkClient(token="test") - + mock_data = { "userId": 12345, "username": "testuser", "email": "test@example.com", "balance": 50000.0, } - - respx.post(f"{BASE_URL}/user").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/user").mock(return_value=Response(200, json=mock_data)) + result = await client.user.get_info() - + assert result["userId"] == 12345 assert result["username"] == "testuser" - + @respx.mock async def test_get_reviews(self): """Test getting user reviews.""" client = KworkClient(token="test") - + mock_data = { "reviews": [ - {"id": 1, "rating": 5, "comment": "Great work!", "author": {"id": 999, "username": "client1"}}, + { + "id": 1, + "rating": 5, + "comment": "Great work!", + "author": {"id": 999, "username": "client1"}, + }, ], "pagination": {"current_page": 1, "total_pages": 5}, } - - respx.post(f"{BASE_URL}/userReviews").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/userReviews").mock(return_value=Response(200, json=mock_data)) + result = await client.user.get_reviews(user_id=12345, page=1) - + assert len(result.reviews) == 1 assert result.reviews[0].rating == 5 - + @respx.mock async def test_get_favorite_kworks(self): """Test getting favorite kworks.""" client = KworkClient(token="test") - + mock_data = { "kworks": [ {"id": 100, "title": "Favorite Kwork 1", "price": 2000.0}, {"id": 101, "title": "Favorite Kwork 2", "price": 3000.0}, ] } - - respx.post(f"{BASE_URL}/favoriteKworks").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/favoriteKworks").mock(return_value=Response(200, json=mock_data)) + result = await client.user.get_favorite_kworks() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Kwork) @@ -204,133 +199,129 @@ class TestUserAPI: class TestReferenceAPI: """Tests for ReferenceAPI.""" - + @respx.mock async def test_get_cities(self): """Test getting cities list.""" client = KworkClient(token="test") - + mock_data = { "cities": [ {"id": 1, "name": "Москва", "country_id": 1}, {"id": 2, "name": "Санкт-Петербург", "country_id": 1}, ] } - - respx.post(f"{BASE_URL}/cities").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/cities").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_cities() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], City) - + @respx.mock async def test_get_countries(self): """Test getting countries list.""" client = KworkClient(token="test") - + mock_data = { "countries": [ {"id": 1, "name": "Россия", "code": "RU"}, {"id": 2, "name": "Беларусь", "code": "BY"}, ] } - - respx.post(f"{BASE_URL}/countries").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/countries").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_countries() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Country) - + @respx.mock async def test_get_timezones(self): """Test getting timezones list.""" client = KworkClient(token="test") - + mock_data = { "timezones": [ {"id": 1, "name": "Europe/Moscow", "offset": "+03:00"}, {"id": 2, "name": "Europe/Kaliningrad", "offset": "+02:00"}, ] } - - respx.post(f"{BASE_URL}/timezones").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/timezones").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_timezones() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], TimeZone) - + @respx.mock async def test_get_features(self): """Test getting features list.""" client = KworkClient(token="test") - + mock_data = { "features": [ {"id": 1, "name": "Feature 1", "category_id": 5, "price": 1000, "type": "extra"}, {"id": 2, "name": "Feature 2", "category_id": 5, "price": 2000, "type": "extra"}, ] } - + respx.post(f"{BASE_URL}/getAvailableFeatures").mock( return_value=Response(200, json=mock_data) ) - + result = await client.reference.get_features() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Feature) - + @respx.mock async def test_get_public_features(self): """Test getting public features list.""" client = KworkClient(token="test") - + mock_data = { "features": [ - {"id": 10, "name": "Public Feature", "is_public": True, "price": 500, "type": "extra"}, + { + "id": 10, + "name": "Public Feature", + "is_public": True, + "price": 500, + "type": "extra", + }, ] } - - respx.post(f"{BASE_URL}/getPublicFeatures").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/getPublicFeatures").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_public_features() - + assert isinstance(result, list) assert len(result) == 1 - + @respx.mock async def test_get_badges_info(self): """Test getting badges info.""" client = KworkClient(token="test") - + mock_data = { "badges": [ {"id": 1, "name": "Pro Seller", "icon_url": "https://example.com/badge1.png"}, {"id": 2, "name": "Verified", "icon_url": "https://example.com/badge2.png"}, ] } - - respx.post(f"{BASE_URL}/getBadgesInfo").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/getBadgesInfo").mock(return_value=Response(200, json=mock_data)) + result = await client.reference.get_badges_info() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Badge) @@ -338,406 +329,400 @@ class TestReferenceAPI: class TestNotificationsAPI: """Tests for NotificationsAPI.""" - + @respx.mock async def test_get_list(self): """Test getting notifications list.""" client = KworkClient(token="test") - + mock_data = { "notifications": [ - {"id": 1, "type": "order", "title": "New Order", "message": "New order received", "is_read": False}, - {"id": 2, "type": "message", "title": "New Message", "message": "You have a new message", "is_read": True}, + { + "id": 1, + "type": "order", + "title": "New Order", + "message": "New order received", + "is_read": False, + }, + { + "id": 2, + "type": "message", + "title": "New Message", + "message": "You have a new message", + "is_read": True, + }, ], "unread_count": 5, } - - respx.post(f"{BASE_URL}/notifications").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/notifications").mock(return_value=Response(200, json=mock_data)) + result = await client.notifications.get_list() - + assert isinstance(result, NotificationsResponse) assert result.unread_count == 5 - + @respx.mock async def test_fetch(self): """Test fetching notifications.""" client = KworkClient(token="test") - + mock_data = { "notifications": [ - {"id": 3, "type": "system", "title": "System Update", "message": "System update available", "is_read": False}, + { + "id": 3, + "type": "system", + "title": "System Update", + "message": "System update available", + "is_read": False, + }, ], "unread_count": 1, } - + respx.post(f"{BASE_URL}/notificationsFetch").mock( return_value=Response(200, json=mock_data) ) - + result = await client.notifications.fetch() - + assert isinstance(result, NotificationsResponse) assert len(result.notifications) == 1 - + @respx.mock async def test_get_dialogs(self): """Test getting dialogs.""" client = KworkClient(token="test") - + mock_data = { "dialogs": [ {"id": 1, "user_id": 100, "last_message": "Hello", "unread": 2}, {"id": 2, "user_id": 200, "last_message": "Hi", "unread": 0}, ] } - - respx.post(f"{BASE_URL}/dialogs").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/dialogs").mock(return_value=Response(200, json=mock_data)) + result = await client.notifications.get_dialogs() - + assert isinstance(result, list) assert len(result) == 2 assert isinstance(result[0], Dialog) - + @respx.mock async def test_get_blocked_dialogs(self): """Test getting blocked dialogs.""" client = KworkClient(token="test") - + mock_data = { "dialogs": [ {"id": 99, "user_id": 999, "last_message": "Spam", "blocked": True}, ] } - - respx.post(f"{BASE_URL}/blockedDialogList").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/blockedDialogList").mock(return_value=Response(200, json=mock_data)) + result = await client.notifications.get_blocked_dialogs() - + assert isinstance(result, list) assert len(result) == 1 class TestOtherAPI: """Tests for OtherAPI.""" - + @respx.mock async def test_get_wants(self): """Test getting wants.""" client = KworkClient(token="test") - + mock_data = { "wants": [{"id": 1, "title": "I need a logo"}], "count": 1, } - - respx.post(f"{BASE_URL}/myWants").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/myWants").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_wants() - + assert "wants" in result assert result["count"] == 1 - + @respx.mock async def test_get_wants_status(self): """Test getting wants status.""" client = KworkClient(token="test") - + mock_data = { "active_wants": 5, "completed_wants": 10, } - - respx.post(f"{BASE_URL}/wantsStatusList").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/wantsStatusList").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_wants_status() - + assert result["active_wants"] == 5 - + @respx.mock async def test_get_kworks_status(self): """Test getting kworks status.""" client = KworkClient(token="test") - + mock_data = { "active_kworks": 3, "total_sales": 50, } - - respx.post(f"{BASE_URL}/kworksStatusList").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/kworksStatusList").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_kworks_status() - + assert result["active_kworks"] == 3 - + @respx.mock async def test_get_offers(self): """Test getting offers.""" client = KworkClient(token="test") - + mock_data = { "offers": [{"id": 1, "title": "Special offer"}], } - - respx.post(f"{BASE_URL}/offers").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/offers").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_offers() - + assert "offers" in result - + @respx.mock async def test_get_exchange_info(self): """Test getting exchange info.""" client = KworkClient(token="test") - + mock_data = { "usd_rate": 90.5, "eur_rate": 98.2, } - - respx.post(f"{BASE_URL}/exchangeInfo").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/exchangeInfo").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_exchange_info() - + assert result["usd_rate"] == 90.5 - + @respx.mock async def test_get_channel(self): """Test getting channel info.""" client = KworkClient(token="test") - + mock_data = { "channel_id": "main", "name": "Main Channel", } - - respx.post(f"{BASE_URL}/getChannel").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/getChannel").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_channel() - + assert result["channel_id"] == "main" - + @respx.mock async def test_get_in_app_notification(self): """Test getting in-app notifications.""" client = KworkClient(token="test") - + mock_data = { "notifications": [{"id": 1, "message": "App update"}], } - + respx.post(f"{BASE_URL}/getInAppNotification").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_in_app_notification() - + assert "notifications" in result - + @respx.mock async def test_get_security_user_data(self): """Test getting security user data.""" client = KworkClient(token="test") - + mock_data = { "two_factor_enabled": True, "last_login": "2024-01-01T00:00:00Z", } - + respx.post(f"{BASE_URL}/getSecurityUserData").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_security_user_data() - + assert result["two_factor_enabled"] is True - + @respx.mock async def test_is_dialog_allow_true(self): """Test is_dialog_allow returns True.""" client = KworkClient(token="test") - + respx.post(f"{BASE_URL}/isDialogAllow").mock( return_value=Response(200, json={"allowed": True}) ) - + result = await client.other.is_dialog_allow(12345) - + assert result is True - + @respx.mock async def test_is_dialog_allow_false(self): """Test is_dialog_allow returns False.""" client = KworkClient(token="test") - + respx.post(f"{BASE_URL}/isDialogAllow").mock( return_value=Response(200, json={"allowed": False}) ) - + result = await client.other.is_dialog_allow(67890) - + assert result is False - + @respx.mock async def test_get_viewed_kworks(self): """Test getting viewed kworks.""" client = KworkClient(token="test") - + mock_data = { "kworks": [ {"id": 500, "title": "Viewed Kwork", "price": 1500.0}, ] } - + respx.post(f"{BASE_URL}/viewedCatalogKworks").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_viewed_kworks() - + assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], Kwork) - + @respx.mock async def test_get_favorite_categories(self): """Test getting favorite categories.""" client = KworkClient(token="test") - + mock_data = { "categories": [1, 5, 10, 15], } - + respx.post(f"{BASE_URL}/favoriteCategories").mock( return_value=Response(200, json=mock_data) ) - + result = await client.other.get_favorite_categories() - + assert isinstance(result, list) assert 1 in result assert 5 in result - + @respx.mock async def test_update_settings(self): """Test updating settings.""" client = KworkClient(token="test") - + mock_data = { "success": True, "updated": {"notifications_enabled": False}, } - - respx.post(f"{BASE_URL}/updateSettings").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/updateSettings").mock(return_value=Response(200, json=mock_data)) + settings = {"notifications_enabled": False, "theme": "dark"} result = await client.other.update_settings(settings) - + assert result["success"] is True - + @respx.mock async def test_go_offline(self): """Test going offline.""" client = KworkClient(token="test") - + mock_data = { "success": True, "status": "offline", } - - respx.post(f"{BASE_URL}/offline").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/offline").mock(return_value=Response(200, json=mock_data)) + result = await client.other.go_offline() - + assert result["success"] is True assert result["status"] == "offline" - + @respx.mock async def test_get_actor(self): """Test getting actor info.""" client = KworkClient(token="test") - + mock_data = { "actor_id": 123, "name": "Test Actor", } - - respx.post(f"{BASE_URL}/actor").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/actor").mock(return_value=Response(200, json=mock_data)) + result = await client.other.get_actor() - + assert result["actor_id"] == 123 class TestClientInternals: """Tests for internal client methods.""" - + def test_handle_response_success(self): """Test _handle_response with successful response.""" import httpx + client = KworkClient(token="test") - + response = httpx.Response(200, json={"success": True, "data": "test"}) result = client._handle_response(response) - + assert result["success"] is True assert result["data"] == "test" - + def test_handle_response_error(self): """Test _handle_response with error response.""" import httpx + client = KworkClient(token="test") - + response = httpx.Response(400, json={"message": "Bad request"}) - + with pytest.raises(KworkApiError) as exc_info: client._handle_response(response) - + assert exc_info.value.status_code == 400 - + @respx.mock async def test_request_method(self): """Test _request method directly.""" client = KworkClient(token="test") - + mock_data = {"result": "success"} - - respx.post(f"{BASE_URL}/test-endpoint").mock( - return_value=Response(200, json=mock_data) - ) - + + respx.post(f"{BASE_URL}/test-endpoint").mock(return_value=Response(200, json=mock_data)) + result = await client._request("POST", "/test-endpoint", json={"param": "value"}) - + assert result["result"] == "success" - + async def test_context_manager_creates_client(self): """Test that context manager creates httpx client.""" async with KworkClient(token="test") as client: assert client.token == "test" - + assert client._client is None or client._client.is_closed