This commit is contained in:
2026-04-22 18:34:13 +03:00
parent 3992121397
commit ce7a1f70b2
60 changed files with 10243 additions and 3443 deletions

21
tests/conftest.py Normal file
View 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
View 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,
},
}
)

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

View 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

View 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
View 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"