From 2dcf3ef087e423f687b61caf1d62c47caaee63c5 Mon Sep 17 00:00:00 2001 From: gy9vin Date: Sun, 12 Oct 2025 10:13:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BE=D0=B4=D0=B0=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 + tests/conftest.py | 151 ++++++ tests/external/__init__.py | 1 + tests/external/test_cryptobot_service.py | 85 +++ tests/external/test_webhook_server.py | 137 +++++ tests/services/__init__.py | 1 + .../services/test_mulenpay_service_adapter.py | 107 ++++ tests/services/test_pal24_service_adapter.py | 120 +++++ .../test_payment_service_cryptobot.py | 152 ++++++ .../services/test_payment_service_mulenpay.py | 140 +++++ tests/services/test_payment_service_pal24.py | 166 ++++++ tests/services/test_payment_service_stars.py | 142 +++++ .../services/test_payment_service_tribute.py | 79 +++ .../services/test_payment_service_webhooks.py | 490 ++++++++++++++++++ .../services/test_payment_service_yookassa.py | 245 +++++++++ .../services/test_yookassa_service_adapter.py | 179 +++++++ tests/utils/__init__.py | 1 + tests/utils/test_formatters_basic.py | 104 ++++ tests/utils/test_security.py | 54 ++ tests/utils/test_validators_basic.py | 141 +++++ 20 files changed, 2497 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/external/__init__.py create mode 100644 tests/external/test_cryptobot_service.py create mode 100644 tests/external/test_webhook_server.py create mode 100644 tests/services/__init__.py create mode 100644 tests/services/test_mulenpay_service_adapter.py create mode 100644 tests/services/test_pal24_service_adapter.py create mode 100644 tests/services/test_payment_service_cryptobot.py create mode 100644 tests/services/test_payment_service_mulenpay.py create mode 100644 tests/services/test_payment_service_pal24.py create mode 100644 tests/services/test_payment_service_stars.py create mode 100644 tests/services/test_payment_service_tribute.py create mode 100644 tests/services/test_payment_service_webhooks.py create mode 100644 tests/services/test_payment_service_yookassa.py create mode 100644 tests/services/test_yookassa_service_adapter.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/test_formatters_basic.py create mode 100644 tests/utils/test_security.py create mode 100644 tests/utils/test_validators_basic.py diff --git a/.gitignore b/.gitignore index 20d34a51..178d6060 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ !app/** !locales/ !locales/** +!tests/ +!tests/** # Дополнительно разрешаем README и лицензию (опционально) !README.md diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..54fd8a56 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,151 @@ +"""Глобальные фикстуры и настройки окружения для тестов.""" + +import os +import sys +import types +from datetime import datetime, timezone + +import pytest + +# Подменяем параметры подключения к БД, чтобы SQLAlchemy не требовал aiosqlite. +os.environ.setdefault("DATABASE_MODE", "postgresql") +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/test_db") +os.environ.setdefault("BOT_TOKEN", "test-token") + +# Создаём заглушки для драйверов, которых может не быть в окружении тестов. +sys.modules.setdefault("asyncpg", types.ModuleType("asyncpg")) +sys.modules.setdefault("aiosqlite", types.ModuleType("aiosqlite")) + +# Эмуляция redis.asyncio, чтобы модуль кеша мог импортироваться. +if "redis.asyncio" not in sys.modules: + redis_module = types.ModuleType("redis") + redis_async_module = types.ModuleType("redis.asyncio") + + class _FakeRedisClient: + async def ping(self): + """Имитируем успешный ответ ping.""" + return True + + async def close(self): + """Закрытие соединения ничего не делает.""" + + async def get(self, key): # noqa: ANN001 + return None + + async def set(self, key, value, ex=None): # noqa: ANN001 + return True + + async def delete(self, *keys): # noqa: ANN001 + return 0 + + async def keys(self, pattern="*"): # noqa: ANN001 + return [] + + async def exists(self, key): # noqa: ANN001 + return False + + async def expire(self, key, seconds): # noqa: ANN001 + return True + + async def incr(self, key): # noqa: ANN001 + return 1 + + def _from_url(url): # noqa: ANN001 + return _FakeRedisClient() + + redis_async_module.from_url = _from_url + redis_async_module.Redis = _FakeRedisClient + sys.modules["redis"] = redis_module + sys.modules["redis.asyncio"] = redis_async_module + +# Минимальная реализация SDK YooKassa, чтобы импорт сервисов не падал. +if "yookassa" not in sys.modules: + fake_yookassa = types.ModuleType("yookassa") + + class _FakeConfiguration: + @staticmethod + def configure(*args, **kwargs): + """Конфигурация заглушки ничего не делает.""" + + class _FakePayment: + @staticmethod + def create(*args, **kwargs): + """Возвращает объект с минимально необходимыми атрибутами.""" + + class _Response: + id = "yk_fake" + status = "pending" + paid = False + refundable = False + metadata = {} + amount = types.SimpleNamespace(value="0.00", currency="RUB") + confirmation = types.SimpleNamespace(confirmation_url="https://example.com") + created_at = datetime.utcnow() + description = "" + test = False + + return _Response() + + fake_yookassa.Configuration = _FakeConfiguration + fake_yookassa.Payment = _FakePayment + sys.modules["yookassa"] = fake_yookassa + + # Подготавливаем вложенные пакеты, используемые сервисом. + domain_module = types.ModuleType("yookassa.domain") + request_module = types.ModuleType("yookassa.domain.request") + payment_builder_module = types.ModuleType("yookassa.domain.request.payment_request_builder") + common_module = types.ModuleType("yookassa.domain.common") + confirmation_module = types.ModuleType("yookassa.domain.common.confirmation_type") + + class _FakePaymentRequestBuilder: + def __init__(self): + self.data: dict = {} + + def set_amount(self, value): # noqa: ANN001 - упрощённая заглушка + self.data["amount"] = value + return self + + def set_capture(self, value): # noqa: ANN001 + self.data["capture"] = value + return self + + def set_confirmation(self, value): # noqa: ANN001 + self.data["confirmation"] = value + return self + + def set_description(self, value): # noqa: ANN001 + self.data["description"] = value + return self + + def set_metadata(self, value): # noqa: ANN001 + self.data["metadata"] = value + return self + + def set_receipt(self, value): # noqa: ANN001 + self.data["receipt"] = value + return self + + def set_payment_method_data(self, value): # noqa: ANN001 + self.data["payment_method_data"] = value + return self + + def build(self): + return self.data + + class _FakeConfirmationType: + REDIRECT = "redirect" + + payment_builder_module.PaymentRequestBuilder = _FakePaymentRequestBuilder + confirmation_module.ConfirmationType = _FakeConfirmationType + + sys.modules["yookassa.domain"] = domain_module + sys.modules["yookassa.domain.request"] = request_module + sys.modules["yookassa.domain.request.payment_request_builder"] = payment_builder_module + sys.modules["yookassa.domain.common"] = common_module + sys.modules["yookassa.domain.common.confirmation_type"] = confirmation_module + + +@pytest.fixture +def fixed_datetime() -> datetime: + """Возвращает фиксированную отметку времени для воспроизводимых проверок.""" + return datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) diff --git a/tests/external/__init__.py b/tests/external/__init__.py new file mode 100644 index 00000000..aa15cee5 --- /dev/null +++ b/tests/external/__init__.py @@ -0,0 +1 @@ +# Пакет для тестов внешних клиентов и вебхуков. diff --git a/tests/external/test_cryptobot_service.py b/tests/external/test_cryptobot_service.py new file mode 100644 index 00000000..a419fa3a --- /dev/null +++ b/tests/external/test_cryptobot_service.py @@ -0,0 +1,85 @@ +"""Тесты для внешнего клиента CryptoBotService.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional +import sys +import hashlib +import hmac + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings # noqa: E402 +from app.external.cryptobot import CryptoBotService # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def _enable_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "token", raising=False) + monkeypatch.setattr(type(settings), "get_cryptobot_base_url", lambda self: "https://cryptobot.test", raising=False) + monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "secret", raising=False) + + +@pytest.mark.anyio("asyncio") +async def test_create_invoice_uses_make_request(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_token(monkeypatch) + service = CryptoBotService() + + captured: Dict[str, Any] = {} + + async def fake_make_request(method: str, endpoint: str, data: Optional[Dict[str, Any]] = None): + captured["method"] = method + captured["endpoint"] = endpoint + captured["data"] = data + return {"invoice_id": 1} + + monkeypatch.setattr(service, "_make_request", fake_make_request, raising=False) + + result = await service.create_invoice( + amount="10.00", + asset="USDT", + description="Пополнение", + payload="payload", + expires_in=600, + ) + + assert result == {"invoice_id": 1} + assert captured["method"] == "POST" + assert captured["endpoint"] == "createInvoice" + assert captured["data"]["amount"] == "10.00" + assert captured["data"]["payload"] == "payload" + + +@pytest.mark.anyio("asyncio") +async def test_make_request_returns_none_without_token(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "CRYPTOBOT_API_TOKEN", "", raising=False) + service = CryptoBotService() + result = await service._make_request("GET", "getMe") + assert result is None + + +def test_verify_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "supersecret", raising=False) + service = CryptoBotService() + + body = '{"invoice_id":1}' + secret_hash = hashlib.sha256(b"supersecret").digest() + signature = hmac.new(secret_hash, body.encode(), hashlib.sha256).hexdigest() + + assert service.verify_webhook_signature(body, signature) is True + assert service.verify_webhook_signature(body, "invalid") is False + + +def test_verify_webhook_signature_without_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "", raising=False) + service = CryptoBotService() + assert service.verify_webhook_signature("{}", "anything") is True diff --git a/tests/external/test_webhook_server.py b/tests/external/test_webhook_server.py new file mode 100644 index 00000000..aaae1e2f --- /dev/null +++ b/tests/external/test_webhook_server.py @@ -0,0 +1,137 @@ +"""Тестирование хендлеров WebhookServer без запуска реального сервера.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Tuple +import sys +from unittest.mock import AsyncMock + +import pytest +from aiohttp.test_utils import make_mocked_request +from aiohttp import web + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings # noqa: E402 +from app.external.webhook_server import WebhookServer # noqa: E402 + + +class DummyBot: + async def send_message(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - уведомления не проверяем + return None + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.fixture +def webhook_server(monkeypatch: pytest.MonkeyPatch) -> Tuple[WebhookServer, AsyncMock, AsyncMock]: + monkeypatch.setattr(settings, "TRIBUTE_WEBHOOK_PATH", "/tribute", raising=False) + monkeypatch.setattr(settings, "MULENPAY_WEBHOOK_PATH", "/mulen", raising=False) + monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_PATH", "/cryptobot", raising=False) + monkeypatch.setattr(settings, "MULENPAY_SECRET_KEY", "mulen-secret", raising=False) + monkeypatch.setattr(settings, "CRYPTOBOT_WEBHOOK_SECRET", "", raising=False) + monkeypatch.setattr(type(settings), "is_mulenpay_enabled", lambda self: True, raising=False) + monkeypatch.setattr(type(settings), "is_cryptobot_enabled", lambda self: True, raising=False) + + server = WebhookServer(DummyBot()) + + tribute_mock = AsyncMock() + tribute_mock.process_webhook = AsyncMock(return_value={"status": "ok"}) + server.tribute_service = tribute_mock + + payment_mock = AsyncMock() + payment_mock.process_mulenpay_callback = AsyncMock(return_value=True) + payment_mock.process_cryptobot_webhook = AsyncMock(return_value=True) + monkeypatch.setattr("app.external.webhook_server.PaymentService", lambda *args, **kwargs: payment_mock) + monkeypatch.setattr("app.services.payment_service.PaymentService", lambda *args, **kwargs: payment_mock) + + server._verify_mulenpay_signature = lambda request, raw: True # type: ignore[attr-defined] + + class DummyDB: + async def commit(self) -> None: # pragma: no cover - не проверяем транзакции + return None + + async def fake_get_db(): + yield DummyDB() + + monkeypatch.setattr("app.external.webhook_server.get_db", fake_get_db) + + class DummySessionManager: + def __init__(self) -> None: + self.session = DummyDB() + + async def __aenter__(self) -> DummyDB: + return self.session + + async def __aexit__(self, exc_type, exc, tb) -> None: + return None + + monkeypatch.setattr("app.database.database.AsyncSessionLocal", lambda: DummySessionManager()) + + return server, tribute_mock, payment_mock + + +def _mock_request(method: str, path: str, body: dict[str, Any], headers: dict[str, str] | None = None) -> AsyncMock: + request = AsyncMock(spec=web.Request) + request.method = method + request.path = path + request.headers = headers or {} + request.read.return_value = json.dumps(body).encode("utf-8") + return request + + +@pytest.mark.anyio("asyncio") +async def test_health_endpoint(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None: + server, _, _ = webhook_server + request = make_mocked_request("GET", "/health") + response = await server._health_check(request) + assert response.status == 200 + data = json.loads(response.text) + assert data["status"] == "ok" + assert data["service"] == "payment-webhooks" + + +@pytest.mark.anyio("asyncio") +async def test_tribute_webhook_success(monkeypatch: pytest.MonkeyPatch, webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None: + server, tribute_mock, _ = webhook_server + monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "key", raising=False) + + class FakeTributeAPI: + def verify_webhook_signature(self, payload: str, signature: str) -> bool: + return True + + monkeypatch.setattr("app.external.tribute.TributeService", FakeTributeAPI) + + request = _mock_request("POST", "/tribute", {"event_type": "payment", "status": "paid"}, headers={"trbt-signature": "sig"}) + response = await server._tribute_webhook_handler(request) + assert response.status == 200 + assert tribute_mock.process_webhook.await_count == 1 + + +@pytest.mark.anyio("asyncio") +async def test_mulenpay_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None: + server, _, payment_mock = webhook_server + request = _mock_request("POST", "/mulen", {"uuid": "uuid", "payment_status": "success"}) + response = await server._mulenpay_webhook_handler(request) + assert response.status == 200 + payment_mock.process_mulenpay_callback.assert_awaited_once() + + +@pytest.mark.anyio("asyncio") +async def test_cryptobot_webhook_success(webhook_server: Tuple[WebhookServer, AsyncMock, AsyncMock]) -> None: + server, _, payment_mock = webhook_server + request = _mock_request( + "POST", + "/cryptobot", + {"update_type": "invoice_paid", "payload": {"invoice_id": 1}}, + ) + response = await server._cryptobot_webhook_handler(request) + assert response.status == 200 + payment_mock.process_cryptobot_webhook.assert_awaited_once() diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 00000000..d60c48b0 --- /dev/null +++ b/tests/services/__init__.py @@ -0,0 +1 @@ +# Пакет для тестов сервисов бота. diff --git a/tests/services/test_mulenpay_service_adapter.py b/tests/services/test_mulenpay_service_adapter.py new file mode 100644 index 00000000..da443375 --- /dev/null +++ b/tests/services/test_mulenpay_service_adapter.py @@ -0,0 +1,107 @@ +"""Юнит-тесты MulenPayService.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional +import sys + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings # noqa: E402 +from app.services.mulenpay_service import MulenPayService # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def _enable_service(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(type(settings), "is_mulenpay_enabled", lambda self: True, raising=False) + monkeypatch.setattr(settings, "MULENPAY_API_KEY", "api", raising=False) + monkeypatch.setattr(settings, "MULENPAY_SHOP_ID", "shop", raising=False) + monkeypatch.setattr(settings, "MULENPAY_SECRET_KEY", "secret", raising=False) + monkeypatch.setattr(settings, "MULENPAY_BASE_URL", "https://mulenpay.test", raising=False) + + +def test_is_configured(monkeypatch: pytest.MonkeyPatch) -> None: + service = MulenPayService() + assert service.is_configured is False + + _enable_service(monkeypatch) + service = MulenPayService() + assert service.is_configured is True + + +def test_format_and_signature(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_service(monkeypatch) + service = MulenPayService() + assert service._format_amount(12345) == "123.45" + signature = service._build_signature("rub", "100.00") + assert isinstance(signature, str) and len(signature) == 40 + + +@pytest.mark.anyio("asyncio") +async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_service(monkeypatch) + + captured_payload: Dict[str, Any] = {} + + async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]: + captured_payload.update({"method": method, "endpoint": endpoint, **kwargs}) + return {"success": True, "id": 101, "paymentUrl": "https://mulenpay/pay"} + + service = MulenPayService() + monkeypatch.setattr(service, "_request", fake_request, raising=False) + + result = await service.create_payment( + amount_kopeks=25000, + description="Пополнение", + uuid="uuid-1", + items=[{"description": "item", "quantity": 1, "price": 250.0}], + language="ru", + website_url="https://example.com", + ) + + assert result is not None + assert result["id"] == 101 + assert captured_payload["method"] == "POST" + assert captured_payload["endpoint"] == "/v2/payments" + assert captured_payload["json_data"]["language"] == "ru" + + +@pytest.mark.anyio("asyncio") +async def test_create_payment_failure(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_service(monkeypatch) + service = MulenPayService() + + async def fake_request(*args: Any, **kwargs: Any) -> Optional[Dict[str, Any]]: + return None + + monkeypatch.setattr(service, "_request", fake_request, raising=False) + + result = await service.create_payment( + amount_kopeks=1000, + description="desc", + uuid="uuid", + items=[], + ) + assert result is None + + +@pytest.mark.anyio("asyncio") +async def test_get_payment(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_service(monkeypatch) + service = MulenPayService() + + async def fake_request(method: str, endpoint: str, **kwargs: Any) -> Dict[str, Any]: + return {"id": 123, "status": "paid"} + + monkeypatch.setattr(service, "_request", fake_request, raising=False) + result = await service.get_payment(123) + assert result == {"id": 123, "status": "paid"} diff --git a/tests/services/test_pal24_service_adapter.py b/tests/services/test_pal24_service_adapter.py new file mode 100644 index 00000000..714a0d1c --- /dev/null +++ b/tests/services/test_pal24_service_adapter.py @@ -0,0 +1,120 @@ +"""Тесты Pal24Service и вспомогательных функций.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from decimal import Decimal +from pathlib import Path +from typing import Any, Dict, Optional +import sys + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings # noqa: E402 +from app.external.pal24_client import Pal24Client, Pal24APIError # noqa: E402 +from app.services.pal24_service import Pal24Service # noqa: E402 + + +class StubPal24Client: + def __init__(self, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None: + self.is_configured = configured + self.response = response or { + "success": True, + "bill_id": "BILL42", + "status": "NEW", + "transfer_url": "https://pal24/sbp", + "link_url": "https://pal24/card", + "currency": "RUB", + } + self.calls: list[Dict[str, Any]] = [] + + async def create_bill(self, **kwargs: Any) -> Dict[str, Any]: + self.calls.append(kwargs) + return self.response + + async def get_bill_status(self, bill_id: str) -> Dict[str, Any]: + return {"id": bill_id, "status": "NEW"} + + async def get_payment_status(self, payment_id: str) -> Dict[str, Any]: + return {"id": payment_id, "status": "SUCCESS"} + + +def _enable_pal24(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(type(settings), "is_pal24_enabled", lambda self: True, raising=False) + monkeypatch.setattr(settings, "PAL24_SHOP_ID", "shop42", raising=False) + monkeypatch.setattr(settings, "PAL24_SIGNATURE_TOKEN", "sigsecret", raising=False) + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.mark.anyio("asyncio") +async def test_create_bill_success(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_pal24(monkeypatch) + client = StubPal24Client() + service = Pal24Service(client) + + monkeypatch.setattr(Pal24Client, "normalize_amount", staticmethod(lambda amount: Decimal("500.00")), raising=False) + + result = await service.create_bill( + amount_kopeks=50000, + user_id=7, + order_id="order-7", + description="Пополнение", + ttl_seconds=600, + custom_payload={"extra": "value"}, + payer_email="user@example.com", + payment_method="CARD", + ) + + assert result["bill_id"] == "BILL42" + assert client.calls and client.calls[0]["amount"] == Decimal("500.00") + assert client.calls[0]["shop_id"] == "shop42" + assert client.calls[0]["description"] == "Пополнение" + + +@pytest.mark.anyio("asyncio") +async def test_create_bill_requires_configuration(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_pal24(monkeypatch) + client = StubPal24Client(configured=False) + service = Pal24Service(client) + + with pytest.raises(Pal24APIError): + await service.create_bill( + amount_kopeks=1000, + user_id=1, + order_id="order", + description="desc", + ) + + +def test_parse_postback_success(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_pal24(monkeypatch) + sig = Pal24Client.calculate_signature("100.00", "INV1", api_token="sigsecret") + payload = { + "InvId": "INV1", + "OutSum": "100.00", + "Status": "SUCCESS", + "SignatureValue": sig, + } + result = Pal24Service.parse_postback(payload) + assert result["InvId"] == "INV1" + + +def test_parse_postback_missing_fields(monkeypatch: pytest.MonkeyPatch) -> None: + _enable_pal24(monkeypatch) + with pytest.raises(Pal24APIError): + Pal24Service.parse_postback({"InvId": "1"}) + + +def test_convert_to_kopeks_and_expiration() -> None: + assert Pal24Service.convert_to_kopeks("10.50") == 1050 + expiration = Pal24Service.get_expiration(60) + assert isinstance(expiration, datetime) + assert expiration - datetime.utcnow() <= timedelta(seconds=61) diff --git a/tests/services/test_payment_service_cryptobot.py b/tests/services/test_payment_service_cryptobot.py new file mode 100644 index 00000000..1edd75df --- /dev/null +++ b/tests/services/test_payment_service_cryptobot.py @@ -0,0 +1,152 @@ +"""Тесты сценариев CryptoBot в PaymentService.""" + +from pathlib import Path +from typing import Any, Dict, Optional +import sys +from datetime import datetime + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.config import settings # noqa: E402 +from app.database.crud import cryptobot as cryptobot_crud # noqa: E402 +from app.services.payment_service import PaymentService # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +class DummySession: + def __init__(self) -> None: + self.added_objects: list[Any] = [] + + async def commit(self) -> None: # pragma: no cover + return None + + def add(self, obj: Any) -> None: # pragma: no cover + self.added_objects.append(obj) + + async def flush(self) -> None: # pragma: no cover + return None + + +class DummyLocalPayment: + def __init__(self, payment_id: int = 888) -> None: + self.id = payment_id + self.created_at = datetime(2024, 3, 1, 9, 0, 0) + + +class StubCryptoBotService: + def __init__(self, response: Optional[Dict[str, Any]]) -> None: + self.response = response + self.calls: list[Dict[str, Any]] = [] + + async def create_invoice(self, **kwargs: Any) -> Optional[Dict[str, Any]]: + self.calls.append(kwargs) + return self.response + + +def _make_service(stub: Optional[StubCryptoBotService]) -> PaymentService: + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = None + service.cryptobot_service = stub + service.mulenpay_service = None + service.pal24_service = None + service.yookassa_service = None + service.stars_service = None + return service + + +@pytest.mark.anyio("asyncio") +async def test_create_cryptobot_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + response = { + "invoice_id": 12345, + "bot_invoice_url": "https://t.me/invoice", + "mini_app_invoice_url": "https://mini.app/invoice", + "web_app_invoice_url": "https://web.app/invoice", + } + stub = StubCryptoBotService(response) + service = _make_service(stub) + db = DummySession() + + captured_args: Dict[str, Any] = {} + + async def fake_create_cryptobot_payment(**kwargs: Any) -> DummyLocalPayment: + captured_args.update(kwargs) + return DummyLocalPayment(payment_id=555) + + monkeypatch.setattr( + cryptobot_crud, + "create_cryptobot_payment", + fake_create_cryptobot_payment, + raising=False, + ) + monkeypatch.setattr( + type(settings), + "get_cryptobot_invoice_expires_seconds", + lambda self: 600, + raising=False, + ) + + result = await service.create_cryptobot_payment( + db=db, + user_id=9, + amount_usd=12.5, + asset="USDT", + description="Пополнение", + payload="custom", + ) + + assert result is not None + assert result["local_payment_id"] == 555 + assert result["invoice_id"] == "12345" + assert result["bot_invoice_url"] == "https://t.me/invoice" + assert stub.calls and stub.calls[0]["expires_in"] == 600 + assert captured_args["invoice_id"] == "12345" + assert captured_args["amount"] == "12.50" + + +@pytest.mark.anyio("asyncio") +async def test_create_cryptobot_payment_returns_none_when_service_missing() -> None: + service = _make_service(None) + db = DummySession() + result = await service.create_cryptobot_payment( + db=db, + user_id=1, + amount_usd=10, + ) + assert result is None + + +@pytest.mark.anyio("asyncio") +async def test_create_cryptobot_payment_handles_empty_response(monkeypatch: pytest.MonkeyPatch) -> None: + stub = StubCryptoBotService(response=None) + service = _make_service(stub) + db = DummySession() + + called = False + + async def fake_create_cryptobot_payment(**kwargs: Any) -> DummyLocalPayment: + nonlocal called + called = True + return DummyLocalPayment() + + monkeypatch.setattr( + cryptobot_crud, + "create_cryptobot_payment", + fake_create_cryptobot_payment, + raising=False, + ) + + result = await service.create_cryptobot_payment( + db=db, + user_id=1, + amount_usd=5, + ) + assert result is None + assert called is False diff --git a/tests/services/test_payment_service_mulenpay.py b/tests/services/test_payment_service_mulenpay.py new file mode 100644 index 00000000..68eec241 --- /dev/null +++ b/tests/services/test_payment_service_mulenpay.py @@ -0,0 +1,140 @@ +"""Тесты для сценариев MulenPay в PaymentService.""" + +from pathlib import Path +from typing import Any, Dict, Optional +import sys +from datetime import datetime + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +import app.services.payment_service as payment_service_module # noqa: E402 +from app.config import settings # noqa: E402 +from app.services.payment_service import PaymentService # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +class DummySession: + async def commit(self) -> None: # pragma: no cover - метод вызывается, но без логики + return None + + +class DummyLocalPayment: + def __init__(self, payment_id: int = 501) -> None: + self.id = payment_id + self.created_at = datetime(2024, 1, 1, 12, 0, 0) + + +class StubMulenPayService: + def __init__(self, response: Optional[Dict[str, Any]]) -> None: + self.response = response + self.calls: list[Dict[str, Any]] = [] + + async def create_payment(self, **kwargs: Any) -> Optional[Dict[str, Any]]: + self.calls.append(kwargs) + return self.response + + +def _make_service(stub: Optional[StubMulenPayService]) -> PaymentService: + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = None + service.mulenpay_service = stub + service.pal24_service = None + service.yookassa_service = None + service.stars_service = None + service.cryptobot_service = None + return service + + +@pytest.mark.anyio("asyncio") +async def test_create_mulenpay_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + response = {"id": 123, "paymentUrl": "https://mulenpay/pay"} + stub = StubMulenPayService(response) + service = _make_service(stub) + db = DummySession() + + captured_args: Dict[str, Any] = {} + + async def fake_create_mulenpay_payment(**kwargs: Any) -> DummyLocalPayment: + captured_args.update(kwargs) + return DummyLocalPayment(payment_id=999) + + monkeypatch.setattr( + payment_service_module, + "create_mulenpay_payment", + fake_create_mulenpay_payment, + raising=False, + ) + monkeypatch.setattr(settings, "MULENPAY_MIN_AMOUNT_KOPEKS", 1000, raising=False) + monkeypatch.setattr(settings, "MULENPAY_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False) + monkeypatch.setattr(settings, "MULENPAY_VAT_CODE", 1, raising=False) + monkeypatch.setattr(settings, "MULENPAY_PAYMENT_SUBJECT", "service", raising=False) + monkeypatch.setattr(settings, "MULENPAY_PAYMENT_MODE", "full_payment", raising=False) + monkeypatch.setattr(settings, "MULENPAY_LANGUAGE", "ru", raising=False) + monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False) + + result = await service.create_mulenpay_payment( + db=db, + user_id=77, + amount_kopeks=25000, + description="Пополнение", + language="en", + ) + + assert result is not None + assert result["local_payment_id"] == 999 + assert result["mulen_payment_id"] == 123 + assert result["payment_url"] == "https://mulenpay/pay" + assert result["status"] == "created" + assert stub.calls and stub.calls[0]["language"] == "en" + assert captured_args["user_id"] == 77 + assert captured_args["amount_kopeks"] == 25000 + assert captured_args["uuid"].startswith("mulen_77_") + + +@pytest.mark.anyio("asyncio") +async def test_create_mulenpay_payment_respects_amount_limits(monkeypatch: pytest.MonkeyPatch) -> None: + stub = StubMulenPayService({"id": 1}) + service = _make_service(stub) + db = DummySession() + + monkeypatch.setattr(settings, "MULENPAY_MIN_AMOUNT_KOPEKS", 5000, raising=False) + monkeypatch.setattr(settings, "MULENPAY_MAX_AMOUNT_KOPEKS", 10_000, raising=False) + + result_low = await service.create_mulenpay_payment( + db=db, + user_id=1, + amount_kopeks=1000, + description="Пополнение", + ) + assert result_low is None + + result_high = await service.create_mulenpay_payment( + db=db, + user_id=1, + amount_kopeks=20_000, + description="Пополнение", + ) + assert result_high is None + assert not stub.calls + + +@pytest.mark.anyio("asyncio") +async def test_create_mulenpay_payment_returns_none_without_service() -> None: + service = _make_service(None) + db = DummySession() + + result = await service.create_mulenpay_payment( + db=db, + user_id=1, + amount_kopeks=5000, + description="Пополнение", + ) + assert result is None diff --git a/tests/services/test_payment_service_pal24.py b/tests/services/test_payment_service_pal24.py new file mode 100644 index 00000000..2b5a0f81 --- /dev/null +++ b/tests/services/test_payment_service_pal24.py @@ -0,0 +1,166 @@ +"""Тесты Pal24 сценариев PaymentService.""" + +from pathlib import Path +from typing import Any, Dict, Optional +import sys +from datetime import datetime + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +import app.services.payment_service as payment_service_module # noqa: E402 +from app.config import settings # noqa: E402 +from app.services.payment_service import PaymentService # noqa: E402 +from app.services.pal24_service import Pal24APIError # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +class DummySession: + async def commit(self) -> None: # pragma: no cover + return None + + +class DummyLocalPayment: + def __init__(self, payment_id: int = 404) -> None: + self.id = payment_id + self.created_at = datetime(2024, 1, 2, 10, 0, 0) + + +class StubPal24Service: + def __init__(self, *, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None: + self.is_configured = configured + self.response = response or { + "success": True, + "bill_id": "BILL-1", + "transfer_url": "https://pal24/sbp", + "link_url": "https://pal24/card", + "status": "NEW", + } + self.calls: list[Dict[str, Any]] = [] + self.raise_error: Optional[Exception] = None + + async def create_bill(self, **kwargs: Any) -> Dict[str, Any]: + self.calls.append(kwargs) + if self.raise_error: + raise self.raise_error + return self.response + + +def _make_service(stub: Optional[StubPal24Service]) -> PaymentService: + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = None + service.pal24_service = stub + service.mulenpay_service = None + service.yookassa_service = None + service.cryptobot_service = None + service.stars_service = None + return service + + +@pytest.mark.anyio("asyncio") +async def test_create_pal24_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + stub = StubPal24Service() + service = _make_service(stub) + db = DummySession() + + captured_args: Dict[str, Any] = {} + + async def fake_create_pal24_payment(*args: Any, **kwargs: Any) -> DummyLocalPayment: + captured_args.update(kwargs) + if args: + captured_args["db_arg"] = args[0] + return DummyLocalPayment(payment_id=321) + + monkeypatch.setattr( + payment_service_module, + "create_pal24_payment", + fake_create_pal24_payment, + raising=False, + ) + monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False) + monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 1_000_000, raising=False) + + result = await service.create_pal24_payment( + db=db, + user_id=15, + amount_kopeks=50000, + description="Оплата подписки", + language="ru", + ttl_seconds=600, + payer_email="user@example.com", + payment_method="card", + ) + + assert result is not None + assert result["local_payment_id"] == 321 + assert result["bill_id"] == "BILL-1" + assert result["payment_method"] == "CARD" + assert result["link_url"] == "https://pal24/sbp" + assert result["card_url"] == "https://pal24/card" + assert stub.calls and stub.calls[0]["amount_kopeks"] == 50000 + assert "links" in captured_args["metadata"] + + +@pytest.mark.anyio("asyncio") +async def test_create_pal24_payment_limits_and_configuration(monkeypatch: pytest.MonkeyPatch) -> None: + stub = StubPal24Service() + service = _make_service(stub) + db = DummySession() + + monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 5000, raising=False) + monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 20_000, raising=False) + + result_low = await service.create_pal24_payment( + db=db, + user_id=1, + amount_kopeks=1000, + description="Пополнение", + language="ru", + ) + assert result_low is None + + result_high = await service.create_pal24_payment( + db=db, + user_id=1, + amount_kopeks=50_000, + description="Пополнение", + language="ru", + ) + assert result_high is None + + service_not_configured = _make_service(StubPal24Service(configured=False)) + result_config = await service_not_configured.create_pal24_payment( + db=db, + user_id=1, + amount_kopeks=10_000, + description="Пополнение", + language="ru", + ) + assert result_config is None + + +@pytest.mark.anyio("asyncio") +async def test_create_pal24_payment_handles_api_errors(monkeypatch: pytest.MonkeyPatch) -> None: + stub = StubPal24Service() + stub.raise_error = Pal24APIError("api failed") + service = _make_service(stub) + db = DummySession() + + monkeypatch.setattr(settings, "PAL24_MIN_AMOUNT_KOPEKS", 1000, raising=False) + monkeypatch.setattr(settings, "PAL24_MAX_AMOUNT_KOPEKS", 10_000, raising=False) + + result = await service.create_pal24_payment( + db=db, + user_id=5, + amount_kopeks=2000, + description="Пополнение", + language="ru", + ) + assert result is None diff --git a/tests/services/test_payment_service_stars.py b/tests/services/test_payment_service_stars.py new file mode 100644 index 00000000..4ff8d456 --- /dev/null +++ b/tests/services/test_payment_service_stars.py @@ -0,0 +1,142 @@ +"""Тесты для Telegram Stars-сценариев внутри PaymentService.""" + +from pathlib import Path +from typing import Any, Dict, Optional +import sys + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.services.payment_service import PaymentService # noqa: E402 +from app.config import settings # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + """Ограничиваем anyio тесты только бэкендом asyncio.""" + return "asyncio" + + +class DummyBot: + """Минимальная заглушка aiogram.Bot для тестов.""" + + def __init__(self) -> None: + self.calls: list[Dict[str, Any]] = [] + + async def create_invoice_link(self, **kwargs: Any) -> str: + """Эмулируем создание платежной ссылки и сохраняем параметры вызова.""" + self.calls.append(kwargs) + return "https://t.me/invoice/stars" + + +def _make_service(bot: Optional[DummyBot]) -> PaymentService: + """Создаёт экземпляр PaymentService без выполнения полного конструктора.""" + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = bot + # Stars-сервис достаточно обозначить любым truthy-значением. + service.stars_service = object() if bot else None + return service + + +@pytest.mark.anyio("asyncio") +async def test_create_stars_invoice_calculates_stars(monkeypatch: pytest.MonkeyPatch) -> None: + """Количество звёзд должно рассчитываться по курсу с округлением вниз и нижним порогом 1.""" + bot = DummyBot() + service = _make_service(bot) + + monkeypatch.setattr( + type(settings), + "get_stars_rate", + lambda self: 70, + raising=False, + ) + monkeypatch.setattr( + type(settings), + "format_price", + lambda self, amount: f"{amount / 100:.0f}₽", + raising=False, + ) + + result = await service.create_stars_invoice( + amount_kopeks=14000, + description="Пополнение", + payload="custom_payload", + ) + + assert result == "https://t.me/invoice/stars" + assert len(bot.calls) == 1 + call = bot.calls[0] + assert call["title"] == "Пополнение баланса VPN" + assert call["payload"] == "custom_payload" + prices = call["prices"] + assert len(prices) == 1 + assert prices[0].amount == 2 # 14000 коп. → 140 ₽ → 2 звезды при курсе 70 + assert "≈2 ⭐" in call["description"] + + +@pytest.mark.anyio("asyncio") +async def test_create_stars_invoice_enforces_minimum_star(monkeypatch: pytest.MonkeyPatch) -> None: + """При слишком маленькой сумме минимум должен составлять 1 звезду.""" + bot = DummyBot() + service = _make_service(bot) + + monkeypatch.setattr(type(settings), "get_stars_rate", lambda self: 500, raising=False) + monkeypatch.setattr(type(settings), "format_price", lambda self, amount: amount, raising=False) + + await service.create_stars_invoice( + amount_kopeks=50, # 0.5 ₽ при курсе 500 => <1 звезды + description="Микроплатёж", + ) + + prices = bot.calls[0]["prices"] + assert prices[0].amount == 1 + + +@pytest.mark.anyio("asyncio") +async def test_create_stars_invoice_uses_explicit_stars(monkeypatch: pytest.MonkeyPatch) -> None: + """Если передано значение stars_amount, функция должна использовать его напрямую.""" + bot = DummyBot() + service = _make_service(bot) + + # При явном указании звёзд курс не запрашивается. + monkeypatch.setattr(type(settings), "format_price", lambda self, amount: amount, raising=False) + + await service.create_stars_invoice( + amount_kopeks=1000, + description="Оплата подписки", + stars_amount=5, + ) + + prices = bot.calls[0]["prices"] + assert prices[0].amount == 5 + assert "≈5 ⭐" in bot.calls[0]["description"] + + +@pytest.mark.anyio("asyncio") +async def test_create_stars_invoice_rejects_invalid_rate(monkeypatch: pytest.MonkeyPatch) -> None: + """Отрицательный или нулевой курс должен приводить к исключению.""" + bot = DummyBot() + service = _make_service(bot) + + monkeypatch.setattr(type(settings), "get_stars_rate", lambda self: 0, raising=False) + + with pytest.raises(ValueError, match="Stars rate must be positive"): + await service.create_stars_invoice( + amount_kopeks=1000, + description="Пополнение", + ) + + +@pytest.mark.anyio("asyncio") +async def test_create_stars_invoice_requires_bot() -> None: + """Без экземпляра бота и stars_service функция должна отказывать.""" + service = _make_service(bot=None) + + with pytest.raises(ValueError, match="Bot instance required"): + await service.create_stars_invoice( + amount_kopeks=1000, + description="Пополнение", + ) diff --git a/tests/services/test_payment_service_tribute.py b/tests/services/test_payment_service_tribute.py new file mode 100644 index 00000000..1c8d5c05 --- /dev/null +++ b/tests/services/test_payment_service_tribute.py @@ -0,0 +1,79 @@ +"""Тесты Tribute-платежей PaymentService.""" + +from pathlib import Path +import sys +import hmac +import hashlib + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from app.services.payment_service import PaymentService # noqa: E402 +from app.config import settings # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def _make_service() -> PaymentService: + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = None + service.yookassa_service = None + service.mulenpay_service = None + service.pal24_service = None + service.cryptobot_service = None + service.stars_service = None + return service + + +@pytest.mark.anyio("asyncio") +async def test_create_tribute_payment_requires_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + service = _make_service() + monkeypatch.setattr(settings, "TRIBUTE_ENABLED", False, raising=False) + + with pytest.raises(ValueError): + await service.create_tribute_payment(1000, 1, "Пополнение") + + +@pytest.mark.anyio("asyncio") +async def test_create_tribute_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + service = _make_service() + monkeypatch.setattr(settings, "TRIBUTE_ENABLED", True, raising=False) + monkeypatch.setattr(settings, "WEBHOOK_URL", "https://example.com", raising=False) + + result = await service.create_tribute_payment( + amount_kopeks=15000, + user_id=5, + description="Оплата подписки", + ) + + assert "https://tribute.ru/pay" in result + assert "amount=15000" in result + assert "user=5" in result + + +def test_verify_tribute_webhook_signature(monkeypatch: pytest.MonkeyPatch) -> None: + service = _make_service() + monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "secret", raising=False) + + payload = {"payment": "ok"} + signature = hmac.new( + b"secret", + str(payload).encode(), + hashlib.sha256, + ).hexdigest() + + assert service.verify_tribute_webhook(payload, signature) is True + assert service.verify_tribute_webhook(payload, "invalid") is False + + +def test_verify_tribute_webhook_returns_false_without_key(monkeypatch: pytest.MonkeyPatch) -> None: + service = _make_service() + monkeypatch.setattr(settings, "TRIBUTE_API_KEY", "", raising=False) + + assert service.verify_tribute_webhook({}, "signature") is False diff --git a/tests/services/test_payment_service_webhooks.py b/tests/services/test_payment_service_webhooks.py new file mode 100644 index 00000000..47e9bf1c --- /dev/null +++ b/tests/services/test_payment_service_webhooks.py @@ -0,0 +1,490 @@ +"""Интеграционные проверки обработки вебхуков PaymentService.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace, ModuleType +from typing import Any, Dict +import sys + +import pytest +from unittest.mock import AsyncMock + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +import app.services.payment_service as payment_service_module # noqa: E402 +from app.services.payment_service import PaymentService # noqa: E402 +from app.config import settings # noqa: E402 + + +class DummyBot: + def __init__(self) -> None: + self.sent_messages: list[Dict[str, Any]] = [] + + async def send_message(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover - бизнес-логика тестируется через вызов + self.sent_messages.append({"args": args, "kwargs": kwargs}) + + +class FakeSession: + def __init__(self) -> None: + self.commits = 0 + self.refreshed: list[Any] = [] + self.added: list[Any] = [] + + async def commit(self) -> None: + self.commits += 1 + + async def rollback(self) -> None: # pragma: no cover + return None + + async def refresh(self, obj: Any) -> None: + self.refreshed.append(obj) + + def add(self, obj: Any) -> None: # pragma: no cover - используется при создании транзакций + self.added.append(obj) + + +def _make_service(bot: DummyBot) -> PaymentService: + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = bot + service.yookassa_service = None + service.stars_service = None + service.mulenpay_service = None + service.pal24_service = None + service.cryptobot_service = None + return service + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.mark.anyio("asyncio") +async def test_process_mulenpay_callback_success(monkeypatch: pytest.MonkeyPatch) -> None: + bot = DummyBot() + service = _make_service(bot) + fake_session = FakeSession() + payment = SimpleNamespace( + uuid="mulen_uuid", + mulen_payment_id=123, + amount_kopeks=5000, + user_id=42, + transaction_id=None, + is_paid=False, + ) + + async def fake_get_by_uuid(db, uuid): + return payment + + async def fake_get_by_id(db, mid): + return None + + monkeypatch.setattr(payment_service_module, "get_mulenpay_payment_by_uuid", fake_get_by_uuid) + monkeypatch.setattr(payment_service_module, "get_mulenpay_payment_by_mulen_id", fake_get_by_id) + + transactions: list[Dict[str, Any]] = [] + + async def fake_create_transaction(db, **kwargs): + transactions.append(kwargs) + return SimpleNamespace(id=777, **kwargs) + + monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + + updated_status: dict[str, Any] = {} + + async def fake_update_status(db, payment=None, status=None, **kwargs): + payment.status = status + payment.is_paid = status == "success" + updated_status.update({"status": status, "kwargs": kwargs}) + + monkeypatch.setattr(payment_service_module, "update_mulenpay_payment_status", fake_update_status) + + async def fake_link(db, payment=None, transaction_id=None): + payment.transaction_id = transaction_id + + monkeypatch.setattr(payment_service_module, "link_mulenpay_payment_to_transaction", fake_link) + + user = SimpleNamespace( + id=42, + telegram_id=100500, + balance_kopeks=0, + has_made_first_topup=False, + promo_group=None, + subscription=None, + referred_by_id=None, + referrer=None, + ) + + async def fake_get_user(db, user_id): + return user + + monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) + monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + + referral_mock = SimpleNamespace(process_referral_topup=AsyncMock()) + monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock) + + class DummyAdminService: + def __init__(self, bot): + self.bot = bot + self.calls: list[Any] = [] + + async def send_balance_topup_notification(self, *args, **kwargs): + self.calls.append((args, kwargs)) + + admin_service = DummyAdminService(bot) + monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: admin_service)) + + service.build_topup_success_keyboard = AsyncMock(return_value=None) + + payload = { + "uuid": "mulen_uuid", + "payment_status": "success", + "id": 123, + "amount": "50.00", + } + + result = await service.process_mulenpay_callback(fake_session, payload) + + assert result is True + assert transactions and transactions[0]["user_id"] == 42 + assert payment.transaction_id == 777 + assert updated_status["status"] == "success" + assert user.balance_kopeks == 5000 + assert fake_session.commits >= 1 + assert bot.sent_messages # сообщение пользователю отправлено + + +@pytest.mark.anyio("asyncio") +async def test_process_cryptobot_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: + bot = DummyBot() + service = _make_service(bot) + fake_session = FakeSession() + payment = SimpleNamespace( + invoice_id="inv_1", + user_id=7, + status="pending", + transaction_id=None, + amount="12.50", + asset="USDT", + amount_float=12.5, + ) + + async def fake_get_crypto(db, invoice_id): + return payment + + async def fake_update_status(db, invoice_id, status, paid_at): + payment.status = status + payment.paid_at = paid_at + return payment + + async def fake_link(db, invoice_id, transaction_id): + payment.transaction_id = transaction_id + + fake_cryptobot_module = ModuleType("app.database.crud.cryptobot") + fake_cryptobot_module.get_cryptobot_payment_by_invoice_id = fake_get_crypto + fake_cryptobot_module.update_cryptobot_payment_status = fake_update_status + fake_cryptobot_module.link_cryptobot_payment_to_transaction = fake_link + monkeypatch.setitem(sys.modules, "app.database.crud.cryptobot", fake_cryptobot_module) + + transactions: list[Dict[str, Any]] = [] + + async def fake_create_transaction(db, **kwargs): + transactions.append(kwargs) + return SimpleNamespace(id=888, **kwargs) + + fake_transaction_module = ModuleType("app.database.crud.transaction") + fake_transaction_module.create_transaction = fake_create_transaction + monkeypatch.setitem(sys.modules, "app.database.crud.transaction", fake_transaction_module) + monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + + user = SimpleNamespace( + id=7, + telegram_id=700, + balance_kopeks=0, + has_made_first_topup=False, + promo_group=None, + subscription=None, + referred_by_id=None, + referrer=None, + ) + + async def fake_get_user_crypto(db, user_id): + return user + + monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user_crypto) + + referral_crypto = SimpleNamespace(process_referral_topup=AsyncMock()) + monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_crypto) + + admin_calls: list[Any] = [] + + class DummyAdminService2: + def __init__(self, bot): + self.bot = bot + + async def send_balance_topup_notification(self, *args, **kwargs): + admin_calls.append((args, kwargs)) + + monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService2(bot))) + monkeypatch.setattr(payment_service_module.currency_converter, "usd_to_rub", AsyncMock(return_value=140.0)) + monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + service.build_topup_success_keyboard = AsyncMock(return_value=None) + + payload = { + "update_type": "invoice_paid", + "payload": { + "invoice_id": "inv_1", + "paid_at": "2024-01-01T12:00:00Z", + }, + } + + result = await service.process_cryptobot_webhook(fake_session, payload) + + assert result is True + assert transactions and transactions[0]["amount_kopeks"] == 14000 + assert user.balance_kopeks == 14000 + assert payment.transaction_id == 888 + assert bot.sent_messages + assert admin_calls + + +@pytest.mark.anyio("asyncio") +async def test_process_yookassa_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None: + bot = DummyBot() + service = _make_service(bot) + fake_session = FakeSession() + payment = SimpleNamespace( + yookassa_payment_id="yk_123", + user_id=21, + amount_kopeks=10000, + transaction_id=None, + status="pending", + is_paid=False, + ) + + async def fake_get_payment(db, payment_id): + return payment + + async def fake_update(db, payment_id, status, is_paid, is_captured, captured_at, payment_method_type): + payment.status = status + payment.is_paid = is_paid + payment.captured_at = captured_at + return payment + + async def fake_link(db, payment_id, transaction_id): + payment.transaction_id = transaction_id + + yk_module = ModuleType("app.database.crud.yookassa") + yk_module.get_yookassa_payment_by_id = fake_get_payment + yk_module.update_yookassa_payment_status = fake_update + yk_module.link_yookassa_payment_to_transaction = fake_link + monkeypatch.setitem(sys.modules, "app.database.crud.yookassa", yk_module) + + transactions: list[Dict[str, Any]] = [] + + async def fake_create_transaction(db, **kwargs): + transactions.append(kwargs) + return SimpleNamespace(id=999, **kwargs) + + trx_module = ModuleType("app.database.crud.transaction") + trx_module.create_transaction = fake_create_transaction + monkeypatch.setitem(sys.modules, "app.database.crud.transaction", trx_module) + monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + + user = SimpleNamespace( + id=21, + telegram_id=2100, + balance_kopeks=0, + has_made_first_topup=False, + promo_group=None, + subscription=None, + referred_by_id=None, + referrer=None, + ) + + async def fake_get_user(db, user_id): + return user + + monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) + monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + + referral_mock = SimpleNamespace(process_referral_topup=AsyncMock()) + monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_mock) + + admin_calls: list[Any] = [] + + class DummyAdminService: + def __init__(self, bot): + self.bot = bot + + async def send_balance_topup_notification(self, *args, **kwargs): + admin_calls.append((args, kwargs)) + + monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminService(bot))) + service.build_topup_success_keyboard = AsyncMock(return_value=None) + + payload = { + "object": { + "id": "yk_123", + "status": "succeeded", + "paid": True, + "payment_method": {"type": "bank_card"}, + } + } + + result = await service.process_yookassa_webhook(fake_session, payload) + + assert result is True + assert transactions and transactions[0]["amount_kopeks"] == 10000 + assert payment.transaction_id == 999 + assert user.balance_kopeks == 10000 + assert bot.sent_messages + assert admin_calls + + +@pytest.mark.anyio("asyncio") +async def test_process_yookassa_webhook_missing_id(monkeypatch: pytest.MonkeyPatch) -> None: + bot = DummyBot() + service = _make_service(bot) + db = FakeSession() + + result = await service.process_yookassa_webhook(db, {"object": {}}) + assert result is False + + +@pytest.mark.anyio("asyncio") +async def test_process_pal24_postback_success(monkeypatch: pytest.MonkeyPatch) -> None: + bot = DummyBot() + service = _make_service(bot) + service.pal24_service = SimpleNamespace(is_configured=True) + fake_session = FakeSession() + payment = SimpleNamespace( + bill_id="BILL-1", + order_id="order-1", + amount_kopeks=5000, + user_id=33, + transaction_id=None, + is_paid=False, + status="NEW", + ) + + async def fake_get_by_order(db, order_id): + return payment + + async def fake_get_by_bill(db, bill_id): + return payment + + async def fake_update(db, payment_obj, **kwargs): + payment.status = kwargs.get("status", payment.status) + payment.is_paid = kwargs.get("is_paid", payment.is_paid) + payment.payment_status = kwargs.get("payment_status", payment.status) + payment.callback_payload = kwargs.get("callback_payload") + return payment + + async def fake_link(db, payment_obj, transaction_id): + payment.transaction_id = transaction_id + + pal_module = ModuleType("app.database.crud.pal24") + pal_module.get_pal24_payment_by_order_id = fake_get_by_order + pal_module.get_pal24_payment_by_bill_id = fake_get_by_bill + pal_module.update_pal24_payment_status = fake_update + pal_module.link_pal24_payment_to_transaction = fake_link + monkeypatch.setitem(sys.modules, "app.database.crud.pal24", pal_module) + monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_order_id", fake_get_by_order) + monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_bill_id", fake_get_by_bill) + monkeypatch.setattr(payment_service_module, "update_pal24_payment_status", fake_update) + monkeypatch.setattr(payment_service_module, "link_pal24_payment_to_transaction", fake_link) + + async def fake_create_transaction(db, **kwargs): + payment.transaction_id = 654 + return SimpleNamespace(id=654, **kwargs) + + trx_module = ModuleType("app.database.crud.transaction") + trx_module.create_transaction = fake_create_transaction + monkeypatch.setitem(sys.modules, "app.database.crud.transaction", trx_module) + monkeypatch.setattr(payment_service_module, "create_transaction", fake_create_transaction) + + user = SimpleNamespace( + id=33, + telegram_id=3300, + balance_kopeks=0, + has_made_first_topup=False, + promo_group=None, + subscription=None, + referred_by_id=None, + referrer=None, + ) + + async def fake_get_user(db, user_id): + return user + + monkeypatch.setattr(payment_service_module, "get_user_by_id", fake_get_user) + monkeypatch.setattr(type(settings), "format_price", lambda self, amount: f"{amount / 100:.2f}₽", raising=False) + + referral_pal = SimpleNamespace(process_referral_topup=AsyncMock()) + monkeypatch.setitem(sys.modules, "app.services.referral_service", referral_pal) + + admin_calls: list[Any] = [] + + class DummyAdminServicePal: + def __init__(self, bot): + self.bot = bot + + async def send_balance_topup_notification(self, *args, **kwargs): + admin_calls.append((args, kwargs)) + + monkeypatch.setitem(sys.modules, "app.services.admin_notification_service", SimpleNamespace(AdminNotificationService=lambda bot: DummyAdminServicePal(bot))) + service.build_topup_success_keyboard = AsyncMock(return_value=None) + + payload = { + "InvId": "order-1", + "OutSum": "50.00", + "Status": "SUCCESS", + "TrsId": "trs-1", + } + + result = await service.process_pal24_postback(fake_session, payload) + + assert result is True + assert payment.transaction_id == 654 + assert user.balance_kopeks == 5000 + assert bot.sent_messages + assert admin_calls + + +@pytest.mark.anyio("asyncio") +async def test_process_pal24_postback_payment_not_found(monkeypatch: pytest.MonkeyPatch) -> None: + bot = DummyBot() + service = _make_service(bot) + service.pal24_service = SimpleNamespace(is_configured=True) + db = FakeSession() + + async def fake_get_by_order(db, order_id): + return None + + async def fake_get_by_bill(db, bill_id): + return None + + pal_module = ModuleType("app.database.crud.pal24") + pal_module.get_pal24_payment_by_order_id = fake_get_by_order + pal_module.get_pal24_payment_by_bill_id = fake_get_by_bill + pal_module.update_pal24_payment_status = AsyncMock() + pal_module.link_pal24_payment_to_transaction = AsyncMock() + monkeypatch.setitem(sys.modules, "app.database.crud.pal24", pal_module) + monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_order_id", fake_get_by_order) + monkeypatch.setattr(payment_service_module, "get_pal24_payment_by_bill_id", fake_get_by_bill) + + payload = { + "InvId": "order-unknown", + "OutSum": "10.00", + "Status": "SUCCESS", + } + + result = await service.process_pal24_postback(db, payload) + assert result is False diff --git a/tests/services/test_payment_service_yookassa.py b/tests/services/test_payment_service_yookassa.py new file mode 100644 index 00000000..f8a950cf --- /dev/null +++ b/tests/services/test_payment_service_yookassa.py @@ -0,0 +1,245 @@ +"""Тесты для YooKassa-сценариев PaymentService.""" + +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +import app.services.payment_service as payment_service_module # noqa: E402 +from app.config import settings # noqa: E402 +from app.services.payment_service import PaymentService # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + """Запускаем async-тесты на asyncio, чтобы избежать зависимостей trio.""" + return "asyncio" + + +class DummySession: + """Простейшая заглушка AsyncSession.""" + + def __init__(self) -> None: + self.committed = False + + async def commit(self) -> None: + self.committed = True + + async def rollback(self) -> None: + self.rolled_back = True # type: ignore[attr-defined] + + +class DummyLocalPayment: + """Объект, имитирующий локальную запись платежа.""" + + def __init__(self, payment_id: int = 101) -> None: + self.id = payment_id + self.created_at = datetime(2024, 1, 1, 12, 0, 0) + + +class StubYooKassaService: + """Заглушка для SDK, сохраняющая вызовы.""" + + def __init__(self, response: Dict[str, Any]) -> None: + self.response = response + self.calls: list[Dict[str, Any]] = [] + + async def create_payment(self, **kwargs: Any) -> Dict[str, Any]: + self.calls.append(kwargs) + return self.response + + async def create_sbp_payment(self, **kwargs: Any) -> Dict[str, Any]: + self.calls.append(kwargs) + return self.response + + +def _make_service(yookassa_service: Optional[StubYooKassaService]) -> PaymentService: + service = PaymentService.__new__(PaymentService) # type: ignore[call-arg] + service.bot = None + service.yookassa_service = yookassa_service + service.stars_service = None + service.mulenpay_service = None + service.pal24_service = None + service.mulenpay_service = None + service.cryptobot_service = None + return service + + +@pytest.mark.anyio("asyncio") +async def test_create_yookassa_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + """Успешное создание платежа формирует корректные метаданные и локальную запись.""" + + response = { + "id": "yk_123", + "status": "pending", + "confirmation_url": "https://yookassa.ru/confirm", + "amount": {"value": "140.00", "currency": "RUB"}, + "metadata": {"existing": "value"}, + "created_at": "2024-01-01T12:00:00Z", + "test_mode": False, + } + service = _make_service(StubYooKassaService(response)) + db = DummySession() + + captured_args: Dict[str, Any] = {} + + async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment: + captured_args.update(kwargs) + return DummyLocalPayment(payment_id=555) + + monkeypatch.setattr( + payment_service_module, + "create_yookassa_payment", + fake_create_yookassa_payment, + raising=False, + ) + monkeypatch.setattr( + type(settings), + "format_price", + lambda self, amount: f"{amount / 100:.0f}₽", + raising=False, + ) + + result = await service.create_yookassa_payment( + db=db, + user_id=42, + amount_kopeks=14000, + description="Пополнение", + receipt_email="user@example.com", + metadata={"custom": "data"}, + ) + + assert result is not None + assert result["local_payment_id"] == 555 + assert result["yookassa_payment_id"] == "yk_123" + assert result["amount_kopeks"] == 14000 + assert result["amount_rubles"] == 140 + assert result["status"] == "pending" + + assert captured_args["user_id"] == 42 + assert captured_args["metadata_json"]["custom"] == "data" + assert captured_args["metadata_json"]["user_id"] == "42" + assert captured_args["metadata_json"]["amount_kopeks"] == "14000" + assert isinstance(captured_args["yookassa_created_at"], datetime) + + +@pytest.mark.anyio("asyncio") +async def test_create_yookassa_payment_returns_none_when_service_missing() -> None: + """Если сервис не настроен, метод должен вернуть None.""" + service = _make_service(None) + db = DummySession() + result = await service.create_yookassa_payment( + db=db, + user_id=1, + amount_kopeks=1000, + description="Пополнение", + ) + assert result is None + + +@pytest.mark.anyio("asyncio") +async def test_create_yookassa_payment_handles_error_response(monkeypatch: pytest.MonkeyPatch) -> None: + """Ответ с ключом error должен приводить к None без записи в БД.""" + response = {"error": True} + service = _make_service(StubYooKassaService(response)) + db = DummySession() + + called = False + + async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment: + nonlocal called + called = True + return DummyLocalPayment() + + monkeypatch.setattr( + payment_service_module, + "create_yookassa_payment", + fake_create_yookassa_payment, + raising=False, + ) + + result = await service.create_yookassa_payment( + db=db, + user_id=1, + amount_kopeks=5000, + description="Пополнение", + ) + assert result is None + assert called is False + + +@pytest.mark.anyio("asyncio") +async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + """Проверяем SBP-сценарий, включая передачу confirmation_token.""" + + response = { + "id": "yk_sbp_001", + "status": "pending", + "confirmation_url": "https://yookassa.ru/confirm", + "confirmation": {"confirmation_token": "token123"}, + "created_at": "2024-02-01T10:00:00Z", + } + service = _make_service(StubYooKassaService(response)) + db = DummySession() + + captured_args: Dict[str, Any] = {} + + async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment: + captured_args.update(kwargs) + return DummyLocalPayment(payment_id=777) + + monkeypatch.setattr( + payment_service_module, + "create_yookassa_payment", + fake_create_yookassa_payment, + raising=False, + ) + + result = await service.create_yookassa_sbp_payment( + db=db, + user_id=7, + amount_kopeks=25000, + description="СБП пополнение", + ) + + assert result is not None + assert result["confirmation_token"] == "token123" + assert captured_args["payment_method_type"] == "bank_card" + assert captured_args["metadata_json"]["type"] == "balance_topup_sbp" + + +@pytest.mark.anyio("asyncio") +async def test_create_yookassa_sbp_payment_returns_none_on_error(monkeypatch: pytest.MonkeyPatch) -> None: + """Ошибочный ответ СБП не должен создавать запись.""" + response = {"error": "invalid"} + service = _make_service(StubYooKassaService(response)) + db = DummySession() + + called = False + + async def fake_create_yookassa_payment(**kwargs: Any) -> DummyLocalPayment: + nonlocal called + called = True + return DummyLocalPayment() + + monkeypatch.setattr( + payment_service_module, + "create_yookassa_payment", + fake_create_yookassa_payment, + raising=False, + ) + + result = await service.create_yookassa_sbp_payment( + db=db, + user_id=1, + amount_kopeks=1000, + description="СБП пополнение", + ) + assert result is None + assert called is False diff --git a/tests/services/test_yookassa_service_adapter.py b/tests/services/test_yookassa_service_adapter.py new file mode 100644 index 00000000..014dc75d --- /dev/null +++ b/tests/services/test_yookassa_service_adapter.py @@ -0,0 +1,179 @@ +"""Тесты низкоуровневого сервиса YooKassaService.""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +ROOT_DIR = Path(__file__).resolve().parents[2] +if str(ROOT_DIR) not in sys.path: + sys.path.insert(0, str(ROOT_DIR)) + +from yookassa import Configuration, Payment as YooKassaPayment # type: ignore # noqa: E402 +from app.config import settings # noqa: E402 +from app.services.yookassa_service import YooKassaService # noqa: E402 + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +class DummyLoop: + async def run_in_executor(self, _executor, func): + return func() + + +def _prepare_config(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "shop123", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "secret123", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_RETURN_URL", "https://example.com/return", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_VAT_CODE", 1, raising=False) + monkeypatch.setattr(settings, "YOOKASSA_PAYMENT_MODE", "full_payment", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_PAYMENT_SUBJECT", "service", raising=False) + + +def test_init_without_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "", raising=False) + service = YooKassaService() + assert service.configured is False + assert service.return_url == "https://t.me/" + + +@pytest.mark.anyio("asyncio") +async def test_create_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + _prepare_config(monkeypatch) + monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", None, raising=False) + monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False) + + captured_config: dict[str, tuple[str, str]] = {} + + def fake_configure(shop_id: str, secret_key: str) -> None: + captured_config["values"] = (shop_id, secret_key) + + monkeypatch.setattr(Configuration, "configure", fake_configure, raising=False) + + response_obj = SimpleNamespace( + id="yk_1", + status="pending", + paid=False, + confirmation=SimpleNamespace(confirmation_url="https://yk/confirm"), + metadata={"meta": "value"}, + amount=SimpleNamespace(value="140.00", currency="RUB"), + refundable=True, + created_at=datetime(2024, 1, 1, 12, 0, 0), + description="Desc", + test=False, + ) + + monkeypatch.setattr( + YooKassaPayment, + "create", + staticmethod(lambda payload, key: response_obj), + raising=False, + ) + + service = YooKassaService() + monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", "fallback@example.com", raising=False) + + result = await service.create_payment( + amount=140.0, + currency="RUB", + description="Пополнение", + metadata={"order": "1"}, + receipt_email="user@example.com", + ) + + assert service.configured is True + assert captured_config["values"] == ("shop123", "secret123") + assert result is not None + assert result["id"] == "yk_1" + assert result["confirmation_url"] == "https://yk/confirm" + assert result["amount_value"] == 140.0 + assert result["status"] == "pending" + + +@pytest.mark.anyio("asyncio") +async def test_create_payment_without_contacts(monkeypatch: pytest.MonkeyPatch) -> None: + _prepare_config(monkeypatch) + monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", None, raising=False) + monkeypatch.setattr(Configuration, "configure", lambda *args, **kwargs: None, raising=False) + monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False) + monkeypatch.setattr( + YooKassaPayment, + "create", + staticmethod(lambda payload, key: SimpleNamespace()), + raising=False, + ) + + service = YooKassaService() + result = await service.create_payment( + amount=10, + currency="RUB", + description="desc", + metadata={}, + ) + assert result is not None + assert result.get("error") is True + + +@pytest.mark.anyio("asyncio") +async def test_create_payment_returns_none_when_not_configured(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "YOOKASSA_SHOP_ID", "", raising=False) + monkeypatch.setattr(settings, "YOOKASSA_SECRET_KEY", "", raising=False) + service = YooKassaService() + result = await service.create_payment( + amount=10, + currency="RUB", + description="desc", + metadata={}, + ) + assert result is None + + +@pytest.mark.anyio("asyncio") +async def test_create_sbp_payment_success(monkeypatch: pytest.MonkeyPatch) -> None: + _prepare_config(monkeypatch) + monkeypatch.setattr(asyncio, "get_running_loop", lambda: DummyLoop(), raising=False) + monkeypatch.setattr(Configuration, "configure", lambda *args, **kwargs: None, raising=False) + monkeypatch.setattr(settings, "YOOKASSA_DEFAULT_RECEIPT_EMAIL", "fallback@example.com", raising=False) + + response_obj = SimpleNamespace( + id="sbp_001", + status="pending", + paid=False, + confirmation=SimpleNamespace(confirmation_url="https://sbp/confirm"), + metadata={"meta": "value"}, + amount=SimpleNamespace(value="200.00", currency="RUB"), + refundable=False, + created_at=datetime(2024, 2, 1, 9, 0, 0), + description="SBP payment", + test=True, + ) + + monkeypatch.setattr( + YooKassaPayment, + "create", + staticmethod(lambda payload, key: response_obj), + raising=False, + ) + + service = YooKassaService() + result = await service.create_sbp_payment( + amount=200.0, + currency="rub", + description="Оплата", + metadata={"type": "sbp"}, + receipt_phone="+70000000000", + ) + + assert result is not None + assert result["id"] == "sbp_001" + assert result["confirmation_url"] == "https://sbp/confirm" + assert result["status"] == "pending" diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..b0e99d1a --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1 @@ +# В этом пакете будут жить модульные тесты для вспомогательных утилит приложения. diff --git a/tests/utils/test_formatters_basic.py b/tests/utils/test_formatters_basic.py new file mode 100644 index 00000000..338eb498 --- /dev/null +++ b/tests/utils/test_formatters_basic.py @@ -0,0 +1,104 @@ +"""Тесты для базовых форматтеров из app.utils.formatters.""" + +from datetime import datetime, timedelta + +from app.utils import formatters + + +def test_format_datetime_handles_iso_strings(fixed_datetime: datetime) -> None: + """ISO-строка должна корректно преобразовываться в отформатированный текст.""" + iso_value = fixed_datetime.isoformat() + assert formatters.format_datetime(iso_value) == fixed_datetime.strftime("%d.%m.%Y %H:%M") + + +def test_format_date_uses_custom_format(fixed_datetime: datetime) -> None: + """Можно задавать собственный шаблон вывода.""" + iso_value = fixed_datetime.isoformat() + assert formatters.format_date(iso_value, format_str="%Y/%m/%d") == fixed_datetime.strftime("%Y/%m/%d") + + +def test_format_time_ago_returns_human_readable_text() -> None: + """Разница во времени должна переводиться в человеко-понятную строку.""" + point_in_time = datetime.utcnow() - timedelta(minutes=5) + assert formatters.format_time_ago(point_in_time, language="ru") == "5 мин. назад" + assert formatters.format_time_ago(point_in_time, language="en") == "5 minutes ago" + + +def test_format_days_declension_handles_russian_rules() -> None: + """Склонение дней в русском языке зависит от числа.""" + assert formatters.format_days_declension(1) == "1 день" + assert formatters.format_days_declension(3) == "3 дня" + assert formatters.format_days_declension(10) == "10 дней" + + +def test_format_duration_switches_units() -> None: + """В зависимости от длины интервала выбирается подходящая единица измерения.""" + assert formatters.format_duration(45) == "45 сек." + assert formatters.format_duration(120) == "2 мин." + assert formatters.format_duration(7200) == "2 ч." + assert formatters.format_duration(172800) == "2 дн." + + +def test_format_bytes_scales_value() -> None: + """Размер должен выражаться в наиболее подходящей единице.""" + assert formatters.format_bytes(0) == "0 B" + assert formatters.format_bytes(1024) == "1 KB" + assert formatters.format_bytes(1024 * 1024) == "1 MB" + + +def test_format_percentage_respects_precision() -> None: + """Проценты форматируются с нужным количеством знаков.""" + assert formatters.format_percentage(12.3456, decimals=2) == "12.35%" + + +def test_format_number_inserts_separators() -> None: + """Разделители тысяч должны расставляться корректно как для int, так и для float.""" + assert formatters.format_number(1234567) == "1 234 567" + assert formatters.format_number(1234.56) == "1 234.55" + + +def test_truncate_text_appends_suffix() -> None: + """Строки, превышающие лимит, должны обрезаться и дополняться суффиксом.""" + source = "a" * 10 + assert formatters.truncate_text(source, max_length=5) == "aa..." + + +def test_format_username_prefers_full_name() -> None: + """Полное имя имеет приоритет, затем username, затем ID.""" + assert formatters.format_username("nickname", 1, full_name="Имя") == "Имя" + assert formatters.format_username("nickname", 1, full_name=None) == "@nickname" + assert formatters.format_username(None, 42, full_name=None) == "ID42" + + +def test_format_subscription_status_handles_active_and_expired() -> None: + """Статус подписки различается для активных/просроченных случаев.""" + future = datetime.utcnow() + timedelta(days=2) + active = formatters.format_subscription_status( + is_active=True, + is_trial=False, + end_date=future, + language="ru", + ) + assert active.startswith("✅ Активна") + assert "(" in active and ")" in active + + past = datetime.utcnow() - timedelta(days=1) + expired = formatters.format_subscription_status( + is_active=True, + is_trial=False, + end_date=past, + language="ru", + ) + assert expired == "⏰ Истекла" + + +def test_format_traffic_usage_supports_unlimited() -> None: + """При безлимитном тарифе в строке должна появляться бесконечность.""" + assert formatters.format_traffic_usage(50.0, 0, language="ru") == "50.0 ГБ / ∞" + assert formatters.format_traffic_usage(10.0, 100, language="ru") == "10.0 ГБ / 100 ГБ (10.0%)" + + +def test_format_boolean_localises_output() -> None: + """Булевые значения отображаются локализованными словами.""" + assert formatters.format_boolean(True, language="ru") == "✅ Да" + assert formatters.format_boolean(False, language="en") == "❌ No" diff --git a/tests/utils/test_security.py b/tests/utils/test_security.py new file mode 100644 index 00000000..77fe5bad --- /dev/null +++ b/tests/utils/test_security.py @@ -0,0 +1,54 @@ +"""Тесты для функций безопасности из app.utils.security.""" + +import hashlib + +import pytest + +from app.utils.security import generate_api_token, hash_api_token + + +def test_hash_api_token_default_algorithm_matches_hashlib() -> None: + """Проверяем, что алгоритм по умолчанию совпадает с hashlib.sha256.""" + sample = "secret-token" + # Самостоятельно считаем эталонное значение. + expected = hashlib.sha256(sample.encode("utf-8")).hexdigest() + # Сравниваем с функцией проекта. + assert hash_api_token(sample) == expected + + +@pytest.mark.parametrize( + "algorithm,hash_factory", + [ + ("sha256", hashlib.sha256), + ("sha384", hashlib.sha384), + ("sha512", hashlib.sha512), + ], +) +def test_hash_api_token_accepts_supported_algorithms(algorithm, hash_factory) -> None: + """Каждый поддерживаемый алгоритм должен выдавать корректный результат.""" + sample = "token-value" + expected = hash_factory(sample.encode("utf-8")).hexdigest() + assert hash_api_token(sample, algorithm=algorithm) == expected + + +def test_hash_api_token_rejects_unknown_algorithm() -> None: + """Некорректное имя алгоритма должно приводить к ValueError.""" + with pytest.raises(ValueError): + hash_api_token("value", algorithm="md5") # type: ignore[arg-type] + + +@pytest.mark.parametrize("length", [8, 24, 48, 256]) +def test_generate_api_token_respects_length_bounds(length: int) -> None: + """Функция должна ограничивать длину токена безопасным диапазоном.""" + token = generate_api_token(length) + clamped = max(24, min(length, 128)) + assert len(token) >= clamped + # token_urlsafe расширяет строку, поэтому добавляем запас по длине. + assert len(token) <= clamped * 2 + + +def test_generate_api_token_produces_random_values() -> None: + """Два последовательных вызова должны выдавать разные токены.""" + first = generate_api_token(48) + second = generate_api_token(48) + assert first != second diff --git a/tests/utils/test_validators_basic.py b/tests/utils/test_validators_basic.py new file mode 100644 index 00000000..3f81f5b7 --- /dev/null +++ b/tests/utils/test_validators_basic.py @@ -0,0 +1,141 @@ +"""Базовые тесты для валидаторов из app.utils.validators.""" + +import pytest + +from app.utils import validators + + +@pytest.mark.parametrize( + "email,is_valid", + [ + ("user@example.com", True), + ("user.name+tag@sub.domain.ru", True), + ("plain-address", False), + ("missing-at.example.com", False), + ("user@invalid", False), + ], +) +def test_validate_email_handles_expected_patterns(email: str, is_valid: bool) -> None: + """Проверяем типичные корректные и некорректные адреса.""" + assert validators.validate_email(email) is is_valid + + +@pytest.mark.parametrize( + "phone,is_valid", + [ + ("+71234567890", True), + ("+1 (202) 555-0101", True), + ("12345", True), + ("+0 123456789", False), + ("abc", False), + ], +) +def test_validate_phone_strips_formatting_and_checks_pattern(phone: str, is_valid: bool) -> None: + """Телефон должен соответствовать стандарту E.164 после очистки.""" + assert validators.validate_phone(phone) is is_valid + + +@pytest.mark.parametrize( + "username,is_valid", + [ + ("@valid_name", True), + ("simpleUser", True), + ("bad", False), + ("toolongusername_more_than32_chars", False), + ("", False), + ], +) +def test_validate_telegram_username_enforces_length(username: str, is_valid: bool) -> None: + """Telegram-логин должен быть 5-32 символов и содержать допустимые символы.""" + assert validators.validate_telegram_username(username) is is_valid + + +def test_validate_amount_returns_float_within_bounds() -> None: + """Числа должны конвертироваться с уважением к диапазону.""" + assert validators.validate_amount("10.5", min_amount=5, max_amount=20) == pytest.approx(10.5) + assert validators.validate_amount("2", min_amount=5, max_amount=20) is None + assert validators.validate_amount("abc", min_amount=0, max_amount=10) is None + + +def test_validate_positive_integer_enforces_upper_bound() -> None: + """Положительное целое число выходит за пределы — возвращаем None.""" + assert validators.validate_positive_integer("12", max_value=20) == 12 + assert validators.validate_positive_integer("0", max_value=20) is None + assert validators.validate_positive_integer("50", max_value=20) is None + assert validators.validate_positive_integer("NaN") is None + + +@pytest.mark.parametrize( + "value,expected", + [ + ("500", 500), + ("10gb", 10240), + ("2 TB", 2097152), + ("безлимит", 0), + ("invalid", None), + ], +) +def test_validate_traffic_amount_supports_units(value: str, expected: int | None) -> None: + """Валидатор трафика распознаёт разные единицы измерения и особые значения.""" + assert validators.validate_traffic_amount(value) == expected + + +def test_validate_subscription_period_accepts_reasonable_range() -> None: + """Диапазон допустимой длительности от 1 до 3650 дней.""" + assert validators.validate_subscription_period("30") == 30 + assert validators.validate_subscription_period(0) is None + assert validators.validate_subscription_period(4000) is None + + +def test_validate_uuid_detects_standard_format() -> None: + """UUID должен соответствовать HEX шаблону версии 4/5.""" + sample = "123e4567-e89b-12d3-a456-426614174000" + assert validators.validate_uuid(sample) is True + assert validators.validate_uuid("not-a-uuid") is False + + +def test_validate_url_recognises_https_links() -> None: + """Валидатор URL допускает http/https ссылки и отклоняет произвольные строки.""" + assert validators.validate_url("https://example.com/path?query=1") + assert not validators.validate_url("ftp://example.com") + + +def test_validate_html_tags_rejects_unknown_tags() -> None: + """Неизвестные HTML теги должны приводить к отказу.""" + ok, message = validators.validate_html_tags("bold") + assert ok is True + bad, error = validators.validate_html_tags("run") + assert bad is False + assert "Неподдерживаемый тег" in error + + +def test_validate_html_structure_detects_wrong_nesting() -> None: + """Неправильная вложенность тегов должна сообщаться пользователю.""" + ok, message = validators.validate_html_structure("text") + assert ok is True + bad, error = validators.validate_html_structure("text") + assert bad is False + assert "Неправильная вложенность" in error + + +def test_fix_html_tags_repairs_missing_quotes() -> None: + """Автоисправление должно добавлять кавычки у ссылок.""" + broken = 'link' + fixed = validators.fix_html_tags(broken) + assert 'href="https://example.com"' in fixed + + +def test_validate_rules_content_detects_structure_error() -> None: + """При нарушении структуры должны вернуться сообщение и отсутствие подсказки.""" + is_valid, message, suggestion = validators.validate_rules_content("text") + assert is_valid is False + assert "Неправильная вложенность" in message + assert suggestion is None + + +def test_validate_rules_content_accepts_supported_markup() -> None: + """Корректный HTML должен проходить проверку без сообщений.""" + is_valid, message, suggestion = validators.validate_rules_content("Добро пожаловать!") + assert is_valid is True + assert message == "" + assert suggestion is None