Files
remnawave-bedolaga-telegram…/tests/services/test_account_merge_service.py
T
Fringg dc7b8dc72a feat: account linking and merge system for cabinet
Add OAuth provider linking/unlinking endpoints, merge token service
(Redis-backed, 30-min TTL), and atomic account merge executor that
transfers OAuth IDs, telegram_id, email, balance, subscriptions,
transactions, payments, referral data, and partner status between
two user accounts. Unchosen subscription is deleted from RemnaWave
with disable as fallback.

Includes 39 unit tests covering all merge scenarios.
2026-03-04 07:24:15 +03:00

644 lines
23 KiB
Python

"""Tests for app.services.account_merge_service."""
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app.services import account_merge_service
from app.services.account_merge_service import (
_build_subscription_preview,
_build_user_preview,
_compute_auth_methods,
execute_merge,
get_merge_preview,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_user(
*,
id: int = 1,
telegram_id: int | None = None,
email: str | None = None,
email_verified: bool = False,
email_verified_at: datetime | None = None,
password_hash: str | None = None,
google_id: str | None = None,
yandex_id: str | None = None,
discord_id: str | None = None,
vk_id: int | None = None,
balance_kopeks: int = 0,
username: str | None = None,
first_name: str | None = None,
status: str = 'active',
partner_status: str = 'none',
referral_code: str | None = None,
referral_commission_percent: int | None = None,
remnawave_uuid: str | None = None,
subscription: object | None = None,
created_at: datetime | None = None,
updated_at: datetime | None = None,
) -> SimpleNamespace:
return SimpleNamespace(
id=id,
telegram_id=telegram_id,
email=email,
email_verified=email_verified,
email_verified_at=email_verified_at,
password_hash=password_hash,
google_id=google_id,
yandex_id=yandex_id,
discord_id=discord_id,
vk_id=vk_id,
balance_kopeks=balance_kopeks,
username=username,
first_name=first_name,
status=status,
partner_status=partner_status,
referral_code=referral_code,
referral_commission_percent=referral_commission_percent,
remnawave_uuid=remnawave_uuid,
subscription=subscription,
created_at=created_at or datetime(2024, 1, 1, tzinfo=UTC),
updated_at=updated_at or datetime(2024, 1, 1, tzinfo=UTC),
)
def _make_subscription(
*,
user_id: int = 1,
status: str = 'active',
is_trial: bool = False,
end_date: datetime | None = None,
traffic_limit_gb: float = 100.0,
traffic_used_gb: float = 10.0,
device_limit: int = 3,
tariff_name: str = 'Basic',
autopay_enabled: bool = False,
) -> SimpleNamespace:
tariff = SimpleNamespace(name=tariff_name)
return SimpleNamespace(
user_id=user_id,
status=status,
is_trial=is_trial,
end_date=end_date or datetime(2025, 1, 1, tzinfo=UTC),
traffic_limit_gb=traffic_limit_gb,
traffic_used_gb=traffic_used_gb,
device_limit=device_limit,
tariff=tariff,
autopay_enabled=autopay_enabled,
)
def _make_db() -> SimpleNamespace:
return SimpleNamespace(
execute=AsyncMock(),
delete=AsyncMock(),
flush=AsyncMock(),
)
# ---------------------------------------------------------------------------
# _compute_auth_methods
# ---------------------------------------------------------------------------
class TestComputeAuthMethods:
def test_no_methods(self):
user = _make_user()
assert _compute_auth_methods(user) == []
def test_telegram_only(self):
user = _make_user(telegram_id=12345)
assert _compute_auth_methods(user) == ['telegram']
def test_email_only(self):
user = _make_user(email='test@example.com', password_hash='hash123')
assert _compute_auth_methods(user) == ['email']
def test_email_without_password_not_counted(self):
user = _make_user(email='test@example.com')
assert _compute_auth_methods(user) == []
def test_all_methods(self):
user = _make_user(
telegram_id=12345,
email='test@example.com',
password_hash='hash',
google_id='g123',
yandex_id='y123',
discord_id='d123',
vk_id=99999,
)
assert _compute_auth_methods(user) == ['telegram', 'email', 'google', 'yandex', 'discord', 'vk']
def test_oauth_only(self):
user = _make_user(google_id='g123', discord_id='d123')
assert _compute_auth_methods(user) == ['google', 'discord']
# ---------------------------------------------------------------------------
# _build_subscription_preview
# ---------------------------------------------------------------------------
class TestBuildSubscriptionPreview:
def test_none_subscription(self):
assert _build_subscription_preview(None) is None
def test_valid_subscription(self):
sub = _make_subscription(tariff_name='Premium')
result = _build_subscription_preview(sub)
assert result['tariff_name'] == 'Premium'
assert result['status'] == 'active'
assert result['is_trial'] is False
assert result['device_limit'] == 3
def test_subscription_without_tariff(self):
sub = _make_subscription()
sub.tariff = None
result = _build_subscription_preview(sub)
assert result['tariff_name'] is None
# ---------------------------------------------------------------------------
# _build_user_preview
# ---------------------------------------------------------------------------
class TestBuildUserPreview:
def test_basic_user(self):
user = _make_user(id=42, username='alice', email='a@b.com', balance_kopeks=5000)
result = _build_user_preview(user)
assert result['id'] == 42
assert result['username'] == 'alice'
assert result['balance_kopeks'] == 5000
assert result['subscription'] is None
def test_user_with_subscription(self):
sub = _make_subscription(user_id=1)
user = _make_user(id=1, subscription=sub)
result = _build_user_preview(user)
assert result['subscription'] is not None
assert result['subscription']['status'] == 'active'
# ---------------------------------------------------------------------------
# get_merge_preview
# ---------------------------------------------------------------------------
class TestGetMergePreview:
async def test_same_user_ids_raises(self):
db = _make_db()
with pytest.raises(ValueError, match='не могут совпадать'):
await get_merge_preview(db, 1, 1)
async def test_primary_not_found_raises(self, monkeypatch):
db = _make_db()
monkeypatch.setattr(account_merge_service, 'get_user_by_id', AsyncMock(return_value=None))
with pytest.raises(ValueError, match='Основной пользователь'):
await get_merge_preview(db, 1, 2)
async def test_secondary_not_found_raises(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, None]),
)
with pytest.raises(ValueError, match='Вторичный пользователь'):
await get_merge_preview(db, 1, 2)
async def test_success(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, username='primary', telegram_id=111)
secondary = _make_user(id=2, username='secondary', google_id='g123')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
result = await get_merge_preview(db, 1, 2)
assert result['primary']['id'] == 1
assert result['secondary']['id'] == 2
assert 'telegram' in result['primary']['auth_methods']
assert 'google' in result['secondary']['auth_methods']
# ---------------------------------------------------------------------------
# execute_merge — validation
# ---------------------------------------------------------------------------
class TestExecuteMergeValidation:
async def test_same_ids_raises(self):
db = _make_db()
with pytest.raises(ValueError, match='не могут совпадать'):
await execute_merge(db, 1, 1)
async def test_primary_not_found_raises(self, monkeypatch):
db = _make_db()
monkeypatch.setattr(account_merge_service, 'get_user_by_id', AsyncMock(return_value=None))
with pytest.raises(ValueError, match='Основной пользователь'):
await execute_merge(db, 1, 2)
async def test_secondary_not_found_raises(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, None]),
)
with pytest.raises(ValueError, match='Вторичный пользователь'):
await execute_merge(db, 1, 2)
async def test_deleted_secondary_raises(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
secondary = _make_user(id=2, status='deleted')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with pytest.raises(ValueError, match='уже удалён'):
await execute_merge(db, 1, 2)
# ---------------------------------------------------------------------------
# execute_merge — data transfer
# ---------------------------------------------------------------------------
def _patch_remnawave_delete():
return patch.object(
account_merge_service,
'_delete_remnawave_user_with_fallback',
new_callable=AsyncMock,
)
class TestExecuteMergeOAuthTransfer:
async def test_transfers_oauth_ids(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, google_id='g_primary')
secondary = _make_user(id=2, yandex_id='y_sec', discord_id='d_sec', vk_id=12345)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
# google_id stays on primary (already set)
assert result.google_id == 'g_primary'
# transferred from secondary
assert result.yandex_id == 'y_sec'
assert result.discord_id == 'd_sec'
assert result.vk_id == 12345
# cleared on secondary
assert secondary.yandex_id is None
assert secondary.discord_id is None
assert secondary.vk_id is None
async def test_does_not_overwrite_existing_oauth(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, google_id='g_primary')
secondary = _make_user(id=2, google_id='g_secondary')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
# Primary keeps its own google_id
assert result.google_id == 'g_primary'
class TestExecuteMergeTelegramTransfer:
async def test_transfers_telegram_id(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
secondary = _make_user(id=2, telegram_id=99999)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.telegram_id == 99999
assert secondary.telegram_id is None
async def test_does_not_overwrite_telegram_id(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, telegram_id=11111)
secondary = _make_user(id=2, telegram_id=22222)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.telegram_id == 11111
class TestExecuteMergeEmailTransfer:
async def test_transfers_email_and_password(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
secondary = _make_user(
id=2,
email='sec@example.com',
email_verified=True,
email_verified_at=datetime(2024, 6, 1, tzinfo=UTC),
password_hash='hash_sec',
)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.email == 'sec@example.com'
assert result.email_verified is True
assert result.password_hash == 'hash_sec'
# secondary cleared
assert secondary.email is None
assert secondary.password_hash is None
async def test_does_not_overwrite_existing_email(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, email='pri@example.com', password_hash='hash_pri')
secondary = _make_user(id=2, email='sec@example.com', password_hash='hash_sec')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.email == 'pri@example.com'
class TestExecuteMergeBalance:
async def test_sums_balances(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, balance_kopeks=5000)
secondary = _make_user(id=2, balance_kopeks=3000)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.balance_kopeks == 8000
assert secondary.balance_kopeks == 0
async def test_zero_secondary_balance_unchanged(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, balance_kopeks=5000)
secondary = _make_user(id=2, balance_kopeks=0)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.balance_kopeks == 5000
class TestExecuteMergePartnerStatus:
async def test_higher_priority_transferred(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, partner_status='none')
secondary = _make_user(id=2, partner_status='approved')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.partner_status == 'approved'
async def test_lower_priority_not_overwritten(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, partner_status='approved')
secondary = _make_user(id=2, partner_status='pending')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.partner_status == 'approved'
class TestExecuteMergeReferralCommission:
async def test_transfers_if_primary_has_none(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, referral_commission_percent=None)
secondary = _make_user(id=2, referral_commission_percent=15)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.referral_commission_percent == 15
async def test_does_not_overwrite_if_primary_has_value(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1, referral_commission_percent=20)
secondary = _make_user(id=2, referral_commission_percent=15)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
result = await execute_merge(db, 1, 2)
assert result.referral_commission_percent == 20
# ---------------------------------------------------------------------------
# execute_merge — secondary marked as deleted
# ---------------------------------------------------------------------------
class TestExecuteMergeSecondaryDeleted:
async def test_secondary_marked_deleted(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
secondary = _make_user(id=2, referral_code='REF123', email='sec@e.com')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
await execute_merge(db, 1, 2)
assert secondary.status == 'deleted'
assert secondary.referral_code is None
assert secondary.remnawave_uuid is None
assert secondary.email is None
async def test_db_flush_called(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
secondary = _make_user(id=2)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
await execute_merge(db, 1, 2)
db.flush.assert_awaited_once()
# ---------------------------------------------------------------------------
# execute_merge — subscription merge scenarios
# ---------------------------------------------------------------------------
class TestExecuteMergeSubscription:
async def test_neither_has_subscription(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
secondary = _make_user(id=2)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete() as mock_del:
await execute_merge(db, 1, 2)
mock_del.assert_not_awaited()
async def test_only_primary_has_subscription(self, monkeypatch):
db = _make_db()
sub = _make_subscription(user_id=1)
primary = _make_user(id=1, subscription=sub, remnawave_uuid='rw-primary')
secondary = _make_user(id=2, remnawave_uuid='rw-secondary')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete() as mock_del:
await execute_merge(db, 1, 2)
mock_del.assert_awaited_once_with('rw-secondary')
# secondary remnawave_uuid cleared
assert secondary.remnawave_uuid is None
async def test_only_secondary_has_subscription(self, monkeypatch):
db = _make_db()
sub = _make_subscription(user_id=2)
primary = _make_user(id=1)
secondary = _make_user(id=2, subscription=sub, remnawave_uuid='rw-secondary')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
await execute_merge(db, 1, 2)
# Subscription transferred to primary
assert sub.user_id == 1
assert primary.remnawave_uuid == 'rw-secondary'
assert secondary.remnawave_uuid is None
async def test_both_have_subscription_keep_primary(self, monkeypatch):
db = _make_db()
sub_p = _make_subscription(user_id=1)
sub_s = _make_subscription(user_id=2)
primary = _make_user(id=1, subscription=sub_p, remnawave_uuid='rw-primary')
secondary = _make_user(id=2, subscription=sub_s, remnawave_uuid='rw-secondary')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete() as mock_del:
await execute_merge(db, 1, 2, keep_subscription_from='primary')
mock_del.assert_awaited_once_with('rw-secondary')
db.delete.assert_awaited_once_with(sub_s)
async def test_both_have_subscription_keep_secondary(self, monkeypatch):
db = _make_db()
sub_p = _make_subscription(user_id=1)
sub_s = _make_subscription(user_id=2)
primary = _make_user(id=1, subscription=sub_p, remnawave_uuid='rw-primary')
secondary = _make_user(id=2, subscription=sub_s, remnawave_uuid='rw-secondary')
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete() as mock_del:
await execute_merge(db, 1, 2, keep_subscription_from='secondary')
mock_del.assert_awaited_once_with('rw-primary')
db.delete.assert_awaited_once_with(sub_p)
# Secondary subscription transferred
assert sub_s.user_id == 1
assert primary.remnawave_uuid == 'rw-secondary'
# ---------------------------------------------------------------------------
# execute_merge — bulk updates called
# ---------------------------------------------------------------------------
class TestExecuteMergeBulkUpdates:
async def test_execute_called_for_transactions_and_payments(self, monkeypatch):
db = _make_db()
primary = _make_user(id=1)
secondary = _make_user(id=2)
monkeypatch.setattr(
account_merge_service,
'get_user_by_id',
AsyncMock(side_effect=[primary, secondary]),
)
with _patch_remnawave_delete():
await execute_merge(db, 1, 2)
# Transaction + 10 payment models + 2 referral_earnings + 1 referral chain
# + 1 withdrawal_requests + 1 refresh tokens = 16 total execute calls
assert db.execute.await_count == 16