From 81707d9c7c80a939e3fbc95664f7366898b604d4 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Fri, 22 May 2026 14:11:31 +0300
Subject: [PATCH 01/14] fix: clean merged panel identities
---
backend/bot/app/web/webapp/account.py | 47 ++-------
backend/bot/app/web/webapp/auth.py | 96 ++++++++++++++++++-
tests/test_account_linking_panel.py | 133 ++++++++++++++++++++++++++
3 files changed, 236 insertions(+), 40 deletions(-)
create mode 100644 tests/test_account_linking_panel.py
diff --git a/backend/bot/app/web/webapp/account.py b/backend/bot/app/web/webapp/account.py
index 67259ec..aa92be2 100644
--- a/backend/bot/app/web/webapp/account.py
+++ b/backend/bot/app/web/webapp/account.py
@@ -2,7 +2,7 @@
from ._runtime import * # noqa: F403,F405
from bot.app.web.webapp.cache_helpers import webapp_cached_user_payload
-from .auth import _hash_email_password
+from .auth import _hash_email_password, _sync_merged_panel_identity_for_user
from .common import _invalidate_webapp_user_caches
@@ -109,7 +109,8 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
)
current_user.email = email
current_user.email_verified_at = datetime.now(timezone.utc)
- await _sync_panel_identity_for_user(request, current_user)
+ if not merge_notice:
+ await _sync_panel_identity_for_user(request, current_user)
await session.commit()
final_user_id = int(current_user.user_id)
final_telegram_id = _telegram_id_for_user(current_user)
@@ -122,28 +123,13 @@ async def account_email_verify_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
- await _sync_panel_identity_for_user(
+ await _sync_merged_panel_identity_for_user(
request,
current_user,
+ source_panel_uuid=source_panel_uuid,
+ final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
- # Best-effort cleanup of the removed panel account after the DB merge.
- if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
- subscription_service: SubscriptionService = request.app.get(
- "subscription_service"
- )
- if subscription_service and subscription_service.panel_service:
- try:
- await subscription_service.panel_service.delete_user_from_panel(
- source_panel_uuid,
- log_response=False,
- )
- except Exception as exc:
- logger.warning(
- "Failed to delete merged source panel user %s: %s",
- source_panel_uuid,
- exc,
- )
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
@@ -345,28 +331,13 @@ async def account_telegram_link_route(request: web.Request) -> web.Response:
merge_end_date = (
datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
)
- await _sync_panel_identity_for_user(
+ await _sync_merged_panel_identity_for_user(
request,
db_user,
+ source_panel_uuid=source_panel_uuid,
+ final_panel_uuid=final_panel_uuid,
expire_at=merge_end_date,
)
- # Best-effort cleanup of the removed panel account after the DB merge.
- if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid:
- subscription_service: SubscriptionService = request.app.get(
- "subscription_service"
- )
- if subscription_service and subscription_service.panel_service:
- try:
- await subscription_service.panel_service.delete_user_from_panel(
- source_panel_uuid,
- log_response=False,
- )
- except Exception as exc:
- logger.warning(
- "Failed to delete merged source panel user %s: %s",
- source_panel_uuid,
- exc,
- )
email_service: EmailAuthService = request.app.get("email_auth_service")
if email_service and final_email:
diff --git a/backend/bot/app/web/webapp/auth.py b/backend/bot/app/web/webapp/auth.py
index bceffc6..1f89045 100644
--- a/backend/bot/app/web/webapp/auth.py
+++ b/backend/bot/app/web/webapp/auth.py
@@ -339,10 +339,20 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
redirect_path = "/settings" if purpose == "link" else "/"
async_session_factory: sessionmaker = request.app["async_session_factory"]
final_user_id: Optional[int] = None
+ source_user_id_for_cache: Optional[int] = None
+ linked_user_for_panel: Optional[User] = None
+ link_source_panel_uuid: Optional[str] = None
+ link_final_panel_uuid: Optional[str] = None
+ link_merge_notice: Optional[Dict[str, Any]] = None
async with async_session_factory() as session:
try:
if purpose == "link":
current_user_id = int(state.get("user_id") or 0)
+ source_user_id_for_cache = current_user_id
+ current_user_before_link = await user_dal.get_user_by_id(session, current_user_id)
+ link_source_panel_uuid = (
+ current_user_before_link.panel_user_uuid if current_user_before_link else None
+ )
db_user = await _link_telegram_to_user(
request,
session,
@@ -350,6 +360,16 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
telegram_user=telegram_user,
settings=settings,
)
+ if int(db_user.user_id) != current_user_id:
+ link_final_panel_uuid = db_user.panel_user_uuid
+ link_merge_notice = await _build_account_merge_notice(
+ session,
+ merged_user=db_user,
+ source_user_id=current_user_id,
+ source_panel_uuid=link_source_panel_uuid,
+ settings=settings,
+ )
+ linked_user_for_panel = db_user
else:
db_user = await _ensure_user_from_telegram(
session,
@@ -388,6 +408,25 @@ async def telegram_oauth_callback_route(request: web.Request) -> web.Response:
raise redirect(redirect_path, "failed")
await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True)
+ if source_user_id_for_cache and source_user_id_for_cache != final_user_id:
+ await _invalidate_webapp_user_caches(
+ settings,
+ source_user_id_for_cache,
+ final_user_id,
+ include_devices=True,
+ )
+
+ if purpose == "link" and link_merge_notice and linked_user_for_panel:
+ merge_end_date_raw = link_merge_notice.get("final_end_date")
+ merge_end_date = datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None
+ await _sync_merged_panel_identity_for_user(
+ request,
+ linked_user_for_panel,
+ source_panel_uuid=link_source_panel_uuid,
+ final_panel_uuid=link_final_panel_uuid,
+ expire_at=merge_end_date,
+ )
+
token = create_webapp_session_token(settings, int(final_user_id))
response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success"))
_clear_telegram_oauth_state_cookie(response)
@@ -974,6 +1013,14 @@ def _panel_description_for_user(user: User) -> str:
return "\n".join(line for line in lines if line).strip()
+def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
+ raw_value = telegram_user.get("photo_url")
+ if not raw_value:
+ return None
+ value = str(raw_value).strip()
+ return value or None
+
+
async def _sync_panel_identity_for_user(
request: web.Request,
user: User,
@@ -1013,6 +1060,53 @@ async def _sync_panel_identity_for_user(
return False
+async def _delete_merged_source_panel_user(
+ request: web.Request,
+ *,
+ source_panel_uuid: Optional[str],
+ final_panel_uuid: Optional[str],
+) -> bool:
+ if not source_panel_uuid or not final_panel_uuid or source_panel_uuid == final_panel_uuid:
+ return True
+
+ subscription_service: SubscriptionService = request.app.get("subscription_service")
+ if not subscription_service or not subscription_service.panel_service:
+ return False
+
+ try:
+ return bool(
+ await subscription_service.panel_service.delete_user_from_panel(
+ source_panel_uuid,
+ log_response=False,
+ )
+ )
+ except Exception as exc:
+ logger.warning(
+ "Failed to delete merged source panel user %s: %s",
+ source_panel_uuid,
+ exc,
+ )
+ return False
+
+
+async def _sync_merged_panel_identity_for_user(
+ request: web.Request,
+ user: User,
+ *,
+ source_panel_uuid: Optional[str],
+ final_panel_uuid: Optional[str],
+ expire_at: Optional[datetime] = None,
+) -> bool:
+ # Remnawave keeps email/telegramId unique. Remove the losing panel identity
+ # before patching the surviving one so merged accounts can accept both IDs.
+ await _delete_merged_source_panel_user(
+ request,
+ source_panel_uuid=source_panel_uuid,
+ final_panel_uuid=final_panel_uuid or user.panel_user_uuid,
+ )
+ return await _sync_panel_identity_for_user(request, user, expire_at=expire_at)
+
+
async def _build_account_merge_notice(
session: AsyncSession,
*,
@@ -1102,7 +1196,6 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
- await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if not existing_telegram_user and int(current_user.user_id) < 0:
@@ -1134,7 +1227,6 @@ async def _link_telegram_to_user(
)
_apply_telegram_profile_to_user(merged_user, telegram_user, settings)
await session.flush()
- await _sync_panel_identity_for_user(request, merged_user)
return merged_user
if current_user.telegram_id and int(current_user.telegram_id) != telegram_id:
diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py
new file mode 100644
index 0000000..cb66e9b
--- /dev/null
+++ b/tests/test_account_linking_panel.py
@@ -0,0 +1,133 @@
+import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+from bot.app.web.webapp.auth import (
+ _link_telegram_to_user,
+ _sync_merged_panel_identity_for_user,
+)
+
+
+class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
+ async def test_merged_panel_identity_deletes_source_before_updating_target(self):
+ calls = []
+
+ async def delete_source(*args, **kwargs):
+ calls.append("delete")
+ return True
+
+ async def update_target(*args, **kwargs):
+ calls.append("update")
+ return {"uuid": "panel-target"}
+
+ panel_service = SimpleNamespace(
+ delete_user_from_panel=AsyncMock(side_effect=delete_source),
+ update_user_details_on_panel=AsyncMock(side_effect=update_target),
+ )
+ request = SimpleNamespace(
+ app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
+ )
+ user = SimpleNamespace(
+ user_id=42,
+ panel_user_uuid="panel-target",
+ telegram_id=42,
+ email="linked@example.com",
+ username="alice",
+ first_name="Alice",
+ last_name=None,
+ )
+
+ result = await _sync_merged_panel_identity_for_user(
+ request,
+ user,
+ source_panel_uuid="panel-source",
+ final_panel_uuid="panel-target",
+ )
+
+ self.assertTrue(result)
+ self.assertEqual(calls, ["delete", "update"])
+ panel_service.delete_user_from_panel.assert_awaited_once_with(
+ "panel-source",
+ log_response=False,
+ )
+ panel_service.update_user_details_on_panel.assert_awaited_once()
+ update_uuid, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
+ self.assertEqual(update_uuid, "panel-target")
+ self.assertEqual(payload["email"], "linked@example.com")
+ self.assertEqual(payload["telegramId"], 42)
+
+ async def test_telegram_merge_defers_panel_sync_until_source_cleanup(self):
+ current_user = SimpleNamespace(
+ user_id=-100,
+ email="linked@example.com",
+ email_verified_at=None,
+ panel_user_uuid="panel-source",
+ telegram_id=None,
+ username=None,
+ first_name=None,
+ last_name=None,
+ language_code="ru",
+ telegram_photo_url=None,
+ )
+ existing_telegram_user = SimpleNamespace(
+ user_id=42,
+ email=None,
+ email_verified_at=None,
+ panel_user_uuid="panel-target",
+ telegram_id=42,
+ username="old",
+ first_name=None,
+ last_name=None,
+ language_code="ru",
+ telegram_photo_url=None,
+ )
+ merged_user = SimpleNamespace(
+ user_id=42,
+ email="linked@example.com",
+ email_verified_at=None,
+ panel_user_uuid="panel-target",
+ telegram_id=42,
+ username="old",
+ first_name=None,
+ last_name=None,
+ language_code="ru",
+ telegram_photo_url=None,
+ )
+ panel_service = SimpleNamespace(update_user_details_on_panel=AsyncMock())
+ request = SimpleNamespace(
+ app={"subscription_service": SimpleNamespace(panel_service=panel_service)}
+ )
+ session = SimpleNamespace(flush=AsyncMock())
+ telegram_user = {
+ "id": 42,
+ "username": "alice",
+ "first_name": "Alice",
+ "last_name": "",
+ "language_code": "ru",
+ }
+
+ with (
+ patch(
+ "bot.app.web.webapp.auth.user_dal.get_user_by_id",
+ AsyncMock(return_value=current_user),
+ ),
+ patch(
+ "bot.app.web.webapp.auth.user_dal.get_user_by_telegram_id",
+ AsyncMock(return_value=existing_telegram_user),
+ ),
+ patch(
+ "bot.app.web.webapp.auth.user_dal.merge_users",
+ AsyncMock(return_value=merged_user),
+ ),
+ ):
+ result = await _link_telegram_to_user(
+ request,
+ session,
+ current_user_id=-100,
+ telegram_user=telegram_user,
+ settings=SimpleNamespace(DEFAULT_LANGUAGE="ru"),
+ )
+
+ self.assertIs(result, merged_user)
+ panel_service.update_user_details_on_panel.assert_not_awaited()
+ self.assertEqual(merged_user.username, "alice")
From 2254b9ad195c64ce7abb091ed61d60c92e93d68d Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Fri, 22 May 2026 14:11:38 +0300
Subject: [PATCH 02/14] fix: avoid repeated panel identity syncs
---
backend/bot/handlers/admin/sync_admin.py | 72 +++++++++++++++++-------
tests/test_admin_sync_performance.py | 14 +++++
2 files changed, 67 insertions(+), 19 deletions(-)
diff --git a/backend/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py
index fa74835..c56b507 100644
--- a/backend/bot/handlers/admin/sync_admin.py
+++ b/backend/bot/handlers/admin/sync_admin.py
@@ -42,8 +42,55 @@ def _normalize_description(value: Optional[str]) -> str:
return "\n".join((value or "").split()).strip()
+def _repair_cp1251_mojibake(value: str) -> str:
+ try:
+ return value.encode("latin1").decode("cp1251")
+ except (UnicodeEncodeError, UnicodeDecodeError):
+ return value
+
+
+def _description_variants(value: Optional[str]) -> set[str]:
+ normalized = _normalize_description(value)
+ variants = {normalized}
+ repaired = _normalize_description(_repair_cp1251_mojibake(normalized))
+ if repaired:
+ variants.add(repaired)
+ return variants
+
+
def _description_matches(current: Optional[str], desired: str) -> bool:
- return _normalize_description(current) == _normalize_description(desired)
+ return bool(_description_variants(current) & _description_variants(desired))
+
+
+def _panel_identity_matches_user(
+ panel_user: dict[str, Any],
+ user: User,
+ desired_description: str,
+) -> bool:
+ if desired_description and not _description_matches(
+ panel_user.get("description"),
+ desired_description,
+ ):
+ return False
+
+ if user.email and _normalize_panel_email(panel_user.get("email")) != user.email.strip().lower():
+ return False
+
+ if user.telegram_id and _coerce_panel_telegram_id(panel_user.get("telegramId")) != int(
+ user.telegram_id
+ ):
+ return False
+
+ return True
+
+
+def _panel_identity_update_payload(user: User, description_text: str) -> dict[str, Any]:
+ payload: dict[str, Any] = {"description": description_text}
+ if user.email:
+ payload["email"] = user.email
+ if user.telegram_id:
+ payload["telegramId"] = user.telegram_id
+ return payload
def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
@@ -589,28 +636,15 @@ async def _perform_sync_impl(
if line
)
# Update description only when it differs from the current one on panel
- current_panel_description = (
- panel_user_dict.get("description") or ""
- ).strip()
desired_description = description_text.strip()
- if desired_description and not _description_matches(
- current_panel_description, desired_description
+ if desired_description and not _panel_identity_matches_user(
+ panel_user_dict,
+ existing_user,
+ desired_description,
):
await panel_service.update_user_details_on_panel(
panel_uuid,
- {
- "description": description_text,
- **(
- {"email": existing_user.email}
- if existing_user.email
- else {}
- ),
- **(
- {"telegramId": existing_user.telegram_id}
- if existing_user.telegram_id
- else {}
- ),
- },
+ _panel_identity_update_payload(existing_user, description_text),
)
except Exception as e_desc:
logging.warning(
diff --git a/tests/test_admin_sync_performance.py b/tests/test_admin_sync_performance.py
index 68a7ecd..94183ca 100644
--- a/tests/test_admin_sync_performance.py
+++ b/tests/test_admin_sync_performance.py
@@ -14,6 +14,20 @@ def test_description_match_ignores_whitespace_shape():
assert _description_matches("email@example.com username", "email@example.com\nusername")
+def test_description_match_accepts_cp1251_mojibake_from_panel():
+ desired = "user@example.com\nalice\nАлексей\nЧерников"
+ panel_value = "user@example.com\nalice\nÀëåêñåé\n×åðíèêîâ"
+
+ assert _description_matches(panel_value, desired)
+
+
+def test_description_match_rejects_different_identity_after_mojibake_repair():
+ desired = "user@example.com\nalice\nАлексей"
+ panel_value = "other@example.com\nalice\nÀëåêñåé"
+
+ assert not _description_matches(panel_value, desired)
+
+
def test_panel_telegram_id_is_coerced_to_int():
assert _coerce_panel_telegram_id("12345") == 12345
assert _coerce_panel_telegram_id("") is None
From 9e56715e77f300e0fdaaf86a7a1f294bd7d4a2cf Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Fri, 22 May 2026 14:41:56 +0300
Subject: [PATCH 03/14] fix: cover email telegram account linking
---
tests/test_account_linking_panel.py | 157 ++++++++++++++++++++++++++++
1 file changed, 157 insertions(+)
diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py
index cb66e9b..cb1c8d4 100644
--- a/tests/test_account_linking_panel.py
+++ b/tests/test_account_linking_panel.py
@@ -1,7 +1,10 @@
+import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
+from bot.app.web import subscription_webapp # noqa: F401
+from bot.app.web.webapp import account as account_routes
from bot.app.web.webapp.auth import (
_link_telegram_to_user,
_sync_merged_panel_identity_for_user,
@@ -9,6 +12,23 @@ from bot.app.web.webapp.auth import (
class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
+ class _AsyncSessionFactory:
+ def __init__(self):
+ self.session = SimpleNamespace(
+ commit=AsyncMock(),
+ rollback=AsyncMock(),
+ flush=AsyncMock(),
+ )
+
+ def __call__(self):
+ return self
+
+ async def __aenter__(self):
+ return self.session
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return None
+
async def test_merged_panel_identity_deletes_source_before_updating_target(self):
calls = []
@@ -131,3 +151,140 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
self.assertIs(result, merged_user)
panel_service.update_user_details_on_panel.assert_not_awaited()
self.assertEqual(merged_user.username, "alice")
+
+ async def test_email_only_session_can_link_existing_telegram_only_account(self):
+ email_user = SimpleNamespace(
+ user_id=-100,
+ email="linked@example.com",
+ email_verified_at=object(),
+ panel_user_uuid="panel-email",
+ telegram_id=None,
+ username=None,
+ first_name=None,
+ last_name=None,
+ language_code="ru",
+ telegram_photo_url=None,
+ is_banned=False,
+ )
+ telegram_user_record = SimpleNamespace(
+ user_id=42,
+ email=None,
+ email_verified_at=None,
+ panel_user_uuid="panel-telegram",
+ telegram_id=42,
+ username="old",
+ first_name=None,
+ last_name=None,
+ language_code="ru",
+ telegram_photo_url=None,
+ is_banned=False,
+ )
+ merged_user = SimpleNamespace(
+ user_id=42,
+ email="linked@example.com",
+ email_verified_at=object(),
+ panel_user_uuid="panel-telegram",
+ telegram_id=42,
+ username="old",
+ first_name=None,
+ last_name=None,
+ language_code="ru",
+ telegram_photo_url=None,
+ is_banned=False,
+ )
+ panel_calls = []
+
+ async def delete_source(*args, **kwargs):
+ panel_calls.append("delete")
+ return True
+
+ async def update_target(*args, **kwargs):
+ panel_calls.append("update")
+ return {"uuid": "panel-telegram"}
+
+ panel_service = SimpleNamespace(
+ delete_user_from_panel=AsyncMock(side_effect=delete_source),
+ update_user_details_on_panel=AsyncMock(side_effect=update_target),
+ )
+ settings = SimpleNamespace(
+ WEBAPP_SESSION_SECRET="session-secret",
+ WEBAPP_SESSION_TTL_SECONDS=3600,
+ REDIS_URL=None,
+ REDIS_KEY_PREFIX="test",
+ DEFAULT_LANGUAGE="ru",
+ )
+ request = SimpleNamespace(
+ app={
+ "settings": settings,
+ "async_session_factory": self._AsyncSessionFactory(),
+ "subscription_service": SimpleNamespace(panel_service=panel_service),
+ "email_auth_service": None,
+ "i18n": None,
+ "bot": SimpleNamespace(),
+ },
+ json=AsyncMock(return_value={"init_data": "telegram-init-data"}),
+ )
+ telegram_auth_payload = {
+ "id": 42,
+ "username": "alice",
+ "first_name": "Alice",
+ "last_name": "",
+ "language_code": "ru",
+ }
+
+ with (
+ patch.object(account_routes, "_require_user_id", return_value=-100),
+ patch.object(
+ account_routes,
+ "_validate_telegram_auth_payload",
+ AsyncMock(return_value=telegram_auth_payload),
+ ),
+ patch.object(
+ account_routes.user_dal,
+ "get_user_by_id",
+ AsyncMock(return_value=email_user),
+ ),
+ patch.object(
+ account_routes.user_dal,
+ "get_user_by_telegram_id",
+ AsyncMock(return_value=telegram_user_record),
+ ),
+ patch.object(
+ account_routes.user_dal,
+ "merge_users",
+ AsyncMock(return_value=merged_user),
+ ) as merge_users,
+ patch.object(
+ account_routes.subscription_dal,
+ "get_active_subscription_by_user_id",
+ AsyncMock(return_value=None),
+ ),
+ patch(
+ "bot.services.notification_service.NotificationService",
+ return_value=SimpleNamespace(notify_account_telegram_linked=AsyncMock()),
+ ),
+ ):
+ response = await account_routes.account_telegram_link_route(request)
+
+ self.assertEqual(response.status, 200)
+ payload = json.loads(response.text)
+ self.assertTrue(payload["ok"])
+ self.assertEqual(payload["user_id"], 42)
+ self.assertEqual(payload["telegram_id"], 42)
+ self.assertEqual(payload["account_merge"]["removed_user_id"], -100)
+ self.assertEqual(payload["account_merge"]["primary_user_id"], 42)
+ merge_users.assert_awaited_once_with(
+ request.app["async_session_factory"].session,
+ source_user_id=-100,
+ target_user_id=42,
+ )
+ self.assertEqual(panel_calls, ["delete", "update"])
+ panel_service.delete_user_from_panel.assert_awaited_once_with(
+ "panel-email",
+ log_response=False,
+ )
+ update_uuid, update_payload = panel_service.update_user_details_on_panel.await_args.args[:2]
+ self.assertEqual(update_uuid, "panel-telegram")
+ self.assertEqual(update_payload["email"], "linked@example.com")
+ self.assertEqual(update_payload["telegramId"], 42)
+ self.assertIn("rw_webapp_session", response.cookies)
From c82779e15b9a04b2cf8aaa0c461a91ab07357546 Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Fri, 22 May 2026 14:47:01 +0300
Subject: [PATCH 04/14] fix: restore telegram mini app auth
---
frontend/src/App.svelte | 2 ++
.../src/lib/webapp/stores/accountStore.js | 1 +
frontend/src/lib/webapp/webappBoot.js | 10 +++---
.../src/webapp/screens/SettingsScreen.svelte | 13 ++++---
tests/test_webapp_telegram_logout.py | 35 +++++++++++++++++++
5 files changed, 51 insertions(+), 10 deletions(-)
create mode 100644 tests/test_webapp_telegram_logout.py
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index fe4f70a..21bb499 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -397,6 +397,7 @@
$: telegramOAuthClientId = Number(CFG.telegramOAuthClientId || telegramLoginBotId || 0);
$: telegramMiniAppInitData = tg?.initData || readTelegramMiniAppInitDataFromLocation();
$: telegramMiniAppAuthAvailable = Boolean(telegramMiniAppInitData);
+ $: telegramMiniAppContext = hasTelegramLaunchParams();
$: telegramLoginUnavailable =
!telegramMiniAppAuthAvailable && !telegramOAuthClientId && telegramSdkStatus !== "loading";
$: telegramLoginChecking =
@@ -1407,6 +1408,7 @@
{user}
{userAgreementUrl}
{userLanguage}
+ showLogout={!telegramMiniAppContext}
linkTelegramAccount={accountStore.linkTelegramAccount}
logout={accountStore.logout}
{openAdminPanel}
diff --git a/frontend/src/lib/webapp/stores/accountStore.js b/frontend/src/lib/webapp/stores/accountStore.js
index 34a59eb..0345a02 100644
--- a/frontend/src/lib/webapp/stores/accountStore.js
+++ b/frontend/src/lib/webapp/stores/accountStore.js
@@ -362,6 +362,7 @@ export function createAccountStore({
}
async function logout() {
+ if (telegramSdk.hasLaunchParams()) return;
markManualLogout();
clearToken();
try {
diff --git a/frontend/src/lib/webapp/webappBoot.js b/frontend/src/lib/webapp/webappBoot.js
index feea227..33e9b23 100644
--- a/frontend/src/lib/webapp/webappBoot.js
+++ b/frontend/src/lib/webapp/webappBoot.js
@@ -61,11 +61,6 @@ export async function runWebappBoot({
);
}
- if (isManuallyLoggedOut()) {
- showLogin();
- return;
- }
-
const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData && (await finalizeTelegramAuth(widgetAuthData, "auth_data"))) return;
@@ -78,6 +73,11 @@ export async function runWebappBoot({
}
}
+ if (isManuallyLoggedOut()) {
+ showLogin();
+ return;
+ }
+
if (getToken() || getCsrfToken()) {
try {
await loadData();
diff --git a/frontend/src/webapp/screens/SettingsScreen.svelte b/frontend/src/webapp/screens/SettingsScreen.svelte
index e056f63..68b70f5 100644
--- a/frontend/src/webapp/screens/SettingsScreen.svelte
+++ b/frontend/src/webapp/screens/SettingsScreen.svelte
@@ -34,6 +34,7 @@
export let user = {};
export let userAgreementUrl = "";
export let userLanguage = "";
+ export let showLogout = true;
export let linkTelegramAccount = () => {};
export let logout = () => {};
@@ -187,10 +188,12 @@
{displayValue}
+ {user_id}\n📧 Email: {email}{referral_text}\n📅 Time: {timestamp}",
"log_account_email_linked": "📧 Email linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}",
"log_account_telegram_linked": "📨 Telegram linked\n\n🆔 User ID: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n🕐 Time: {timestamp}",
+ "log_account_merged": "🔗 Accounts merged\n\n🆔 Kept user ID: {primary_user_id}\n🗑 Removed user ID: {removed_user_id}\n📨 Telegram ID: {telegram_id}\n👤 User: {user_display}\n📧 Email: {email}\n⏰ New end date: {final_end_date}\n📋 Kept panel UUID: {primary_panel_user_uuid}\n📋 Removed panel UUID: {removed_panel_user_uuid}\n🕐 Time: {timestamp}",
"log_payment_received": "{provider_emoji} Payment Received\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n📅 Period: {months} mo.\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} Payment Received (traffic top-up)\n\n👤 User: {user_display}\n💰 Amount: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_traffic_purchase_line": "{gb} GB · {kind}",
diff --git a/locales/ru.json b/locales/ru.json
index d77da6f..9411693 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -363,6 +363,7 @@
"log_new_email_user_registration": "📧 Новый пользователь (email)\n\n🆔 ID: {user_id}\n📧 Email: {email}{referral_text}\n📅 Время: {timestamp}",
"log_account_email_linked": "📧 Привязана почта\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}",
"log_account_telegram_linked": "📨 Привязан Telegram\n\n🆔 ID пользователя: {user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n🕐 Время: {timestamp}",
+ "log_account_merged": "🔗 Аккаунты объединены\n\n🆔 Оставлен ID: {primary_user_id}\n🗑 Удалён ID: {removed_user_id}\n📨 Telegram ID: {telegram_id}\n👤 Пользователь: {user_display}\n📧 Email: {email}\n⏰ Новая дата окончания: {final_end_date}\n📋 UUID оставленного в панели: {primary_panel_user_uuid}\n📋 UUID удалённого в панели: {removed_panel_user_uuid}\n🕐 Время: {timestamp}",
"log_payment_received": "{provider_emoji} Получен платеж\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n📅 Период: {months} мес.\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} Получен платеж (докупка трафика)\n\n👤 Пользователь: {user_display}\n💰 Сумма: {amount} {currency}\n🗂 {traffic_summary}\n{tariff_line}🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_traffic_purchase_line": "{gb} ГБ · {kind}",
diff --git a/tests/test_account_linking_panel.py b/tests/test_account_linking_panel.py
index ff8549a..46139fa 100644
--- a/tests/test_account_linking_panel.py
+++ b/tests/test_account_linking_panel.py
@@ -269,6 +269,10 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
"last_name": "",
"language_code": "ru",
}
+ notification_service = SimpleNamespace(
+ notify_account_telegram_linked=AsyncMock(),
+ notify_account_merged=AsyncMock(),
+ )
with (
patch.object(account_routes, "_require_user_id", return_value=-100),
@@ -299,7 +303,7 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
),
patch(
"bot.services.notification_service.NotificationService",
- return_value=SimpleNamespace(notify_account_telegram_linked=AsyncMock()),
+ return_value=notification_service,
),
):
response = await account_routes.account_telegram_link_route(request)
@@ -325,4 +329,15 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(update_uuid, "panel-telegram")
self.assertEqual(update_payload["email"], "linked@example.com")
self.assertEqual(update_payload["telegramId"], 42)
+ notification_service.notify_account_merged.assert_awaited_once_with(
+ primary_user_id=42,
+ removed_user_id=-100,
+ email="linked@example.com",
+ telegram_id=42,
+ username="alice",
+ first_name="Alice",
+ final_end_date_text="",
+ primary_panel_user_uuid="panel-telegram",
+ removed_panel_user_uuid="panel-email",
+ )
self.assertIn("rw_webapp_session", response.cookies)
diff --git a/tests/test_support_notifications.py b/tests/test_support_notifications.py
index 35d94eb..d73e4c1 100644
--- a/tests/test_support_notifications.py
+++ b/tests/test_support_notifications.py
@@ -364,3 +364,51 @@ def test_support_user_reply_can_send_email_without_telegram_channels():
assert channels == []
assert emails[0]["ticket_id"] == 7
+
+
+def test_account_merge_notification_goes_to_log_channel():
+ messages = []
+
+ class I18n:
+ def gettext(self, _language, key, **kwargs):
+ if key == "log_open_profile_link":
+ return "Open profile"
+ assert key == "log_account_merged"
+ return (
+ f"merged primary={kwargs['primary_user_id']} "
+ f"removed={kwargs['removed_user_id']} "
+ f"email={kwargs['email']} end={kwargs['final_end_date']}"
+ )
+
+ service = NotificationService(
+ bot=SimpleNamespace(),
+ settings=_settings(LOG_CHAT_ID=-100123, DEFAULT_LANGUAGE="en"),
+ i18n=I18n(),
+ )
+
+ async def send_to_log_channel(message, thread_id=None, reply_markup=None):
+ messages.append((message, thread_id, reply_markup))
+
+ service._send_to_log_channel = send_to_log_channel
+
+ asyncio.run(
+ service.notify_account_merged(
+ primary_user_id=42,
+ removed_user_id=-100,
+ email="paid@example.com",
+ telegram_id=100200300,
+ username="alice",
+ first_name="Alice",
+ final_end_date_text="2026-06-21 10:00",
+ primary_panel_user_uuid="panel-telegram",
+ removed_panel_user_uuid="panel-email",
+ )
+ )
+
+ assert len(messages) == 1
+ message, thread_id, reply_markup = messages[0]
+ assert "primary=42" in message
+ assert "removed=-100" in message
+ assert "paid@example.com" in message
+ assert thread_id is None
+ assert reply_markup.inline_keyboard[0][0].url == "tg://user?id=100200300"
From 443e2e62db58ff1508daf274c653cbf60a181eda Mon Sep 17 00:00:00 2001
From: 3252a8 <3252a8@proton.me>
Date: Fri, 22 May 2026 22:39:45 +0300
Subject: [PATCH 14/14] fix: auto-merge duplicate panel identities
---
backend/bot/handlers/admin/sync_admin.py | 278 ++++++++++++++++++++++-
tests/test_admin_sync_performance.py | 98 ++++++++
2 files changed, 374 insertions(+), 2 deletions(-)
diff --git a/backend/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py
index c56b507..453d206 100644
--- a/backend/bot/handlers/admin/sync_admin.py
+++ b/backend/bot/handlers/admin/sync_admin.py
@@ -1,6 +1,6 @@
import asyncio
import logging
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from typing import Any, Optional, Union
from aiogram import Bot, Router, types
@@ -108,6 +108,21 @@ def _as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc)
+def _panel_expire_at(panel_user: dict[str, Any]) -> Optional[datetime]:
+ raw_value = panel_user.get("expireAt")
+ if not raw_value:
+ return None
+ try:
+ return datetime.fromisoformat(str(raw_value).replace("Z", "+00:00"))
+ except (TypeError, ValueError):
+ return None
+
+
+def _panel_subscription_uuid(panel_user: dict[str, Any]) -> Optional[str]:
+ value = panel_user.get("subscriptionUuid") or panel_user.get("shortUuid")
+ return str(value) if value else None
+
+
def _should_update_lifetime_used_traffic(
existing_user,
lifetime_used: int,
@@ -350,6 +365,211 @@ async def _bind_panel_email_to_user(
return existing_user, True
+async def _merge_local_duplicate_panel_user_if_needed(
+ session: AsyncSession,
+ *,
+ existing_user,
+ duplicate_panel_uuid: str,
+):
+ duplicate_local_user = await user_dal.get_user_by_panel_uuid(session, duplicate_panel_uuid)
+ if not duplicate_local_user or duplicate_local_user.user_id == existing_user.user_id:
+ return existing_user, True
+
+ try:
+ merged_user = await user_dal.merge_users(
+ session,
+ source_user_id=duplicate_local_user.user_id,
+ target_user_id=existing_user.user_id,
+ )
+ logging.info(
+ "Sync: merged local duplicate user %s into %s for duplicate panel UUID %s.",
+ duplicate_local_user.user_id,
+ merged_user.user_id,
+ duplicate_panel_uuid,
+ )
+ return merged_user, True
+ except Exception as exc:
+ logging.warning(
+ "Sync: could not merge local duplicate user %s into %s for panel UUID %s: %s",
+ duplicate_local_user.user_id,
+ existing_user.user_id,
+ duplicate_panel_uuid,
+ exc,
+ )
+ return existing_user, False
+
+
+def _panel_identity_payload_with_expiry(
+ user,
+ *,
+ expire_at: datetime,
+) -> dict[str, Any]:
+ description_text = "\n".join(
+ line
+ for line in [
+ user.email or "",
+ user.username or "",
+ user.first_name or "",
+ user.last_name or "",
+ ]
+ if line
+ )
+ payload = _panel_identity_update_payload(user, description_text)
+ payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
+ if expire_at > datetime.now(timezone.utc):
+ payload["status"] = "ACTIVE"
+ return payload
+
+
+async def _absorb_duplicate_panel_identity(
+ session: AsyncSession,
+ *,
+ panel_service: PanelApiService,
+ existing_user,
+ keep_panel_uuid: str,
+ keep_panel_user: Optional[dict[str, Any]],
+ duplicate_panel_user: dict[str, Any],
+ settings: Settings,
+ subscriptions_by_panel_uuid: dict[str, Subscription],
+ active_subscriptions_by_user_panel: dict[tuple[int, str], Subscription],
+) -> dict[str, int | bool]:
+ duplicate_panel_uuid = str(duplicate_panel_user.get("uuid") or "")
+ if not duplicate_panel_uuid:
+ return {"resolved": False, "subscriptions_created": 0, "subscriptions_updated": 0}
+
+ subscriptions_created = 0
+ subscriptions_updated = 0
+ now = datetime.now(timezone.utc)
+ duplicate_expire_at = _panel_expire_at(duplicate_panel_user)
+ duplicate_status = str(duplicate_panel_user.get("status") or "").upper()
+ duplicate_is_active = bool(
+ duplicate_expire_at and duplicate_status == "ACTIVE" and duplicate_expire_at > now
+ )
+
+ keep_subscription_uuid = _panel_subscription_uuid(keep_panel_user or {})
+ target_sub = (
+ subscriptions_by_panel_uuid.get(keep_subscription_uuid) if keep_subscription_uuid else None
+ )
+ if not target_sub:
+ target_sub = active_subscriptions_by_user_panel.get(
+ (int(existing_user.user_id), keep_panel_uuid)
+ )
+
+ final_end_date: Optional[datetime] = None
+ if duplicate_is_active and duplicate_expire_at:
+ source_remaining = max(timedelta(0), duplicate_expire_at - now)
+ if target_sub:
+ target_end = _as_utc(target_sub.end_date)
+ base_end = target_end if target_end > now else now
+ final_end_date = base_end + source_remaining
+ update_payload: dict[str, Any] = {
+ "user_id": int(existing_user.user_id),
+ "panel_user_uuid": keep_panel_uuid,
+ "end_date": final_end_date,
+ "is_active": True,
+ "status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE",
+ }
+ if keep_subscription_uuid:
+ update_payload["panel_subscription_uuid"] = keep_subscription_uuid
+ update_delta = _subscription_update_delta(target_sub, update_payload)
+ if update_delta:
+ await subscription_dal.update_subscription(
+ session,
+ target_sub.subscription_id,
+ update_delta,
+ )
+ for key, value in update_delta.items():
+ setattr(target_sub, key, value)
+ subscriptions_updated += 1
+ elif keep_subscription_uuid:
+ final_end_date = now + (duplicate_expire_at - now)
+ created_sub = await subscription_dal.upsert_subscription(
+ session,
+ {
+ "user_id": int(existing_user.user_id),
+ "panel_user_uuid": keep_panel_uuid,
+ "panel_subscription_uuid": keep_subscription_uuid,
+ "start_date": None,
+ "end_date": final_end_date,
+ "duration_months": None,
+ "is_active": True,
+ "status_from_panel": "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE",
+ "traffic_limit_bytes": getattr(settings, "user_traffic_limit_bytes", 0),
+ "auto_renew_enabled": False,
+ },
+ )
+ subscriptions_by_panel_uuid[keep_subscription_uuid] = created_sub
+ active_subscriptions_by_user_panel[
+ (int(created_sub.user_id), created_sub.panel_user_uuid)
+ ] = created_sub
+ subscriptions_created += 1
+
+ duplicate_subscription_uuid = _panel_subscription_uuid(duplicate_panel_user)
+ duplicate_sub = (
+ subscriptions_by_panel_uuid.get(duplicate_subscription_uuid)
+ if duplicate_subscription_uuid
+ else None
+ )
+ if duplicate_sub and duplicate_sub is not target_sub:
+ await subscription_dal.update_subscription(
+ session,
+ duplicate_sub.subscription_id,
+ {
+ "user_id": int(existing_user.user_id),
+ "is_active": False,
+ "skip_notifications": True,
+ "status_from_panel": "MERGED_PANEL_DUPLICATE",
+ },
+ )
+ duplicate_sub.user_id = int(existing_user.user_id)
+ duplicate_sub.is_active = False
+ duplicate_sub.skip_notifications = True
+ duplicate_sub.status_from_panel = "MERGED_PANEL_DUPLICATE"
+ subscriptions_updated += 1
+ elif not duplicate_sub:
+ await session.execute(
+ update(Subscription)
+ .where(Subscription.panel_user_uuid == duplicate_panel_uuid)
+ .values(
+ user_id=int(existing_user.user_id),
+ is_active=False,
+ skip_notifications=True,
+ status_from_panel="MERGED_PANEL_DUPLICATE",
+ )
+ )
+
+ if final_end_date:
+ await panel_service.update_user_details_on_panel(
+ keep_panel_uuid,
+ _panel_identity_payload_with_expiry(existing_user, expire_at=final_end_date),
+ log_response=False,
+ )
+
+ deleted = await panel_service.delete_user_from_panel(
+ duplicate_panel_uuid,
+ log_response=False,
+ )
+ if deleted:
+ logging.info(
+ "Sync: absorbed duplicate panel UUID %s into kept panel UUID %s for user %s.",
+ duplicate_panel_uuid,
+ keep_panel_uuid,
+ existing_user.user_id,
+ )
+ else:
+ logging.warning(
+ "Sync: failed to delete duplicate panel UUID %s after absorbing it into %s.",
+ duplicate_panel_uuid,
+ keep_panel_uuid,
+ )
+
+ return {
+ "resolved": bool(deleted),
+ "subscriptions_created": subscriptions_created,
+ "subscriptions_updated": subscriptions_updated,
+ }
+
+
async def perform_sync(
panel_service: PanelApiService,
session: AsyncSession,
@@ -430,6 +650,11 @@ async def _perform_sync_impl(
subscriptions_by_panel_uuid = sync_indexes["subscriptions_by_panel_uuid"]
active_subscriptions_by_user_panel = sync_indexes["active_subscriptions_by_user_panel"]
panel_uuids_by_telegram_id = sync_indexes["panel_uuids_by_telegram_id"]
+ panel_users_by_uuid = {
+ str(panel_user["uuid"]): panel_user
+ for panel_user in panel_users_data
+ if panel_user.get("uuid")
+ }
for panel_user_dict in panel_users_data:
try:
@@ -579,12 +804,61 @@ async def _perform_sync_impl(
)
if linked_uuid_still_present:
is_duplicate_panel_identity = True
+ (
+ existing_user,
+ can_absorb_duplicate_panel_user,
+ ) = await _merge_local_duplicate_panel_user_if_needed(
+ session,
+ existing_user=existing_user,
+ duplicate_panel_uuid=panel_uuid,
+ )
+ if not can_absorb_duplicate_panel_user:
+ logging.warning(
+ "Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s because local duplicate merge failed.", # noqa: E501
+ telegram_id_from_panel,
+ linked_uuid,
+ panel_uuid,
+ )
+ continue
+ actual_user_id = existing_user.user_id
+ users_by_panel_uuid[linked_uuid] = existing_user
+ if existing_user.telegram_id is not None:
+ users_by_telegram_id[int(existing_user.telegram_id)] = existing_user
+ users_by_user_id[int(existing_user.user_id)] = existing_user
+ if existing_user.email:
+ users_by_email[existing_user.email.strip().lower()] = existing_user
+ merge_result = await _absorb_duplicate_panel_identity(
+ session,
+ panel_service=panel_service,
+ existing_user=existing_user,
+ keep_panel_uuid=str(linked_uuid),
+ keep_panel_user=panel_users_by_uuid.get(str(linked_uuid)),
+ duplicate_panel_user=panel_user_dict,
+ settings=settings,
+ subscriptions_by_panel_uuid=subscriptions_by_panel_uuid,
+ active_subscriptions_by_user_panel=(
+ active_subscriptions_by_user_panel
+ ),
+ )
+ subscriptions_created += int(merge_result["subscriptions_created"])
+ subscriptions_updated += int(merge_result["subscriptions_updated"])
+ subscriptions_synced_count += int(
+ merge_result["subscriptions_created"]
+ ) + int(merge_result["subscriptions_updated"])
+ if merge_result["resolved"]:
+ users_updated += 1
+ users_uuid_updated += 1
+ panel_uuids_by_telegram_id.get(telegram_id_from_panel, set()).discard(
+ str(panel_uuid)
+ )
+ users_by_panel_uuid.pop(str(panel_uuid), None)
logging.warning(
- "Sync: duplicate panel users share telegramId %s; keeping local panel UUID %s and skipping duplicate panel UUID %s.", # noqa: E501
+ "Sync: duplicate panel users share telegramId %s; kept local panel UUID %s and processed duplicate panel UUID %s.", # noqa: E501
telegram_id_from_panel,
linked_uuid,
panel_uuid,
)
+ continue
else:
existing_user.panel_user_uuid = panel_uuid
user_was_updated = True
diff --git a/tests/test_admin_sync_performance.py b/tests/test_admin_sync_performance.py
index 94183ca..ce9e156 100644
--- a/tests/test_admin_sync_performance.py
+++ b/tests/test_admin_sync_performance.py
@@ -1,7 +1,10 @@
+import asyncio
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
from bot.handlers.admin.sync_admin import (
+ _absorb_duplicate_panel_identity,
_coerce_panel_telegram_id,
_description_matches,
_should_update_lifetime_used_traffic,
@@ -136,3 +139,98 @@ def test_lifetime_traffic_update_allows_large_delta_and_skips_duplicate_panel_id
settings=settings,
is_duplicate_panel_identity=True,
)
+
+
+def test_absorb_duplicate_panel_identity_extends_kept_user_and_deletes_duplicate():
+ now = datetime.now(timezone.utc)
+ target_sub = SimpleNamespace(
+ subscription_id=10,
+ user_id=42,
+ panel_user_uuid="panel-keep",
+ panel_subscription_uuid="sub-keep",
+ end_date=now - timedelta(days=2),
+ is_active=False,
+ status_from_panel="EXPIRED",
+ )
+ duplicate_sub = SimpleNamespace(
+ subscription_id=11,
+ user_id=42,
+ panel_user_uuid="panel-duplicate",
+ panel_subscription_uuid="sub-duplicate",
+ end_date=now + timedelta(days=30),
+ is_active=True,
+ skip_notifications=False,
+ status_from_panel="ACTIVE",
+ )
+ panel_service = SimpleNamespace(
+ update_user_details_on_panel=AsyncMock(return_value={"uuid": "panel-keep"}),
+ delete_user_from_panel=AsyncMock(return_value=True),
+ )
+ session = SimpleNamespace(execute=AsyncMock())
+ settings = SimpleNamespace(user_traffic_limit_bytes=0)
+ user = SimpleNamespace(
+ user_id=42,
+ panel_user_uuid="panel-keep",
+ telegram_id=969808056,
+ email="paid@example.com",
+ username="alice",
+ first_name="Alice",
+ last_name=None,
+ )
+
+ async def update_subscription(_session, subscription_id, update_data):
+ sub = target_sub if subscription_id == target_sub.subscription_id else duplicate_sub
+ for key, value in update_data.items():
+ setattr(sub, key, value)
+ return sub
+
+ with patch(
+ "bot.handlers.admin.sync_admin.subscription_dal.update_subscription",
+ AsyncMock(side_effect=update_subscription),
+ ):
+ result = asyncio.run(
+ _absorb_duplicate_panel_identity(
+ session,
+ panel_service=panel_service,
+ existing_user=user,
+ keep_panel_uuid="panel-keep",
+ keep_panel_user={
+ "uuid": "panel-keep",
+ "subscriptionUuid": "sub-keep",
+ "status": "EXPIRED",
+ "expireAt": (now - timedelta(days=2)).isoformat(),
+ },
+ duplicate_panel_user={
+ "uuid": "panel-duplicate",
+ "subscriptionUuid": "sub-duplicate",
+ "telegramId": 969808056,
+ "status": "ACTIVE",
+ "expireAt": (now + timedelta(days=30)).isoformat(),
+ },
+ settings=settings,
+ subscriptions_by_panel_uuid={
+ "sub-keep": target_sub,
+ "sub-duplicate": duplicate_sub,
+ },
+ active_subscriptions_by_user_panel={},
+ )
+ )
+
+ assert result["resolved"]
+ assert result["subscriptions_updated"] == 2
+ assert target_sub.is_active
+ assert target_sub.status_from_panel == "ACTIVE_EXTENDED_BY_PANEL_DUPLICATE_MERGE"
+ assert target_sub.panel_user_uuid == "panel-keep"
+ assert target_sub.end_date > now + timedelta(days=29)
+ assert not duplicate_sub.is_active
+ assert duplicate_sub.skip_notifications
+ assert duplicate_sub.status_from_panel == "MERGED_PANEL_DUPLICATE"
+ panel_service.update_user_details_on_panel.assert_awaited_once()
+ update_uuid, update_payload = panel_service.update_user_details_on_panel.await_args.args[:2]
+ assert update_uuid == "panel-keep"
+ assert update_payload["status"] == "ACTIVE"
+ assert update_payload["telegramId"] == 969808056
+ panel_service.delete_user_from_panel.assert_awaited_once_with(
+ "panel-duplicate",
+ log_response=False,
+ )