Merge branch 'dev' into feature/migration-remnashop
This commit is contained in:
@@ -264,10 +264,15 @@ def test_remnawave_settings_include_panel_webhook_metadata():
|
||||
remnawave_keys = (
|
||||
"PANEL_API_URL",
|
||||
"PANEL_API_KEY",
|
||||
"PANEL_API_TOTAL_TIMEOUT_SECONDS",
|
||||
"PANEL_API_CONNECT_TIMEOUT_SECONDS",
|
||||
"PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS",
|
||||
"PANEL_API_SOCK_READ_TIMEOUT_SECONDS",
|
||||
"PANEL_WEBHOOK_SECRET",
|
||||
"USER_SQUAD_UUIDS",
|
||||
"USER_EXTERNAL_SQUAD_UUID",
|
||||
)
|
||||
timeout_keys = remnawave_keys[2:6]
|
||||
|
||||
assert field["webhook_path"] == "/webhook/panel"
|
||||
assert field["webhook_requires_base_url"] is True
|
||||
@@ -278,10 +283,18 @@ def test_remnawave_settings_include_panel_webhook_metadata():
|
||||
assert manifest[setting_key]["section_order"] == 3
|
||||
assert manifest[setting_key]["subsection"] is None
|
||||
|
||||
for setting_key in timeout_keys:
|
||||
assert manifest[setting_key]["type"] == "float"
|
||||
assert manifest[setting_key]["optional"] is False
|
||||
assert manifest[setting_key]["min"] == 1
|
||||
|
||||
for language in ("ru", "en"):
|
||||
messages = _locale(language)
|
||||
assert "admin_settings_section_remnawave" in messages
|
||||
assert field["webhook_hint_i18n_key"] in messages
|
||||
for setting_key in timeout_keys:
|
||||
assert manifest[setting_key]["i18n_label_key"] in messages
|
||||
assert manifest[setting_key]["i18n_description_key"] in messages
|
||||
|
||||
|
||||
def test_payment_provider_admin_only_toggles_are_mutually_exclusive():
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from bot.app.web.admin_api_impl import common as admin_common
|
||||
from bot.app.web.admin_api_impl import users as admin_users
|
||||
|
||||
|
||||
@@ -73,5 +75,68 @@ class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertFalse(session.rolled_back)
|
||||
|
||||
|
||||
class AdminUserTrialPresentationTests(unittest.TestCase):
|
||||
def test_trial_subscription_serializes_display_label(self):
|
||||
start_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc)
|
||||
end_at = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc)
|
||||
sub = SimpleNamespace(
|
||||
subscription_id=7,
|
||||
panel_user_uuid="panel-user",
|
||||
panel_subscription_uuid=None,
|
||||
start_date=start_at,
|
||||
end_date=end_at,
|
||||
duration_months=None,
|
||||
is_active=False,
|
||||
status_from_panel="EXPIRED",
|
||||
traffic_limit_bytes=10,
|
||||
traffic_used_bytes=2,
|
||||
tier_baseline_bytes=0,
|
||||
topup_balance_bytes=0,
|
||||
premium_used_bytes=0,
|
||||
premium_baseline_bytes=0,
|
||||
premium_topup_balance_bytes=0,
|
||||
premium_topup_used_bytes=0,
|
||||
premium_bonus_bytes=0,
|
||||
regular_bonus_bytes=0,
|
||||
regular_unlimited_override=False,
|
||||
premium_unlimited_override=False,
|
||||
premium_is_limited=False,
|
||||
tariff_key=None,
|
||||
auto_renew_enabled=False,
|
||||
provider="trial",
|
||||
is_throttled=False,
|
||||
)
|
||||
|
||||
payload = admin_common._serialize_subscription(sub)
|
||||
|
||||
self.assertTrue(payload["is_trial"])
|
||||
self.assertEqual(payload["display_label"], "Trial")
|
||||
self.assertIsNone(payload["tariff_key"])
|
||||
|
||||
def test_trial_summary_includes_usage_dates_and_reset_marker(self):
|
||||
first_at = datetime(2026, 1, 2, 3, 4, tzinfo=timezone.utc)
|
||||
latest_at = datetime(2026, 2, 3, 4, 5, tzinfo=timezone.utc)
|
||||
latest_end = datetime(2026, 2, 10, 4, 5, tzinfo=timezone.utc)
|
||||
reset_at = datetime(2026, 3, 1, tzinfo=timezone.utc)
|
||||
user = SimpleNamespace(trial_eligibility_reset_at=reset_at)
|
||||
trial_subs = [
|
||||
SimpleNamespace(
|
||||
start_date=first_at,
|
||||
end_date=datetime(2026, 1, 9, tzinfo=timezone.utc),
|
||||
),
|
||||
SimpleNamespace(start_date=latest_at, end_date=latest_end, is_active=True),
|
||||
]
|
||||
|
||||
payload = admin_users._serialize_trial_summary(user, trial_subs)
|
||||
|
||||
self.assertTrue(payload["used"])
|
||||
self.assertTrue(payload["active"])
|
||||
self.assertEqual(payload["count"], 2)
|
||||
self.assertEqual(payload["first_activated_at"], first_at.isoformat())
|
||||
self.assertEqual(payload["latest_activated_at"], latest_at.isoformat())
|
||||
self.assertEqual(payload["latest_end_date"], latest_end.isoformat())
|
||||
self.assertEqual(payload["last_reset_at"], reset_at.isoformat())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -9,6 +9,7 @@ from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware
|
||||
from bot.utils.channel_subscription import (
|
||||
is_required_channel_access_error,
|
||||
normalize_required_channel_id,
|
||||
normalize_required_channel_link,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,10 +22,13 @@ class I18nStub:
|
||||
|
||||
|
||||
class FakeBot:
|
||||
def __init__(self, *, status="member", error=None):
|
||||
def __init__(self, *, status="member", error=None, chat=None, chat_error=None):
|
||||
self.status = status
|
||||
self.error = error
|
||||
self.chat = chat
|
||||
self.chat_error = chat_error
|
||||
self.calls = []
|
||||
self.get_chat_calls = []
|
||||
|
||||
async def get_chat_member(self, chat_id, user_id):
|
||||
self.calls.append((chat_id, user_id))
|
||||
@@ -32,11 +36,17 @@ class FakeBot:
|
||||
raise self.error
|
||||
return SimpleNamespace(status=self.status)
|
||||
|
||||
async def get_chat(self, chat_id):
|
||||
self.get_chat_calls.append(chat_id)
|
||||
if self.chat_error:
|
||||
raise self.chat_error
|
||||
return self.chat or SimpleNamespace(username="required_channel")
|
||||
|
||||
def _settings(required_channel_id):
|
||||
|
||||
def _settings(required_channel_id, required_channel_link="https://t.me/example"):
|
||||
return SimpleNamespace(
|
||||
REQUIRED_CHANNEL_ID=required_channel_id,
|
||||
REQUIRED_CHANNEL_LINK="https://t.me/example",
|
||||
REQUIRED_CHANNEL_LINK=required_channel_link,
|
||||
ADMIN_IDS=[],
|
||||
DEFAULT_LANGUAGE="en",
|
||||
)
|
||||
@@ -70,6 +80,24 @@ class RequiredChannelIdNormalizationTests(unittest.TestCase):
|
||||
self.assertIsNone(normalize_required_channel_id(""))
|
||||
self.assertIsNone(normalize_required_channel_id(0))
|
||||
|
||||
def test_normalizes_channel_links_for_join_button(self):
|
||||
self.assertEqual(
|
||||
normalize_required_channel_link("@required_channel"), "https://t.me/required_channel"
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_required_channel_link("required_channel"), "https://t.me/required_channel"
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_required_channel_link("t.me/required_channel"),
|
||||
"https://t.me/required_channel",
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_required_channel_link("https://t.me/required_channel"),
|
||||
"https://t.me/required_channel",
|
||||
)
|
||||
self.assertEqual(normalize_required_channel_link("+inviteHash"), "https://t.me/+inviteHash")
|
||||
self.assertIsNone(normalize_required_channel_link("not a valid link"))
|
||||
|
||||
def test_detects_channel_configuration_errors(self):
|
||||
self.assertTrue(
|
||||
is_required_channel_access_error(
|
||||
@@ -124,6 +152,33 @@ class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase):
|
||||
event.answer.assert_awaited_once_with("check failed")
|
||||
update_user.assert_not_awaited()
|
||||
|
||||
async def test_join_button_prefers_channel_link_resolved_from_required_id(self):
|
||||
bot = FakeBot(
|
||||
status="left",
|
||||
chat=SimpleNamespace(username="required_channel", invite_link=None),
|
||||
)
|
||||
event = _message_event(bot)
|
||||
user = _db_user()
|
||||
|
||||
with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()):
|
||||
result = await ensure_required_channel_subscription(
|
||||
event,
|
||||
_settings(1234567890, required_channel_link="https://t.me/main_sales_bot"),
|
||||
I18nStub(),
|
||||
"en",
|
||||
AsyncMock(),
|
||||
db_user=user,
|
||||
)
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(bot.calls, [(-1001234567890, 42)])
|
||||
self.assertEqual(bot.get_chat_calls, [-1001234567890])
|
||||
reply_markup = event.answer.await_args.kwargs["reply_markup"]
|
||||
self.assertEqual(
|
||||
reply_markup.inline_keyboard[0][0].url,
|
||||
"https://t.me/required_channel",
|
||||
)
|
||||
|
||||
|
||||
class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self):
|
||||
@@ -146,6 +201,40 @@ class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(result, "ok")
|
||||
handler.assert_awaited_once_with(event, data)
|
||||
|
||||
async def test_middleware_prompt_uses_channel_link_resolved_from_required_id(self):
|
||||
middleware = ChannelSubscriptionMiddleware(
|
||||
_settings(1234567890, required_channel_link="https://t.me/main_sales_bot"),
|
||||
I18nStub(),
|
||||
)
|
||||
handler = AsyncMock(return_value="ok")
|
||||
message = SimpleNamespace(text="menu", answer=AsyncMock())
|
||||
event = SimpleNamespace(callback_query=None, message=message)
|
||||
bot = FakeBot(
|
||||
chat=SimpleNamespace(username="required_channel", invite_link=None),
|
||||
)
|
||||
data = {
|
||||
"bot": bot,
|
||||
"event_from_user": SimpleNamespace(id=42),
|
||||
"session": AsyncMock(),
|
||||
"i18n_data": {"current_language": "en", "i18n_instance": I18nStub()},
|
||||
}
|
||||
user = _db_user(verified=False, verified_for=None)
|
||||
|
||||
with patch(
|
||||
"bot.middlewares.channel_subscription.user_dal.get_user_by_id",
|
||||
AsyncMock(return_value=user),
|
||||
):
|
||||
result = await middleware(handler, event, data)
|
||||
|
||||
self.assertIsNone(result)
|
||||
handler.assert_not_awaited()
|
||||
self.assertEqual(bot.get_chat_calls, [-1001234567890])
|
||||
reply_markup = message.answer.await_args.kwargs["reply_markup"]
|
||||
self.assertEqual(
|
||||
reply_markup.inline_keyboard[0][0].url,
|
||||
"https://t.me/required_channel",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import time
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import aiohttp
|
||||
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
|
||||
|
||||
@@ -16,6 +19,68 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_client_timeout_uses_panel_settings(self):
|
||||
service = PanelApiService(
|
||||
SimpleNamespace(
|
||||
PANEL_API_URL="https://panel.example.test/api",
|
||||
PANEL_API_KEY="panel-key",
|
||||
PANEL_API_TOTAL_TIMEOUT_SECONDS="30",
|
||||
PANEL_API_CONNECT_TIMEOUT_SECONDS="10",
|
||||
PANEL_API_SOCK_CONNECT_TIMEOUT_SECONDS="9",
|
||||
PANEL_API_SOCK_READ_TIMEOUT_SECONDS="20",
|
||||
)
|
||||
)
|
||||
|
||||
timeout = service._client_timeout()
|
||||
|
||||
self.assertEqual(timeout.total, 30)
|
||||
self.assertEqual(timeout.connect, 10)
|
||||
self.assertEqual(timeout.sock_connect, 9)
|
||||
self.assertEqual(timeout.sock_read, 20)
|
||||
|
||||
async def test_get_request_retries_connection_timeout(self):
|
||||
service = self._make_service()
|
||||
request_calls = 0
|
||||
|
||||
class OkResponse:
|
||||
status = 200
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
return None
|
||||
|
||||
async def text(self):
|
||||
return '{"response": {"ok": true}}'
|
||||
|
||||
def fake_request(*_args, **_kwargs):
|
||||
nonlocal request_calls
|
||||
request_calls += 1
|
||||
if request_calls == 1:
|
||||
raise aiohttp.ConnectionTimeoutError("connect took too long")
|
||||
return OkResponse()
|
||||
|
||||
service._get_session = AsyncMock(return_value=SimpleNamespace(request=fake_request))
|
||||
|
||||
with patch("bot.services.panel_api_service.asyncio.sleep", new=AsyncMock()):
|
||||
result = await service._request("GET", "/internal-squads")
|
||||
|
||||
self.assertEqual(result, {"response": {"ok": True}})
|
||||
self.assertEqual(request_calls, 2)
|
||||
|
||||
async def test_get_internal_squads_uses_stale_cache_after_refresh_failure(self):
|
||||
service = self._make_service()
|
||||
stale_squads = [{"uuid": "squad-1", "name": "Squad 1"}]
|
||||
service._squads_cache._data["list"] = (time.monotonic() - 1, stale_squads)
|
||||
service._get_internal_squads_uncached = AsyncMock(return_value=None)
|
||||
|
||||
squads = await service.get_internal_squads()
|
||||
|
||||
self.assertEqual(squads, stale_squads)
|
||||
service._get_internal_squads_uncached.assert_awaited_once()
|
||||
|
||||
async def test_update_user_details_does_not_log_full_response_by_default(self):
|
||||
service = self._make_service()
|
||||
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
|
||||
|
||||
@@ -42,6 +42,14 @@ class AsyncTTLCacheSingleflightTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class AsyncTTLCacheInvalidationTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_get_stale_returns_expired_cacheable_value(self):
|
||||
cache = AsyncTTLCache(ttl_seconds=60)
|
||||
value = {"ok": True}
|
||||
cache._data["same"] = (time.monotonic() - 1, value)
|
||||
|
||||
self.assertIsNone(cache.get_fresh("same"))
|
||||
self.assertEqual(cache.get_stale("same"), value)
|
||||
|
||||
async def test_invalidate_remote_deletes_single_redis_key(self):
|
||||
settings = SimpleNamespace(REDIS_URL="redis://example", REDIS_KEY_PREFIX="test")
|
||||
cache = AsyncTTLCache(ttl_seconds=60, settings=settings, namespace="bench")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest import IsolatedAsyncioTestCase
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import bot.app.web.subscription_webapp # noqa: F401
|
||||
from bot.app.web.webapp import billing as billing_module
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self):
|
||||
self.commit_count = 0
|
||||
self.rollback_count = 0
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
async def commit(self):
|
||||
self.commit_count += 1
|
||||
|
||||
async def rollback(self):
|
||||
self.rollback_count += 1
|
||||
|
||||
|
||||
class _SessionFactory:
|
||||
def __init__(self, session):
|
||||
self.session = session
|
||||
|
||||
def __call__(self):
|
||||
return self.session
|
||||
|
||||
|
||||
class WebAppTrialActivationTests(IsolatedAsyncioTestCase):
|
||||
async def test_email_only_trial_activation_is_written_to_admin_logs(self):
|
||||
session = _Session()
|
||||
end_date = datetime(2026, 1, 9, 3, 4, tzinfo=timezone.utc)
|
||||
settings = SimpleNamespace(
|
||||
TRIAL_ENABLED=True,
|
||||
TRIAL_DURATION_DAYS=7,
|
||||
TRIAL_TRAFFIC_LIMIT_GB=10,
|
||||
LOG_TRIAL_ACTIVATIONS=False,
|
||||
)
|
||||
db_user = SimpleNamespace(
|
||||
user_id=42,
|
||||
is_banned=False,
|
||||
username=None,
|
||||
first_name=None,
|
||||
email="email-only@example.com",
|
||||
)
|
||||
subscription_service = SimpleNamespace(
|
||||
activate_trial_subscription=AsyncMock(
|
||||
return_value={
|
||||
"activated": True,
|
||||
"days": 7,
|
||||
"end_date": end_date,
|
||||
"traffic_gb": 10,
|
||||
"subscription_url": "https://panel.example/sub",
|
||||
}
|
||||
)
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
app={
|
||||
"settings": settings,
|
||||
"async_session_factory": _SessionFactory(session),
|
||||
"subscription_service": subscription_service,
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(billing_module, "_require_user_id", return_value=42),
|
||||
patch.object(
|
||||
billing_module,
|
||||
"_enforce_webapp_rate_limit",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(
|
||||
billing_module.user_dal,
|
||||
"get_user_by_id",
|
||||
AsyncMock(return_value=db_user),
|
||||
),
|
||||
patch.object(
|
||||
billing_module,
|
||||
"prepare_config_links",
|
||||
AsyncMock(return_value=("https://panel.example/sub", "https://connect.example")),
|
||||
),
|
||||
patch.object(
|
||||
billing_module.message_log_dal,
|
||||
"create_message_log_no_commit",
|
||||
AsyncMock(),
|
||||
) as create_log,
|
||||
patch.object(
|
||||
billing_module,
|
||||
"invalidate_webapp_user_caches",
|
||||
AsyncMock(),
|
||||
),
|
||||
patch("db.dal.ad_dal.mark_trial_activated", AsyncMock()) as mark_trial_activated,
|
||||
):
|
||||
response = await billing_module.activate_trial_route(request)
|
||||
|
||||
payload = json.loads(response.text)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertTrue(payload["activated"])
|
||||
subscription_service.activate_trial_subscription.assert_awaited_once_with(session, 42)
|
||||
create_log.assert_awaited_once()
|
||||
log_payload = create_log.await_args.args[1]
|
||||
self.assertEqual(log_payload["user_id"], 42)
|
||||
self.assertEqual(log_payload["target_user_id"], 42)
|
||||
self.assertEqual(log_payload["event_type"], "webapp_trial_activate")
|
||||
self.assertFalse(log_payload["is_admin_event"])
|
||||
self.assertIn("email-only@example.com", log_payload["content"])
|
||||
mark_trial_activated.assert_awaited_once_with(session, 42)
|
||||
self.assertEqual(session.commit_count, 2)
|
||||
self.assertEqual(session.rollback_count, 0)
|
||||
Reference in New Issue
Block a user