539 lines
16 KiB
Python
539 lines
16 KiB
Python
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_contains_email,
|
|
_description_matches,
|
|
_description_without_email,
|
|
_format_panel_update_changes,
|
|
_identity_panel_update_reasons,
|
|
_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,
|
|
_panel_update_changes,
|
|
_should_update_lifetime_used_traffic,
|
|
_subscription_update_delta,
|
|
)
|
|
from db.models import Subscription
|
|
|
|
|
|
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
|
|
|
|
|
|
def test_panel_description_for_user_excludes_email():
|
|
user = SimpleNamespace(
|
|
email="linked@example.com",
|
|
username="alice",
|
|
first_name="Alice",
|
|
last_name="Smith",
|
|
)
|
|
|
|
assert _panel_description_for_user(user) == "alice\nAlice\nSmith"
|
|
|
|
|
|
def test_panel_update_change_summary_is_compact_and_field_based():
|
|
changes = _panel_update_changes(
|
|
{
|
|
"description": "old@example.com\nalice",
|
|
"email": "old@example.com",
|
|
"telegramId": "41",
|
|
},
|
|
{
|
|
"uuid": "panel-user-uuid",
|
|
"description": "alice",
|
|
"email": "new@example.com",
|
|
"telegramId": 42,
|
|
},
|
|
)
|
|
|
|
assert [field for field, _old, _new in changes] == [
|
|
"description",
|
|
"email",
|
|
"telegramId",
|
|
]
|
|
assert _identity_panel_update_reasons(
|
|
changes,
|
|
description_has_email=True,
|
|
) == [
|
|
"remove_email_from_description",
|
|
"description_mismatch",
|
|
"email_mismatch",
|
|
"telegramId_mismatch",
|
|
]
|
|
summary = _format_panel_update_changes(changes)
|
|
assert "\n" not in summary
|
|
assert "description:len=21:old@example.com\\nalice->len=5:alice" in summary
|
|
assert "telegramId:41->42" in summary
|
|
|
|
|
|
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",
|
|
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_matches_user(
|
|
panel_user,
|
|
user,
|
|
_panel_description_for_user(user),
|
|
)
|
|
|
|
|
|
def test_panel_identity_payload_with_expiry_keeps_email_out_of_description():
|
|
expire_at = datetime(2026, 6, 1, 12, 0, tzinfo=timezone.utc)
|
|
user = SimpleNamespace(
|
|
email="linked@example.com",
|
|
telegram_id=42,
|
|
username="alice",
|
|
first_name="Alice",
|
|
last_name=None,
|
|
)
|
|
|
|
payload = _panel_identity_payload_with_expiry(user, expire_at=expire_at)
|
|
|
|
assert payload["description"] == "alice\nAlice"
|
|
assert payload["email"] == "linked@example.com"
|
|
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": "alice",
|
|
"telegramId": 42,
|
|
}
|
|
|
|
assert _panel_identity_matches_user(
|
|
panel_user,
|
|
user,
|
|
"alice",
|
|
)
|
|
|
|
|
|
def test_panel_identity_match_treats_missing_full_email_as_mismatch():
|
|
user = SimpleNamespace(
|
|
email="linked@example.com",
|
|
telegram_id=42,
|
|
)
|
|
panel_user = {
|
|
"description": "alice",
|
|
"telegramId": 42,
|
|
}
|
|
|
|
assert not _panel_identity_matches_user(
|
|
panel_user,
|
|
user,
|
|
"alice",
|
|
missing_identity_fields_match=False,
|
|
)
|
|
|
|
|
|
def test_panel_identity_match_rejects_different_returned_email():
|
|
user = SimpleNamespace(
|
|
email="linked@example.com",
|
|
telegram_id=42,
|
|
)
|
|
panel_user = {
|
|
"description": "alice",
|
|
"email": "other@example.com",
|
|
"telegramId": 42,
|
|
}
|
|
|
|
assert not _panel_identity_matches_user(
|
|
panel_user,
|
|
user,
|
|
"alice",
|
|
)
|
|
|
|
|
|
def test_panel_identity_needs_full_fetch_for_missing_or_blank_identity_fields():
|
|
user = SimpleNamespace(
|
|
email="linked@example.com",
|
|
telegram_id=42,
|
|
)
|
|
|
|
assert _panel_identity_needs_full_fetch({"telegramId": 42}, user)
|
|
assert _panel_identity_needs_full_fetch({"email": "", "telegramId": 42}, user)
|
|
assert _panel_identity_needs_full_fetch({"email": "linked@example.com"}, user)
|
|
assert not _panel_identity_needs_full_fetch(
|
|
{"email": "linked@example.com", "telegramId": "42"},
|
|
user,
|
|
)
|
|
|
|
|
|
def test_panel_identity_view_fetches_full_user_when_list_email_missing():
|
|
panel_service = SimpleNamespace(
|
|
get_user_by_uuid=AsyncMock(
|
|
return_value={
|
|
"uuid": "panel-1",
|
|
"description": "linked@example.com\nalice",
|
|
"email": "linked@example.com",
|
|
"telegramId": 42,
|
|
}
|
|
)
|
|
)
|
|
user = SimpleNamespace(
|
|
email="linked@example.com",
|
|
telegram_id=42,
|
|
)
|
|
|
|
panel_user, missing_fields_match = asyncio.run(
|
|
_panel_identity_view_for_comparison(
|
|
panel_service,
|
|
"panel-1",
|
|
{
|
|
"uuid": "panel-1",
|
|
"description": "linked@example.com\nalice",
|
|
"telegramId": 42,
|
|
},
|
|
user,
|
|
)
|
|
)
|
|
|
|
assert panel_user["email"] == "linked@example.com"
|
|
assert not missing_fields_match
|
|
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(
|
|
user_id=1,
|
|
panel_user_uuid="panel-1",
|
|
panel_subscription_uuid="sub-1",
|
|
end_date=end_date,
|
|
is_active=True,
|
|
status_from_panel="ACTIVE",
|
|
)
|
|
|
|
assert (
|
|
_subscription_update_delta(
|
|
subscription,
|
|
{
|
|
"user_id": 1,
|
|
"panel_user_uuid": "panel-1",
|
|
"end_date": end_date + timedelta(milliseconds=500),
|
|
"is_active": True,
|
|
"status_from_panel": "ACTIVE",
|
|
},
|
|
)
|
|
== {}
|
|
)
|
|
|
|
|
|
def test_subscription_update_delta_returns_only_changed_fields():
|
|
end_date = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
|
|
subscription = Subscription(
|
|
user_id=1,
|
|
panel_user_uuid="panel-1",
|
|
panel_subscription_uuid="sub-1",
|
|
end_date=end_date,
|
|
is_active=True,
|
|
status_from_panel="ACTIVE",
|
|
)
|
|
|
|
assert _subscription_update_delta(
|
|
subscription,
|
|
{
|
|
"user_id": 2,
|
|
"panel_user_uuid": "panel-1",
|
|
"end_date": end_date + timedelta(seconds=2),
|
|
"is_active": False,
|
|
"status_from_panel": "EXPIRED",
|
|
},
|
|
) == {
|
|
"user_id": 2,
|
|
"end_date": end_date + timedelta(seconds=2),
|
|
"is_active": False,
|
|
"status_from_panel": "EXPIRED",
|
|
}
|
|
|
|
|
|
def test_lifetime_traffic_update_waits_for_time_window_for_small_delta():
|
|
now = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
|
|
settings = SimpleNamespace(
|
|
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_INTERVAL_SECONDS=3600,
|
|
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES=100 * 1024 * 1024,
|
|
)
|
|
user = SimpleNamespace(
|
|
lifetime_used_traffic_bytes=10 * 1024 * 1024,
|
|
lifetime_used_traffic_synced_at=now - timedelta(minutes=15),
|
|
)
|
|
|
|
assert not _should_update_lifetime_used_traffic(
|
|
user,
|
|
11 * 1024 * 1024,
|
|
now=now,
|
|
settings=settings,
|
|
)
|
|
assert _should_update_lifetime_used_traffic(
|
|
user,
|
|
11 * 1024 * 1024,
|
|
now=now + timedelta(hours=1),
|
|
settings=settings,
|
|
)
|
|
|
|
|
|
def test_lifetime_traffic_update_allows_large_delta_and_skips_duplicate_panel_identity():
|
|
now = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
|
|
settings = SimpleNamespace(
|
|
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_INTERVAL_SECONDS=3600,
|
|
PANEL_SYNC_LIFETIME_TRAFFIC_MIN_DELTA_BYTES=100 * 1024 * 1024,
|
|
)
|
|
user = SimpleNamespace(
|
|
lifetime_used_traffic_bytes=10 * 1024 * 1024,
|
|
lifetime_used_traffic_synced_at=now,
|
|
)
|
|
|
|
assert _should_update_lifetime_used_traffic(
|
|
user,
|
|
200 * 1024 * 1024,
|
|
now=now,
|
|
settings=settings,
|
|
)
|
|
assert not _should_update_lifetime_used_traffic(
|
|
user,
|
|
0,
|
|
now=now + timedelta(hours=2),
|
|
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,
|
|
)
|