Files
remnawave-minishop/tests/test_auto_renew_wiring.py
3252a8 fbb89793cb fix: separate HWID device renewal flows
Keep one-off device top-ups scoped to the active subscription term and move device renewal into subscription checkout.

Carry HWID renewal metadata through provider callbacks and webhooks, including YooKassa saved-card flows.

Add admin extension controls, docs, demo data, and regression coverage.
2026-06-03 23:51:46 +03:00

336 lines
12 KiB
Python

"""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 implementation lives in ``bot.payment_providers.yookassa``), 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 datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, 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)
async def test_includes_hwid_device_renewal_in_saved_method_charge(self):
yk = _FakeYooKassaService(response={"id": "auto-pay-8", "status": "pending"})
mixin = _make_mixin(yk=yk, price_for_months=399.0)
valid_from = datetime(2099, 2, 1, tzinfo=timezone.utc)
valid_until = datetime(2099, 3, 1, tzinfo=timezone.utc)
mixin.quote_hwid_device_renewal_for_subscription = AsyncMock(
return_value={
"device_count": 2,
"price": 50.0,
"full_price": 50.0,
"valid_from": valid_from,
"valid_until": valid_until,
"pricing_period_months": 1,
"proration_ratio": 1.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,
tariff_key="standard",
duration_months=1,
),
)
self.assertTrue(ok)
self.assertEqual(len(yk.calls), 1)
call = yk.calls[0]
self.assertEqual(call["amount"], 449.0)
meta = call["metadata"]
self.assertEqual(meta["sale_mode"], "subscription@standard")
self.assertEqual(meta["hwid_devices"], "2")
self.assertEqual(meta["hwid_valid_from"], valid_from.isoformat())
self.assertEqual(meta["hwid_valid_until"], valid_until.isoformat())
self.assertEqual(meta["hwid_pricing_period_months"], "1")
self.assertEqual(meta["hwid_proration_ratio"], "1.0")
self.assertEqual(meta["hwid_full_price"], "50.0")
mixin.quote_hwid_device_renewal_for_subscription.assert_awaited_once_with(
None,
user_id=77,
target_tariff_key="standard",
months=1,
currency="rub",
)
if __name__ == "__main__": # pragma: no cover
unittest.main()