feat: introduced pytest powered tests
This commit is contained in:
17
tests/conftest.py
Normal file
17
tests/conftest.py
Normal 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
125
tests/test_auth_routes.py
Normal 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."
|
||||
106
tests/test_services_and_payments.py
Normal file
106
tests/test_services_and_payments.py
Normal 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
|
||||
131
tests/test_user_and_plan_routes.py
Normal file
131
tests/test_user_and_plan_routes.py
Normal 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
|
||||
Reference in New Issue
Block a user