feat: show optional serivce description before payment

This commit is contained in:
3252a8
2026-05-20 21:55:22 +03:00
parent d8a1da1f13
commit 7521f89ffd
51 changed files with 1199 additions and 499 deletions
@@ -16,6 +16,12 @@ SUPPORT_RELATED_SETTINGS = (
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
)
SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS = (
"SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_RU",
"SUBSCRIPTION_PURCHASE_DESCRIPTION_EN",
)
def _manifest_by_key() -> dict[str, dict]:
return {item["key"]: item for item in manifest_payload()}
@@ -50,3 +56,15 @@ def test_support_settings_i18n_keys_exist_in_admin_locales():
field = manifest[setting_key]
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
def test_subscription_purchase_description_settings_i18n_keys_exist():
manifest = _manifest_by_key()
for language in ("ru", "en"):
messages = _locale(language)
for setting_key in SUBSCRIPTION_PURCHASE_DESCRIPTION_SETTINGS:
field = manifest[setting_key]
assert field["section"] == "pricing"
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
+7 -2
View File
@@ -51,8 +51,13 @@ class ConfigureLoggingTests(unittest.TestCase):
# Confirm the format string structure by emitting a record into a buffer.
formatter = handler.formatter
record = logging.LogRecord(
name="test", level=logging.INFO, pathname=__file__, lineno=1,
msg="hello", args=(), exc_info=None,
name="test",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg="hello",
args=(),
exc_info=None,
)
rendered = formatter.format(record) if formatter else ""
# Format is "%(asctime)s - %(name)s - %(levelname)s - %(message)s".
+2 -6
View File
@@ -155,15 +155,11 @@ class LiveGitFallbackTests(unittest.TestCase):
return _resolve()
def test_tag_with_zero_commits_since_returns_bare_tag(self):
result = self._run_with_git(
{"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"}
)
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "0"})
self.assertEqual(result, "v2.0.0")
def test_tag_plus_distance_plus_sha_format(self):
result = self._run_with_git(
{"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"}
)
result = self._run_with_git({"tag": "v2.0.0", "sha": "abcdef1", "commits_since_tag": "7"})
self.assertEqual(result, "v2.0.0+7.gabcdef1")
def test_sha_only_when_no_tag(self):
+1 -3
View File
@@ -33,9 +33,7 @@ class _FakeYooKassaService:
def __init__(self, configured: bool = True, response: Optional[Dict[str, Any]] = None) -> None:
self.configured = configured
self.calls: List[Dict[str, Any]] = []
self._response = response if response is not None else {
"id": "pay-1", "status": "pending"
}
self._response = response if response is not None else {"id": "pay-1", "status": "pending"}
async def create_payment(self, **kwargs):
self.calls.append(kwargs)
+12 -8
View File
@@ -29,16 +29,22 @@ from config.settings import Settings
# Strip all provider env so per-provider BaseSettings models don't pick up
# real credentials from the local .env file during tests.
_PROVIDER_ENV_PREFIXES = (
"FREEKASSA_", "PLATEGA_", "SEVERPAY_", "WATA_", "HELEKET_",
"CRYPTOPAY_", "YOOKASSA_", "STARS_",
"FREEKASSA_",
"PLATEGA_",
"SEVERPAY_",
"WATA_",
"HELEKET_",
"CRYPTOPAY_",
"YOOKASSA_",
"STARS_",
)
def _clean_env() -> dict[str, str]:
return {
k: v for k, v in os.environ.items()
if not any(k.startswith(p) for p in _PROVIDER_ENV_PREFIXES)
and not k.startswith("PAYMENT_")
k: v
for k, v in os.environ.items()
if not any(k.startswith(p) for p in _PROVIDER_ENV_PREFIXES) and not k.startswith("PAYMENT_")
}
@@ -116,9 +122,7 @@ class BuildServicesWiringTests(unittest.TestCase):
panel_webhook = services["panel_webhook_service"]
subscription = services["subscription_service"]
self.assertIsInstance(panel_webhook, PanelWebhookService)
self.assertIs(
getattr(panel_webhook, "subscription_service", None), subscription
)
self.assertIs(getattr(panel_webhook, "subscription_service", None), subscription)
def test_factory_returns_every_documented_service(self):
"""Guards against silently dropping a service from the bundle. The
+3 -8
View File
@@ -59,18 +59,14 @@ class MigrationDocumentationFactsTests(unittest.TestCase):
def test_doc_lists_every_running_container_in_current_compose(self):
"""The architecture table must reflect what ``docker compose up``
actually produces today."""
missing = sorted(
name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc
)
missing = sorted(name for name in EXPECTED_CONTAINER_NAMES if name not in self.doc)
self.assertFalse(
missing,
f"migration-to-minishop.md is missing container names from current compose: {missing}",
)
def test_doc_lists_every_volume_in_current_compose(self):
missing = sorted(
name for name in EXPECTED_VOLUME_NAMES if name not in self.doc
)
missing = sorted(name for name in EXPECTED_VOLUME_NAMES if name not in self.doc)
self.assertFalse(
missing,
f"migration-to-minishop.md is missing volume names from current compose: {missing}",
@@ -185,8 +181,7 @@ class MigrationScriptCoverageTests(unittest.TestCase):
missing = sorted(EXPECTED_CONTAINER_NAMES - self.known)
self.assertFalse(
missing,
f"KNOWN_CONTAINERS missing split-arch entries: {missing}\n"
f"actual: {sorted(self.known)}",
f"KNOWN_CONTAINERS missing split-arch entries: {missing}\nactual: {sorted(self.known)}",
)
def test_known_containers_still_covers_legacy_eras(self):
+2 -6
View File
@@ -86,9 +86,7 @@ class HandleWebhookQueueingTests(unittest.IsolatedAsyncioTestCase):
captured: List[dict] = []
async def fake_enqueue(settings, provider, payload, *, event_id=None):
captured.append(
{"provider": provider, "payload": payload, "event_id": event_id}
)
captured.append({"provider": provider, "payload": payload, "event_id": event_id})
return True
body = json.dumps(
@@ -121,9 +119,7 @@ class HandleWebhookQueueingTests(unittest.IsolatedAsyncioTestCase):
async def fake_handle_event(event_name, user_payload):
background_seen.append((event_name, user_payload))
body = json.dumps(
{"name": "user.expired", "payload": {"telegramId": 7}}
).encode()
body = json.dumps({"name": "user.expired", "payload": {"telegramId": 7}}).encode()
with (
patch.object(pws, "enqueue_webhook_event", fake_enqueue),
+11 -15
View File
@@ -97,11 +97,7 @@ def test_yookassa_provider_keeps_autorenew_entrypoints_local():
def test_every_payment_method_has_registry_driven_webapp_creator():
missing = [
spec.id
for spec in iter_provider_specs()
if spec.create_webapp_payment is None
]
missing = [spec.id for spec in iter_provider_specs() if spec.create_webapp_payment is None]
assert missing == []
@@ -192,10 +188,7 @@ def test_provider_presentation_ignores_cross_language_override():
settings = SimpleNamespace(PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта")
assert (
resolve_provider_presentation(spec, settings, language="en").webapp_label
== "Bank card"
)
assert resolve_provider_presentation(spec, settings, language="en").webapp_label == "Bank card"
def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback(monkeypatch):
@@ -255,12 +248,15 @@ def test_provider_callbacks_are_built_from_specs():
)
== "pay_stars:1:42:subscription"
)
assert stars.callback_data(
value="1",
rub_price=150,
stars_price=None,
sale_mode="subscription",
) is None
assert (
stars.callback_data(
value="1",
rub_price=150,
stars_price=None,
sale_mode="subscription",
)
is None
)
def test_provider_visibility_uses_service_configuration():
+1 -3
View File
@@ -87,9 +87,7 @@ class SendPaymentSuccessEmailTests(unittest.IsolatedAsyncioTestCase):
DEFAULT_CURRENCY_SYMBOL="RUB",
SUBSCRIPTION_MINI_APP_URL="https://app.example.com/",
)
user = _FakeUser(
user_id=42, email="buyer@example.com", language_code="en"
)
user = _FakeUser(user_id=42, email="buyer@example.com", language_code="en")
with (
patch.object(payments_module, "render_payment_success", fake_render),
+32 -54
View File
@@ -61,9 +61,7 @@ class SkipPathTests(unittest.IsolatedAsyncioTestCase):
async def test_no_inviter_returns_empty_payload(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
# Referred_by_id is None → bail out immediately.
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
@@ -85,16 +83,14 @@ class SkipPathTests(unittest.IsolatedAsyncioTestCase):
# when the same referee buys multiple subscriptions.
settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=True)
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
),
patch(
@@ -118,15 +114,13 @@ class SkipPathTests(unittest.IsolatedAsyncioTestCase):
settings = _make_settings(REFERRAL_ONE_BONUS_PER_REFEREE=False)
subscription_service = AsyncMock()
subscription_service.has_active_subscription = AsyncMock(return_value=True)
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -156,16 +150,14 @@ class InviterBonusTests(unittest.IsolatedAsyncioTestCase):
)
new_end = datetime(2026, 1, 1, tzinfo=timezone.utc)
subscription_service.extend_active_subscription_days = AsyncMock(return_value=new_end)
service, bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -204,17 +196,15 @@ class InviterBonusTests(unittest.IsolatedAsyncioTestCase):
return_value={"ok": True}
)
service, bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
),
patch(
@@ -262,16 +252,14 @@ class RefereeBonusTests(unittest.IsolatedAsyncioTestCase):
subscription_service.extend_active_subscription_days = AsyncMock(
return_value=referee_new_end
)
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
# 3-month plan → 10-day referee bonus, 21-day inviter bonus.
@@ -303,16 +291,14 @@ class RefereeBonusTests(unittest.IsolatedAsyncioTestCase):
subscription_service = AsyncMock()
subscription_service.has_active_subscription = AsyncMock(return_value=False)
subscription_service.extend_active_subscription_days = AsyncMock(return_value=None)
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -331,16 +317,14 @@ class RefereeBonusTests(unittest.IsolatedAsyncioTestCase):
subscription_service = AsyncMock()
subscription_service.has_active_subscription = AsyncMock(return_value=False)
subscription_service.extend_active_subscription_days = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
AsyncMock(
side_effect=lambda session, uid: _make_user(uid, referred_by_id=1)
if uid == 42
else _make_user(uid)
side_effect=lambda session, uid: (
_make_user(uid, referred_by_id=1) if uid == 42 else _make_user(uid)
)
),
):
result = await service.apply_referral_bonuses_for_payment(
@@ -359,9 +343,7 @@ class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase):
async def test_includes_bot_username_and_referral_code(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
@@ -383,9 +365,7 @@ class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase):
async def test_returns_none_when_user_missing(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with patch(
"bot.services.referral_service.user_dal.get_user_by_id",
@@ -401,9 +381,7 @@ class GenerateReferralLinkTests(unittest.IsolatedAsyncioTestCase):
async def test_returns_none_when_referral_code_unavailable(self):
settings = _make_settings()
subscription_service = AsyncMock()
service, _bot = _make_service(
settings=settings, subscription_service=subscription_service
)
service, _bot = _make_service(settings=settings, subscription_service=subscription_service)
with (
patch(
+16
View File
@@ -162,6 +162,22 @@ class SettingsTests(unittest.TestCase):
self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED)
def test_subscription_purchase_description_is_localized_and_toggleable(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
SUBSCRIPTION_PURCHASE_DESCRIPTION_RU="Русский текст",
SUBSCRIPTION_PURCHASE_DESCRIPTION_EN="English text",
)
self.assertEqual(settings.subscription_purchase_description("ru"), "Русский текст")
self.assertEqual(settings.subscription_purchase_description("en"), "English text")
settings.SUBSCRIPTION_PURCHASE_DESCRIPTION_ENABLED = False
self.assertEqual(settings.subscription_purchase_description("ru"), "")
def test_payment_button_presentation_env_values_are_available(self):
"""Presentation overrides now live on each provider's BaseSettings
model instead of the central Settings — verify they're loaded from
+25
View File
@@ -5,6 +5,7 @@ from types import SimpleNamespace
from bot.app.web import subscription_webapp
from bot.handlers.user import referral
from bot.handlers.user.subscription.core import _with_subscription_purchase_description
from bot.keyboards.inline.user_keyboards import (
get_bot_interface_inline_keyboard,
get_information_links_keyboard,
@@ -101,6 +102,30 @@ class UserBotMenuTests(unittest.TestCase):
self.assertIn("set_lang_ru:bot", self._callback_data(language_markup))
self.assertIn("subscribe_period:1:bot", self._callback_data(subscription_markup))
def test_subscription_purchase_description_is_prepended_before_period_selection(self):
settings = SimpleNamespace(
subscription_purchase_description=lambda language: f"Description {language}"
)
self.assertEqual(
_with_subscription_purchase_description(
"Choose period",
settings,
"en",
include=True,
),
"Description en\n\nChoose period",
)
self.assertEqual(
_with_subscription_purchase_description(
"Choose traffic",
settings,
"en",
include=False,
),
"Choose traffic",
)
def test_payment_navigation_context_keeps_bot_menu_source(self):
settings = SimpleNamespace(
payment_methods_order=[],
+2 -6
View File
@@ -58,9 +58,7 @@ class FakeRedis:
bucket.insert(0, value)
return len(bucket)
async def brpop(
self, key: str, timeout: int = 0
) -> Optional[Tuple[str, str]]:
async def brpop(self, key: str, timeout: int = 0) -> Optional[Tuple[str, str]]:
bucket = self._lists.get(key)
if bucket:
return key, bucket.pop()
@@ -230,9 +228,7 @@ class RedisLockTests(unittest.IsolatedAsyncioTestCase):
settings = _make_settings()
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as first:
self.assertTrue(first)
async with redis_infra.redis_lock(
settings, "panel-sync", ttl_seconds=30
) as second:
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as second:
self.assertFalse(second)
# After exit, the lock is released and can be re-acquired.
async with redis_infra.redis_lock(settings, "panel-sync", ttl_seconds=30) as again: