v2.0
This commit is contained in:
21
tests/conftest.py
Normal file
21
tests/conftest.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def session_factory(tmp_path) -> AsyncIterator[async_sessionmaker[AsyncSession]]:
|
||||
database_path = tmp_path / "test.db"
|
||||
engine = create_async_engine(f"sqlite+aiosqlite:///{database_path}")
|
||||
|
||||
async with engine.begin() as connection:
|
||||
await connection.run_sync(Base.metadata.create_all)
|
||||
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
try:
|
||||
yield factory
|
||||
finally:
|
||||
await engine.dispose()
|
||||
61
tests/helpers.py
Normal file
61
tests/helpers.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.schemas.remnawave import RemnawaveUser
|
||||
|
||||
|
||||
def make_remote_user(
|
||||
*,
|
||||
user_uuid: str,
|
||||
user_id: int,
|
||||
short_uuid: str,
|
||||
username: str,
|
||||
telegram_id: int | None,
|
||||
expire_at: datetime | None = None,
|
||||
traffic_limit_bytes: int = 0,
|
||||
) -> RemnawaveUser:
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
expire = expire_at or (now + timedelta(days=30))
|
||||
|
||||
return RemnawaveUser.model_validate(
|
||||
{
|
||||
"uuid": user_uuid,
|
||||
"id": user_id,
|
||||
"shortUuid": short_uuid,
|
||||
"username": username,
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": traffic_limit_bytes,
|
||||
"expireAt": expire.isoformat().replace("+00:00", "Z"),
|
||||
"telegramId": telegram_id,
|
||||
"email": None,
|
||||
"description": None,
|
||||
"tag": "BOT",
|
||||
"hwidDeviceLimit": None,
|
||||
"externalSquadUuid": None,
|
||||
"trojanPassword": f"trojan-{user_id}",
|
||||
"vlessUuid": f"00000000-0000-0000-0000-{user_id:012d}",
|
||||
"ssPassword": f"ss-{user_id}",
|
||||
"lastTriggeredThreshold": 0,
|
||||
"subRevokedAt": None,
|
||||
"subLastUserAgent": None,
|
||||
"subLastOpenedAt": None,
|
||||
"lastTrafficResetAt": None,
|
||||
"createdAt": now.isoformat().replace("+00:00", "Z"),
|
||||
"updatedAt": now.isoformat().replace("+00:00", "Z"),
|
||||
"subscriptionUrl": f"https://example.com/sub/{short_uuid}",
|
||||
"activeInternalSquads": [
|
||||
{
|
||||
"uuid": "11111111-1111-1111-1111-111111111111",
|
||||
"name": "VPN",
|
||||
}
|
||||
],
|
||||
"userTraffic": {
|
||||
"usedTrafficBytes": 0,
|
||||
"lifetimeUsedTrafficBytes": 0,
|
||||
"onlineAt": None,
|
||||
"firstConnectedAt": None,
|
||||
"lastConnectedNodeUuid": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
74
tests/test_bot_config_service.py
Normal file
74
tests/test_bot_config_service.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from app.config import Settings
|
||||
from app.services.bot_config_service import BotConfigService
|
||||
|
||||
|
||||
def _make_settings() -> Settings:
|
||||
return Settings.model_construct(
|
||||
bot_brand_name="OREOL VPN",
|
||||
bot_public_username="oreol_vpn_bot",
|
||||
bot_support_url="https://t.me/support_account",
|
||||
bot_terms_url="https://example.com/terms",
|
||||
bot_support_ticket_link="",
|
||||
bot_support_ticket_chat_id_raw="",
|
||||
bot_support_ticket_thread_id=0,
|
||||
bot_start_image_enabled=True,
|
||||
bot_start_image_path="assets/main.png",
|
||||
payment_review_link="",
|
||||
payment_review_chat_id_raw="",
|
||||
payment_review_thread_id=0,
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
payment_plans_raw="30:250,180:600",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_support_text="Напишите в поддержку",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
payment_internal_squad_uuids_raw="uuid-1",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_user_tag="BOT",
|
||||
)
|
||||
|
||||
|
||||
async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> None:
|
||||
service = BotConfigService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
)
|
||||
|
||||
await service.update_setting(
|
||||
key="bot_brand_name",
|
||||
raw_value="NEW BRAND",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
await service.update_setting(
|
||||
key="payment_plans_raw",
|
||||
raw_value="30:300,90:700",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
await service.update_setting(
|
||||
key="referral_enabled",
|
||||
raw_value="off",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
|
||||
snapshot = await service.get_snapshot()
|
||||
|
||||
assert snapshot.bot_brand_name == "NEW BRAND"
|
||||
assert snapshot.referral_enabled is False
|
||||
assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
|
||||
(30, 300),
|
||||
(90, 700),
|
||||
]
|
||||
|
||||
|
||||
async def test_bot_config_service_formats_values_for_admin_ui(session_factory) -> None:
|
||||
service = BotConfigService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
)
|
||||
snapshot = await service.get_snapshot()
|
||||
|
||||
assert BotConfigService.format_value(snapshot=snapshot, key="bot_public_username") == "@oreol_vpn_bot"
|
||||
assert BotConfigService.format_value(snapshot=snapshot, key="referral_enabled") == "on"
|
||||
assert BotConfigService.format_value(snapshot=snapshot, key="payment_plan_traffic_limit_gb") == "0 GB (unlimited)"
|
||||
81
tests/test_config.py
Normal file
81
tests/test_config.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def test_database_server_url_excludes_database_name() -> None:
|
||||
settings = Settings.model_construct(
|
||||
db_host="127.0.0.1",
|
||||
db_port=3306,
|
||||
db_name="oreolvpn",
|
||||
db_user="user",
|
||||
db_password="pass",
|
||||
)
|
||||
|
||||
assert settings.database_server_url == "mysql+aiomysql://user:pass@127.0.0.1:3306/?charset=utf8mb4"
|
||||
|
||||
|
||||
def test_support_ticket_link_parses_private_topic_link() -> None:
|
||||
settings = Settings.model_construct(
|
||||
bot_support_ticket_link="https://t.me/c/3646494169/2/3",
|
||||
bot_support_ticket_chat_id_raw="",
|
||||
bot_support_ticket_thread_id=0,
|
||||
)
|
||||
|
||||
assert settings.support_ticket_chat_id == -1003646494169
|
||||
assert settings.support_ticket_message_thread_id == 2
|
||||
|
||||
|
||||
def test_payment_review_link_parses_private_topic_link() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_review_link="https://t.me/c/3646494169/56/57",
|
||||
payment_review_chat_id_raw="",
|
||||
payment_review_thread_id=0,
|
||||
)
|
||||
|
||||
assert settings.payment_review_chat_id == -1003646494169
|
||||
assert settings.payment_review_message_thread_id == 56
|
||||
|
||||
|
||||
def test_moderator_ids_are_parsed() -> None:
|
||||
settings = Settings.model_construct(
|
||||
bot_moderator_ids_raw="123, 456 ,789",
|
||||
)
|
||||
|
||||
assert settings.bot_moderator_ids == {123, 456, 789}
|
||||
assert settings.is_moderator(456) is True
|
||||
assert settings.is_moderator(999) is False
|
||||
|
||||
|
||||
def test_public_username_is_normalized() -> None:
|
||||
settings = Settings.model_construct(
|
||||
bot_public_username="@oreol_vpn_bot",
|
||||
)
|
||||
|
||||
assert settings.bot_public_username_normalized == "oreol_vpn_bot"
|
||||
|
||||
|
||||
def test_payment_internal_squad_uuids_are_parsed() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_internal_squad_uuids_raw="uuid-1, uuid-2 ,uuid-3",
|
||||
)
|
||||
|
||||
assert settings.payment_internal_squad_uuids == ["uuid-1", "uuid-2", "uuid-3"]
|
||||
|
||||
|
||||
def test_payment_username_prefix_is_normalized() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_username_prefix="Oreol!! prefix",
|
||||
)
|
||||
|
||||
assert settings.payment_username_prefix_normalized == "Oreolprefix"
|
||||
|
||||
|
||||
def test_payment_plans_are_parsed() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_plans_raw="30:250,180:600,365:1000",
|
||||
)
|
||||
|
||||
plans = settings.payment_plans
|
||||
|
||||
assert [plan.code for plan in plans] == ["30d", "180d", "365d"]
|
||||
assert [plan.days for plan in plans] == [30, 180, 365]
|
||||
assert [plan.amount_rub for plan in plans] == [250, 600, 1000]
|
||||
77
tests/test_db_session.py
Normal file
77
tests/test_db_session.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import pytest
|
||||
|
||||
from app.db.session import _normalize_payment_order_provision_username_index
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._rows)
|
||||
|
||||
|
||||
class _FakeConnection:
|
||||
def __init__(self, *, dialect_name: str, responses: list[list[tuple[str]]]) -> None:
|
||||
self.dialect = type("Dialect", (), {"name": dialect_name})()
|
||||
self._responses = list(responses)
|
||||
self.executed: list[tuple[str, dict[str, object] | None]] = []
|
||||
|
||||
async def execute(self, statement, params=None):
|
||||
self.executed.append((str(statement), params))
|
||||
rows = self._responses.pop(0) if self._responses else []
|
||||
return _FakeResult(rows)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_payment_order_index_drops_stale_unique_and_recreates_plain_index() -> None:
|
||||
connection = _FakeConnection(
|
||||
dialect_name="mysql",
|
||||
responses=[
|
||||
[("ix_payment_orders_provision_username",)],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
],
|
||||
)
|
||||
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
|
||||
executed_sql = [sql for sql, _ in connection.executed]
|
||||
|
||||
assert any(
|
||||
"DROP INDEX `ix_payment_orders_provision_username` ON `payment_orders`" in sql
|
||||
for sql in executed_sql
|
||||
)
|
||||
assert any(
|
||||
"CREATE INDEX `ix_payment_orders_provision_username` ON `payment_orders` (`provision_username`)"
|
||||
in sql
|
||||
for sql in executed_sql
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_payment_order_index_keeps_existing_non_unique_index() -> None:
|
||||
connection = _FakeConnection(
|
||||
dialect_name="mysql",
|
||||
responses=[
|
||||
[],
|
||||
[("custom_payment_orders_provision_username",)],
|
||||
],
|
||||
)
|
||||
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
|
||||
executed_sql = [sql for sql, _ in connection.executed]
|
||||
|
||||
assert not any("DROP INDEX" in sql for sql in executed_sql)
|
||||
assert not any("CREATE INDEX" in sql for sql in executed_sql)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalize_payment_order_index_skips_non_mysql_backends() -> None:
|
||||
connection = _FakeConnection(dialect_name="sqlite", responses=[])
|
||||
|
||||
await _normalize_payment_order_provision_username_index(connection)
|
||||
|
||||
assert connection.executed == []
|
||||
88
tests/test_formatters.py
Normal file
88
tests/test_formatters.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from app.utils.formatters import (
|
||||
build_help_text,
|
||||
format_bytes,
|
||||
format_datetime,
|
||||
format_datetime_with_days_left,
|
||||
format_issued_access,
|
||||
format_payment_plan,
|
||||
format_traffic_limit,
|
||||
)
|
||||
|
||||
|
||||
def test_format_bytes_for_megabytes() -> None:
|
||||
assert format_bytes(1024 * 1024) == "1.00 MB"
|
||||
|
||||
|
||||
def test_format_traffic_limit_without_limit() -> None:
|
||||
assert format_traffic_limit(0) == "без лимита"
|
||||
|
||||
|
||||
def test_format_datetime_value() -> None:
|
||||
assert format_datetime(datetime(2026, 4, 21, 12, 30)) == "21.04.2026 12:30"
|
||||
|
||||
|
||||
def test_build_help_text_contains_only_start_command() -> None:
|
||||
text = build_help_text(is_admin=False)
|
||||
|
||||
assert "/start - открыть главное меню" in text
|
||||
assert "Дальше используйте кнопки внутри интерфейса бота." in text
|
||||
assert "/menu - " not in text
|
||||
assert "/buy - " not in text
|
||||
assert "/paysupport - " not in text
|
||||
assert "/sync - " not in text
|
||||
assert "/link " not in text
|
||||
assert "/help - " not in text
|
||||
|
||||
|
||||
def test_format_datetime_with_days_left() -> None:
|
||||
value = datetime.now().replace(microsecond=0) + timedelta(days=3, hours=2)
|
||||
|
||||
text = format_datetime_with_days_left(value)
|
||||
|
||||
assert value.strftime("%d.%m.%Y %H:%M") in text
|
||||
assert "(4 " in text
|
||||
|
||||
|
||||
def test_format_payment_plan_uses_rubles() -> None:
|
||||
text = format_payment_plan(
|
||||
title="OREOL VPN на 30 дней",
|
||||
description="Доступ к VPN на 30 дней",
|
||||
price_stars=250,
|
||||
duration_days=30,
|
||||
traffic_limit_bytes=0,
|
||||
)
|
||||
|
||||
assert "OREOL VPN на 30 дней" in text
|
||||
assert "Цена: <b>250 ₽</b>" in text
|
||||
assert "Трафик: <b>без лимита</b>" in text
|
||||
|
||||
|
||||
def test_format_issued_access_contains_subscription_data() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
expire_at=datetime(2026, 5, 21, 18, 0),
|
||||
traffic_limit_bytes=0,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "OREOL VPN" in text
|
||||
assert "https://example.com/sub" in text
|
||||
assert "21.05.2026 18:00" in text
|
||||
assert "Подписка активирована" in text
|
||||
assert "Что делать дальше" in text
|
||||
assert "/me</code>" not in text
|
||||
|
||||
|
||||
def test_format_issued_access_for_renewal_mentions_extension() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
expire_at=datetime(2026, 5, 21, 18, 0),
|
||||
traffic_limit_bytes=0,
|
||||
brand_name="OREOL VPN",
|
||||
is_renewal=True,
|
||||
)
|
||||
|
||||
assert "подписка продлена" in text.lower()
|
||||
assert "Подписка продлена." in text
|
||||
472
tests/test_panel_ui.py
Normal file
472
tests/test_panel_ui.py
Normal file
@@ -0,0 +1,472 @@
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.bot.ui.panel import (
|
||||
PANEL_SECTION_ADMIN,
|
||||
PANEL_SECTION_HOME,
|
||||
PANEL_SECTION_REFERRAL,
|
||||
PANEL_SECTION_SUBSCRIPTION,
|
||||
PANEL_SECTION_SUPPORT,
|
||||
PANEL_SECTION_TERMS,
|
||||
PanelContext,
|
||||
build_panel_keyboard,
|
||||
render_panel_caption,
|
||||
)
|
||||
from app.services.sync_service import CachedUserView
|
||||
|
||||
|
||||
def _button_texts(markup) -> list[str]:
|
||||
return [button.text for row in markup.inline_keyboard for button in row]
|
||||
|
||||
|
||||
def _cached_user_view(*, telegram_id: int = 123456789) -> CachedUserView:
|
||||
return CachedUserView(
|
||||
record=SimpleNamespace(
|
||||
username="oreol-renew",
|
||||
status="ACTIVE",
|
||||
expire_at=datetime.now() + timedelta(days=10),
|
||||
used_traffic_bytes=0,
|
||||
traffic_limit_bytes=0,
|
||||
short_uuid="renew999",
|
||||
subscription_url="https://example.com/sub/renew999",
|
||||
telegram_id=telegram_id,
|
||||
),
|
||||
internal_squads=["VPN"],
|
||||
)
|
||||
|
||||
|
||||
def test_home_panel_caption_without_subscription() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_HOME,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "Добро пожаловать в <b>OREOL VPN</b>" in caption
|
||||
assert "<b>Профиль</b>" not in caption
|
||||
assert "<b>Подписка</b>" in caption
|
||||
assert "Статус: <b>Не активна</b>" in caption
|
||||
assert "Срок: <b>—</b>" in caption
|
||||
assert "Трафик: <b>—</b>" in caption
|
||||
|
||||
|
||||
def test_home_panel_keyboard_has_expected_buttons() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "Профиль" not in button_texts
|
||||
assert "💳 Подписка" in button_texts
|
||||
assert "🆘 Поддержка" in button_texts
|
||||
assert "👥 Реферальная система" in button_texts
|
||||
assert "📜 Условия использования" in button_texts
|
||||
assert len(markup.inline_keyboard[0]) == 1
|
||||
assert markup.inline_keyboard[0][0].text == "💳 Подписка"
|
||||
assert markup.inline_keyboard[0][0].callback_data == "payment:menu"
|
||||
|
||||
|
||||
def test_admin_home_panel_keyboard_has_admin_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "🛠 Админ-панель" in button_texts
|
||||
assert len(markup.inline_keyboard[1]) == 1
|
||||
assert markup.inline_keyboard[1][0].text == "🛠 Админ-панель"
|
||||
assert markup.inline_keyboard[1][0].callback_data == "panel:admin"
|
||||
|
||||
|
||||
def test_admin_panel_caption_contains_dashboard_stats() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
admin_total_telegram_users=120,
|
||||
admin_total_cached_users=80,
|
||||
admin_active_cached_users=67,
|
||||
admin_pending_orders=9,
|
||||
admin_review_orders=4,
|
||||
admin_open_tickets=3,
|
||||
admin_pending_referral_bonuses=2,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_ADMIN,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>🛠 Админ-панель OREOL VPN</b>" in caption
|
||||
assert "Пользователей в боте: <b>120</b>" in caption
|
||||
assert "Чеков на проверке: <b>4</b>" in caption
|
||||
assert "Открытых тикетов: <b>3</b>" in caption
|
||||
assert "Бонусов в ожидании: <b>2</b>" in caption
|
||||
|
||||
|
||||
def test_admin_panel_keyboard_has_expected_buttons() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(
|
||||
section=PANEL_SECTION_ADMIN,
|
||||
context=context,
|
||||
payment_review_url="https://t.me/c/123/56/57",
|
||||
support_ticket_url="https://t.me/c/123/2/3",
|
||||
)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "🔍 Найти пользователя" in button_texts
|
||||
assert "👤 Пользователи" in button_texts
|
||||
assert "🔄 Полная синхронизация" in button_texts
|
||||
assert "💸 Очередь оплат" in button_texts
|
||||
assert "🎫 Тикеты" in button_texts
|
||||
assert "↻ Обновить" in button_texts
|
||||
assert "🏠 Главное меню" in button_texts
|
||||
|
||||
|
||||
def test_subscription_caption_without_subscription_but_with_payment() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
payment_enabled=True,
|
||||
payment_plan_title="OREOL VPN на 30 дней",
|
||||
payment_plan_description="Доступ к VPN на 30 дней",
|
||||
payment_plan_price_stars=250,
|
||||
payment_plan_duration_days=30,
|
||||
payment_plan_traffic_limit_bytes=0,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUBSCRIPTION,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "У вас пока нет активного доступа." in caption
|
||||
assert "OREOL VPN на 30 дней" in caption
|
||||
assert "Цена: <b>250 ₽</b>" in caption
|
||||
assert "После выбора тарифа бот покажет реквизиты" in caption
|
||||
|
||||
|
||||
def test_subscription_caption_without_payment_uses_ui_instructions() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
payment_enabled=False,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUBSCRIPTION,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "откройте поддержку" in caption
|
||||
assert "short UUID" in caption
|
||||
assert "/link" not in caption
|
||||
|
||||
|
||||
def test_subscription_keyboard_has_buy_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
payment_enabled=True,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_SUBSCRIPTION, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "💳 Купить подписку" in button_texts
|
||||
assert "🏠 Главное меню" in button_texts
|
||||
|
||||
|
||||
def test_subscription_keyboard_has_renew_button_when_access_exists() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[_cached_user_view()],
|
||||
payment_enabled=True,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_SUBSCRIPTION, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "💳 Продлить подписку" in button_texts
|
||||
assert "💳 Купить подписку" not in button_texts
|
||||
|
||||
|
||||
def test_subscription_caption_with_existing_access_mentions_renewal() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[_cached_user_view()],
|
||||
payment_enabled=True,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUBSCRIPTION,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "Продлить подписку" in caption
|
||||
assert "автоматически добавит выбранный срок" in caption
|
||||
|
||||
|
||||
def test_terms_panel_caption() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_TERMS,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>📜 Правила VPN</b>" in caption
|
||||
assert "использовать VPN только в законных целях" in caption
|
||||
assert "не гарантирует полной анонимности" in caption
|
||||
|
||||
|
||||
def test_support_panel_has_create_ticket_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_SUPPORT, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "🎫 Создать тикет" in button_texts
|
||||
assert "🏠 Главное меню" in button_texts
|
||||
|
||||
|
||||
def test_support_panel_has_support_url_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(
|
||||
section=PANEL_SECTION_SUPPORT,
|
||||
context=context,
|
||||
support_url="https://t.me/support_account",
|
||||
)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert "Написать в поддержку" in button_texts
|
||||
|
||||
|
||||
def test_support_panel_caption() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_SUPPORT,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>🆘 Помощь и сопровождение</b>" in caption
|
||||
assert "откройте тикет через кнопку ниже" in caption
|
||||
|
||||
|
||||
def test_referral_panel_caption_with_link() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_code="ABCD1234",
|
||||
referral_count=3,
|
||||
referral_link="https://t.me/oreol_vpn_bot?start=ref_ABCD1234",
|
||||
referral_share_url="https://t.me/share/url?url=test",
|
||||
referral_recent_names=["Alex", "Maria"],
|
||||
referral_discount_percent=12,
|
||||
referral_bonus_days=7,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_REFERRAL,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "<b>👥 Реферальная система</b>" in caption
|
||||
assert "Ваш реферальный код: <code>ABCD1234</code>" in caption
|
||||
assert "Приглашено пользователей: <b>3</b>" in caption
|
||||
assert "+7 дней" in caption
|
||||
assert "Чтобы получить скидку 12%" in caption
|
||||
assert "https://t.me/oreol_vpn_bot?start=ref_ABCD1234" in caption
|
||||
assert "Alex" in caption
|
||||
assert "Maria" in caption
|
||||
|
||||
|
||||
def test_referral_caption_uses_runtime_discount_for_applied_code() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_code="ABCD1234",
|
||||
referral_count=1,
|
||||
applied_referral_code="PROMO777",
|
||||
referral_discount_percent=17,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_REFERRAL,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "Код: <code>PROMO777</code>" in caption
|
||||
assert "Скидка перед оплатой: <b>17%</b>" in caption
|
||||
|
||||
|
||||
def test_admin_panel_keyboard_has_settings_button() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
users=[],
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_ADMIN, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert any("Настрой" in text for text in button_texts)
|
||||
|
||||
|
||||
def test_home_panel_hides_referral_when_disabled() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_enabled=False,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
markup = build_panel_keyboard(section=PANEL_SECTION_HOME, context=context)
|
||||
button_texts = _button_texts(markup)
|
||||
|
||||
assert all("Реферал" not in text for text in button_texts)
|
||||
|
||||
|
||||
def test_referral_panel_caption_when_disabled() -> None:
|
||||
context = PanelContext(
|
||||
telegram_id=123456789,
|
||||
display_name="Evgeniy Spirin",
|
||||
username="evgeniy",
|
||||
language_code="ru",
|
||||
is_admin=False,
|
||||
users=[],
|
||||
referral_enabled=False,
|
||||
api_error=None,
|
||||
)
|
||||
|
||||
caption = render_panel_caption(
|
||||
section=PANEL_SECTION_REFERRAL,
|
||||
context=context,
|
||||
brand_name="OREOL VPN",
|
||||
)
|
||||
|
||||
assert "отключена" in caption
|
||||
484
tests/test_payment_service.py
Normal file
484
tests/test_payment_service.py
Normal file
@@ -0,0 +1,484 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.models import PaymentOrder, ReferralBonus, ReferralInvite, TelegramUser
|
||||
from app.services.payment_service import (
|
||||
FULFILLED_PAYMENT_STATUS,
|
||||
PENDING_PAYMENT_STATUS,
|
||||
REFERRAL_BONUS_APPLIED_STATUS,
|
||||
REFERRAL_BONUS_PENDING_STATUS,
|
||||
REVIEW_PAYMENT_STATUS,
|
||||
PaymentService,
|
||||
)
|
||||
from app.services.remnawave_client import RemnawaveApiError
|
||||
from tests.helpers import make_remote_user
|
||||
|
||||
|
||||
class _StubSyncService:
|
||||
def __init__(self, *, cached_users_by_telegram: dict[int, list] | None = None) -> None:
|
||||
self.refreshed: list[int] = []
|
||||
self.cached_users_by_telegram = cached_users_by_telegram or {}
|
||||
|
||||
async def get_referral_summary(self, telegram_id: int):
|
||||
return type(
|
||||
"ReferralSummaryStub",
|
||||
(),
|
||||
{
|
||||
"applied_referral_code": "",
|
||||
"referral_code": "",
|
||||
"total_invited": 0,
|
||||
"recent_names": [],
|
||||
},
|
||||
)()
|
||||
|
||||
async def refresh_cached_users_for_telegram(self, *, telegram_id: int):
|
||||
self.refreshed.append(telegram_id)
|
||||
return []
|
||||
|
||||
async def get_cached_users_for_telegram(self, telegram_id: int):
|
||||
return list(self.cached_users_by_telegram.get(telegram_id, []))
|
||||
|
||||
|
||||
class _StubRemnawaveClient:
|
||||
def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None:
|
||||
self.created_user = created_user
|
||||
self.created_bodies: list[dict[str, object]] = []
|
||||
self.updated_bodies: list[dict[str, object]] = []
|
||||
self.users_by_telegram_id = users_by_telegram_id or {}
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: str):
|
||||
raise RemnawaveApiError(status_code=404, message="not found")
|
||||
|
||||
async def get_user_by_username(self, username: str):
|
||||
raise RemnawaveApiError(status_code=404, message="not found")
|
||||
|
||||
async def get_users_by_telegram_id(self, telegram_id: int):
|
||||
return list(self.users_by_telegram_id.get(telegram_id, []))
|
||||
|
||||
async def create_user(self, body: dict[str, object]):
|
||||
self.created_bodies.append(body)
|
||||
return self.created_user
|
||||
|
||||
async def update_user(self, body: dict[str, object]):
|
||||
self.updated_bodies.append(body)
|
||||
raw_uuid = str(body.get("uuid") or "")
|
||||
raw_expire_at = str(body.get("expireAt") or "")
|
||||
if raw_expire_at.endswith("Z"):
|
||||
raw_expire_at = raw_expire_at.replace("Z", "+00:00")
|
||||
next_expire_at = datetime.fromisoformat(raw_expire_at) if raw_expire_at else None
|
||||
|
||||
for telegram_id, users in self.users_by_telegram_id.items():
|
||||
for index, user in enumerate(users):
|
||||
if str(user.uuid) != raw_uuid:
|
||||
continue
|
||||
updated_user = make_remote_user(
|
||||
user_uuid=str(user.uuid),
|
||||
user_id=user.id,
|
||||
short_uuid=user.short_uuid,
|
||||
username=user.username,
|
||||
telegram_id=telegram_id,
|
||||
expire_at=next_expire_at or user.expire_at,
|
||||
traffic_limit_bytes=user.traffic_limit_bytes,
|
||||
)
|
||||
users[index] = updated_user
|
||||
return updated_user
|
||||
|
||||
raise AssertionError("update_user should not be called in this scenario")
|
||||
|
||||
|
||||
async def _seed_order(
|
||||
session_factory,
|
||||
*,
|
||||
telegram_id: int,
|
||||
status: str,
|
||||
order_uuid: str | None = None,
|
||||
) -> str:
|
||||
resolved_order_uuid = order_uuid or str(uuid4())
|
||||
|
||||
async with session_factory() as session:
|
||||
user = TelegramUser(
|
||||
telegram_id=telegram_id,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
language_code="ru",
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
|
||||
session.add(
|
||||
PaymentOrder(
|
||||
order_uuid=resolved_order_uuid,
|
||||
telegram_user_id=user.id,
|
||||
telegram_id=telegram_id,
|
||||
plan_code="30d",
|
||||
plan_title="OREOL VPN на 30 дней",
|
||||
plan_duration_days=30,
|
||||
traffic_limit_bytes=0,
|
||||
traffic_limit_strategy="NO_RESET",
|
||||
amount_stars=250,
|
||||
currency="RUB",
|
||||
status=status,
|
||||
invoice_payload=f"payload-{resolved_order_uuid}",
|
||||
provision_username="Oreol-1001-tester",
|
||||
error_message=None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return resolved_order_uuid
|
||||
|
||||
|
||||
async def _seed_referral_invite(
|
||||
session_factory,
|
||||
*,
|
||||
inviter_telegram_id: int,
|
||||
invited_telegram_id: int,
|
||||
) -> None:
|
||||
async with session_factory() as session:
|
||||
session.add(
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=inviter_telegram_id,
|
||||
invited_telegram_id=invited_telegram_id,
|
||||
invited_username="buyer",
|
||||
invited_display_name="Buyer",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
def _make_settings() -> Settings:
|
||||
return Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
)
|
||||
|
||||
|
||||
async def test_mark_order_under_review_only_transitions_once(session_factory) -> None:
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(
|
||||
created_user=make_remote_user(
|
||||
user_uuid="11111111-1111-1111-1111-111111111111",
|
||||
user_id=1,
|
||||
short_uuid="abc123",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
),
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
order = await service.mark_order_under_review(order_uuid=order_uuid, telegram_id=1001)
|
||||
|
||||
assert order.status == REVIEW_PAYMENT_STATUS
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await service.mark_order_under_review(order_uuid=order_uuid, telegram_id=1001)
|
||||
|
||||
|
||||
async def test_approve_order_requires_review_status(session_factory) -> None:
|
||||
remote_user = make_remote_user(
|
||||
user_uuid="22222222-2222-2222-2222-222222222222",
|
||||
user_id=2,
|
||||
short_uuid="def456",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(created_user=remote_user),
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=PENDING_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
|
||||
async def test_approve_order_fulfills_and_refreshes_cache(session_factory) -> None:
|
||||
remote_user = make_remote_user(
|
||||
user_uuid="33333333-3333-3333-3333-333333333333",
|
||||
user_id=3,
|
||||
short_uuid="ghi789",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(created_user=remote_user)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.remnawave_user_uuid == str(remote_user.uuid)
|
||||
assert access.is_renewal is False
|
||||
assert sync_service.refreshed == [1001]
|
||||
assert remnawave_client.created_bodies
|
||||
|
||||
async with session_factory() as session:
|
||||
order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order_uuid)
|
||||
)
|
||||
|
||||
assert order is not None
|
||||
assert order.status == FULFILLED_PAYMENT_STATUS
|
||||
assert order.remnawave_user_uuid == str(remote_user.uuid)
|
||||
|
||||
|
||||
async def test_approve_order_grants_and_applies_referral_bonus(session_factory) -> None:
|
||||
buyer_remote_user = make_remote_user(
|
||||
user_uuid="44444444-4444-4444-4444-444444444444",
|
||||
user_id=4,
|
||||
short_uuid="buyer444",
|
||||
username="oreol-buyer",
|
||||
telegram_id=1001,
|
||||
)
|
||||
inviter_remote_user = make_remote_user(
|
||||
user_uuid="55555555-5555-5555-5555-555555555555",
|
||||
user_id=5,
|
||||
short_uuid="inviter555",
|
||||
username="oreol-inviter",
|
||||
telegram_id=2002,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(
|
||||
created_user=buyer_remote_user,
|
||||
users_by_telegram_id={2002: [inviter_remote_user]},
|
||||
)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
await _seed_referral_invite(
|
||||
session_factory,
|
||||
inviter_telegram_id=2002,
|
||||
invited_telegram_id=1001,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.referral_bonus is not None
|
||||
assert access.referral_bonus.inviter_telegram_id == 2002
|
||||
assert access.referral_bonus.bonus_days == 7
|
||||
assert access.referral_bonus.status == REFERRAL_BONUS_APPLIED_STATUS
|
||||
assert sync_service.refreshed == [1001, 2002]
|
||||
assert any(
|
||||
body.get("uuid") == str(inviter_remote_user.uuid)
|
||||
for body in remnawave_client.updated_bodies
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
bonus = await session.scalar(
|
||||
select(ReferralBonus).where(ReferralBonus.source_order_uuid == order_uuid)
|
||||
)
|
||||
|
||||
assert bonus is not None
|
||||
assert bonus.status == REFERRAL_BONUS_APPLIED_STATUS
|
||||
|
||||
|
||||
async def test_approve_order_keeps_referral_bonus_pending_without_inviter_access(
|
||||
session_factory,
|
||||
) -> None:
|
||||
buyer_remote_user = make_remote_user(
|
||||
user_uuid="66666666-6666-6666-6666-666666666666",
|
||||
user_id=6,
|
||||
short_uuid="buyer666",
|
||||
username="oreol-buyer-2",
|
||||
telegram_id=1001,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(created_user=buyer_remote_user)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
await _seed_referral_invite(
|
||||
session_factory,
|
||||
inviter_telegram_id=2002,
|
||||
invited_telegram_id=1001,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.referral_bonus is not None
|
||||
assert access.referral_bonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||||
assert sync_service.refreshed == [1001]
|
||||
|
||||
async with session_factory() as session:
|
||||
bonus = await session.scalar(
|
||||
select(ReferralBonus).where(ReferralBonus.source_order_uuid == order_uuid)
|
||||
)
|
||||
|
||||
assert bonus is not None
|
||||
assert bonus.status == REFERRAL_BONUS_PENDING_STATUS
|
||||
|
||||
|
||||
async def test_create_order_generates_unique_provision_username(session_factory) -> None:
|
||||
service = PaymentService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_plans_raw="30:250",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(
|
||||
created_user=make_remote_user(
|
||||
user_uuid="77777777-7777-7777-7777-777777777777",
|
||||
user_id=7,
|
||||
short_uuid="buyer777",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
),
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
|
||||
first_order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
second_order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
|
||||
assert first_order.provision_username != second_order.provision_username
|
||||
assert first_order.extends_existing_access is False
|
||||
assert second_order.extends_existing_access is False
|
||||
|
||||
|
||||
async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None:
|
||||
service = PaymentService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_plans_raw="30:250",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(
|
||||
created_user=make_remote_user(
|
||||
user_uuid="77777777-7777-7777-7777-777777777777",
|
||||
user_id=7,
|
||||
short_uuid="buyer777",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
),
|
||||
sync_service=_StubSyncService(cached_users_by_telegram={1001: [object()]}),
|
||||
)
|
||||
|
||||
order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username="tester",
|
||||
first_name="Test",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
|
||||
assert order.extends_existing_access is True
|
||||
assert "продлит текущую подписку" in service.build_transfer_text(order=order)
|
||||
|
||||
|
||||
async def test_approve_order_extends_existing_user_found_by_telegram_id(session_factory) -> None:
|
||||
existing_remote_user = make_remote_user(
|
||||
user_uuid="88888888-8888-8888-8888-888888888888",
|
||||
user_id=8,
|
||||
short_uuid="existing888",
|
||||
username="oreol-existing",
|
||||
telegram_id=1001,
|
||||
)
|
||||
sync_service = _StubSyncService()
|
||||
remnawave_client = _StubRemnawaveClient(
|
||||
created_user=existing_remote_user,
|
||||
users_by_telegram_id={1001: [existing_remote_user]},
|
||||
)
|
||||
service = PaymentService(
|
||||
settings=_make_settings(),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=sync_service,
|
||||
)
|
||||
order_uuid = await _seed_order(
|
||||
session_factory,
|
||||
telegram_id=1001,
|
||||
status=REVIEW_PAYMENT_STATUS,
|
||||
)
|
||||
|
||||
access = await service.approve_order(order_uuid=order_uuid)
|
||||
|
||||
assert access.remnawave_user_uuid == str(existing_remote_user.uuid)
|
||||
assert access.is_renewal is True
|
||||
assert remnawave_client.created_bodies == []
|
||||
assert remnawave_client.updated_bodies
|
||||
59
tests/test_remnawave_client.py
Normal file
59
tests/test_remnawave_client.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import httpx
|
||||
|
||||
from app.services.remnawave_client import RemnawaveApiClient
|
||||
|
||||
|
||||
def test_normalize_base_url_adds_api_suffix() -> None:
|
||||
assert (
|
||||
RemnawaveApiClient.normalize_base_url("https://panel.example.com")
|
||||
== "https://panel.example.com/api/"
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_base_url_keeps_existing_api_suffix() -> None:
|
||||
assert (
|
||||
RemnawaveApiClient.normalize_base_url("https://panel.example.com/api/")
|
||||
== "https://panel.example.com/api/"
|
||||
)
|
||||
|
||||
|
||||
def test_unwrap_payload_response_field() -> None:
|
||||
payload = {"response": {"ok": True}}
|
||||
assert RemnawaveApiClient.unwrap_payload(payload) == {"ok": True}
|
||||
|
||||
|
||||
def test_build_error_includes_error_code() -> None:
|
||||
client = object.__new__(RemnawaveApiClient)
|
||||
response = httpx.Response(
|
||||
500,
|
||||
json={
|
||||
"message": "Failed to create user",
|
||||
"errorCode": "A018",
|
||||
},
|
||||
request=httpx.Request("POST", "https://panel.example.com/api/users"),
|
||||
)
|
||||
|
||||
error = client._build_error(response)
|
||||
|
||||
assert error.message == "Failed to create user [A018]"
|
||||
|
||||
|
||||
def test_build_error_includes_validation_details() -> None:
|
||||
client = object.__new__(RemnawaveApiClient)
|
||||
response = httpx.Response(
|
||||
400,
|
||||
json={
|
||||
"message": "Validation failed",
|
||||
"errors": [
|
||||
{
|
||||
"message": "Invalid uuid",
|
||||
"path": ["uuid"],
|
||||
}
|
||||
],
|
||||
},
|
||||
request=httpx.Request("DELETE", "https://panel.example.com/api/users/not-a-uuid"),
|
||||
)
|
||||
|
||||
error = client._build_error(response)
|
||||
|
||||
assert error.message == "Validation failed | uuid: Invalid uuid"
|
||||
208
tests/test_sync_service.py
Normal file
208
tests/test_sync_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings
|
||||
from app.db.models import ReferralCode, ReferralInvite, RemnawaveUser, TelegramUser
|
||||
from app.services.sync_service import SyncService
|
||||
from tests.helpers import make_remote_user
|
||||
|
||||
|
||||
class _StubRemnawaveClient:
|
||||
def __init__(self, users) -> None:
|
||||
self.users = users
|
||||
|
||||
async def get_users_by_telegram_id(self, telegram_id: int):
|
||||
return list(self.users)
|
||||
|
||||
|
||||
async def test_refresh_cached_users_clears_stale_links_and_keeps_profile(
|
||||
session_factory,
|
||||
) -> None:
|
||||
user_one = make_remote_user(
|
||||
user_uuid="44444444-4444-4444-4444-444444444444",
|
||||
user_id=4,
|
||||
short_uuid="user-one",
|
||||
username="oreol-one",
|
||||
telegram_id=1001,
|
||||
)
|
||||
user_two = make_remote_user(
|
||||
user_uuid="55555555-5555-5555-5555-555555555555",
|
||||
user_id=5,
|
||||
short_uuid="user-two",
|
||||
username="oreol-two",
|
||||
telegram_id=1001,
|
||||
)
|
||||
client = _StubRemnawaveClient([user_one, user_two])
|
||||
service = SyncService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
sync_subscription_history=False,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=client,
|
||||
)
|
||||
|
||||
await service.sync_users_for_telegram(
|
||||
telegram_id=1001,
|
||||
username="keeper",
|
||||
first_name="Keep",
|
||||
last_name="Me",
|
||||
language_code="ru",
|
||||
)
|
||||
|
||||
client.users = [user_one]
|
||||
views = await service.refresh_cached_users_for_telegram(telegram_id=1001)
|
||||
|
||||
assert [view.record.rw_uuid for view in views] == [str(user_one.uuid)]
|
||||
|
||||
async with session_factory() as session:
|
||||
telegram_user = await session.scalar(
|
||||
select(TelegramUser).where(TelegramUser.telegram_id == 1001)
|
||||
)
|
||||
stale_user = await session.scalar(
|
||||
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(user_two.uuid))
|
||||
)
|
||||
|
||||
assert telegram_user is not None
|
||||
assert telegram_user.username == "keeper"
|
||||
assert stale_user is not None
|
||||
assert stale_user.telegram_id is None
|
||||
assert stale_user.owner_telegram_user_id is None
|
||||
|
||||
|
||||
async def test_get_admin_telegram_users_page_returns_paginated_users(
|
||||
session_factory,
|
||||
) -> None:
|
||||
service = SyncService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
sync_subscription_history=False,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient([]),
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add_all(
|
||||
[
|
||||
TelegramUser(
|
||||
telegram_id=1001,
|
||||
username="first",
|
||||
first_name="First",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
last_seen_at=datetime(2026, 4, 21, 10, 0),
|
||||
created_at=datetime(2026, 4, 1, 10, 0),
|
||||
updated_at=datetime(2026, 4, 21, 10, 0),
|
||||
),
|
||||
TelegramUser(
|
||||
telegram_id=1002,
|
||||
username="second",
|
||||
first_name="Second",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
last_seen_at=datetime(2026, 4, 22, 12, 0),
|
||||
created_at=datetime(2026, 4, 2, 10, 0),
|
||||
updated_at=datetime(2026, 4, 22, 12, 0),
|
||||
),
|
||||
TelegramUser(
|
||||
telegram_id=1003,
|
||||
username="third",
|
||||
first_name="Third",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
last_seen_at=datetime(2026, 4, 22, 12, 0),
|
||||
created_at=datetime(2026, 4, 3, 10, 0),
|
||||
updated_at=datetime(2026, 4, 22, 12, 0),
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
page_one = await service.get_admin_telegram_users_page(page=1, page_size=2)
|
||||
page_two = await service.get_admin_telegram_users_page(page=2, page_size=2)
|
||||
|
||||
assert page_one.total_items == 3
|
||||
assert page_one.total_pages == 2
|
||||
assert [item.telegram_id for item in page_one.items] == [1003, 1002]
|
||||
assert [item.telegram_id for item in page_two.items] == [1001]
|
||||
|
||||
|
||||
async def test_get_admin_telegram_user_profile_returns_referral_and_access_data(
|
||||
session_factory,
|
||||
) -> None:
|
||||
remote_user = make_remote_user(
|
||||
user_uuid="66666666-6666-6666-6666-666666666666",
|
||||
user_id=6,
|
||||
short_uuid="profile-user",
|
||||
username="oreol-profile",
|
||||
telegram_id=2001,
|
||||
)
|
||||
service = SyncService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="1001",
|
||||
sync_subscription_history=False,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient([remote_user]),
|
||||
)
|
||||
|
||||
await service.sync_users_for_telegram(
|
||||
telegram_id=2001,
|
||||
username="profile",
|
||||
first_name="Profile",
|
||||
last_name="User",
|
||||
language_code="ru",
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
session.add(
|
||||
TelegramUser(
|
||||
telegram_id=1001,
|
||||
username="inviter",
|
||||
first_name="Inviter",
|
||||
last_name="Admin",
|
||||
language_code="ru",
|
||||
is_admin=True,
|
||||
last_seen_at=datetime(2026, 4, 22, 11, 0),
|
||||
created_at=datetime(2026, 4, 1, 8, 0),
|
||||
updated_at=datetime(2026, 4, 22, 11, 0),
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
ReferralCode(telegram_id=1001, code="INVITER1001")
|
||||
)
|
||||
session.add(
|
||||
ReferralCode(telegram_id=2001, code="SELF2001")
|
||||
)
|
||||
session.add_all(
|
||||
[
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=1001,
|
||||
invited_telegram_id=2001,
|
||||
invited_username="profile",
|
||||
invited_display_name="Profile User",
|
||||
),
|
||||
ReferralInvite(
|
||||
inviter_telegram_id=2001,
|
||||
invited_telegram_id=3001,
|
||||
invited_username="child",
|
||||
invited_display_name="Child User",
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
profile = await service.get_admin_telegram_user_profile(telegram_id=2001)
|
||||
|
||||
assert profile is not None
|
||||
assert profile.user.telegram_id == 2001
|
||||
assert profile.referral_code == "SELF2001"
|
||||
assert profile.applied_referral_code == "INVITER1001"
|
||||
assert profile.invited_count == 1
|
||||
assert profile.recent_invited_names == ["Child User"]
|
||||
assert len(profile.accesses) == 1
|
||||
assert profile.accesses[0].record.username == "oreol-profile"
|
||||
Reference in New Issue
Block a user