diff --git a/tests/test_auto_renew_wiring.py b/tests/test_auto_renew_wiring.py new file mode 100644 index 0000000..a875e1a --- /dev/null +++ b/tests/test_auto_renew_wiring.py @@ -0,0 +1,283 @@ +"""Regression tests for the auto-renew code path in ``RenewalMixin``. + +Prior to the fix, ``charge_subscription_renewal`` did:: + + try: + from .yookassa_service import YooKassaService # local import to avoid cycles + yk: YooKassaService = self.yookassa_service + except Exception: + yk = None + +There is no ``yookassa_service`` module inside ``subscription_service_impl`` +(the real one lives in ``bot.services.yookassa_service``), so the import +always raised ``ModuleNotFoundError``, ``yk`` became ``None``, and every +auto-renew silently logged ``YooKassa unavailable for auto-renew`` and +returned False — even though ``build_core_services`` had wired a real +``yookassa_service`` onto the subscription service via ``setattr``. + +These tests pin the working contract end-to-end so the regression cannot +return. +""" + +import unittest +from types import SimpleNamespace +from typing import Any, Dict, List, Optional +from unittest.mock import patch + +from bot.services.subscription_service_impl.renewal import RenewalMixin + + +class _FakeYooKassaService: + """Stand-in for the real ``YooKassaService``.""" + + 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" + } + + async def create_payment(self, **kwargs): + self.calls.append(kwargs) + return self._response + + +class _FakePaymentMethod: + def __init__(self, pm_id: str = "pm-42") -> None: + self.provider_payment_method_id = pm_id + + +class _FakeSub(SimpleNamespace): + pass + + +def _make_mixin(*, yk: Optional[_FakeYooKassaService], price_for_months: Optional[float] = 100.0): + mixin = RenewalMixin() + mixin.settings = SimpleNamespace( + traffic_sale_mode=False, + yookassa_autopayments_active=True, + subscription_options={1: price_for_months} if price_for_months else {}, + ) + if yk is not None: + mixin.yookassa_service = yk # type: ignore[attr-defined] + return mixin + + +async def _stub_default_pm(session, user_id): + return _FakePaymentMethod() + + +async def _no_default_pm(session, user_id): + return None + + +class ChargeRenewalShortCircuitTests(unittest.IsolatedAsyncioTestCase): + """Negative paths that should return *without* hitting YooKassa.""" + + async def test_skips_when_traffic_sale_mode_enabled(self): + mixin = _make_mixin(yk=None) + mixin.settings.traffic_sale_mode = True + ok = await mixin.charge_subscription_renewal(session=None, sub=_FakeSub()) + self.assertTrue(ok) + + async def test_skips_when_auto_renew_disabled(self): + mixin = _make_mixin(yk=None) + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub(auto_renew_enabled=False), + ) + self.assertTrue(ok) + + async def test_skips_when_autopayments_globally_disabled(self): + mixin = _make_mixin(yk=None) + mixin.settings.yookassa_autopayments_active = False + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub(auto_renew_enabled=True, provider="yookassa"), + ) + self.assertTrue(ok) + + async def test_skips_for_non_yookassa_provider(self): + mixin = _make_mixin(yk=None) + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub(auto_renew_enabled=True, provider="freekassa"), + ) + self.assertTrue(ok) + + +class ChargeRenewalFailureTests(unittest.IsolatedAsyncioTestCase): + async def test_returns_false_when_no_saved_payment_method(self): + mixin = _make_mixin(yk=_FakeYooKassaService()) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _no_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_yookassa_service_missing(self): + # Reproduces the historic bug: build_core_services did not attach + # ``yookassa_service`` for some reason → auto-renew must report False. + mixin = _make_mixin(yk=None) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_yookassa_not_configured(self): + mixin = _make_mixin(yk=_FakeYooKassaService(configured=False)) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_legacy_price_for_months_missing(self): + mixin = _make_mixin(yk=_FakeYooKassaService(), price_for_months=None) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + async def test_returns_false_when_yookassa_response_unrecognized_status(self): + yk = _FakeYooKassaService(response={"id": "p", "status": "failed"}) + mixin = _make_mixin(yk=yk) + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=10, + duration_months=1, + ), + ) + self.assertFalse(ok) + + +class ChargeRenewalHappyPathTests(unittest.IsolatedAsyncioTestCase): + async def test_initiates_payment_with_saved_method(self): + yk = _FakeYooKassaService(response={"id": "auto-pay-7", "status": "pending"}) + mixin = _make_mixin(yk=yk, price_for_months=399.0) + + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=77, + subscription_id=555, + duration_months=1, + ), + ) + + self.assertTrue(ok) + self.assertEqual(len(yk.calls), 1) + call = yk.calls[0] + self.assertEqual(call["amount"], 399.0) + self.assertEqual(call["currency"], "RUB") + self.assertEqual(call["payment_method_id"], "pm-42") + self.assertEqual(call["save_payment_method"], False) + self.assertEqual(call["capture"], True) + meta = call["metadata"] + self.assertEqual(meta["user_id"], "77") + self.assertEqual(meta["auto_renew_for_subscription_id"], "555") + self.assertEqual(meta["subscription_months"], "1") + + async def test_accepts_waiting_for_capture_status(self): + yk = _FakeYooKassaService(response={"id": "p", "status": "waiting_for_capture"}) + mixin = _make_mixin(yk=yk) + + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=2, + duration_months=1, + ), + ) + + self.assertTrue(ok) + + async def test_defaults_to_one_month_when_duration_missing(self): + yk = _FakeYooKassaService() + mixin = _make_mixin(yk=yk, price_for_months=99.0) + + with patch( + "db.dal.user_billing_dal.get_user_default_payment_method", + _stub_default_pm, + ): + ok = await mixin.charge_subscription_renewal( + session=None, + sub=_FakeSub( + auto_renew_enabled=True, + provider="yookassa", + user_id=1, + subscription_id=2, + duration_months=None, + ), + ) + + self.assertTrue(ok) + self.assertEqual(yk.calls[0]["metadata"]["subscription_months"], "1") + self.assertEqual(yk.calls[0]["amount"], 99.0) + + +if __name__ == "__main__": # pragma: no cover + unittest.main()