RemnaWave-bot Update

This commit is contained in:
2026-05-06 15:41:56 +03:00
parent ce7a1f70b2
commit 2cc2f12a21
22 changed files with 3087 additions and 129 deletions

View File

@@ -19,11 +19,14 @@ def _make_settings() -> Settings:
referral_discount_percent=5,
referral_bonus_days=7,
payment_plans_raw="30:250,180:600",
payment_product_plans_raw="",
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_vpn_squad_uuids_raw="vpn-uuid",
payment_white_squad_uuids_raw="white-uuid",
payment_external_squad_uuid="",
payment_username_prefix="Oreol",
payment_user_tag="BOT",
@@ -46,6 +49,11 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N
raw_value="30:300,90:700",
updated_by_telegram_id=1,
)
await service.update_setting(
key="payment_product_plans_raw",
raw_value="vpn_white:30:450,white:30:250,vpn:30:200",
updated_by_telegram_id=1,
)
await service.update_setting(
key="referral_enabled",
raw_value="off",
@@ -56,9 +64,10 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N
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),
assert [(plan.code, plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
("vpn_white", 30, 450),
("white", 30, 250),
("vpn", 30, 200),
]
@@ -72,3 +81,4 @@ async def test_bot_config_service_formats_values_for_admin_ui(session_factory) -
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)"
assert BotConfigService.format_value(snapshot=snapshot, key="payment_vpn_squad_uuids_raw") == "vpn-uuid"

View File

@@ -79,3 +79,22 @@ def test_payment_plans_are_parsed() -> None:
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]
def test_payment_product_plans_override_legacy_plans() -> None:
settings = Settings.model_construct(
payment_product_plans_raw="vpn_white:30:450,white:30:250,vpn:30:200",
payment_plans_raw="30:999",
payment_plan_duration_days=30,
)
plans = settings.payment_plans
assert [plan.code for plan in plans] == ["vpn_white", "white", "vpn"]
assert [plan.title for plan in plans] == ["VPN + Белые списки", "Белые списки", "VPN"]
assert [plan.amount_rub for plan in plans] == [450, 250, 200]
assert [plan.squad_groups for plan in plans] == [
("vpn", "white"),
("white",),
("vpn",),
]

View File

@@ -158,7 +158,8 @@ def test_admin_panel_keyboard_has_expected_buttons() -> None:
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
assert "🏠 Главное меню" in button_texts

View File

