From 8382fa92327797fc76d598f04dabea64ea3fc7dd Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Sun, 17 May 2026 09:46:52 +0300 Subject: [PATCH] chore: cover provider label regression in payment success email path --- tests/test_provider_labels.py | 194 ++++++++++++++++++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/test_provider_labels.py diff --git a/tests/test_provider_labels.py b/tests/test_provider_labels.py new file mode 100644 index 0000000..ffdd5b8 --- /dev/null +++ b/tests/test_provider_labels.py @@ -0,0 +1,194 @@ +"""Regression test for the YooKassa webhook 500 caused by a missing class attr. + +A real production crash looked like:: + + YooKassa Webhook Parsed: Event='payment.succeeded' + AttributeError: 'SubscriptionService' object has no attribute '_PROVIDER_LABELS' + +The fix wires ``_PROVIDER_LABELS`` onto ``PaymentContextMixin`` so the +payment-success email lookup can never blow up the webhook handler again. +These tests pin the contract end-to-end. +""" + +import unittest +from types import SimpleNamespace +from typing import List +from unittest.mock import patch + +from bot.services.subscription_service_impl import payments as payments_module +from bot.services.subscription_service_impl.payments import PaymentContextMixin + +# Provider strings that flow into ``Subscription.provider`` — every value +# present at a real call site must be representable here. +_PROVIDERS_FROM_CALLERS = { + "yookassa", + "freekassa", + "platega", + "severpay", + "cryptopay", + "telegram_stars", +} + + +class ProviderLabelsTests(unittest.TestCase): + def test_attribute_is_defined_on_mixin(self): + # The crash was specifically ``AttributeError`` — guard against that. + self.assertTrue(hasattr(PaymentContextMixin, "_PROVIDER_LABELS")) + self.assertIsInstance(PaymentContextMixin._PROVIDER_LABELS, dict) + + def test_every_runtime_provider_has_a_label(self): + labels = PaymentContextMixin._PROVIDER_LABELS + for provider in _PROVIDERS_FROM_CALLERS: + with self.subTest(provider=provider): + self.assertIn(provider, labels) + self.assertTrue(labels[provider]) # non-empty display text + + def test_labels_are_keyed_by_lowercase_string(self): + # Lookup site does ``self._PROVIDER_LABELS.get((provider or '').lower())`` + # so keys must already be lowercase to ever resolve. + for key in PaymentContextMixin._PROVIDER_LABELS: + self.assertEqual(key, key.lower()) + + +class _FakeUser(SimpleNamespace): + pass + + +class _FakeEmailService: + instances: List["_FakeEmailService"] = [] + + def __init__(self, settings): + self.settings = settings + self.sent: List[dict] = [] + _FakeEmailService.instances.append(self) + + async def send_rendered_email(self, *, email, content): + self.sent.append({"email": email, "content": content}) + + +class SendPaymentSuccessEmailTests(unittest.IsolatedAsyncioTestCase): + """Exercise the exact code path that hit the AttributeError in prod.""" + + def setUp(self) -> None: + _FakeEmailService.instances = [] + + async def _invoke(self, provider: str) -> dict: + captured: dict = {} + + def fake_render(settings, **kwargs): + captured.update(kwargs) + return SimpleNamespace(subject="s", text="t", html="
h
") + + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace( + email_auth_configured=True, + DEFAULT_LANGUAGE="ru", + DEFAULT_CURRENCY_SYMBOL="RUB", + SUBSCRIPTION_MINI_APP_URL="https://app.example.com/", + ) + user = _FakeUser( + user_id=42, email="buyer@example.com", language_code="en" + ) + + with ( + patch.object(payments_module, "render_payment_success", fake_render), + patch.object(payments_module, "EmailAuthService", _FakeEmailService), + ): + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=3, + traffic_gb=None, + payment_amount=450.0, + end_date=None, + provider=provider, + ) + return captured + + async def test_yookassa_resolves_to_human_label(self): + kwargs = await self._invoke("yookassa") + self.assertEqual(kwargs.get("provider_label"), "YooKassa") + self.assertEqual(len(_FakeEmailService.instances), 1) + self.assertEqual(len(_FakeEmailService.instances[0].sent), 1) + + async def test_unknown_provider_renders_as_none_without_raising(self): + # The email template treats ``None`` as "skip the provider row" — we + # need that fallback because new providers may ship before labels do. + kwargs = await self._invoke("paypal_someday") + self.assertIsNone(kwargs.get("provider_label")) + + async def test_provider_lookup_is_case_insensitive(self): + kwargs = await self._invoke("YooKassa") + self.assertEqual(kwargs.get("provider_label"), "YooKassa") + + async def test_skips_email_when_smtp_unconfigured(self): + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace(email_auth_configured=False) + user = _FakeUser(user_id=1, email="x@y.z", language_code="en") + + with patch.object(payments_module, "EmailAuthService", _FakeEmailService): + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=1, + traffic_gb=None, + payment_amount=10.0, + end_date=None, + provider="yookassa", + ) + + self.assertEqual(_FakeEmailService.instances, []) + + async def test_skips_email_when_user_has_no_email(self): + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace( + email_auth_configured=True, + DEFAULT_LANGUAGE="ru", + DEFAULT_CURRENCY_SYMBOL="RUB", + SUBSCRIPTION_MINI_APP_URL="", + ) + user = _FakeUser(user_id=1, email=" ", language_code="en") + + with patch.object(payments_module, "EmailAuthService", _FakeEmailService): + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=1, + traffic_gb=None, + payment_amount=10.0, + end_date=None, + provider="yookassa", + ) + + self.assertEqual(_FakeEmailService.instances, []) + + async def test_render_exceptions_are_swallowed(self): + # The mail path must never block payment processing — this is exactly + # the contract that the original crash violated for a different reason. + def boom(*args, **kwargs): + raise RuntimeError("smtp on fire") + + mixin = PaymentContextMixin() + mixin.settings = SimpleNamespace( + email_auth_configured=True, + DEFAULT_LANGUAGE="ru", + DEFAULT_CURRENCY_SYMBOL="RUB", + SUBSCRIPTION_MINI_APP_URL="", + ) + user = _FakeUser(user_id=1, email="x@y.z", language_code="en") + + with patch.object(payments_module, "render_payment_success", boom): + # No assertion needed — completion without raising IS the assertion. + await mixin._send_payment_success_email( + db_user=user, + sale_mode="subscription", + months=1, + traffic_gb=None, + payment_amount=10.0, + end_date=None, + provider="yookassa", + ) + + +if __name__ == "__main__": # pragma: no cover + unittest.main()