197 lines
8.5 KiB
Python
197 lines
8.5 KiB
Python
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')])
|