From 0250264fa03feb20cd59fd3eb45bbf61f7802aee Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 27 May 2026 13:59:03 +0300 Subject: [PATCH] fix: serialize HWID top-up validity dates --- backend/bot/app/web/webapp/billing.py | 41 ++++++++-- tests/test_webapp_device_topup_options.py | 96 +++++++++++++++++++++++ 2 files changed, 129 insertions(+), 8 deletions(-) create mode 100644 tests/test_webapp_device_topup_options.py diff --git a/backend/bot/app/web/webapp/billing.py b/backend/bot/app/web/webapp/billing.py index f62bae9..e5bc212 100644 --- a/backend/bot/app/web/webapp/billing.py +++ b/backend/bot/app/web/webapp/billing.py @@ -4,6 +4,29 @@ from ._runtime import * # noqa: F403,F405 from bot.app.web.webapp.cache_helpers import invalidate_webapp_user_caches +def _billing_iso_datetime(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.isoformat() + return str(value) + + +def _billing_datetime_text(value: Optional[Any]) -> Optional[str]: + if not value: + return None + if isinstance(value, datetime): + normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + return normalized.strftime("%d.%m.%Y %H:%M") + text = str(value) + try: + normalized = datetime.fromisoformat(text.replace("Z", "+00:00")) + return normalized.strftime("%d.%m.%Y %H:%M") + except Exception: + return text + + async def apply_promo_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) payload = await _read_json(request) @@ -619,6 +642,12 @@ async def device_topup_options_route(request: web.Request) -> web.Response: lang = db_user.language_code or settings.DEFAULT_LANGUAGE active = await subscription_service.get_active_subscription_details(session, user_id) renewal_available = bool(active and active.get("device_topup_renewal_available")) + extra_hwid_valid_until = ( + active.get("extra_hwid_devices_valid_until") if active else None + ) + extra_hwid_valid_until_text = ( + active.get("extra_hwid_devices_valid_until_text") if active else None + ) or _billing_datetime_text(extra_hwid_valid_until) packages = tariff.hwid_device_packages rub_counts = {int(package.count) for package in (packages.rub if packages else [])} stars_counts = {int(package.count) for package in (packages.stars if packages else [])} @@ -687,14 +716,10 @@ async def device_topup_options_route(request: web.Request) -> web.Response: "extra_hwid_devices": int(active.get("extra_hwid_devices") or 0) if active else int(sub.extra_hwid_devices or 0), - "extra_hwid_devices_valid_until": active.get("extra_hwid_devices_valid_until") - if active - else None, - "extra_hwid_devices_valid_until_text": active.get( - "extra_hwid_devices_valid_until_text" - ) - if active - else None, + "extra_hwid_devices_valid_until": _billing_iso_datetime( + extra_hwid_valid_until + ), + "extra_hwid_devices_valid_until_text": extra_hwid_valid_until_text, "renewal_available": renewal_available, "renewal_recommended_count": int(active.get("extra_hwid_devices") or 0) if active and renewal_available diff --git a/tests/test_webapp_device_topup_options.py b/tests/test_webapp_device_topup_options.py new file mode 100644 index 0000000..3eba071 --- /dev/null +++ b/tests/test_webapp_device_topup_options.py @@ -0,0 +1,96 @@ +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 _SessionFactory: + def __call__(self): + return self + + async def __aenter__(self): + return SimpleNamespace() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +class WebAppDeviceTopupOptionsTests(IsolatedAsyncioTestCase): + async def test_serializes_active_hwid_validity_window(self): + active_until = datetime(2099, 1, 2, 3, 4, tzinfo=timezone.utc) + valid_from = datetime(2099, 1, 1, 3, 4, tzinfo=timezone.utc) + tariff = SimpleNamespace( + key="standard", + billing_model="period", + hwid_device_packages=SimpleNamespace( + rub=[SimpleNamespace(count=1)], + stars=[], + ), + name=lambda lang: "Standard", + ) + settings = SimpleNamespace( + MY_DEVICES_SECTION_ENABLED=True, + tariffs_config=SimpleNamespace(require=lambda key: tariff), + DEFAULT_LANGUAGE="en", + DEFAULT_CURRENCY_SYMBOL="RUB", + ) + subscription_service = SimpleNamespace( + get_active_subscription_details=AsyncMock( + return_value={ + "max_devices": 4, + "extra_hwid_devices": 1, + "extra_hwid_devices_valid_until": active_until, + "device_topup_renewal_available": True, + } + ), + quote_hwid_device_topup=AsyncMock( + return_value={ + "price": 50, + "valid_from": valid_from, + "valid_until": active_until, + "proration_ratio": 0.5, + } + ), + ) + request = SimpleNamespace( + app={ + "settings": settings, + "async_session_factory": _SessionFactory(), + "subscription_service": subscription_service, + } + ) + db_user = SimpleNamespace( + is_banned=False, + panel_user_uuid="panel-user", + language_code="en", + ) + sub = SimpleNamespace( + tariff_key="standard", + extra_hwid_devices=1, + ) + + with ( + patch.object(billing_module, "_require_user_id", return_value=42), + patch.object( + billing_module.user_dal, + "get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch.object( + billing_module.subscription_dal, + "get_active_subscription_by_user_id", + AsyncMock(return_value=sub), + ), + ): + response = await billing_module.device_topup_options_route(request) + + self.assertEqual(response.status, 200) + payload = json.loads(response.text) + self.assertEqual(payload["extra_hwid_devices_valid_until"], active_until.isoformat()) + self.assertEqual(payload["extra_hwid_devices_valid_until_text"], "02.01.2099 03:04") + self.assertEqual(payload["plans"][0]["valid_from"], valid_from.isoformat()) + self.assertEqual(payload["plans"][0]["valid_until"], active_until.isoformat())