feat: introduced pytest powered tests

This commit is contained in:
2026-08-18 20:09:59 +07:00
parent 938e924107
commit 7ae3c98585
5 changed files with 381 additions and 0 deletions

View File

@@ -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

17
tests/conftest.py Normal file
View File

@@ -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()

125
tests/test_auth_routes.py Normal file
View File

@@ -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."

View File

@@ -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

View File

@@ -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