@@ -21,9 +21,15 @@ from tests.helpers import make_remote_user
class _StubSyncService:
def __init__(self, *, cached_users_by_telegram: dict[int, list] | None = None) -> None:
def __init__(
self,
*,
cached_users_by_telegram: dict[int, list] | None = None,
active_promo_by_telegram: dict[int, object] | None = None,
) -> None:
self.refreshed: list[int] = []
self.cached_users_by_telegram = cached_users_by_telegram or {}
self.active_promo_by_telegram = active_promo_by_telegram or {}
async def get_referral_summary(self, telegram_id: int):
return type(
@@ -44,6 +50,9 @@ class _StubSyncService:
async def get_cached_users_for_telegram(self, telegram_id: int):
return list(self.cached_users_by_telegram.get(telegram_id, []))
async def get_active_promo_for_user(self, telegram_id: int):
return self.active_promo_by_telegram.get(telegram_id)
class _StubRemnawaveClient:
def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None:
@@ -98,6 +107,9 @@ async def _seed_order(
telegram_id: int,
status: str,
order_uuid: str | None = None,
plan_code: str = "30d",
plan_title: str = "OREOL VPN на 30 дней",
amount_stars: int = 250,
) -> str:
resolved_order_uuid = order_uuid or str(uuid4())
@@ -116,12 +128,12 @@ async def _seed_order(
order_uuid=resolved_order_uuid,
telegram_user_id=user.id,
telegram_id=telegram_id,
plan_code="30d",
plan_title="OREOL VPN на 30 дней",
plan_code=plan_code,
plan_title=plan_title,
plan_duration_days=30,
traffic_limit_bytes=0,
traffic_limit_strategy="NO_RESET",
amount_stars=250,
amount_stars=amount_stars,
currency="RUB",
status=status,
invoice_payload=f"payload-{resolved_order_uuid}",
@@ -155,7 +167,29 @@ async def _seed_referral_invite(
def _make_settings() -> Settings:
return Settings.model_construct(
bot_admin_ids_raw="",
payment_product_plans_raw="",
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
payment_vpn_squad_uuids_raw="",
payment_white_squad_uuids_raw="",
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,
)
def _make_product_settings() -> Settings:
return Settings.model_construct(
bot_admin_ids_raw="",
payment_plans_raw="30:999",
payment_product_plans_raw="vpn_white:30:450,white:30:250,vpn:30:200",
payment_transfer_text="Реквизиты",
payment_internal_squad_uuids_raw="",
payment_vpn_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
payment_white_squad_uuids_raw="22222222-2222-2222-2222-222222222222",
payment_user_tag="BOT",
payment_external_squad_uuid="",
payment_username_prefix="Oreol",
@@ -258,6 +292,37 @@ async def test_approve_order_fulfills_and_refreshes_cache(session_factory) -> No
assert order.remnawave_user_uuid == str(remote_user.uuid)
async def test_approve_product_order_sends_product_squads_to_remnawave(session_factory) -> None:
remote_user = make_remote_user(
user_uuid="99999999-9999-9999-9999-999999999999",
user_id=9,
short_uuid="product999",
username="oreol-product",
telegram_id=1001,
)
remnawave_client = _StubRemnawaveClient(created_user=remote_user)
service = PaymentService(
settings=_make_product_settings(),
session_factory=session_factory,
remnawave_client=remnawave_client,
sync_service=_StubSyncService(),
)
order_uuid = await _seed_order(
session_factory,
telegram_id=1001,
status=REVIEW_PAYMENT_STATUS,
plan_code="white",
plan_title="OREOL VPN · Белые списки",
amount_stars=250,
)
await service.approve_order(order_uuid=order_uuid)
assert remnawave_client.created_bodies[0]["activeInternalSquads"] == [
"22222222-2222-2222-2222-222222222222",
]
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",
@@ -410,6 +475,95 @@ async def test_create_order_generates_unique_provision_username(session_factory)
assert second_order.extends_existing_access is False
async def test_product_plan_uses_matching_squads_and_price(session_factory) -> None:
service = PaymentService(
settings=_make_product_settings(),
session_factory=session_factory,
remnawave_client=_StubRemnawaveClient(
created_user=make_remote_user(
user_uuid="99999999-9999-9999-9999-999999999999",
user_id=9,
short_uuid="buyer999",
username="oreol-product",
telegram_id=1001,
)
),
sync_service=_StubSyncService(),
)
order = await service.create_order(
telegram_id=1001,
username="tester",
first_name="Test",
last_name=None,
language_code="ru",
plan_code="vpn_white",
)
assert order.plan.label == "VPN + Белые списки"
assert order.plan.amount_rub == 450
assert order.plan.internal_squad_uuids == [
"11111111-1111-1111-1111-111111111111",
"22222222-2222-2222-2222-222222222222",
]
async def test_promo_code_discounts_only_matching_plan(session_factory) -> None:
promo = type(
"PromoStub",
(),
{
"code": "WHITE50",
"discount_percent": 50,
"plan_code": "white",
"expires_at": None,
},
)()
service = PaymentService(
settings=_make_product_settings(),
session_factory=session_factory,
remnawave_client=_StubRemnawaveClient(
created_user=make_remote_user(
user_uuid="10101010-1010-1010-1010-101010101010",
user_id=10,
short_uuid="buyer101",
username="oreol-promo",
telegram_id=1001,
)
),
sync_service=_StubSyncService(active_promo_by_telegram={1001: promo}),
)
plans = await service.get_available_plans(telegram_id=1001)
assert [(plan.code, plan.amount_rub, plan.discount_code) for plan in plans] == [
("vpn_white", 450, ""),
("white", 125, "WHITE50"),
("vpn", 200, ""),
]
order = await service.create_order(
telegram_id=1001,
username="tester",
first_name="Test",
last_name=None,
language_code="ru",
plan_code="white",
)
assert order.plan.amount_rub == 125
assert order.plan.discount_source == "promo"
async with session_factory() as session:
stored_order = await session.scalar(
select(PaymentOrder).where(PaymentOrder.order_uuid == order.order_uuid)
)
assert stored_order is not None
assert stored_order.amount_stars == 125
assert stored_order.error_message == "WHITE50"
async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None:
service = PaymentService(
settings=Settings.model_construct(

View File

@@ -0,0 +1,104 @@
from app.services.support_ticket_service import (
ANSWERED_SUPPORT_TICKET_STATUS,
CLOSED_SUPPORT_TICKET_STATUS,
OPEN_SUPPORT_TICKET_STATUS,
SupportTicketService,
)
async def test_support_ticket_lifecycle(session_factory) -> None:
service = SupportTicketService(session_factory=session_factory)
ticket = await service.create_ticket(
public_id="ABCD1234",
telegram_id=1001,
username="tester",
display_name="Test User",
user_message="Need help",
support_chat_id=-1001234567890,
support_thread_id=12,
support_message_id=345,
)
assert ticket.status == OPEN_SUPPORT_TICKET_STATUS
assert await service.get_open_tickets_count() == 1
answered_ticket = await service.mark_answered(ticket_id=ticket.id)
assert answered_ticket is not None
assert answered_ticket.status == ANSWERED_SUPPORT_TICKET_STATUS
assert await service.get_open_tickets_count() == 1
noted_ticket = await service.append_note(
ticket_id=ticket.id,
note_text="First note",
status=OPEN_SUPPORT_TICKET_STATUS,
)
assert noted_ticket is not None
assert noted_ticket.support_note == "First note"
assert noted_ticket.status == OPEN_SUPPORT_TICKET_STATUS
appended_ticket = await service.append_note(ticket_id=ticket.id, note_text="Second note")
assert appended_ticket is not None
assert appended_ticket.support_note == "First note\n\nSecond note"
closed_ticket = await service.close_ticket(
ticket_id=ticket.id,
closed_by_telegram_id=9999,
)
assert closed_ticket is not None
assert closed_ticket.status == CLOSED_SUPPORT_TICKET_STATUS
assert closed_ticket.closed_by_telegram_id == 9999
assert closed_ticket.closed_at is not None
assert await service.get_open_tickets_count() == 0
still_closed_ticket = await service.mark_answered(ticket_id=ticket.id)
assert still_closed_ticket is not None
assert still_closed_ticket.status == CLOSED_SUPPORT_TICKET_STATUS
async def test_get_ticket_by_support_message_returns_ticket(session_factory) -> None:
service = SupportTicketService(session_factory=session_factory)
created_ticket = await service.create_ticket(
public_id="EFGH5678",
telegram_id=2002,
username=None,
display_name="Another User",
user_message="Another issue",
support_chat_id=-1009876543210,
support_thread_id=None,
support_message_id=777,
)
loaded_ticket = await service.get_ticket_by_support_message(
support_chat_id=-1009876543210,
support_message_id=777,
)
assert loaded_ticket is not None
assert loaded_ticket.id == created_ticket.id
assert loaded_ticket.public_id == "EFGH5678"
async def test_get_admin_tickets_page_returns_latest_first(session_factory) -> None:
service = SupportTicketService(session_factory=session_factory)
for index in range(3):
await service.create_ticket(
public_id=f"TICKET{index}",
telegram_id=3000 + index,
username=f"user{index}",
display_name=f"User {index}",
user_message=f"Message {index}",
support_chat_id=-100500,
support_thread_id=None,
support_message_id=900 + index,
)
first_page = await service.get_admin_tickets_page(page=1, page_size=2)
second_page = await service.get_admin_tickets_page(page=2, page_size=2)
assert first_page.total_items == 3
assert first_page.total_pages == 2
assert [item.public_id for item in first_page.items] == ["TICKET2", "TICKET1"]
assert [item.public_id for item in second_page.items] == ["TICKET0"]

View File

@@ -1,11 +1,20 @@
from __future__ import annotations
from datetime import datetime
from datetime import datetime, timedelta
from sqlalchemy import select
from app.config import Settings
from app.db.models import ReferralCode, ReferralInvite, RemnawaveUser, TelegramUser
from app.db.base import utcnow
from app.db.models import (
PromoCode,
PromoCodeApplication,
ReferralCode,
ReferralInvite,
RemnawaveUser,
TelegramUser,
)
from app.schemas.remnawave import PaginatedUsers
from app.services.sync_service import SyncService
from tests.helpers import make_remote_user
@@ -17,6 +26,10 @@ class _StubRemnawaveClient:
async def get_users_by_telegram_id(self, telegram_id: int):
return list(self.users)
async def get_all_users(self, *, start: int = 0, size: int = 100):
items = list(self.users)[start : start + size]
return PaginatedUsers(users=items, total=len(self.users))
async def test_refresh_cached_users_clears_stale_links_and_keeps_profile(
session_factory,
@@ -206,3 +219,103 @@ async def test_get_admin_telegram_user_profile_returns_referral_and_access_data(
assert profile.recent_invited_names == ["Child User"]
assert len(profile.accesses) == 1
assert profile.accesses[0].record.username == "oreol-profile"
async def test_promo_code_can_be_applied_through_referral_input(session_factory) -> None:
service = SyncService(
settings=Settings.model_construct(
bot_admin_ids_raw="",
sync_subscription_history=False,
),
session_factory=session_factory,
remnawave_client=_StubRemnawaveClient([]),
)
promo = await service.create_promo_code(
code="white50",
plan_code="white",
discount_percent=50,
expires_at=utcnow() + timedelta(days=7),
created_by_telegram_id=1001,
)
applied_code = await service.apply_referral_code(
telegram_id=2001,
username="buyer",
first_name="Buyer",
last_name=None,
language_code="ru",
referral_code="white50",
)
summary = await service.get_referral_summary(2001)
active_promo = await service.get_active_promo_for_user(2001)
assert promo.code == "WHITE50"
assert applied_code == "WHITE50"
assert summary.applied_referral_code == ""
assert summary.applied_promo_code == "WHITE50"
assert summary.applied_promo_discount_percent == 50
assert summary.applied_promo_plan_code == "white"
assert active_promo is not None
assert active_promo.code == "WHITE50"
async with session_factory() as session:
promo_record = await session.scalar(
select(PromoCode).where(PromoCode.code == "WHITE50")
)
application = await session.scalar(
select(PromoCodeApplication).where(PromoCodeApplication.telegram_id == 2001)
)
assert promo_record is not None
assert application is not None
assert application.promo_code_id == promo_record.id
async def test_sync_all_users_does_not_autoflush_half_built_user(
session_factory,
) -> None:
remote_user = make_remote_user(
user_uuid="77777777-7777-7777-7777-777777777777",
user_id=7,
short_uuid="sync-all-user",
username="oreol-sync-all",
telegram_id=4001,
)
service = SyncService(
settings=Settings.model_construct(
bot_admin_ids_raw="",
sync_subscription_history=False,
),
session_factory=session_factory,
remnawave_client=_StubRemnawaveClient([remote_user]),
)
async with session_factory() as session:
session.add(
TelegramUser(
telegram_id=4001,
username="owner",
first_name="Sync",
last_name="Owner",
language_code="ru",
)
)
await session.commit()
total = await service.sync_all_users(batch_size=50)
assert total == 1
async with session_factory() as session:
owner = await session.scalar(
select(TelegramUser).where(TelegramUser.telegram_id == 4001)
)
cached_user = await session.scalar(
select(RemnawaveUser).where(RemnawaveUser.rw_uuid == str(remote_user.uuid))
)
assert owner is not None
assert cached_user is not None
assert cached_user.rw_id == 7
assert cached_user.owner_telegram_user_id == owner.id
assert cached_user.username == "oreol-sync-all"