From 7d3178bd48cc61790c16341ea3cb385044eb5547 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 18:04:08 +0300 Subject: [PATCH 1/7] feat: improve admin user filters --- .../src/admin/sections/UsersSection.svelte | 310 +++++++++++++++++- .../patterns/admin/AdminSelect.svelte | 5 +- frontend/src/styles/admin.css | 8 + locales/en.json | 7 + locales/ru.json | 7 + 5 files changed, 335 insertions(+), 2 deletions(-) diff --git a/frontend/src/admin/sections/UsersSection.svelte b/frontend/src/admin/sections/UsersSection.svelte index b9a4bc3..605651d 100644 --- a/frontend/src/admin/sections/UsersSection.svelte +++ b/frontend/src/admin/sections/UsersSection.svelte @@ -5,8 +5,11 @@ ArrowUp, ChevronsUpDown, DollarSign, + Sliders, + X, UsersRound, } from "$components/ui/icons.js"; + import Dialog from "$components/ui/dialog.svelte"; import { Label } from "$components/ui/primitives.js"; import { AdminBadge, @@ -44,6 +47,7 @@ } = $usersStore); const USERS_PAGE_SIZE = 25; + let usersFilterSheetOpen = false; $: usersHasMore = users.length === USERS_PAGE_SIZE; const USERS_FILTER_OPTIONS = [ @@ -102,6 +106,29 @@ { value: "critical", label: at("premium_traffic_filter_critical", {}, "Премиум: исчерпан") }, ]; + function optionLabel(options, value) { + return options.find((item) => item.value === value)?.label || value; + } + + function updateUsersFilterState(patch) { + usersStore.updateState({ ...patch, usersPage: 0 }); + usersStore.loadUsers(); + } + + function resetUsersFilters() { + updateUsersFilterState({ + usersFilter: "all", + usersPanelStatus: "all", + usersPremiumTraffic: "all", + }); + } + + function clearUsersFilter(key) { + if (key === "usersFilter") updateUsersFilterState({ usersFilter: "all" }); + if (key === "usersPanelStatus") updateUsersFilterState({ usersPanelStatus: "all" }); + if (key === "usersPremiumTraffic") updateUsersFilterState({ usersPremiumTraffic: "all" }); + } + /** @param {Record | null | undefined} pt */ function premiumTrafficBadgeVariant(pt) { if (!pt || pt.state === "none") return "muted"; @@ -187,6 +214,24 @@ return fmtMoney(user?.payments_total_amount ?? 0, user?.payments_currency || "RUB"); } + $: activeUserFilterChips = [ + usersFilter !== "all" && { + key: "usersFilter", + label: at("filter", {}, "Фильтр"), + value: optionLabel(USERS_FILTER_OPTIONS, usersFilter), + }, + usersPanelStatus !== "all" && { + key: "usersPanelStatus", + label: at("panel_status", {}, "Статус панели"), + value: optionLabel(USERS_PANEL_STATUS_OPTIONS, usersPanelStatus), + }, + usersPremiumTraffic !== "all" && { + key: "usersPremiumTraffic", + label: at("premium_traffic_filter_label", {}, "Премиум трафик"), + value: optionLabel(USERS_PREMIUM_TRAFFIC_OPTIONS, usersPremiumTraffic), + }, + ].filter(Boolean); + $: activeUsersFilterCount = activeUserFilterChips.length; $: userTableHeaders = userTableColumns().map((column) => column.label); onMount(() => { @@ -194,6 +239,65 @@ }); +{#snippet renderUserFilterControls()} + + {at("filter", {}, "Фильтр")} + updateUsersFilterState({ usersFilter: value })} + /> + + + + {at("panel_status", {}, "Статус панели")} + updateUsersFilterState({ usersPanelStatus: value })} + /> + + + + {at("premium_traffic_filter_label", {}, "Премиум трафик")} + updateUsersFilterState({ usersPremiumTraffic: value })} + /> + +{/snippet} + +{#snippet renderActiveUserFilterChips()} + {#if activeUsersFilterCount} +
+ {#each activeUserFilterChips as chip (chip.key)} + + + {chip.label} + {chip.value} + + + + {/each} +
+ {/if} +{/snippet} +
@@ -264,8 +385,45 @@ {usersTotal}
+ + {@render renderActiveUserFilterChips()} + { + usersFilterSheetOpen = false; + }} +> +
+
+ {@render renderUserFilterControls()} +
+ {@render renderActiveUserFilterChips()} +
+ + {at("reset", {}, "Сбросить")} + + { + usersFilterSheetOpen = false; + }} + > + {at("done", {}, "Готово")} + +
+
+
+
{#if usersLoading} :global(.admin-toolbar-users .admin-toolbar-controls) { - grid-template-columns: repeat(3, minmax(130px, 1fr)) minmax(96px, auto); + grid-template-columns: repeat(3, minmax(150px, 1fr)) minmax(82px, auto); + gap: 10px; + } + + :global(.admin-users-search-button) { + min-width: 82px; + } + + :global(.admin-users-filter-toggle) { + display: none; + position: relative; + align-items: center; + gap: 7px; + min-width: 0; + } + + .admin-users-filter-count { + display: inline-grid; + min-width: 18px; + height: 18px; + place-items: center; + padding: 0 5px; + border-radius: 999px; + background: color-mix(in srgb, var(--admin-bg) 74%, transparent); + color: inherit; + font-size: 11px; + font-weight: 750; + line-height: 1; + } + + .admin-users-filter-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; + } + + .admin-users-filter-chip { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 100%; + min-height: 28px; + padding: 3px 5px 3px 10px; + border: 1px solid var(--admin-border); + border-radius: 999px; + background: color-mix(in srgb, var(--admin-muted) 8%, transparent); + color: var(--admin-text); + font-size: 12px; + } + + .admin-users-filter-chip-text { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + max-width: 260px; + } + + .admin-users-filter-chip strong { + color: var(--admin-muted); + font-size: 11px; + font-weight: 650; + } + + .admin-users-filter-chip-text > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .admin-users-filter-chip button { + display: inline-grid; + width: 20px; + height: 20px; + place-items: center; + border: 0; + border-radius: 999px; + background: transparent; + color: var(--admin-muted); + cursor: pointer; + } + + .admin-users-filter-chip button:hover, + .admin-users-filter-chip button:focus-visible { + background: color-mix(in srgb, var(--admin-muted) 14%, transparent); + color: var(--admin-text); + outline: none; + } + + .admin-users-filter-fields-sheet, + .admin-users-filter-sheet-body { + display: grid; + gap: 12px; + } + + .admin-users-filter-sheet-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 8px; + padding-top: 2px; + } + + :global(.admin-users-filter-dialog) { + width: min(100%, 420px); } .admin-users-table-wrap :global(.admin-table-wrap) { @@ -571,6 +833,52 @@ } @media (max-width: 720px) { + :global(.admin-toolbar-users .admin-toolbar-search) { + grid-template-columns: minmax(0, 1fr) auto auto; + } + + :global(.admin-toolbar-users .admin-toolbar-controls) { + display: none; + } + + :global(.admin-users-search-button) { + min-width: 0; + padding-inline: 10px; + } + + :global(.admin-users-filter-toggle) { + display: inline-flex; + min-width: 38px; + padding-inline: 10px; + } + + .admin-users-filter-toggle-label { + display: none; + } + + .admin-users-filter-chips { + gap: 5px; + } + + .admin-users-filter-chip-text { + max-width: min(250px, calc(100vw - 96px)); + } + + :global(.dialog:has(.admin-users-filter-dialog)) { + align-items: end; + padding: max(12px, env(safe-area-inset-top)) 0 0; + } + + :global(.admin-users-filter-dialog) { + width: 100%; + max-height: min(82dvh, 620px); + padding: 16px; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 18px 18px 0 0; + } + .admin-users-table-wrap :global(.admin-users-table thead) { display: table-header-group; } diff --git a/frontend/src/lib/components/patterns/admin/AdminSelect.svelte b/frontend/src/lib/components/patterns/admin/AdminSelect.svelte index b271585..53831ab 100644 --- a/frontend/src/lib/components/patterns/admin/AdminSelect.svelte +++ b/frontend/src/lib/components/patterns/admin/AdminSelect.svelte @@ -7,7 +7,10 @@ export let ariaLabel = ""; export let placeholder = ""; export let disabled = false; + export let side = "bottom"; + export let align = "start"; export let sideOffset = 6; + export let collisionPadding = 12; export let onValueChange = () => {}; let className = ""; export { className as class }; @@ -29,7 +32,7 @@ - + {#each items as item (item.value)} diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index a8ede06..2bc9294 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -4045,6 +4045,7 @@ .admin-select-content { z-index: 90; min-width: var(--bits-select-anchor-width); + max-width: calc(100vw - 24px); max-height: var(--bits-select-content-available-height, 320px); overflow: hidden; border: 1px solid var(--admin-border); @@ -4098,6 +4099,13 @@ user-select: none; } +.admin-select-item span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .admin-select-item[data-highlighted], .admin-select-item:hover { background: var(--admin-surface-2); diff --git a/locales/en.json b/locales/en.json index 3c1d8eb..4929475 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1043,6 +1043,13 @@ "admin_users_search_placeholder": "ID, @username, or email", "admin_find": "Find", "admin_filter": "Filter", + "admin_filters": "Filters", + "admin_active_filters": "Active filters", + "admin_clear_filter": "Clear {label}", + "admin_done": "Done", + "admin_users_filters_open": "Open filters", + "admin_users_filters_title": "User filters", + "admin_users_filters_description": "Refine the user list without leaving the table.", "admin_sort": "Sort", "admin_total": "Total", "admin_users_empty": "No users found", diff --git a/locales/ru.json b/locales/ru.json index a32bd98..3e21e15 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1043,6 +1043,13 @@ "admin_users_search_placeholder": "ID, @username или email", "admin_find": "Найти", "admin_filter": "Фильтр", + "admin_filters": "Фильтры", + "admin_active_filters": "Активные фильтры", + "admin_clear_filter": "Сбросить {label}", + "admin_done": "Готово", + "admin_users_filters_open": "Открыть фильтры", + "admin_users_filters_title": "Фильтры пользователей", + "admin_users_filters_description": "Уточните список пользователей, не уходя из таблицы.", "admin_sort": "Сортировка", "admin_total": "Всего", "admin_users_empty": "Никого не найдено", From 4fa18a1262a85edd2eeea1c188a769930639a06b Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 18:17:02 +0300 Subject: [PATCH 2/7] fix: enlarge mobile renewal button --- frontend/src/styles/webapp.css | 12 ++++++++++++ frontend/src/webapp/screens/HomeScreen.svelte | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/styles/webapp.css b/frontend/src/styles/webapp.css index 0318d1b..43707fa 100644 --- a/frontend/src/styles/webapp.css +++ b/frontend/src/styles/webapp.css @@ -336,6 +336,18 @@ a { margin-left: 0; justify-content: center; } + + .subscription-renew-action { + min-height: 54px; + padding-block: 12px; + font-size: 15px; + } + + .subscription-renew-action svg { + width: 19px; + height: 19px; + flex: 0 0 auto; + } } .traffic-top, diff --git a/frontend/src/webapp/screens/HomeScreen.svelte b/frontend/src/webapp/screens/HomeScreen.svelte index 334a595..429ee1f 100644 --- a/frontend/src/webapp/screens/HomeScreen.svelte +++ b/frontend/src/webapp/screens/HomeScreen.svelte @@ -343,7 +343,7 @@ {/if} {/if} - {#if isCompact && (adminLanguageMenuOpen || adminLanguageClickGuard)} + {#if adminLanguageGuardActive} {/if} -
From 687fc03e8c5a31b44fc1f8e8b1827af5caad3ae5 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 19:14:54 +0300 Subject: [PATCH 5/7] fix: reset trial eligibility from web admin --- backend/bot/app/web/admin_api_impl/users.py | 15 ++-- backend/bot/app/web/webapp/serializers.py | 2 +- backend/bot/handlers/admin/user_management.py | 3 +- backend/bot/handlers/user/start.py | 8 +- backend/bot/handlers/user/trial_handler.py | 16 ++-- .../subscription_service_impl/payments.py | 3 + .../subscription_service_impl/trial.py | 2 +- backend/db/dal/subscription_dal.py | 28 ++++++- backend/db/dal/user_dal.py | 14 ++++ backend/db/migrator.py | 14 ++++ backend/db/models.py | 1 + frontend/src/lib/admin/stores/usersStore.js | 7 +- tests/test_admin_user_reset_trial.py | 77 +++++++++++++++++++ tests/test_subscription_service_behavior.py | 45 ++++++++++- tests/test_support_migration.py | 10 +++ tests/test_user_dal.py | 40 +++++++++- 16 files changed, 253 insertions(+), 32 deletions(-) create mode 100644 tests/test_admin_user_reset_trial.py diff --git a/backend/bot/app/web/admin_api_impl/users.py b/backend/bot/app/web/admin_api_impl/users.py index f578cbd..31587c4 100644 --- a/backend/bot/app/web/admin_api_impl/users.py +++ b/backend/bot/app/web/admin_api_impl/users.py @@ -1172,10 +1172,6 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response: actor_id = _require_admin_user_id(request) target_id = int(request.match_info["user_id"]) settings: Settings = request.app["settings"] - panel_service = request.app.get("panel_service") - subscription_service = request.app.get("subscription_service") - if panel_service is None or subscription_service is None: - return _error(503, "service_unavailable") async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: @@ -1183,16 +1179,17 @@ async def admin_user_reset_trial_route(request: web.Request) -> web.Response: if not user: return _error(404, "not_found") - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if active: - await session.delete(active) + reset_at = await user_dal.mark_trial_eligibility_reset(session, target_id) + if reset_at is None: + await session.rollback() + return _error(404, "not_found") - await message_log_dal.create_message_log( + await message_log_dal.create_message_log_no_commit( session, { "user_id": actor_id, "event_type": "admin_reset_trial_webapp", - "content": f"Reset trial for user_id={target_id}", + "content": f"Reset trial eligibility for user_id={target_id}", "is_admin_event": True, "target_user_id": target_id, }, diff --git a/backend/bot/app/web/webapp/serializers.py b/backend/bot/app/web/webapp/serializers.py index b08a98d..075daed 100644 --- a/backend/bot/app/web/webapp/serializers.py +++ b/backend/bot/app/web/webapp/serializers.py @@ -67,7 +67,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A trial_available = bool( settings.TRIAL_ENABLED and settings.TRIAL_DURATION_DAYS > 0 - and not await subscription_service.has_had_any_subscription(session, user_id) + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ) avatar = await _ensure_cached_telegram_avatar(request, session, db_user) try: diff --git a/backend/bot/handlers/admin/user_management.py b/backend/bot/handlers/admin/user_management.py index cc29674..de463bf 100644 --- a/backend/bot/handlers/admin/user_management.py +++ b/backend/bot/handlers/admin/user_management.py @@ -932,8 +932,7 @@ async def handle_reset_trial( _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) try: - # Delete all user subscriptions to reset trial eligibility - await subscription_dal.delete_all_user_subscriptions(session, user.user_id) + await user_dal.mark_trial_eligibility_reset(session, user.user_id) await session.commit() await callback.answer(_("admin_user_trial_reset_success"), show_alert=True) diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index 9db11a5..a44d2d2 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -45,12 +45,12 @@ async def should_show_trial_button( if not settings.TRIAL_ENABLED: return False - if hasattr(subscription_service, "has_had_any_subscription") and callable( - getattr(subscription_service, "has_had_any_subscription") + if hasattr(subscription_service, "has_trial_blocking_subscription") and callable( + getattr(subscription_service, "has_trial_blocking_subscription") ): - return not await subscription_service.has_had_any_subscription(session, user_id) + return not await subscription_service.has_trial_blocking_subscription(session, user_id) - logging.error("Method has_had_any_subscription is missing in SubscriptionService!") + logging.error("Method has_trial_blocking_subscription is missing in SubscriptionService!") return False diff --git a/backend/bot/handlers/user/trial_handler.py b/backend/bot/handlers/user/trial_handler.py index dd99177..767a9ef 100644 --- a/backend/bot/handlers/user/trial_handler.py +++ b/backend/bot/handlers/user/trial_handler.py @@ -46,7 +46,7 @@ async def request_trial_confirmation_handler( return if settings.TRIAL_ENABLED: - if not await subscription_service.has_had_any_subscription(session, user_id): + if not await subscription_service.has_trial_blocking_subscription(session, user_id): pass if not settings.TRIAL_ENABLED: @@ -60,7 +60,7 @@ async def request_trial_confirmation_handler( pass return - if await subscription_service.has_had_any_subscription(session, user_id): + if await subscription_service.has_trial_blocking_subscription(session, user_id): await callback.message.edit_text( _("trial_already_had_subscription_or_trial"), reply_markup=get_main_menu_inline_keyboard(current_lang, i18n, settings, False), @@ -147,8 +147,9 @@ async def request_trial_confirmation_handler( await callback.answer(final_message_text_in_chat, show_alert=True) except Exception: pass - if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( - session, user_id + if ( + settings.TRIAL_ENABLED + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ): show_trial_button_after_action = True @@ -218,7 +219,7 @@ async def confirm_activate_trial_handler( callback, settings, i18n_data, subscription_service, session, is_edit=True ) return - if await subscription_service.has_had_any_subscription(session, user_id): + if await subscription_service.has_trial_blocking_subscription(session, user_id): try: await callback.answer(_("trial_already_had_subscription_or_trial"), show_alert=True) except Exception: @@ -283,8 +284,9 @@ async def confirm_activate_trial_handler( await callback.answer(final_message_text_in_chat, show_alert=True) except Exception: pass - if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription( - session, user_id + if ( + settings.TRIAL_ENABLED + and not await subscription_service.has_trial_blocking_subscription(session, user_id) ): show_trial_button_after_action = True diff --git a/backend/bot/services/subscription_service_impl/payments.py b/backend/bot/services/subscription_service_impl/payments.py index 2e48814..012c4c4 100644 --- a/backend/bot/services/subscription_service_impl/payments.py +++ b/backend/bot/services/subscription_service_impl/payments.py @@ -62,6 +62,9 @@ class PaymentContextMixin: async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool: return await subscription_dal.has_any_subscription_for_user(session, user_id) + async def has_trial_blocking_subscription(self, session: AsyncSession, user_id: int) -> bool: + return await subscription_dal.has_trial_blocking_subscription_for_user(session, user_id) + async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool: """Return True if user currently has an active subscription (end_date in future).""" try: diff --git a/backend/bot/services/subscription_service_impl/trial.py b/backend/bot/services/subscription_service_impl/trial.py index 636e042..a963a85 100644 --- a/backend/bot/services/subscription_service_impl/trial.py +++ b/backend/bot/services/subscription_service_impl/trial.py @@ -22,7 +22,7 @@ class TrialSubscriptionMixin: "message_key": "user_not_found_for_trial", } - if await self.has_had_any_subscription(session, user_id): + if await self.has_trial_blocking_subscription(session, user_id): return { "eligible": False, "activated": False, diff --git a/backend/db/dal/subscription_dal.py b/backend/db/dal/subscription_dal.py index a59d900..d81a723 100644 --- a/backend/db/dal/subscription_dal.py +++ b/backend/db/dal/subscription_dal.py @@ -4,12 +4,12 @@ import secrets from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional -from sqlalchemy import delete, func, or_, update +from sqlalchemy import and_, delete, func, or_, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload -from db.models import Subscription, SubscriptionNotification +from db.models import Subscription, SubscriptionNotification, User INSTALL_SHARE_TOKEN_BYTES = 16 @@ -252,7 +252,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int) async def delete_all_user_subscriptions(session: AsyncSession, user_id: int) -> int: - """Completely delete all user subscriptions (for trial reset)""" + """Completely delete all user subscriptions.""" stmt = delete(Subscription).where(Subscription.user_id == user_id) result = await session.execute(stmt) if result.rowcount > 0: @@ -284,6 +284,28 @@ async def has_any_subscription_for_user(session: AsyncSession, user_id: int) -> return result.scalar_one_or_none() is not None +async def has_trial_blocking_subscription_for_user(session: AsyncSession, user_id: int) -> bool: + now_utc = datetime.now(timezone.utc) + reset_at = ( + select(User.trial_eligibility_reset_at).where(User.user_id == user_id).scalar_subquery() + ) + subscription_anchor = func.coalesce(Subscription.start_date, Subscription.end_date) + stmt = ( + select(Subscription.subscription_id) + .where( + Subscription.user_id == user_id, + or_( + reset_at.is_(None), + and_(Subscription.is_active == True, Subscription.end_date > now_utc), + subscription_anchor > reset_at, + ), + ) + .limit(1) + ) + result = await session.execute(stmt) + return result.scalar_one_or_none() is not None + + async def get_subscriptions_near_expiration( session: AsyncSession, days_threshold: int ) -> List[Subscription]: diff --git a/backend/db/dal/user_dal.py b/backend/db/dal/user_dal.py index 4d0308b..6a3c1af 100644 --- a/backend/db/dal/user_dal.py +++ b/backend/db/dal/user_dal.py @@ -261,6 +261,20 @@ async def create_email_user( ) +async def mark_trial_eligibility_reset( + session: AsyncSession, + user_id: int, + *, + reset_at: Optional[datetime] = None, +) -> Optional[datetime]: + reset_at = reset_at or datetime.now(timezone.utc) + stmt = update(User).where(User.user_id == user_id).values(trial_eligibility_reset_at=reset_at) + result = await session.execute(stmt) + if result.rowcount <= 0: + return None + return reset_at + + async def _has_active_panel_subscription( session: AsyncSession, user_id: int, panel_user_uuid: str ) -> bool: diff --git a/backend/db/migrator.py b/backend/db/migrator.py index c09e732..73b3e3c 100644 --- a/backend/db/migrator.py +++ b/backend/db/migrator.py @@ -1061,6 +1061,15 @@ def _migration_0032_add_telegram_notification_status(connection: Connection) -> connection.execute(text(f"ALTER TABLE users ADD COLUMN {column} {ddl_type}")) +def _migration_0033_add_trial_eligibility_reset_marker(connection: Connection) -> None: + inspector = inspect(connection) + columns: Set[str] = {col["name"] for col in inspector.get_columns("users")} + if "trial_eligibility_reset_at" not in columns: + connection.execute( + text("ALTER TABLE users ADD COLUMN trial_eligibility_reset_at TIMESTAMPTZ") + ) + + MIGRATIONS: List[Migration] = [ Migration( id="0001_add_channel_subscription_fields", @@ -1233,6 +1242,11 @@ MIGRATIONS: List[Migration] = [ description="Track whether the bot can message Telegram-linked users", upgrade=_migration_0032_add_telegram_notification_status, ), + Migration( + id="0033_add_trial_eligibility_reset_marker", + description="Track admin resets of per-user trial eligibility without deleting history", + upgrade=_migration_0033_add_trial_eligibility_reset_marker, + ), ] diff --git a/backend/db/models.py b/backend/db/models.py index 209abde..78bb8c3 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -47,6 +47,7 @@ class User(Base): referred_by_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True) lifetime_used_traffic_bytes = Column(BigInteger, nullable=True) lifetime_used_traffic_synced_at = Column(DateTime(timezone=True), nullable=True) + trial_eligibility_reset_at = Column(DateTime(timezone=True), nullable=True) channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_checked_at = Column(DateTime(timezone=True), nullable=True) channel_subscription_verified_for = Column(BigInteger, nullable=True) diff --git a/frontend/src/lib/admin/stores/usersStore.js b/frontend/src/lib/admin/stores/usersStore.js index 8c8b1e6..9529e0e 100644 --- a/frontend/src/lib/admin/stores/usersStore.js +++ b/frontend/src/lib/admin/stores/usersStore.js @@ -469,8 +469,11 @@ export function createUsersStore({ api, onToast, at, routePrefix = "" }) { state.update((st) => ({ ...st, userActionBusy: true })); try { const res = await api(`/admin/users/${s.openedUser.user_id}/reset-trial`, { method: "POST" }); - if (res?.ok) onToast(at("trial_reset", {}, "Триал сброшен")); - else onToast(res?.error || at("error", {}, "Ошибка")); + if (res?.ok) { + onToast(at("trial_reset", {}, "Триал сброшен")); + await openUser(s.openedUser.user_id, { skipPush: true, pathContext: _pathContext }); + if (_activeRef === "users") await loadUsers(); + } else onToast(res?.error || at("error", {}, "Ошибка")); } finally { state.update((st) => ({ ...st, userActionBusy: false })); } diff --git a/tests/test_admin_user_reset_trial.py b/tests/test_admin_user_reset_trial.py new file mode 100644 index 0000000..8954bc2 --- /dev/null +++ b/tests/test_admin_user_reset_trial.py @@ -0,0 +1,77 @@ +import json +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.app.web.admin_api_impl import users as admin_users + + +class FakeSession: + def __init__(self): + self.committed = False + self.rolled_back = False + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def commit(self): + self.committed = True + + async def rollback(self): + self.rolled_back = True + + +class AdminUserResetTrialRouteTests(unittest.IsolatedAsyncioTestCase): + def _request(self, session: FakeSession): + return SimpleNamespace( + app={ + "settings": SimpleNamespace(), + "async_session_factory": lambda: session, + }, + match_info={"user_id": "42"}, + ) + + async def test_marks_trial_reset_without_deleting_subscription_history(self): + session = FakeSession() + request = self._request(session) + user = SimpleNamespace(user_id=42) + + with ( + patch.object(admin_users, "_require_admin_user_id", return_value=100), + patch.object(admin_users.user_dal, "get_user_by_id", AsyncMock(return_value=user)), + patch.object( + admin_users.user_dal, + "mark_trial_eligibility_reset", + AsyncMock(return_value=object()), + ) as mark_reset, + patch.object( + admin_users.subscription_dal, + "delete_all_user_subscriptions", + AsyncMock(), + ) as delete_all, + patch.object( + admin_users.message_log_dal, "create_message_log_no_commit", AsyncMock() + ) as log, + patch.object( + admin_users, "_invalidate_after_admin_user_mutation", AsyncMock() + ) as invalidate, + ): + response = await admin_users.admin_user_reset_trial_route(request) + + self.assertEqual(response.status, 200) + self.assertEqual(json.loads(response.text)["ok"], True) + mark_reset.assert_awaited_once_with(session, 42) + delete_all.assert_not_awaited() + log_payload = log.await_args.args[1] + self.assertEqual(log_payload["event_type"], "admin_reset_trial_webapp") + self.assertEqual(log_payload["target_user_id"], 42) + invalidate.assert_awaited_once() + self.assertTrue(session.committed) + self.assertFalse(session.rolled_back) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py index 9453289..a3eb2a5 100644 --- a/tests/test_subscription_service_behavior.py +++ b/tests/test_subscription_service_behavior.py @@ -202,7 +202,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas TRIAL_SQUAD_UUIDS="trial-squad", ) service = _make_service(settings) - service.has_had_any_subscription = AsyncMock(return_value=False) + service.has_trial_blocking_subscription = AsyncMock(return_value=False) service._get_or_create_panel_user_link_details = AsyncMock( return_value=("panel-user", "panel-sub", "short", True) ) @@ -255,7 +255,7 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas TRIAL_SQUAD_UUIDS=" , ", ) service = _make_service(settings) - service.has_had_any_subscription = AsyncMock(return_value=False) + service.has_trial_blocking_subscription = AsyncMock(return_value=False) service._get_or_create_panel_user_link_details = AsyncMock( return_value=("panel-user", "panel-sub", "short", True) ) @@ -292,6 +292,47 @@ class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCas panel_payload = service.panel_service.update_user_details_on_panel.await_args.args[1] self.assertEqual(panel_payload["activeInternalSquads"], ["fallback-a", "fallback-b"]) + async def test_activate_trial_rejects_users_with_blocking_subscription_history(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + TRIAL_ENABLED=True, + TRIAL_DURATION_DAYS=3, + ) + service = _make_service(settings) + service.has_trial_blocking_subscription = AsyncMock(return_value=True) + service._get_or_create_panel_user_link_details = AsyncMock() + service.panel_service.update_user_details_on_panel = AsyncMock() + session = AsyncMock() + db_user = SimpleNamespace( + user_id=42, + telegram_id=42, + email=None, + username="trial-user", + first_name="Trial", + last_name="User", + ) + + with ( + patch( + "bot.services.subscription_service_impl.trial.user_dal.get_user_by_id", + AsyncMock(return_value=db_user), + ), + patch( + "bot.services.subscription_service_impl.trial.subscription_dal.upsert_subscription", + AsyncMock(), + ) as upsert_subscription, + ): + result = await service.activate_trial_subscription(session, user_id=42) + + self.assertFalse(result["activated"]) + self.assertFalse(result["eligible"]) + self.assertEqual(result["message_key"], "trial_already_had_subscription_or_trial") + service._get_or_create_panel_user_link_details.assert_not_awaited() + service.panel_service.update_user_details_on_panel.assert_not_awaited() + upsert_subscription.assert_not_awaited() + async def test_activate_subscription_dispatches_traffic_sale_mode(self): with tempfile.TemporaryDirectory() as tmpdir: settings = _make_settings(_tariffs_config_payload(), tmpdir) diff --git a/tests/test_support_migration.py b/tests/test_support_migration.py index ec113a2..5fa5292 100644 --- a/tests/test_support_migration.py +++ b/tests/test_support_migration.py @@ -29,3 +29,13 @@ def test_support_models_expose_expected_tables(): def test_user_model_tracks_lifetime_traffic_sync_timestamp(): assert "lifetime_used_traffic_synced_at" in User.__table__.columns + + +def test_trial_eligibility_reset_migration_and_model_are_registered(): + ids = [migration.id for migration in MIGRATIONS] + + assert "0033_add_trial_eligibility_reset_marker" in ids + assert ids.index("0033_add_trial_eligibility_reset_marker") > ids.index( + "0032_add_telegram_notification_status" + ) + assert "trial_eligibility_reset_at" in User.__table__.columns diff --git a/tests/test_user_dal.py b/tests/test_user_dal.py index ede5e3f..5b8bd30 100644 --- a/tests/test_user_dal.py +++ b/tests/test_user_dal.py @@ -6,7 +6,7 @@ from unittest.mock import AsyncMock, patch from sqlalchemy.dialects import postgresql from sqlalchemy.sql.dml import Delete, Update -from db.dal import user_dal +from db.dal import subscription_dal, user_dal class FakeResult: @@ -122,6 +122,44 @@ class UserDalReferralTests(unittest.IsolatedAsyncioTestCase): self.assertIs(result, referrer) + async def test_mark_trial_eligibility_reset_updates_user_marker(self): + reset_at = datetime(2026, 6, 1, tzinfo=timezone.utc) + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(rowcount=1))) + + result = await user_dal.mark_trial_eligibility_reset(session, 42, reset_at=reset_at) + + self.assertEqual(result, reset_at) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("UPDATE USERS", sql) + self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql) + self.assertIn("USER_ID = 42", sql) + + +class SubscriptionDalTrialEligibilityTests(unittest.IsolatedAsyncioTestCase): + async def test_trial_blocking_history_honors_user_reset_marker(self): + session = SimpleNamespace(execute=AsyncMock(return_value=FakeResult(7))) + + result = await subscription_dal.has_trial_blocking_subscription_for_user(session, 42) + + self.assertTrue(result) + stmt = session.execute.await_args.args[0] + sql = str( + stmt.compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ).upper() + self.assertIn("TRIAL_ELIGIBILITY_RESET_AT", sql) + self.assertIn("SUBSCRIPTIONS.IS_ACTIVE = TRUE", sql) + self.assertIn("COALESCE(SUBSCRIPTIONS.START_DATE, SUBSCRIPTIONS.END_DATE)", sql) + self.assertIn("SUBSCRIPTIONS.USER_ID = 42", sql) + class UserDalMergeTests(unittest.IsolatedAsyncioTestCase): async def test_get_panel_user_uuids_for_user_includes_subscription_fallbacks_once(self): From 5b19ba2c2feccfdb6cea19e3756142e152f40f1d Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 20:29:54 +0300 Subject: [PATCH 6/7] fix: keep deeplink gateway open during app prompt --- backend/bot/app/web/templates/open_app_gateway.html | 8 ++++++-- frontend/src/webapp/screens/AppLaunchScreen.svelte | 8 ++++---- tests/test_webapp_route_contract.py | 4 ++++ tests/test_webapp_telegram_logout.py | 3 +++ 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/backend/bot/app/web/templates/open_app_gateway.html b/backend/bot/app/web/templates/open_app_gateway.html index 5bc02c9..287e051 100644 --- a/backend/bot/app/web/templates/open_app_gateway.html +++ b/backend/bot/app/web/templates/open_app_gateway.html @@ -109,6 +109,8 @@ let attempted = false; let pageLeft = false; let state = "opening"; + let closeAttemptTimer = null; + const CLOSE_ATTEMPT_DELAY_MS = 2500; function hasControlChars(value) { return Array.from(String(value || "")).some((char) => { @@ -167,7 +169,10 @@ function markDone() { if (state === "done" || isUnsafe) return; render("done"); - window.setTimeout(tryCloseWindow, 120); + if (closeAttemptTimer) window.clearTimeout(closeAttemptTimer); + closeAttemptTimer = window.setTimeout(() => { + if (pageLeft || document.hidden) tryCloseWindow(); + }, CLOSE_ATTEMPT_DELAY_MS); } function notePageLeft() { @@ -201,7 +206,6 @@ render("done"); }); window.addEventListener("pagehide", notePageLeft); - window.addEventListener("blur", notePageLeft); document.addEventListener("visibilitychange", () => { if (!attempted) return; if (document.hidden) { diff --git a/frontend/src/webapp/screens/AppLaunchScreen.svelte b/frontend/src/webapp/screens/AppLaunchScreen.svelte index d996fb7..ea7c286 100644 --- a/frontend/src/webapp/screens/AppLaunchScreen.svelte +++ b/frontend/src/webapp/screens/AppLaunchScreen.svelte @@ -6,7 +6,7 @@ const AUTO_OPEN_DELAY_MS = 80; const MANUAL_STATE_DELAY_MS = 1600; const DONE_STATE_DELAY_MS = 900; - const CLOSE_ATTEMPT_DELAY_MS = 120; + const CLOSE_ATTEMPT_DELAY_MS = 2500; export let brand = {}; export let appLaunchTarget = ""; @@ -54,7 +54,6 @@ autoOpenTimer = window.setTimeout(openTarget, AUTO_OPEN_DELAY_MS); window.addEventListener("pagehide", notePageLeft); - window.addEventListener("blur", notePageLeft); document.addEventListener("visibilitychange", handleVisibilityChange); return () => { @@ -63,7 +62,6 @@ clearTimer(doneStateTimer); clearTimer(closeAttemptTimer); window.removeEventListener("pagehide", notePageLeft); - window.removeEventListener("blur", notePageLeft); document.removeEventListener("visibilitychange", handleVisibilityChange); }; }); @@ -91,7 +89,9 @@ if (!attempted || state === "done" || !activeTarget) return; state = "done"; clearTimer(closeAttemptTimer); - closeAttemptTimer = window.setTimeout(tryCloseWindow, CLOSE_ATTEMPT_DELAY_MS); + closeAttemptTimer = window.setTimeout(() => { + if (pageLeft || document.hidden) tryCloseWindow(); + }, CLOSE_ATTEMPT_DELAY_MS); } function notePageLeft() { diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py index e201b8a..80418fd 100644 --- a/tests/test_webapp_route_contract.py +++ b/tests/test_webapp_route_contract.py @@ -296,6 +296,10 @@ class WebAppRouteContractTests(unittest.TestCase): self.assertIn("URLSearchParams", response.text) self.assertIn("Settings added", response.text) self.assertIn("window.close()", response.text) + self.assertNotIn('window.addEventListener("blur", notePageLeft)', response.text) + self.assertNotIn("window.setTimeout(tryCloseWindow, 120)", response.text) + self.assertIn("const CLOSE_ATTEMPT_DELAY_MS = 2500", response.text) + self.assertIn("if (pageLeft || document.hidden) tryCloseWindow();", response.text) self.assertIn(r"/^(?:javascript|data|vbscript|https?):/i", response.text) def test_app_deeplink_gateway_uses_i18n_template(self): diff --git a/tests/test_webapp_telegram_logout.py b/tests/test_webapp_telegram_logout.py index aa19b63..db70249 100644 --- a/tests/test_webapp_telegram_logout.py +++ b/tests/test_webapp_telegram_logout.py @@ -44,6 +44,9 @@ def test_open_app_route_uses_fallback_screen_without_auth_flow(): assert "AppLaunchScreen" in app_source assert 'mode = isAppLaunchRoute ? "appLaunch"' in app_source assert "window.close()" in screen_source + assert 'window.addEventListener("blur", notePageLeft)' not in screen_source + assert "CLOSE_ATTEMPT_DELAY_MS = 2500" in screen_source + assert "if (pageLeft || document.hidden) tryCloseWindow();" in screen_source launch_guard_pos = app_source.index("if (isAppLaunchRoute) return;") boot_pos = app_source.index("boot();", launch_guard_pos) From 5218ede0f1f48a3299d6c32f28f7e5c0a708b24c Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 1 Jun 2026 22:13:35 +0300 Subject: [PATCH 7/7] fix: normalize required channel checks --- backend/bot/handlers/user/start.py | 29 +++- .../bot/middlewares/channel_subscription.py | 3 +- backend/bot/utils/channel_subscription.py | 40 +++++ backend/config/settings.py | 1 + tests/test_channel_subscription.py | 151 ++++++++++++++++++ 5 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 backend/bot/utils/channel_subscription.py create mode 100644 tests/test_channel_subscription.py diff --git a/backend/bot/handlers/user/start.py b/backend/bot/handlers/user/start.py index a44d2d2..44cc598 100644 --- a/backend/bot/handlers/user/start.py +++ b/backend/bot/handlers/user/start.py @@ -24,6 +24,10 @@ from bot.services.referral_service import ReferralService from bot.services.subscription_service import SubscriptionService from bot.services.telegram_notifications import TELEGRAM_NOTIFICATIONS_ENABLED from bot.utils.callback_answer import safe_answer_callback +from bot.utils.channel_subscription import ( + is_required_channel_access_error, + normalize_required_channel_id, +) from bot.utils.install_links import ( append_install_share_link_text, ensure_user_install_guide_links, @@ -215,7 +219,7 @@ async def ensure_required_channel_subscription( Verify that the user is a member of the required channel (if configured). Returns True when access can proceed, False when user must subscribe first. """ - required_channel_id = settings.REQUIRED_CHANNEL_ID + required_channel_id = normalize_required_channel_id(settings.REQUIRED_CHANNEL_ID) if not required_channel_id: return True @@ -279,6 +283,29 @@ async def ensure_required_channel_subscription( if status_value in allowed_statuses: is_member = True except TelegramBadRequest as bad_request: + if is_required_channel_access_error(bad_request): + logging.error( + "Required channel check failed due to channel access/configuration error " + "(configured=%s, normalized=%s): %s", + settings.REQUIRED_CHANNEL_ID, + required_channel_id, + bad_request, + ) + error_text = translate("channel_subscription_check_failed") + if isinstance(event, types.CallbackQuery): + try: + await event.answer(error_text, show_alert=True) + except Exception: + pass + if message_obj: + try: + await message_obj.answer(error_text) + except Exception: + pass + else: + await event.answer(error_text) + return False + logging.info( "Required channel check: user %s not subscribed (details: %s)", user_id, diff --git a/backend/bot/middlewares/channel_subscription.py b/backend/bot/middlewares/channel_subscription.py index d6d52cc..0c760ce 100644 --- a/backend/bot/middlewares/channel_subscription.py +++ b/backend/bot/middlewares/channel_subscription.py @@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from bot.keyboards.inline.user_keyboards import get_channel_subscription_keyboard from bot.middlewares.i18n import JsonI18n +from bot.utils.channel_subscription import normalize_required_channel_id from config.settings import Settings from db.dal import user_dal @@ -32,7 +33,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): event: Update, data: Dict[str, Any], ) -> Any: - required_channel_id = self.settings.REQUIRED_CHANNEL_ID + required_channel_id = normalize_required_channel_id(self.settings.REQUIRED_CHANNEL_ID) if not required_channel_id: return await handler(event, data) diff --git a/backend/bot/utils/channel_subscription.py b/backend/bot/utils/channel_subscription.py new file mode 100644 index 0000000..72ba65c --- /dev/null +++ b/backend/bot/utils/channel_subscription.py @@ -0,0 +1,40 @@ +from typing import Optional + + +def normalize_required_channel_id(value: object) -> Optional[int]: + if value is None: + return None + + raw = str(value).strip() + if not raw: + return None + + try: + channel_id = int(raw) + except (TypeError, ValueError): + return None + + if channel_id == 0: + return None + + if channel_id > 0: + return int(f"-100{channel_id}") + + raw_abs = str(abs(channel_id)) + if raw.startswith("-100"): + return channel_id + if abs(channel_id) < 1_000_000_000: + return channel_id + return -int(f"100{raw_abs}") + + +def is_required_channel_access_error(error: BaseException) -> bool: + message = str(error).lower() + configuration_markers = ( + "chat not found", + "bot is not a member", + "not enough rights", + "have no rights", + "kicked", + ) + return any(marker in message for marker in configuration_markers) diff --git a/backend/config/settings.py b/backend/config/settings.py index 23bbfd6..bd09406 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -1018,6 +1018,7 @@ class Settings(BaseSettings): "LOG_SUPPORT_THREAD_ID", "BACKUP_CHAT_ID", "BACKUP_THREAD_ID", + "REQUIRED_CHANNEL_ID", mode="before", ) @classmethod diff --git a/tests/test_channel_subscription.py b/tests/test_channel_subscription.py new file mode 100644 index 0000000..27c3406 --- /dev/null +++ b/tests/test_channel_subscription.py @@ -0,0 +1,151 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from aiogram.exceptions import TelegramBadRequest + +from bot.handlers.user.start import ensure_required_channel_subscription +from bot.middlewares.channel_subscription import ChannelSubscriptionMiddleware +from bot.utils.channel_subscription import ( + is_required_channel_access_error, + normalize_required_channel_id, +) + + +class I18nStub: + def gettext(self, _lang, key, **_kwargs): + return { + "channel_subscription_required": "subscribe first", + "channel_subscription_check_failed": "check failed", + }.get(key, key) + + +class FakeBot: + def __init__(self, *, status="member", error=None): + self.status = status + self.error = error + self.calls = [] + + async def get_chat_member(self, chat_id, user_id): + self.calls.append((chat_id, user_id)) + if self.error: + raise self.error + return SimpleNamespace(status=self.status) + + +def _settings(required_channel_id): + return SimpleNamespace( + REQUIRED_CHANNEL_ID=required_channel_id, + REQUIRED_CHANNEL_LINK="https://t.me/example", + ADMIN_IDS=[], + DEFAULT_LANGUAGE="en", + ) + + +def _message_event(bot, user_id=42): + return SimpleNamespace( + from_user=SimpleNamespace(id=user_id), + bot=bot, + answer=AsyncMock(), + ) + + +def _db_user(*, verified=False, verified_for=None): + return SimpleNamespace( + channel_subscription_verified=verified, + channel_subscription_verified_for=verified_for, + ) + + +class RequiredChannelIdNormalizationTests(unittest.TestCase): + def test_normalizes_raw_channel_ids_to_bot_api_chat_ids(self): + self.assertEqual(normalize_required_channel_id(1234567890), -1001234567890) + self.assertEqual(normalize_required_channel_id("1234567890"), -1001234567890) + self.assertEqual(normalize_required_channel_id(-1234567890), -1001234567890) + self.assertEqual(normalize_required_channel_id(-1001234567890), -1001234567890) + self.assertEqual(normalize_required_channel_id(-123456789), -123456789) + + def test_ignores_empty_channel_ids(self): + self.assertIsNone(normalize_required_channel_id(None)) + self.assertIsNone(normalize_required_channel_id("")) + self.assertIsNone(normalize_required_channel_id(0)) + + def test_detects_channel_configuration_errors(self): + self.assertTrue( + is_required_channel_access_error( + TelegramBadRequest(method=None, message="Bad Request: chat not found") + ) + ) + self.assertFalse( + is_required_channel_access_error( + TelegramBadRequest(method=None, message="Bad Request: user not found") + ) + ) + + +class RequiredChannelSubscriptionCheckTests(unittest.IsolatedAsyncioTestCase): + async def test_check_uses_normalized_channel_id_and_persists_it(self): + bot = FakeBot(status="member") + event = _message_event(bot) + user = _db_user() + + with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user: + result = await ensure_required_channel_subscription( + event, + _settings(1234567890), + I18nStub(), + "en", + AsyncMock(), + db_user=user, + ) + + self.assertTrue(result) + self.assertEqual(bot.calls, [(-1001234567890, 42)]) + payload = update_user.await_args.args[2] + self.assertTrue(payload["channel_subscription_verified"]) + self.assertEqual(payload["channel_subscription_verified_for"], -1001234567890) + + async def test_channel_access_errors_show_check_failed_without_persisting_false_status(self): + bot = FakeBot(error=TelegramBadRequest(method=None, message="Bad Request: chat not found")) + event = _message_event(bot) + user = _db_user() + + with patch("bot.handlers.user.start.user_dal.update_user", AsyncMock()) as update_user: + result = await ensure_required_channel_subscription( + event, + _settings(1234567890), + I18nStub(), + "en", + AsyncMock(), + db_user=user, + ) + + self.assertFalse(result) + event.answer.assert_awaited_once_with("check failed") + update_user.assert_not_awaited() + + +class ChannelSubscriptionMiddlewareTests(unittest.IsolatedAsyncioTestCase): + async def test_middleware_accepts_cached_verification_for_normalized_channel_id(self): + middleware = ChannelSubscriptionMiddleware(_settings(1234567890), I18nStub()) + handler = AsyncMock(return_value="ok") + event = SimpleNamespace(callback_query=None, message=None) + data = { + "event_from_user": SimpleNamespace(id=42), + "session": AsyncMock(), + "i18n_data": {"current_language": "en", "i18n_instance": I18nStub()}, + } + user = _db_user(verified=True, verified_for=-1001234567890) + + with patch( + "bot.middlewares.channel_subscription.user_dal.get_user_by_id", + AsyncMock(return_value=user), + ): + result = await middleware(handler, event, data) + + self.assertEqual(result, "ok") + handler.assert_awaited_once_with(event, data) + + +if __name__ == "__main__": + unittest.main()