diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index 8d2cc81..847fb08 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -163,9 +163,7 @@ def _legacy_referral_bonus_periods(settings: Settings) -> List[int]: return sorted(int(months) for months in settings.subscription_options) -def _serialize_tariff_period_referral_bonus_details( - tariff: Any, lang: str -) -> List[Dict[str, Any]]: +def _serialize_tariff_period_referral_bonus_details(tariff: Any, lang: str) -> List[Dict[str, Any]]: details: List[Dict[str, Any]] = [] for months in sorted(int(month) for month in tariff.enabled_periods): inviter_days = tariff.referral_inviter_bonus_days(months) @@ -460,6 +458,7 @@ def _serialize_plans( for tariff in tariffs_config.enabled_tariffs: common = { "tariff_key": tariff.key, + "is_default_tariff": tariff.key == tariffs_config.default_tariff, "tariff_name": tariff.name(lang), "billing_model": tariff.billing_model, "description": tariff.description(lang), diff --git a/backend/bot/services/email_templates.py b/backend/bot/services/email_templates.py index c714038..1c33817 100644 --- a/backend/bot/services/email_templates.py +++ b/backend/bot/services/email_templates.py @@ -534,6 +534,7 @@ def render_subscription_lifecycle_notification( message_text: str, end_date_text: str, dashboard_url: Optional[str], + mirrored_from_telegram: bool = False, days_left: Optional[int] = None, hours_before: Optional[int] = None, i18n: Optional[JsonI18n] = None, @@ -551,7 +552,12 @@ def render_subscription_lifecycle_notification( days_left=days_left, hours_before=hours_before, ) - intro = _t_text(i18n, lang, "email_subscription_lifecycle_intro") + intro_key = ( + "email_subscription_lifecycle_intro_mirrored" + if mirrored_from_telegram + else "email_subscription_lifecycle_intro_direct" + ) + intro = _t_text(i18n, lang, intro_key) footer = _t_html(i18n, lang, "email_footer_auto", brand=brand) cta_label = _t_text(i18n, lang, "email_subscription_lifecycle_cta") diff --git a/backend/bot/services/subscription_lifecycle_notifications.py b/backend/bot/services/subscription_lifecycle_notifications.py index f590628..7e744b9 100644 --- a/backend/bot/services/subscription_lifecycle_notifications.py +++ b/backend/bot/services/subscription_lifecycle_notifications.py @@ -2,6 +2,7 @@ import logging from dataclasses import dataclass from datetime import datetime, timezone from typing import Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from aiogram import Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError @@ -67,20 +68,30 @@ class SubscriptionLifecycleNotificationService: resolved_user = user or getattr(sub, "user", None) lang = getattr(resolved_user, "language_code", None) or self.settings.DEFAULT_LANGUAGE user_id = int(getattr(sub, "user_id", 0) or 0) - user_name = getattr(resolved_user, "first_name", None) or f"User {user_id}" final_end_date_text = end_date_text if final_end_date_text is None: end_date = self._as_utc(getattr(sub, "end_date", None)) final_end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" - kwargs = {"user_name": user_name, "end_date": final_end_date_text} + recipient_email = self._email_recipient(resolved_user) + telegram_user_name = self._telegram_display_name(resolved_user, user_id) + email_user_name = self._email_display_name( + resolved_user, + recipient_email=recipient_email, + fallback=telegram_user_name, + ) + + kwargs = {"user_name": telegram_user_name, "end_date": final_end_date_text} if stage.hours_before is not None: kwargs["hours"] = stage.hours_before message_text = self.i18n.gettext(lang, stage.message_key, **kwargs) + email_kwargs = {**kwargs, "user_name": email_user_name} + email_message_text = self.i18n.gettext(lang, stage.message_key, **email_kwargs) final_extra_text = str(extra_text or "").strip() if final_extra_text: message_text = f"{message_text}\n\n{final_extra_text}" + email_message_text = f"{email_message_text}\n\n{final_extra_text}" telegram_sent = await self._send_telegram( session, @@ -98,8 +109,10 @@ class SubscriptionLifecycleNotificationService: stage, resolved_user, lang=lang, - message_text=message_text, + message_text=email_message_text, end_date_text=final_end_date_text, + recipient=recipient_email, + telegram_sent=telegram_sent, sent_at=sent_at, ) return SubscriptionNotificationDelivery( @@ -180,13 +193,14 @@ class SubscriptionLifecycleNotificationService: lang: str, message_text: str, end_date_text: str, + recipient: str, + telegram_sent: bool, sent_at: datetime, ) -> bool: if not getattr(self.settings, "SUBSCRIPTION_EMAIL_NOTIFICATIONS_ENABLED", True): return False if not getattr(self.settings, "email_auth_configured", False): return False - recipient = str(getattr(user, "email", "") or "").strip() if user else "" if not recipient: return False if await self._already_sent(session, sub.subscription_id, stage.key, "email"): @@ -199,7 +213,8 @@ class SubscriptionLifecycleNotificationService: notification_key=stage.key, message_text=message_text, end_date_text=end_date_text, - dashboard_url=(self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None, + dashboard_url=self._renewal_dashboard_url(recipient, sub), + mirrored_from_telegram=telegram_sent, days_left=stage.days_left, hours_before=stage.hours_before, i18n=self.i18n, @@ -249,6 +264,64 @@ class SubscriptionLifecycleNotificationService: def _channel_key(stage_key: str, channel: str) -> str: return f"{stage_key}:{channel}" + @staticmethod + def _email_recipient(user: Optional[User]) -> str: + return str(getattr(user, "email", "") or "").strip().lower() if user else "" + + @staticmethod + def _telegram_display_name(user: Optional[User], fallback_user_id: int) -> str: + return str(getattr(user, "first_name", "") or "").strip() or f"User {fallback_user_id}" + + @staticmethod + def _email_display_name( + user: Optional[User], + *, + recipient_email: str, + fallback: str, + ) -> str: + return str(getattr(user, "first_name", "") or "").strip() or recipient_email or fallback + + def _renewal_dashboard_url(self, recipient_email: str, sub: Subscription) -> Optional[str]: + base_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() + if not base_url: + return None + parsed = urlsplit(base_url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return None + + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query.update( + { + "login": "email_code", + "login_email": recipient_email, + "after_login": "renew", + "renew": "1", + } + ) + tariff_key = self._renewal_tariff_key(sub) + if tariff_key: + query["renew_tariff"] = tariff_key + else: + query.pop("renew_tariff", None) + + return urlunsplit( + ( + parsed.scheme, + parsed.netloc, + parsed.path or "/", + urlencode(query), + parsed.fragment, + ) + ) + + @staticmethod + def _renewal_tariff_key(sub: Subscription) -> str: + provider = str(getattr(sub, "provider", "") or "").strip().lower() + status = str(getattr(sub, "status_from_panel", "") or "").strip().upper() + if provider == "trial" or status == "TRIAL": + return "" + return str(getattr(sub, "tariff_key", "") or "").strip() + @staticmethod def _telegram_chat_id(user: Optional[User], fallback_user_id: Optional[int]) -> Optional[int]: for candidate in (getattr(user, "telegram_id", None), fallback_user_id): diff --git a/backend/bot/services/subscription_service_impl/lifecycle.py b/backend/bot/services/subscription_service_impl/lifecycle.py index 603e2b4..49da0a7 100644 --- a/backend/bot/services/subscription_service_impl/lifecycle.py +++ b/backend/bot/services/subscription_service_impl/lifecycle.py @@ -805,9 +805,11 @@ class SubscriptionLifecycleMixin: local_active_sub = await subscription_dal.get_active_subscription_by_user_id( session, user_id, panel_user_uuid ) - panel_user_data, panel_user_confirmed_absent, panel_lookup_failure_reason = ( - await self._lookup_panel_user_for_subscription_details(panel_user_uuid) - ) + ( + panel_user_data, + panel_user_confirmed_absent, + panel_lookup_failure_reason, + ) = await self._lookup_panel_user_for_subscription_details(panel_user_uuid) if not panel_user_data: if panel_user_confirmed_absent: diff --git a/backend/config/tariffs_config.py b/backend/config/tariffs_config.py index c382c6b..d691313 100644 --- a/backend/config/tariffs_config.py +++ b/backend/config/tariffs_config.py @@ -149,7 +149,7 @@ class Tariff(BaseModel): if rub_price <= 0 and stars_price <= 0: raise ValueError( f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501 - ) + ) return self if not self.traffic_packages or not self.traffic_packages.has_any(): diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 09e98fb..a44609b 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -152,6 +152,7 @@ let mode = isAppLaunchRoute ? "appLaunch" : isPreviewBoard ? "preview" : "loading"; let activeTab = "home"; let screen = "home"; + let emailLoginDeeplinkConsumed = false; let data = isPreviewBoard ? structuredCloneSafe(MOCK_SOURCE.data) : null; let appLaunchTarget = isAppLaunchRoute ? readExternalAppLaunchTarget() : ""; let publicInstallSubscription = null; @@ -1142,6 +1143,61 @@ return new URLSearchParams(window.location.search); } + function readEmailCodeLoginDeeplink() { + const params = currentSearchParams(); + if (params.get("login") !== "email_code") return null; + const emailHint = normalizedEmail(params.get("login_email") || ""); + if (!emailHint || !emailHint.includes("@")) return null; + return emailHint; + } + + function hasEmailCodeLoginDeeplink() { + return Boolean(readEmailCodeLoginDeeplink()); + } + + async function startEmailCodeLoginFromDeeplink() { + if (emailLoginDeeplinkConsumed) return; + const emailHint = readEmailCodeLoginDeeplink(); + if (!emailHint) return; + emailLoginDeeplinkConsumed = true; + authStore.update((s) => ({ + ...s, + email: emailHint, + emailCode: "", + pendingEmail: "", + passwordLoginMode: false, + passwordLoginFallback: false, + })); + await tick(); + await authStore.requestEmailCode((nextScreen) => { + screen = nextScreen; + }); + } + + function readRenewalDeeplink() { + const params = currentSearchParams(); + const shouldRenew = params.get("after_login") === "renew" || params.get("renew") === "1"; + if (!shouldRenew) return null; + return { + tariffKey: String(params.get("renew_tariff") || "").trim(), + }; + } + + function stripRenewalLoginQueryFromUrl() { + if (typeof window === "undefined") return; + const url = new URL(window.location.href); + const keys = ["login", "login_email", "after_login", "renew", "renew_tariff"]; + const changed = keys.some((key) => url.searchParams.has(key)); + if (!changed) return; + for (const key of keys) url.searchParams.delete(key); + const search = url.searchParams.toString(); + window.history.replaceState( + null, + "", + `${url.pathname}${search ? `?${search}` : ""}${url.hash}` + ); + } + function docsDemoParentSearchParams() { if (!isDocsDemo) return null; try { @@ -1317,6 +1373,7 @@ clearToken, clearManualLogoutFlag, isManuallyLoggedOut, + hasEmailCodeLoginDeeplink, finalizeMagicLogin: (loginToken) => authStore.finalizeMagicLogin(loginToken), finalizeTelegramAuth: (authData, source) => authStore.finalizeTelegramAuth(authData, source), setAuthStatus: (message, isError) => authStore.setAuthStatus(message, isError), @@ -1509,6 +1566,30 @@ stripTopupQueryFromUrl(); } } + + const renewalDeep = readRenewalDeeplink(); + if (renewalDeep) { + const plansList = payload.plans?.length ? payload.plans : []; + const tariffCatalogLocal = buildTariffCatalog(plansList); + const tariffModeLocal = plansList.some((plan) => plan?.tariff_key); + activeTab = "home"; + screen = "home"; + syncAppSectionPath("home", true); + billingStore.openPaymentModal( + tariffModeLocal, + tariffModeLocal && tariffCatalogLocal.length === 1, + tariffCatalogLocal, + payload.subscription || {}, + plansList, + payload.payment_methods?.[0]?.id || "", + { + preferredTariffKey: renewalDeep.tariffKey, + selectDefaultTariff: true, + preferCheckout: true, + } + ); + stripRenewalLoginQueryFromUrl(); + } return payload; } @@ -1530,6 +1611,7 @@ screen = "login"; activeTab = "home"; setPasswordLoginMode(isPasswordLoginPath(), true); + void startEmailCodeLoginFromDeeplink(); } async function api(path, options = {}) { diff --git a/frontend/src/lib/webapp/stores/billingStore.js b/frontend/src/lib/webapp/stores/billingStore.js index 0d144aa..27df0e3 100644 --- a/frontend/src/lib/webapp/stores/billingStore.js +++ b/frontend/src/lib/webapp/stores/billingStore.js @@ -110,25 +110,43 @@ export function createBillingStore({ tariffCatalog, subscription, plans, - defaultMethod = "" + defaultMethod = "", + options = {} ) { state.update((s) => { let step; let plan = s.selectedPlan; let tariffKey = s.selectedTariffKey; + const catalog = tariffCatalog || []; + const planList = plans || []; + const preferredTariffKey = String(options?.preferredTariffKey || "").trim(); + const preferredTariff = preferredTariffKey + ? catalog.find((tariff) => tariff.key === preferredTariffKey) + : null; + const fallbackTariff = + catalog.find((tariff) => tariff.is_default) || + catalog.find((tariff) => tariff.key === "standard") || + catalog[0] || + null; + const deeplinkTariff = + preferredTariff || (options?.selectDefaultTariff ? fallbackTariff : null); if (tariffMode) { - if (singleTariffMode && tariffCatalog[0]?.key) { - tariffKey = tariffCatalog[0].key; - plan = plans.find((p) => p?.tariff_key === tariffKey) || null; + if (deeplinkTariff?.key) { + tariffKey = deeplinkTariff.key; + plan = planList.find((p) => p?.tariff_key === tariffKey) || null; + step = options?.preferCheckout && plan ? "checkout" : "tariff"; + } else if (singleTariffMode && catalog[0]?.key) { + tariffKey = catalog[0].key; + plan = planList.find((p) => p?.tariff_key === tariffKey) || null; step = "checkout"; } else if ( subscription?.active && subscription?.tariff_key && - tariffCatalog.some((t) => t.key === subscription.tariff_key) + catalog.some((t) => t.key === subscription.tariff_key) ) { tariffKey = subscription.tariff_key; - plan = plans.find((p) => p?.tariff_key === tariffKey) || null; + plan = planList.find((p) => p?.tariff_key === tariffKey) || null; step = "checkout"; } else { step = "tariff"; diff --git a/frontend/src/lib/webapp/tariffs.js b/frontend/src/lib/webapp/tariffs.js index 9e08e40..10e5a65 100644 --- a/frontend/src/lib/webapp/tariffs.js +++ b/frontend/src/lib/webapp/tariffs.js @@ -21,11 +21,13 @@ export function buildTariffCatalog(planList) { (plan?.sale_mode === "traffic_package" || plan?.sale_mode === "traffic" ? "traffic" : "period"), + is_default: Boolean(plan?.is_default_tariff), monthly_gb: Number(plan?.monthly_gb || 0), traffic_packages: [], plans_count: 0, }; if (!entry.description && plan?.description) entry.description = plan.description; + if (plan?.is_default_tariff) entry.is_default = true; if (!entry.monthly_gb && Number(plan?.monthly_gb || 0) > 0) entry.monthly_gb = Number(plan.monthly_gb); const trafficGb = Number(plan?.traffic_gb || 0); diff --git a/frontend/src/lib/webapp/webappBoot.js b/frontend/src/lib/webapp/webappBoot.js index 33e9b23..a1cc639 100644 --- a/frontend/src/lib/webapp/webappBoot.js +++ b/frontend/src/lib/webapp/webappBoot.js @@ -21,6 +21,7 @@ export async function runWebappBoot({ clearToken, clearManualLogoutFlag, isManuallyLoggedOut, + hasEmailCodeLoginDeeplink, finalizeMagicLogin, finalizeTelegramAuth, setAuthStatus, @@ -38,6 +39,13 @@ export async function runWebappBoot({ return; } + if (hasEmailCodeLoginDeeplink?.()) { + clearManualLogoutFlag(); + clearToken(); + showLogin(); + return; + } + const magicToken = readMagicLoginToken(); if (magicToken && (await finalizeMagicLogin(magicToken))) return; diff --git a/locales/en.json b/locales/en.json index bde5b29..c9dd006 100644 --- a/locales/en.json +++ b/locales/en.json @@ -630,7 +630,9 @@ "email_subscription_lifecycle_subject_expired": "Your subscription has expired", "email_subscription_lifecycle_subject_expired_after": "Your subscription expired yesterday", "email_subscription_lifecycle_subject_autorenew": "Auto-renewal runs tomorrow", - "email_subscription_lifecycle_intro": "This notification is mirrored from Telegram so you do not miss an important subscription event.", + "email_subscription_lifecycle_intro": "This is an important subscription notification.", + "email_subscription_lifecycle_intro_direct": "This is an important subscription notification sent to your linked email address.", + "email_subscription_lifecycle_intro_mirrored": "This notification is mirrored from Telegram so you do not miss an important subscription event.", "email_subscription_lifecycle_row_end_date": "Active until", "email_subscription_lifecycle_cta": "Open dashboard", "email_subscription_lifecycle_text_renew": "Dashboard: {url}", diff --git a/locales/ru.json b/locales/ru.json index 15cda73..6e724d9 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -630,7 +630,9 @@ "email_subscription_lifecycle_subject_expired": "Подписка закончилась", "email_subscription_lifecycle_subject_expired_after": "Подписка закончилась сутки назад", "email_subscription_lifecycle_subject_autorenew": "Завтра автопродление подписки", - "email_subscription_lifecycle_intro": "Это уведомление продублировано из Telegram, чтобы вы не пропустили важное событие по подписке.", + "email_subscription_lifecycle_intro": "Это уведомление по важному событию подписки.", + "email_subscription_lifecycle_intro_direct": "Это уведомление по важному событию подписки. Отправляем его на привязанную почту, чтобы вы ничего не пропустили.", + "email_subscription_lifecycle_intro_mirrored": "Это уведомление продублировано из Telegram, чтобы вы не пропустили важное событие по подписке.", "email_subscription_lifecycle_row_end_date": "Действует до", "email_subscription_lifecycle_cta": "Открыть кабинет", "email_subscription_lifecycle_text_renew": "Кабинет: {url}", diff --git a/tests/test_subscription_lifecycle_notifications.py b/tests/test_subscription_lifecycle_notifications.py index bc60f0f..d76fe2f 100644 --- a/tests/test_subscription_lifecycle_notifications.py +++ b/tests/test_subscription_lifecycle_notifications.py @@ -1,6 +1,7 @@ import asyncio from datetime import datetime, timezone from types import SimpleNamespace +from urllib.parse import parse_qs, urlsplit from aiogram.exceptions import TelegramBadRequest from aiogram.methods import SendMessage @@ -22,6 +23,8 @@ class FakeI18n: "email_subscription_lifecycle_subject_expired_after": "Expired yesterday", "email_subscription_lifecycle_subject_autorenew": "Auto-renewal tomorrow", "email_subscription_lifecycle_intro": "Subscription notice", + "email_subscription_lifecycle_intro_direct": "Direct email notice", + "email_subscription_lifecycle_intro_mirrored": "Mirrored Telegram notice", "email_subscription_lifecycle_row_end_date": "Active until", "email_subscription_lifecycle_cta": "Open dashboard", "email_subscription_lifecycle_text_renew": "Dashboard: {url}", @@ -78,23 +81,29 @@ def _settings(**overrides): ) -def _subscription(): - return SimpleNamespace( - subscription_id=42, - user_id=123, - end_date=datetime(2026, 6, 1, tzinfo=timezone.utc), - ) +def _subscription(**overrides): + data = { + "subscription_id": 42, + "user_id": 123, + "end_date": datetime(2026, 6, 1, tzinfo=timezone.utc), + "tariff_key": "standard", + "provider": "yookassa", + "status_from_panel": "ACTIVE", + } + data.update(overrides) + return SimpleNamespace(**data) def _user(**overrides): - return SimpleNamespace( - user_id=123, - telegram_id=555, - email="user@example.test", - language_code="ru", - first_name="Ada", - **overrides, - ) + data = { + "user_id": 123, + "telegram_id": 555, + "email": "user@example.test", + "language_code": "ru", + "first_name": "Ada", + } + data.update(overrides) + return SimpleNamespace(**data) def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): @@ -143,6 +152,7 @@ def test_send_stage_records_telegram_and_email_channel_keys(monkeypatch): } ] assert email_service.messages[0]["email"] == "user@example.test" + assert "Mirrored Telegram notice" in email_service.messages[0]["content"].html assert recorded == ["before_3d:telegram", "before_3d:email"] @@ -232,3 +242,113 @@ def test_terminal_telegram_failure_is_recorded_to_avoid_retry_spam(monkeypatch): assert delivery.email_sent is False assert bot.calls[0][0] == 777 assert recorded == ["before_3d:telegram"] + + +def test_email_only_user_gets_email_name_direct_copy_and_renewal_login_link(monkeypatch): + recorded = [] + + async def fake_has(session, subscription_id, notification_key): + return notification_key in recorded + + async def fake_record(session, subscription_id, notification_key, *, sent_at=None): + recorded.append(notification_key) + + monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has) + monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) + + bot = FakeBot() + email_service = FakeEmailService() + service = SubscriptionLifecycleNotificationService( + _settings(), + bot, + FakeI18n(), + email_service=email_service, + ) + user = _user(user_id=-8758169927032035, telegram_id=None, first_name=None) + + async def run(): + return await service.send_stage( + object(), + _subscription(user_id=user.user_id), + SubscriptionNotificationStage( + key="expired", + message_key="subscription_72h_notification", + days_left=0, + ), + user=user, + telegram_markup="markup", + ) + + delivery = asyncio.run(run()) + + assert delivery.telegram_sent is False + assert delivery.email_sent is True + assert bot.messages == [] + content = email_service.messages[0]["content"] + assert "Hi user@example.test, expires on 2026-06-01" in content.text + assert "User -8758169927032035" not in content.text + assert "Direct email notice" in content.html + assert "Mirrored Telegram notice" not in content.html + + dashboard_line = next( + line for line in content.text.splitlines() if line.startswith("Dashboard: ") + ) + url = dashboard_line.removeprefix("Dashboard: ") + parsed = urlsplit(url) + query = parse_qs(parsed.query) + assert parsed.scheme == "https" + assert parsed.netloc == "app.example.test" + assert query["login"] == ["email_code"] + assert query["login_email"] == ["user@example.test"] + assert query["after_login"] == ["renew"] + assert query["renew"] == ["1"] + assert query["renew_tariff"] == ["standard"] + assert recorded == ["expired:email"] + + +def test_trial_subscription_renewal_link_omits_tariff_for_default_fallback(monkeypatch): + recorded = [] + + async def fake_has(session, subscription_id, notification_key): + return notification_key in recorded + + async def fake_record(session, subscription_id, notification_key, *, sent_at=None): + recorded.append(notification_key) + + monkeypatch.setattr(lifecycle.subscription_dal, "has_subscription_notification", fake_has) + monkeypatch.setattr(lifecycle.subscription_dal, "record_subscription_notification", fake_record) + + email_service = FakeEmailService() + service = SubscriptionLifecycleNotificationService( + _settings(), + FakeBot(), + FakeI18n(), + email_service=email_service, + ) + user = _user(user_id=-1001, telegram_id=None, first_name=None) + + async def run(): + return await service.send_stage( + object(), + _subscription( + user_id=user.user_id, + tariff_key="trial-tariff", + provider="trial", + status_from_panel="TRIAL", + ), + SubscriptionNotificationStage( + key="expired", + message_key="subscription_72h_notification", + days_left=0, + ), + user=user, + ) + + asyncio.run(run()) + + content = email_service.messages[0]["content"] + dashboard_line = next( + line for line in content.text.splitlines() if line.startswith("Dashboard: ") + ) + query = parse_qs(urlsplit(dashboard_line.removeprefix("Dashboard: ")).query) + assert "renew_tariff" not in query diff --git a/tests/test_tariff_worker.py b/tests/test_tariff_worker.py index ae63e44..7fa2bfc 100644 --- a/tests/test_tariff_worker.py +++ b/tests/test_tariff_worker.py @@ -88,9 +88,7 @@ class TariffWorkerTests(unittest.IsolatedAsyncioTestCase): super().__init__("wrapped") self.orig = orig - self.assertTrue( - TariffTrafficWorker._is_retryable_db_exception(WrappedDbError(PgError())) - ) + self.assertTrue(TariffTrafficWorker._is_retryable_db_exception(WrappedDbError(PgError()))) self.assertFalse(TariffTrafficWorker._is_retryable_db_exception(RuntimeError("plain"))) async def test_period_tariff_uses_panel_month_strategy_without_resetting(self): diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index 3f6548a..7365058 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -76,10 +76,12 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): self.assertEqual([plan["tariff_key"] for plan in plans], ["standard", "traffic"]) self.assertEqual(plans[0]["sale_mode"], "subscription") + self.assertTrue(plans[0]["is_default_tariff"]) self.assertEqual(plans[0]["months"], 1) self.assertEqual(plans[0]["hwid_device_limit"], 5) self.assertEqual(plans[0]["hwid_device_packages"][0]["device_count"], 1) self.assertEqual(plans[1]["sale_mode"], "traffic_package") + self.assertFalse(plans[1]["is_default_tariff"]) self.assertEqual(plans[1]["traffic_gb"], 50.0) self.assertEqual(plans[1]["stars_price"], 2500)