import json import httpx import pytest from app.db.models import RemnawaveUser from app.services.remnawave_client import RemnawaveApiClient, RemnawaveApiError from tests.helpers import make_remote_user LEGACY_UUID = 'a1111111-1111-4111-8111-111111111111' def user_payload(user_id=42): payload = make_remote_user( user_uuid=LEGACY_UUID, user_id=user_id, short_uuid=f'short{user_id}', username=f'user{user_id}', telegram_id=777, ).model_dump(by_alias=True, mode='json') payload.pop('uuid') return payload def client_for(handler, legacy=None): http = httpx.AsyncClient(base_url='https://panel.test/api/', transport=httpx.MockTransport(handler)) api = RemnawaveApiClient(base_url='https://panel.test', api_token='', client=http, api_version=3, legacy_user_ids=legacy or {42: LEGACY_UUID}) return api, http async def test_telegram_search_uses_filtered_stream_and_all_pages(): requests = [] def handler(request): requests.append(request) assert request.url.path == '/api/users/stream' assert request.url.params['telegramId'] == '777' next_page = 'cursor' in request.url.params return httpx.Response(200, json={'response': { 'users': [user_payload(43 if next_page else 42)], 'hasMore': not next_page, 'nextCursor': None if next_page else '42', }}) api, http = client_for(handler) async with http: users = await api.get_users_by_telegram_id(777) assert [u.id for u in users] == [42, 43] assert str(users[0].uuid) == LEGACY_UUID assert requests[1].url.params['cursor'] == '42' async def test_renewal_preserves_local_identity_and_sends_only_numeric_identifier(): def handler(request): if request.method == 'PATCH': assert json.loads(request.content) == {'id': 42, 'expireAt': '2027-01-01T00:00:00Z'} else: assert request.url.path == '/api/users/42' return httpx.Response(200, json={'response': user_payload()}) api, http = client_for(handler) async with http: user = await api.update_user({'uuid': LEGACY_UUID, 'username': 'user42', 'expireAt': '2027-01-01T00:00:00Z'}) assert str(user.uuid) == LEGACY_UUID assert user.id == 42 async def test_history_and_hwid_use_ids_and_preserve_local_foreign_keys(): timestamp = '2026-10-19T00:00:00Z' def handler(request): path = request.url.path if path.endswith('subscription-request-history'): assert '/users/42/' in path data = {'total': 1, 'records': [{'id': 1, 'userId': 42, 'requestAt': timestamp}]} elif request.method == 'POST': body = json.loads(request.content) assert body['userId'] == 42 and 'userUuid' not in body data = {'total': 0, 'devices': []} else: assert path == '/api/hwid/devices/42' data = {'total': 1, 'devices': [{'hwid': 'test-device-001', 'userId': 42, 'createdAt': timestamp, 'updatedAt': timestamp}]} return httpx.Response(200, json={'response': data}) api, http = client_for(handler) async with http: history = await api.get_user_subscription_history(LEGACY_UUID) devices = await api.get_user_hwid_devices(LEGACY_UUID) await api.delete_user_hwid_device(user_uuid=LEGACY_UUID, hwid='test-device-001') await api.delete_all_user_hwid_devices(LEGACY_UUID) assert str(history.records[0].user_uuid) == LEGACY_UUID assert str(devices.devices[0].user_uuid) == LEGACY_UUID async def test_uuid_resolution_uses_numeric_id(): def handler(request): assert json.loads(request.content) == {'id': 42} return httpx.Response(200, json={'response': {'id': 42, 'username': 'user42', 'shortUuid': 'short42'}}) api, http = client_for(handler) async with http: resolved = await api.resolve_user(LEGACY_UUID) assert str(resolved.uuid) == LEGACY_UUID async def test_new_user_alias_survives_restart_before_local_cache_sync(): def handler(request): if request.url.path.endswith('/stream'): data = {'users': [user_payload(99)], 'hasMore': False, 'nextCursor': None} else: assert request.url.path in ('/api/users', '/api/users/99') data = user_payload(99) return httpx.Response(200, json={'response': data}) api, http = client_for(handler) async with http: first = await api.create_user({'username': 'user99'}) restarted, http2 = client_for(handler) async with http2: restored = await restarted.get_user_by_uuid(str(first.uuid)) assert restored.uuid == first.uuid assert restored.id == 99 async def test_missing_identity_never_updates_an_unrelated_user(): def handler(request): assert request.method == 'GET' return httpx.Response(200, json={'response': {'users': [], 'hasMore': False, 'nextCursor': None}}) api, http = client_for(handler) async with http: with pytest.raises(RemnawaveApiError) as error: await api.update_user({'uuid': 'b2222222-2222-4222-8222-222222222222', 'status': 'DISABLED'}) assert error.value.status_code == 404 async def test_stream_repeated_cursor_fails_instead_of_looping(): api, http = client_for(lambda request: httpx.Response(200, json={'response': { 'users': [], 'hasMore': True, 'nextCursor': '42'}})) async with http: with pytest.raises(RemnawaveApiError, match='cursor'): await api.get_users_by_telegram_id(777) async def test_v3_client_full_sync_preserves_existing_database_identity(session_factory): from app.config import Settings from app.services.sync_service import SyncService from sqlalchemy import select payload = user_payload() def handler(request): if request.url.path.endswith('/stream'): result = {'users': [payload], 'hasMore': False, 'nextCursor': None} else: result = {'users': [payload], 'total': 1} return httpx.Response(200, json={'response': result}) api, http = client_for(handler) service = SyncService(settings=Settings.model_construct(bot_admin_ids_raw='', sync_subscription_history=False), session_factory=session_factory, remnawave_client=api) async with http: initial = await service.sync_users_for_telegram(telegram_id=777, username='owner', first_name='Owner', last_name=None, language_code='ru') original_id = initial[0].record.id assert initial[0].record.rw_uuid == LEGACY_UUID assert await service.sync_all_users(batch_size=100) == 1 refreshed = await service.refresh_cached_users_for_telegram(telegram_id=777) assert refreshed[0].record.id == original_id assert refreshed[0].record.owner_telegram_user_id == initial[0].record.owner_telegram_user_id async with session_factory() as session: rows = list((await session.scalars(select(RemnawaveUser))).all()) assert len(rows) == 1 assert rows[0].rw_id == 42 assert rows[0].rw_uuid == LEGACY_UUID async def test_renewal_rejects_rename_before_patch(): def handler(request): assert request.method == 'GET' return httpx.Response(200, json={'response': user_payload()}) api, http = client_for(handler) async with http: with pytest.raises(ValueError, match='rename'): await api.update_user({'uuid': LEGACY_UUID, 'username': 'other-user'}) async def test_startup_preserves_deleted_cache_but_never_routes_it_to_numeric_id(): deleted_uuid='b2222222-2222-4222-8222-222222222222' def handler(request): assert request.method=='GET' and request.url.path=='/api/users/stream' return httpx.Response(200, json={'response': {'users':[user_payload()], 'hasMore':False,'nextCursor':None}}) api,http=client_for(handler) async with http: assert await api.validate_cached_identities([(42,LEGACY_UUID),(7,deleted_uuid)])==1 with pytest.raises(RemnawaveApiError) as error: await api.update_user({'uuid':deleted_uuid,'status':'DISABLED'}) assert error.value.status_code==404 async def test_startup_fails_when_deleted_cached_id_is_present_in_remote_stream(): def handler(request): return httpx.Response(200, json={'response': {'users':[user_payload(7)],'hasMore':False,'nextCursor':None}}) api,http=client_for(handler) async with http: with pytest.raises(ValueError, match='cache'): await api.validate_cached_identities([(7,'b2222222-2222-4222-8222-222222222222')])