From 7ae3c98585420162f506cf1150e767ea69cd7e30 Mon Sep 17 00:00:00 2001 From: hexdev Date: Tue, 18 Aug 2026 20:09:59 +0700 Subject: [PATCH] feat: introduced pytest powered tests --- requirements.txt | 2 + tests/conftest.py | 17 ++++ tests/test_auth_routes.py | 125 ++++++++++++++++++++++++++ tests/test_services_and_payments.py | 106 ++++++++++++++++++++++ tests/test_user_and_plan_routes.py | 131 ++++++++++++++++++++++++++++ 5 files changed, 381 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/test_auth_routes.py create mode 100644 tests/test_services_and_payments.py create mode 100644 tests/test_user_and_plan_routes.py diff --git a/requirements.txt b/requirements.txt index 652c32d..b52a11d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,5 @@ aiohttp>=3.14.0 python-multipart==0.0.32 remnawave>=2.6.1 zxcvbn>=4.5.0 +httpx2>=2.11.0 +pytest>=9.1.0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c0f71e8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,17 @@ +import sys +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from main import app + + +@pytest.fixture +def client(): + app.dependency_overrides.clear() + with TestClient(app, raise_server_exceptions=False) as test_client: + yield test_client + app.dependency_overrides.clear() diff --git a/tests/test_auth_routes.py b/tests/test_auth_routes.py new file mode 100644 index 0000000..5272924 --- /dev/null +++ b/tests/test_auth_routes.py @@ -0,0 +1,125 @@ +# ruff: noqa: PLR2004 + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + + +def test_signup_rejects_missing_credentials(client): + response = client.post("/auth/signup", json={"provider": "credentials"}) + + assert response.status_code == 400 + assert response.json()["detail"] == "Username or password is not provided" + + +def test_signup_rejects_unsupported_provider(client): + response = client.post( + "/auth/signup", json={"provider": "telegram", "username": "alice", "password": "Strong123!"} + ) + + assert response.status_code == 400 + assert response.json()["detail"] == "Unsupported provider" + + +def test_signup_rejects_existing_username(client): + repository = SimpleNamespace(get_user_by_username=AsyncMock(return_value=object())) + with patch("routes.auth.UserRepository", return_value=repository): + response = client.post( + "/auth/signup", + json={"provider": "credentials", "username": "alice", "password": "Strong123!"}, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "User already exists" + + +def test_signup_rejects_weak_password(client): + repository = SimpleNamespace(get_user_by_username=AsyncMock(return_value=None)) + with ( + patch("routes.auth.UserRepository", return_value=repository), + patch("routes.auth.estimate_password_strength", return_value=False), + ): + response = client.post( + "/auth/signup", + json={"provider": "credentials", "username": "alice", "password": "weak"}, + ) + + assert response.status_code == 422 + assert response.json()["detail"] == "Password is not secure." + + +def test_signup_accepts_unknown_referral_code(client): + created_user = SimpleNamespace( + username="alice", telegram_id=None, referal_code="new-code", balance=0 + ) + repository = SimpleNamespace( + get_user_by_username=AsyncMock(return_value=None), + get_user_by_ref_code=AsyncMock(return_value=None), + create=AsyncMock(return_value=created_user), + ) + with ( + patch("routes.auth.UserRepository", return_value=repository), + patch("routes.auth.estimate_password_strength", return_value=True), + patch("routes.auth.hash_password", return_value="hashed"), + ): + response = client.post( + "/auth/signup", + json={ + "provider": "credentials", + "username": "alice", + "password": "Strong123!", + "referal_code": "unknown", + }, + ) + + assert response.status_code == 201 + repository.create.assert_awaited_once_with( + username="alice", hashed_password="hashed", referal_id=None + ) + assert response.json()["referal_code"] == "new-code" + + +def test_login_distinguishes_unknown_user_and_bad_password(client): + unknown_repository = SimpleNamespace(get_user_by_username=AsyncMock(return_value=None)) + with patch("routes.auth.UserRepository", return_value=unknown_repository): + unknown_response = client.post( + "/auth/login", + json={"provider": "credentials", "username": "alice", "password": "secret"}, + ) + + existing_user = SimpleNamespace(hashed_password="hash") + existing_repository = SimpleNamespace( + get_user_by_username=AsyncMock(return_value=existing_user) + ) + with ( + patch("routes.auth.UserRepository", return_value=existing_repository), + patch("routes.auth.SessionsRepository"), + patch("routes.auth.verify_password", return_value=False), + ): + password_response = client.post( + "/auth/login", + json={"provider": "credentials", "username": "alice", "password": "secret"}, + ) + + assert unknown_response.status_code == password_response.status_code == 401 + assert unknown_response.json()["detail"] == "User doesn't exist." + assert password_response.json()["detail"] == "Invalid password" + + +def test_login_telegram_is_explicitly_unavailable(client): + response = client.post("/auth/login", json={"provider": "telegram"}) + + assert response.status_code == 503 + + +def test_refresh_requires_query_parameters_and_rejects_unknown_token(client): + missing_response = client.post("/auth/refresh") + repository = SimpleNamespace(get_session_by_hash=AsyncMock(return_value=None)) + with ( + patch("routes.auth.SessionsRepository", return_value=repository), + patch("routes.auth.hash_refresh_token", return_value="token-hash"), + ): + invalid_response = client.post("/auth/refresh?refresh_token=expired&iss=credentials") + + assert missing_response.status_code == 422 + assert invalid_response.status_code == 401 + assert invalid_response.json()["detail"] == "Refresh token is invalid." diff --git a/tests/test_services_and_payments.py b/tests/test_services_and_payments.py new file mode 100644 index 0000000..9f43650 --- /dev/null +++ b/tests/test_services_and_payments.py @@ -0,0 +1,106 @@ +# ruff: noqa: PLR2004 + +import hashlib +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from external.pally import BillStatus +from schemas.plans import AddonData, PricingPlans +from services.payments import validate_pally_signature +from services.subscriptions import calculate_order_total, should_apply_immediately + + +def pricing(): + return PricingPlans( + device_price=100, + addons=[ + AddonData(id="a", name="A", price=25, free_threshold=0, is_enabled=True), + AddonData(id="b", name="B", price=50, free_threshold=0, is_enabled=True), + ], + ) + + +def test_price_calculation_handles_fractional_months_and_unknown_addons(): + assert calculate_order_total(pricing(), devices=2, addon_ids=["a"], duration_days=15) == 112.5 + + with pytest.raises(KeyError): + calculate_order_total(pricing(), devices=1, addon_ids=["missing"], duration_days=30) + + +def test_should_apply_immediately_distinguishes_upgrade_from_downgrade(): + now = datetime.now(UTC) + subscription = SimpleNamespace( + devices=2, + expires_at=now + timedelta(days=10), + addons=[SimpleNamespace(addon_id="a")], + ) + upgrade = SimpleNamespace( + devices=3, addons=[SimpleNamespace(addon_id="a"), SimpleNamespace(addon_id="b")] + ) + downgrade = SimpleNamespace(devices=1, addons=[SimpleNamespace(addon_id="a")]) + + assert should_apply_immediately( + order=upgrade, subscription=subscription, pricing=pricing(), now=now + ) + assert not should_apply_immediately( + order=downgrade, subscription=subscription, pricing=pricing(), now=now + ) + assert should_apply_immediately(order=downgrade, subscription=None, pricing=pricing(), now=now) + + +def test_pally_signature_is_exact_and_case_sensitive(): + signature = hashlib.md5(b"10:42:test-token").hexdigest().upper() + with patch("services.payments.cfg.pally_token", "test-token"): + assert validate_pally_signature("10", "42", signature) + assert not validate_pally_signature("10", "42", signature.lower()) + assert not validate_pally_signature("11", "42", signature) + + +def callback_data(**overrides): + data = { + "InvId": "42", + "OutSum": "10", + "Commission": "0", + "TrsId": "transaction", + "Status": BillStatus.SUCCESS, + "CurrencyIn": "RUB", + "SignatureValue": "valid", + } + data.update(overrides) + return data + + +def test_callback_rejects_invalid_signature_before_processing(client): + with patch("routes.payments.pally.validate_pally_signature", return_value=False): + response = client.post("/payments/pally/result", data=callback_data()) + + assert response.status_code == 403 + assert response.json()["detail"] == "Invalid signature." + + +def test_callback_acknowledges_valid_non_success_and_non_numeric_invoice(client): + with patch("routes.payments.pally.validate_pally_signature", return_value=True): + failed_response = client.post("/payments/pally/result", data=callback_data(Status="Failed")) + invalid_id_response = client.post( + "/payments/pally/result", data=callback_data(InvId="order-42") + ) + + assert failed_response.status_code == invalid_id_response.status_code == 200 + assert failed_response.json() == invalid_id_response.json() == "OK" + + +def test_callback_uses_balance_amount_when_present(client): + process = AsyncMock() + with ( + patch("routes.payments.pally.validate_pally_signature", return_value=True), + patch("routes.payments.pally.process_subscription_purchase", process), + ): + response = client.post("/payments/pally/result", data=callback_data(BalanceAmount="7.9")) + + assert response.status_code == 200 + process.assert_awaited_once() + assert process.await_args.kwargs["invoice_id"] == 42 + assert process.await_args.kwargs["amount"] == 7 diff --git a/tests/test_user_and_plan_routes.py b/tests/test_user_and_plan_routes.py new file mode 100644 index 0000000..d19cc53 --- /dev/null +++ b/tests/test_user_and_plan_routes.py @@ -0,0 +1,131 @@ +# ruff: noqa: PLR2004, PLW0108 + +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from routes import users + + +def auth_context(subscription=None): + return SimpleNamespace( + user=SimpleNamespace( + id=1, + username="alice", + telegram_id=12345, + referal_code="ref-code", + balance=12.5, + subscription=subscription, + ) + ) + + +def test_protected_user_endpoint_requires_authorization(client): + response = client.get("/users/me") + + assert response.status_code == 403 + + +def test_get_me_serializes_auth_context(client): + client.app.dependency_overrides[users.get_auth_context] = lambda: auth_context() + + response = client.get("/users/me") + + assert response.status_code == 200 + assert response.json() == { + "username": "alice", + "telegram_id": 12345, + "referal_code": "ref-code", + "bonus_balance": 12.5, + } + + +def test_subscription_without_local_subscription_has_empty_details(client): + client.app.dependency_overrides[users.get_auth_context] = lambda: auth_context() + + response = client.get("/users/subscription") + + assert response.status_code == 200 + assert response.json() == { + "has_subscription": False, + "devices": None, + "expires_at": None, + "addon_ids": [], + "subscription_link": None, + "duration_days": None, + } + + +def test_subscription_keeps_local_data_when_external_user_is_missing(client): + subscription = SimpleNamespace( + id=2, + devices=3, + expires_at=datetime(2030, 1, 1, tzinfo=UTC), + duration_days=30, + addons=[SimpleNamespace(addon_id="a"), SimpleNamespace(addon_id="b")], + ) + client.app.dependency_overrides[users.get_auth_context] = lambda: auth_context(subscription) + with ( + patch("routes.users.get_sdk", return_value="sdk"), + patch("routes.users.get_rw_user", new=AsyncMock(return_value=None)), + ): + response = client.get("/users/subscription") + + assert response.status_code == 200 + assert response.json()["has_subscription"] is True + assert response.json()["addon_ids"] == ["a", "b"] + assert response.json()["subscription_link"] is None + + +def test_hwid_returns_empty_list_and_rejects_missing_external_user(client): + client.app.dependency_overrides[users.get_auth_context] = lambda: auth_context() + with ( + patch("routes.users.get_sdk", return_value="sdk"), + patch("routes.users.get_rw_user", new=AsyncMock(return_value=None)), + ): + missing_user = client.get("/users/subscription/hwid") + + rw_user = SimpleNamespace(uuid="uuid") + with ( + patch("routes.users.get_sdk", return_value="sdk"), + patch("routes.users.get_rw_user", new=AsyncMock(return_value=rw_user)), + patch("routes.users.get_hwid_list", new=AsyncMock(return_value=None)), + ): + empty_list = client.get("/users/subscription/hwid") + + assert missing_user.status_code == 403 + assert empty_list.status_code == 200 + assert empty_list.json() == [] + + +def test_hwid_maps_client_prefix_and_delete_preserves_external_result(client): + client.app.dependency_overrides[users.get_auth_context] = lambda: auth_context() + rw_user = SimpleNamespace(uuid="uuid") + device = SimpleNamespace( + platform="Windows", device_model="PC", user_agent="v2ray/6.0", hwid="abc" + ) + with ( + patch("routes.users.get_sdk", return_value="sdk"), + patch("routes.users.get_rw_user", new=AsyncMock(return_value=rw_user)), + patch("routes.users.get_hwid_list", new=AsyncMock(return_value=[device])), + patch("routes.users.delete_hwid", new=AsyncMock(return_value=False)), + ): + devices_response = client.get("/users/subscription/hwid") + delete_response = client.delete("/users/subscription/hwid?hwid=abc") + missing_hwid_response = client.delete("/users/subscription/hwid") + + assert devices_response.json() == [ + {"os": "Windows", "model": "PC", "client": "v2ray", "hwid": "abc"} + ] + assert delete_response.status_code == 200 + assert delete_response.json() == {"success": False} + assert missing_hwid_response.status_code == 422 + + +def test_plans_returns_pricing_service_result(client): + expected = {"device_price": 100, "addons": []} + with patch("routes.plans.get_pricing_model", new=AsyncMock(return_value=expected)): + response = client.get("/plans/") + + assert response.status_code == 200 + assert response.json() == expected