fix: stop panel description churn
This commit is contained in:
@@ -49,7 +49,11 @@ from bot.services.referral_service import ReferralService
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.utils.config_link import prepare_config_links
|
from bot.utils.config_link import prepare_config_links
|
||||||
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
from bot.utils.request_security import parse_ip_entries, request_client_ip
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
from bot.utils.text_sanitizer import (
|
||||||
|
panel_description_from_profile,
|
||||||
|
sanitize_display_name,
|
||||||
|
sanitize_username,
|
||||||
|
)
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
|
||||||
from db.dal.user_dal import UserMergeConflictError
|
from db.dal.user_dal import UserMergeConflictError
|
||||||
|
|||||||
@@ -1013,12 +1013,11 @@ def _telegram_id_for_user(user: User) -> Optional[int]:
|
|||||||
|
|
||||||
|
|
||||||
def _panel_description_for_user(user: User) -> str:
|
def _panel_description_for_user(user: User) -> str:
|
||||||
lines = [
|
return panel_description_from_profile(
|
||||||
user.username or "",
|
user.username,
|
||||||
user.first_name or "",
|
user.first_name,
|
||||||
user.last_name or "",
|
user.last_name,
|
||||||
]
|
)
|
||||||
return "\n".join(line for line in lines if line).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]:
|
||||||
@@ -1041,9 +1040,7 @@ async def _sync_panel_identity_for_user(
|
|||||||
if not subscription_service or not subscription_service.panel_service:
|
if not subscription_service or not subscription_service.panel_service:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
payload: Dict[str, Any] = {
|
payload: Dict[str, Any] = {}
|
||||||
"description": _panel_description_for_user(user),
|
|
||||||
}
|
|
||||||
telegram_id = _telegram_id_for_user(user)
|
telegram_id = _telegram_id_for_user(user)
|
||||||
if telegram_id:
|
if telegram_id:
|
||||||
payload["telegramId"] = telegram_id
|
payload["telegramId"] = telegram_id
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.utils.text_sanitizer import panel_description_from_profile
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
from db.dal import panel_sync_dal, subscription_dal, user_dal
|
||||||
from db.models import Subscription, User
|
from db.models import Subscription, User
|
||||||
@@ -354,8 +355,8 @@ async def _panel_identity_view_for_comparison(
|
|||||||
return full_panel_user, False
|
return full_panel_user, False
|
||||||
|
|
||||||
|
|
||||||
def _panel_identity_update_payload(user: User, description_text: str) -> dict[str, Any]:
|
def _panel_identity_fields_update_payload(user: User) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = {"description": description_text}
|
payload: dict[str, Any] = {}
|
||||||
if user.email:
|
if user.email:
|
||||||
payload["email"] = user.email
|
payload["email"] = user.email
|
||||||
if user.telegram_id:
|
if user.telegram_id:
|
||||||
@@ -364,12 +365,11 @@ def _panel_identity_update_payload(user: User, description_text: str) -> dict[st
|
|||||||
|
|
||||||
|
|
||||||
def _panel_description_for_user(user: User) -> str:
|
def _panel_description_for_user(user: User) -> str:
|
||||||
lines = [
|
return panel_description_from_profile(
|
||||||
user.username or "",
|
user.username,
|
||||||
user.first_name or "",
|
user.first_name,
|
||||||
user.last_name or "",
|
user.last_name,
|
||||||
]
|
)
|
||||||
return "\n".join(line for line in lines if line).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
|
def _datetime_matches(current: Optional[datetime], desired: datetime) -> bool:
|
||||||
@@ -683,7 +683,7 @@ def _panel_identity_payload_with_expiry(
|
|||||||
*,
|
*,
|
||||||
expire_at: datetime,
|
expire_at: datetime,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = _panel_identity_update_payload(user, _panel_description_for_user(user))
|
payload = _panel_identity_fields_update_payload(user)
|
||||||
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
if expire_at > datetime.now(timezone.utc):
|
if expire_at > datetime.now(timezone.utc):
|
||||||
payload["status"] = "ACTIVE"
|
payload["status"] = "ACTIVE"
|
||||||
@@ -1272,11 +1272,11 @@ async def _perform_sync_impl(
|
|||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
_append_unique(user_update_reasons, "lifetime_traffic_synced")
|
_append_unique(user_update_reasons, "lifetime_traffic_synced")
|
||||||
|
|
||||||
# Ensure panel description contains Telegram fields
|
# Keep structural identity fields in panel and clean legacy email from
|
||||||
|
# description. Plain description text is intentionally not canonical.
|
||||||
try:
|
try:
|
||||||
if panel_uuid and existing_user and not is_duplicate_panel_identity:
|
if panel_uuid and existing_user and not is_duplicate_panel_identity:
|
||||||
description_text = _panel_description_for_user(existing_user)
|
description_text = _panel_description_for_user(existing_user)
|
||||||
# Update description only when it differs from the current one on panel
|
|
||||||
desired_description = description_text.strip()
|
desired_description = description_text.strip()
|
||||||
(
|
(
|
||||||
panel_user_for_identity,
|
panel_user_for_identity,
|
||||||
@@ -1296,19 +1296,16 @@ async def _perform_sync_impl(
|
|||||||
identity_matches = _panel_identity_matches_user(
|
identity_matches = _panel_identity_matches_user(
|
||||||
panel_user_for_identity,
|
panel_user_for_identity,
|
||||||
existing_user,
|
existing_user,
|
||||||
desired_description,
|
"",
|
||||||
missing_identity_fields_match=missing_identity_fields_match,
|
missing_identity_fields_match=missing_identity_fields_match,
|
||||||
)
|
)
|
||||||
|
panel_payload = _panel_identity_fields_update_payload(existing_user)
|
||||||
if description_has_email:
|
if description_has_email:
|
||||||
description_text = _description_without_email(
|
panel_payload["description"] = _description_without_email(
|
||||||
current_description,
|
current_description,
|
||||||
existing_user.email,
|
existing_user.email,
|
||||||
)
|
)
|
||||||
if description_has_email or not identity_matches:
|
if description_has_email or not identity_matches:
|
||||||
panel_payload = _panel_identity_update_payload(
|
|
||||||
existing_user,
|
|
||||||
description_text,
|
|
||||||
)
|
|
||||||
panel_changes = _panel_update_changes(
|
panel_changes = _panel_update_changes(
|
||||||
panel_user_for_identity,
|
panel_user_for_identity,
|
||||||
panel_payload,
|
panel_payload,
|
||||||
@@ -1339,7 +1336,7 @@ async def _perform_sync_impl(
|
|||||||
)
|
)
|
||||||
except Exception as e_desc:
|
except Exception as e_desc:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" # noqa: E501
|
f"Sync: Failed to update panel identity for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" # noqa: E501
|
||||||
)
|
)
|
||||||
|
|
||||||
# Sync subscription data
|
# Sync subscription data
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from aiogram.types import User as TgUser
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
|
from bot.infra.redis import cache_get_json, cache_set_json, redis_key
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
|
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from db.dal import user_dal
|
from db.dal import user_dal
|
||||||
|
|
||||||
@@ -55,21 +55,13 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
|
f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501
|
||||||
)
|
)
|
||||||
|
|
||||||
# Also update description on panel if linked
|
# Keep panel identity fields fresh, but do not rewrite
|
||||||
|
# description from profile changes. Remnawave may return
|
||||||
|
# description with lossy encoding in list views.
|
||||||
try:
|
try:
|
||||||
panel_service = data.get("panel_service")
|
panel_service = data.get("panel_service")
|
||||||
if panel_service and db_user.panel_user_uuid:
|
if panel_service and db_user.panel_user_uuid:
|
||||||
description_text = "\n".join(
|
|
||||||
[
|
|
||||||
username_for_display(tg_user.username, with_at=False)
|
|
||||||
if sanitized_username is not None
|
|
||||||
else "",
|
|
||||||
sanitized_first_name or "",
|
|
||||||
sanitized_last_name or "",
|
|
||||||
]
|
|
||||||
).strip()
|
|
||||||
panel_payload = {
|
panel_payload = {
|
||||||
"description": description_text,
|
|
||||||
"telegramId": tg_user.id,
|
"telegramId": tg_user.id,
|
||||||
}
|
}
|
||||||
if db_user.email:
|
if db_user.email:
|
||||||
@@ -80,7 +72,7 @@ class ProfileSyncMiddleware(BaseMiddleware):
|
|||||||
)
|
)
|
||||||
except Exception as e_upd_desc:
|
except Exception as e_upd_desc:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501
|
f"ProfileSyncMiddleware: Failed to update panel identity for user {tg_user.id}: {e_upd_desc}" # noqa: E501
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(
|
logging.error(
|
||||||
|
|||||||
@@ -25,5 +25,6 @@ from db.models import Subscription, User
|
|||||||
from bot.services.email_auth_service import EmailAuthService
|
from bot.services.email_auth_service import EmailAuthService
|
||||||
from bot.services.email_templates import render_payment_success
|
from bot.services.email_templates import render_payment_success
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
|
from bot.utils.text_sanitizer import panel_description_from_profile
|
||||||
|
|
||||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||||
|
|||||||
@@ -57,17 +57,14 @@ class PanelIdentityMixin:
|
|||||||
return f"em_{referral_code}"
|
return f"em_{referral_code}"
|
||||||
|
|
||||||
def _panel_description_for_user(self, db_user: User) -> str:
|
def _panel_description_for_user(self, db_user: User) -> str:
|
||||||
lines = [
|
return panel_description_from_profile(
|
||||||
db_user.username or "",
|
db_user.username,
|
||||||
db_user.first_name or "",
|
db_user.first_name,
|
||||||
db_user.last_name or "",
|
db_user.last_name,
|
||||||
]
|
)
|
||||||
return "\n".join(line for line in lines if line).strip()
|
|
||||||
|
|
||||||
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
|
def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]:
|
||||||
payload: Dict[str, Any] = {
|
payload: Dict[str, Any] = {}
|
||||||
"description": self._panel_description_for_user(db_user),
|
|
||||||
}
|
|
||||||
telegram_id = self._telegram_id_for_panel(db_user)
|
telegram_id = self._telegram_id_for_panel(db_user)
|
||||||
if telegram_id:
|
if telegram_id:
|
||||||
payload["telegramId"] = telegram_id
|
payload["telegramId"] = telegram_id
|
||||||
|
|||||||
@@ -146,6 +146,43 @@ _NORMALIZED_BANNED_TOKENS = {
|
|||||||
_USERNAME_PLACEHOLDER = "клиент"
|
_USERNAME_PLACEHOLDER = "клиент"
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_broken_panel_text(value: Optional[str]) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
text = unicodedata.normalize("NFKC", str(value)).strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if "\ufffd" in text:
|
||||||
|
return True
|
||||||
|
|
||||||
|
meaningful = [ch for ch in text if not ch.isspace()]
|
||||||
|
if len(meaningful) < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
question_count = sum(1 for ch in meaningful if ch == "?")
|
||||||
|
if question_count < 2:
|
||||||
|
return False
|
||||||
|
|
||||||
|
has_content = any(
|
||||||
|
ch != "?" and not unicodedata.category(ch).startswith("P") for ch in meaningful
|
||||||
|
)
|
||||||
|
return not has_content and question_count / len(meaningful) >= 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def panel_description_from_profile(
|
||||||
|
username: Optional[str],
|
||||||
|
first_name: Optional[str],
|
||||||
|
last_name: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
lines = []
|
||||||
|
for value in (username, first_name, last_name):
|
||||||
|
line = (value or "").strip()
|
||||||
|
if line and not looks_like_broken_panel_text(line):
|
||||||
|
lines.append(line)
|
||||||
|
return "\n".join(lines).strip()
|
||||||
|
|
||||||
|
|
||||||
def _normalize_for_detection(value: str) -> str:
|
def _normalize_for_detection(value: str) -> str:
|
||||||
if not value:
|
if not value:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertTrue(result)
|
self.assertTrue(result)
|
||||||
panel_service.update_user_details_on_panel.assert_awaited_once()
|
panel_service.update_user_details_on_panel.assert_awaited_once()
|
||||||
_, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
_, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
||||||
self.assertEqual(payload["description"], "alice")
|
self.assertNotIn("description", payload)
|
||||||
self.assertEqual(payload["email"], "linked@example.com")
|
self.assertEqual(payload["email"], "linked@example.com")
|
||||||
|
|
||||||
def test_panel_description_for_user_excludes_email(self):
|
def test_panel_description_for_user_excludes_email(self):
|
||||||
@@ -87,6 +87,16 @@ class AccountLinkingPanelTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
self.assertEqual(_panel_description_for_user(user), "alice\nAlice")
|
self.assertEqual(_panel_description_for_user(user), "alice\nAlice")
|
||||||
|
|
||||||
|
def test_panel_description_for_user_filters_broken_lines(self):
|
||||||
|
user = SimpleNamespace(
|
||||||
|
email="linked@example.com",
|
||||||
|
username="alice??",
|
||||||
|
first_name="????",
|
||||||
|
last_name="Smith",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(_panel_description_for_user(user), "alice??\nSmith")
|
||||||
|
|
||||||
async def test_merged_panel_identity_deletes_source_before_updating_target(self):
|
async def test_merged_panel_identity_deletes_source_before_updating_target(self):
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from bot.handlers.admin.sync_admin import (
|
|||||||
_format_panel_update_changes,
|
_format_panel_update_changes,
|
||||||
_identity_panel_update_reasons,
|
_identity_panel_update_reasons,
|
||||||
_panel_description_for_user,
|
_panel_description_for_user,
|
||||||
|
_panel_identity_fields_update_payload,
|
||||||
_panel_identity_matches_user,
|
_panel_identity_matches_user,
|
||||||
_panel_identity_needs_full_fetch,
|
_panel_identity_needs_full_fetch,
|
||||||
_panel_identity_needs_legacy_description_cleanup,
|
_panel_identity_needs_legacy_description_cleanup,
|
||||||
@@ -58,6 +59,17 @@ def test_panel_description_for_user_excludes_email():
|
|||||||
assert _panel_description_for_user(user) == "alice\nAlice\nSmith"
|
assert _panel_description_for_user(user) == "alice\nAlice\nSmith"
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_description_for_user_filters_broken_lines():
|
||||||
|
user = SimpleNamespace(
|
||||||
|
email="linked@example.com",
|
||||||
|
username="alice??",
|
||||||
|
first_name="????",
|
||||||
|
last_name="Smith",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _panel_description_for_user(user) == "alice??\nSmith"
|
||||||
|
|
||||||
|
|
||||||
def test_panel_update_change_summary_is_compact_and_field_based():
|
def test_panel_update_change_summary_is_compact_and_field_based():
|
||||||
changes = _panel_update_changes(
|
changes = _panel_update_changes(
|
||||||
{
|
{
|
||||||
@@ -133,7 +145,7 @@ def test_panel_identity_match_accepts_list_description_without_email():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_panel_identity_payload_with_expiry_keeps_email_out_of_description():
|
def test_panel_identity_payload_with_expiry_excludes_description_updates():
|
||||||
expire_at = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
expire_at = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
||||||
user = SimpleNamespace(
|
user = SimpleNamespace(
|
||||||
email="linked@example.com",
|
email="linked@example.com",
|
||||||
@@ -145,11 +157,26 @@ def test_panel_identity_payload_with_expiry_keeps_email_out_of_description():
|
|||||||
|
|
||||||
payload = _panel_identity_payload_with_expiry(user, expire_at=expire_at)
|
payload = _panel_identity_payload_with_expiry(user, expire_at=expire_at)
|
||||||
|
|
||||||
assert payload["description"] == "alice\nAlice"
|
assert "description" not in payload
|
||||||
assert payload["email"] == "linked@example.com"
|
assert payload["email"] == "linked@example.com"
|
||||||
assert payload["telegramId"] == 42
|
assert payload["telegramId"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_identity_fields_update_payload_excludes_description():
|
||||||
|
user = SimpleNamespace(
|
||||||
|
email="linked@example.com",
|
||||||
|
telegram_id=42,
|
||||||
|
username="alice",
|
||||||
|
first_name="Alice",
|
||||||
|
last_name=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _panel_identity_fields_update_payload(user) == {
|
||||||
|
"email": "linked@example.com",
|
||||||
|
"telegramId": 42,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_panel_identity_detects_legacy_full_description_cleanup_need():
|
def test_panel_identity_detects_legacy_full_description_cleanup_need():
|
||||||
user = SimpleNamespace(
|
user = SimpleNamespace(
|
||||||
email="linked@example.com",
|
email="linked@example.com",
|
||||||
|
|||||||
@@ -47,6 +47,20 @@ class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
self.assertTrue(service._request.await_args.kwargs["log_full_response"])
|
self.assertTrue(service._request.await_args.kwargs["log_full_response"])
|
||||||
|
|
||||||
|
async def test_create_panel_user_omits_empty_description(self):
|
||||||
|
service = self._make_service()
|
||||||
|
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
|
||||||
|
|
||||||
|
await service.create_panel_user(
|
||||||
|
username_on_panel="tg_42",
|
||||||
|
telegram_id=42,
|
||||||
|
description="",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = service._request.await_args.kwargs["json"]
|
||||||
|
self.assertNotIn("description", payload)
|
||||||
|
self.assertEqual(payload["telegramId"], 42)
|
||||||
|
|
||||||
async def test_get_user_by_uuid_uses_short_ttl_cache_and_update_invalidates(self):
|
async def test_get_user_by_uuid_uses_short_ttl_cache_and_update_invalidates(self):
|
||||||
service = self._make_service()
|
service = self._make_service()
|
||||||
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
|
service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from types import SimpleNamespace
|
|||||||
from bot.services.subscription_service_impl.panel_identity import PanelIdentityMixin
|
from bot.services.subscription_service_impl.panel_identity import PanelIdentityMixin
|
||||||
|
|
||||||
|
|
||||||
def test_subscription_panel_description_excludes_email():
|
def test_subscription_panel_identity_payload_excludes_description_updates():
|
||||||
user = SimpleNamespace(
|
user = SimpleNamespace(
|
||||||
email="linked@example.com",
|
email="linked@example.com",
|
||||||
username="alice",
|
username="alice",
|
||||||
@@ -15,6 +15,19 @@ def test_subscription_panel_description_excludes_email():
|
|||||||
|
|
||||||
payload = PanelIdentityMixin()._panel_identity_payload_for_user(user)
|
payload = PanelIdentityMixin()._panel_identity_payload_for_user(user)
|
||||||
|
|
||||||
assert payload["description"] == "alice\nAlice\nSmith"
|
assert "description" not in payload
|
||||||
assert payload["email"] == "linked@example.com"
|
assert payload["email"] == "linked@example.com"
|
||||||
assert payload["telegramId"] == 42
|
assert payload["telegramId"] == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscription_panel_description_filters_broken_lines_for_creation():
|
||||||
|
user = SimpleNamespace(
|
||||||
|
email="linked@example.com",
|
||||||
|
username="alice??",
|
||||||
|
first_name="????",
|
||||||
|
last_name="Smith",
|
||||||
|
telegram_id=42,
|
||||||
|
user_id=42,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert PanelIdentityMixin()._panel_description_for_user(user) == "alice??\nSmith"
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ class ProfileSyncMiddlewareCacheTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
get_user.assert_awaited_once()
|
get_user.assert_awaited_once()
|
||||||
self.assertEqual(handler.await_count, 2)
|
self.assertEqual(handler.await_count, 2)
|
||||||
|
|
||||||
async def test_profile_sync_keeps_email_out_of_panel_description(self):
|
async def test_profile_sync_does_not_rewrite_panel_description(self):
|
||||||
middleware = ProfileSyncMiddleware()
|
middleware = ProfileSyncMiddleware()
|
||||||
handler = AsyncMock(return_value="ok")
|
handler = AsyncMock(return_value="ok")
|
||||||
event = SimpleNamespace()
|
event = SimpleNamespace()
|
||||||
@@ -124,8 +124,9 @@ class ProfileSyncMiddlewareCacheTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(result, "ok")
|
self.assertEqual(result, "ok")
|
||||||
panel_service.update_user_details_on_panel.assert_awaited_once()
|
panel_service.update_user_details_on_panel.assert_awaited_once()
|
||||||
_, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
_, payload = panel_service.update_user_details_on_panel.await_args.args[:2]
|
||||||
self.assertEqual(payload["description"], "alice\nAlice\nSmith")
|
self.assertNotIn("description", payload)
|
||||||
self.assertEqual(payload["email"], "linked@example.com")
|
self.assertEqual(payload["email"], "linked@example.com")
|
||||||
|
self.assertEqual(payload["telegramId"], 42)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend"))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "backend"))
|
||||||
|
|
||||||
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username, username_for_display
|
from bot.utils.text_sanitizer import (
|
||||||
|
looks_like_broken_panel_text,
|
||||||
|
panel_description_from_profile,
|
||||||
|
sanitize_display_name,
|
||||||
|
sanitize_username,
|
||||||
|
username_for_display,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_username_preserves_underscore_suffixes():
|
def test_sanitize_username_preserves_underscore_suffixes():
|
||||||
@@ -22,3 +28,21 @@ def test_sanitize_username_rejects_free_form_values_instead_of_truncating():
|
|||||||
|
|
||||||
def test_display_name_filters_still_apply_to_free_form_names():
|
def test_display_name_filters_still_apply_to_free_form_names():
|
||||||
assert sanitize_display_name("Name service") == "Name"
|
assert sanitize_display_name("Name service") == "Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_broken_text_detection_is_conservative_about_question_marks():
|
||||||
|
assert not looks_like_broken_panel_text("?")
|
||||||
|
assert not looks_like_broken_panel_text("alice??")
|
||||||
|
assert not looks_like_broken_panel_text("??? 123")
|
||||||
|
assert not looks_like_broken_panel_text("\U0001f0cf")
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_broken_text_detection_filters_replacement_garbage():
|
||||||
|
assert looks_like_broken_panel_text("????")
|
||||||
|
assert looks_like_broken_panel_text("???!")
|
||||||
|
assert looks_like_broken_panel_text("\ufffd\ufffd")
|
||||||
|
|
||||||
|
|
||||||
|
def test_panel_description_filters_only_broken_lines():
|
||||||
|
assert panel_description_from_profile("alice??", "????", "Smith") == "alice??\nSmith"
|
||||||
|
assert panel_description_from_profile(None, "????", "\ufffd\ufffd") == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user