diff --git a/backend/bot/handlers/admin/sync_admin.py b/backend/bot/handlers/admin/sync_admin.py index a4f96db..6f40df7 100644 --- a/backend/bot/handlers/admin/sync_admin.py +++ b/backend/bot/handlers/admin/sync_admin.py @@ -62,6 +62,29 @@ def _description_matches(current: Optional[str], desired: str) -> bool: return bool(_description_variants(current) & _description_variants(desired)) +def _description_contains_email(value: Optional[str], email: Optional[str]) -> bool: + normalized_email = _normalize_panel_email(email) + if not normalized_email: + return False + return normalized_email in _normalize_description(value).lower() + + +def _description_without_email(value: Optional[str], email: Optional[str]) -> str: + normalized_email = _normalize_panel_email(email) + if not normalized_email: + return (value or "").strip() + + cleaned_lines = [] + for raw_line in (value or "").splitlines(): + line = raw_line.strip() + if not line: + continue + if line.lower() == normalized_email: + continue + cleaned_lines.append(line) + return "\n".join(cleaned_lines).strip() + + def _panel_identity_matches_user( panel_user: dict[str, Any], user: User, @@ -106,11 +129,27 @@ def _panel_identity_needs_full_fetch(panel_user: dict[str, Any], user: User) -> return False +def _panel_identity_needs_legacy_description_cleanup( + panel_user: dict[str, Any], + user: User, + desired_description: str, +) -> bool: + if not user.email: + return False + current_description = panel_user.get("description") + if _description_contains_email(current_description, user.email): + return False + if not desired_description: + return not _normalize_description(current_description) + return _description_matches(current_description, desired_description) + + async def _panel_identity_view_for_comparison( panel_service: PanelApiService, panel_uuid: str, panel_user: dict[str, Any], user: User, + desired_description: str = "", ) -> tuple[dict[str, Any], bool]: """Return the most reliable panel user view available for identity comparison. @@ -119,7 +158,15 @@ async def _panel_identity_view_for_comparison( repair PATCH. """ - if not _panel_identity_needs_full_fetch(panel_user, user): + needs_full_fetch = _panel_identity_needs_full_fetch(panel_user, user) + if not needs_full_fetch and _panel_identity_needs_legacy_description_cleanup( + panel_user, + user, + desired_description, + ): + needs_full_fetch = True + + if not needs_full_fetch: return panel_user, True try: full_panel_user = await panel_service.get_user_by_uuid(panel_uuid) @@ -1023,13 +1070,25 @@ async def _perform_sync_impl( panel_uuid, panel_user_dict, existing_user, + desired_description, ) - if desired_description and not _panel_identity_matches_user( + current_description = panel_user_for_identity.get("description") + description_has_email = _description_contains_email( + current_description, + existing_user.email, + ) + identity_matches = _panel_identity_matches_user( panel_user_for_identity, existing_user, desired_description, missing_identity_fields_match=missing_identity_fields_match, - ): + ) + if description_has_email: + description_text = _description_without_email( + current_description, + existing_user.email, + ) + if description_has_email or not identity_matches: await panel_service.update_user_details_on_panel( panel_uuid, _panel_identity_update_payload(existing_user, description_text), diff --git a/tests/test_admin_sync_performance.py b/tests/test_admin_sync_performance.py index a6b398a..6e2b8c4 100644 --- a/tests/test_admin_sync_performance.py +++ b/tests/test_admin_sync_performance.py @@ -6,10 +6,13 @@ from unittest.mock import AsyncMock, patch from bot.handlers.admin.sync_admin import ( _absorb_duplicate_panel_identity, _coerce_panel_telegram_id, + _description_contains_email, _description_matches, + _description_without_email, _panel_description_for_user, _panel_identity_matches_user, _panel_identity_needs_full_fetch, + _panel_identity_needs_legacy_description_cleanup, _panel_identity_payload_with_expiry, _panel_identity_view_for_comparison, _should_update_lifetime_used_traffic, @@ -52,6 +55,25 @@ def test_panel_description_for_user_excludes_email(): assert _panel_description_for_user(user) == "alice\nAlice\nSmith" +def test_description_contains_email_detects_legacy_panel_description(): + assert _description_contains_email( + "Linked@Example.com\nalice\nAlice", + "linked@example.com", + ) + assert not _description_contains_email("alice\nAlice", "linked@example.com") + + +def test_description_without_email_preserves_panel_text(): + assert ( + _description_without_email( + "linked@example.com\nalice\nAlice", + "linked@example.com", + ) + == "alice\nAlice" + ) + assert _description_without_email("linked@example.com", "linked@example.com") == "" + + def test_panel_identity_match_accepts_list_description_without_email(): user = SimpleNamespace( email="linked@example.com", @@ -90,20 +112,61 @@ def test_panel_identity_payload_with_expiry_keeps_email_out_of_description(): assert payload["telegramId"] == 42 +def test_panel_identity_detects_legacy_full_description_cleanup_need(): + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + username="alice", + first_name="Alice", + last_name=None, + ) + panel_user = { + "description": "alice\nAlice", + "email": "linked@example.com", + "telegramId": 42, + } + + assert _panel_identity_needs_legacy_description_cleanup( + panel_user, + user, + _panel_description_for_user(user), + ) + + +def test_panel_identity_detects_email_only_legacy_cleanup_need(): + user = SimpleNamespace( + email="linked@example.com", + telegram_id=None, + username=None, + first_name=None, + last_name=None, + ) + panel_user = { + "description": "", + "email": "linked@example.com", + } + + assert _panel_identity_needs_legacy_description_cleanup( + panel_user, + user, + _panel_description_for_user(user), + ) + + def test_panel_identity_match_treats_missing_list_email_as_unknown(): user = SimpleNamespace( email="linked@example.com", telegram_id=42, ) panel_user = { - "description": "linked@example.com\nalice", + "description": "alice", "telegramId": 42, } assert _panel_identity_matches_user( panel_user, user, - "linked@example.com\nalice", + "alice", ) @@ -113,14 +176,14 @@ def test_panel_identity_match_treats_missing_full_email_as_mismatch(): telegram_id=42, ) panel_user = { - "description": "linked@example.com\nalice", + "description": "alice", "telegramId": 42, } assert not _panel_identity_matches_user( panel_user, user, - "linked@example.com\nalice", + "alice", missing_identity_fields_match=False, ) @@ -131,7 +194,7 @@ def test_panel_identity_match_rejects_different_returned_email(): telegram_id=42, ) panel_user = { - "description": "linked@example.com\nalice", + "description": "alice", "email": "other@example.com", "telegramId": 42, } @@ -139,7 +202,7 @@ def test_panel_identity_match_rejects_different_returned_email(): assert not _panel_identity_matches_user( panel_user, user, - "linked@example.com\nalice", + "alice", ) @@ -192,6 +255,51 @@ def test_panel_identity_view_fetches_full_user_when_list_email_missing(): panel_service.get_user_by_uuid.assert_awaited_once_with("panel-1") +def test_panel_identity_view_fetches_full_user_to_clean_legacy_description_email(): + panel_service = SimpleNamespace( + get_user_by_uuid=AsyncMock( + return_value={ + "uuid": "panel-1", + "description": "linked@example.com\nalice\nAlice", + "email": "linked@example.com", + "telegramId": 42, + } + ) + ) + user = SimpleNamespace( + email="linked@example.com", + telegram_id=42, + username="alice", + first_name="Alice", + last_name=None, + ) + + panel_user, missing_fields_match = asyncio.run( + _panel_identity_view_for_comparison( + panel_service, + "panel-1", + { + "uuid": "panel-1", + "description": "alice\nAlice", + "email": "linked@example.com", + "telegramId": 42, + }, + user, + _panel_description_for_user(user), + ) + ) + + assert panel_user["description"] == "linked@example.com\nalice\nAlice" + assert not missing_fields_match + assert not _panel_identity_matches_user( + panel_user, + user, + _panel_description_for_user(user), + missing_identity_fields_match=missing_fields_match, + ) + panel_service.get_user_by_uuid.assert_awaited_once_with("panel-1") + + def test_subscription_update_delta_skips_unchanged_fields(): end_date = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc) subscription = Subscription(