rewrite v3
This commit is contained in:
@@ -21,7 +21,6 @@ def _make_settings() -> Settings:
|
||||
payment_plans_raw="30:250,180:600",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_support_text="Напишите в поддержку",
|
||||
payment_success_text="Доступ выдан",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
payment_internal_squad_uuids_raw="uuid-1",
|
||||
@@ -52,17 +51,11 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N
|
||||
raw_value="off",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
await service.update_setting(
|
||||
key="payment_success_text",
|
||||
raw_value="Новый текст после оплаты",
|
||||
updated_by_telegram_id=1,
|
||||
)
|
||||
|
||||
snapshot = await service.get_snapshot()
|
||||
|
||||
assert snapshot.bot_brand_name == "NEW BRAND"
|
||||
assert snapshot.referral_enabled is False
|
||||
assert snapshot.payment_success_text == "Новый текст после оплаты"
|
||||
assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
|
||||
(30, 300),
|
||||
(90, 700),
|
||||
|
||||
@@ -15,23 +15,23 @@ def test_database_server_url_excludes_database_name() -> None:
|
||||
|
||||
def test_support_ticket_link_parses_private_topic_link() -> None:
|
||||
settings = Settings.model_construct(
|
||||
bot_support_ticket_link="https://t.me/c/3646494169/2/3",
|
||||
bot_support_ticket_link="https://t.me/c/1234567890/2/3",
|
||||
bot_support_ticket_chat_id_raw="",
|
||||
bot_support_ticket_thread_id=0,
|
||||
)
|
||||
|
||||
assert settings.support_ticket_chat_id == -1003646494169
|
||||
assert settings.support_ticket_chat_id == -1001234567890
|
||||
assert settings.support_ticket_message_thread_id == 2
|
||||
|
||||
|
||||
def test_payment_review_link_parses_private_topic_link() -> None:
|
||||
settings = Settings.model_construct(
|
||||
payment_review_link="https://t.me/c/3646494169/56/57",
|
||||
payment_review_link="https://t.me/c/1234567890/56/57",
|
||||
payment_review_chat_id_raw="",
|
||||
payment_review_thread_id=0,
|
||||
)
|
||||
|
||||
assert settings.payment_review_chat_id == -1003646494169
|
||||
assert settings.payment_review_chat_id == -1001234567890
|
||||
assert settings.payment_review_message_thread_id == 56
|
||||
|
||||
|
||||
|
||||
@@ -75,19 +75,6 @@ def test_format_issued_access_contains_subscription_data() -> None:
|
||||
assert "/me</code>" not in text
|
||||
|
||||
|
||||
def test_format_issued_access_includes_custom_success_text() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
expire_at=datetime(2026, 5, 21, 18, 0),
|
||||
traffic_limit_bytes=0,
|
||||
brand_name="OREOL VPN",
|
||||
success_text="Откройте приложение и импортируйте ссылку <важно>.",
|
||||
)
|
||||
|
||||
assert "Откройте приложение и импортируйте ссылку" in text
|
||||
assert "<важно>" in text
|
||||
|
||||
|
||||
def test_format_issued_access_for_renewal_mentions_extension() -> None:
|
||||
text = format_issued_access(
|
||||
subscription_url="https://example.com/sub",
|
||||
|
||||
@@ -53,10 +53,6 @@ class _StubRemnawaveClient:
|
||||
self.users_by_telegram_id = users_by_telegram_id or {}
|
||||
|
||||
async def get_user_by_uuid(self, user_uuid: str):
|
||||
for users in self.users_by_telegram_id.values():
|
||||
for user in users:
|
||||
if str(user.uuid) == user_uuid:
|
||||
return user
|
||||
raise RemnawaveApiError(status_code=404, message="not found")
|
||||
|
||||
async def get_user_by_username(self, username: str):
|
||||
@@ -414,50 +410,6 @@ async def test_create_order_generates_unique_provision_username(session_factory)
|
||||
assert second_order.extends_existing_access is False
|
||||
|
||||
|
||||
async def test_create_order_transliterates_russian_first_name_without_username(
|
||||
session_factory,
|
||||
) -> None:
|
||||
service = PaymentService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_plans_raw="30:250",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=_StubRemnawaveClient(
|
||||
created_user=make_remote_user(
|
||||
user_uuid="99999999-9999-9999-9999-999999999999",
|
||||
user_id=9,
|
||||
short_uuid="buyer999",
|
||||
username="oreol-user",
|
||||
telegram_id=1001,
|
||||
)
|
||||
),
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
|
||||
order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username=None,
|
||||
first_name="\u0410\u043b\u0435\u043a\u0441\u0435\u0439",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
|
||||
assert order.provision_username.startswith("Oreol-1001-")
|
||||
assert order.provision_username.endswith("-Aleksey")
|
||||
assert order.provision_username.isascii()
|
||||
|
||||
|
||||
async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None:
|
||||
service = PaymentService(
|
||||
settings=Settings.model_construct(
|
||||
@@ -499,68 +451,6 @@ async def test_create_order_marks_existing_access_as_renewal(session_factory) ->
|
||||
assert "продлит текущую подписку" in service.build_transfer_text(order=order)
|
||||
|
||||
|
||||
async def test_create_order_targets_external_subscription_linked_by_telegram_id(
|
||||
session_factory,
|
||||
) -> None:
|
||||
existing_remote_user = make_remote_user(
|
||||
user_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
user_id=10,
|
||||
short_uuid="external10",
|
||||
username="custom-external",
|
||||
telegram_id=1001,
|
||||
)
|
||||
remnawave_client = _StubRemnawaveClient(
|
||||
created_user=existing_remote_user,
|
||||
users_by_telegram_id={1001: [existing_remote_user]},
|
||||
)
|
||||
service = PaymentService(
|
||||
settings=Settings.model_construct(
|
||||
bot_admin_ids_raw="",
|
||||
payment_plans_raw="30:250",
|
||||
payment_transfer_text="Реквизиты",
|
||||
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111",
|
||||
payment_user_tag="BOT",
|
||||
payment_external_squad_uuid="",
|
||||
payment_username_prefix="Oreol",
|
||||
payment_plan_traffic_limit_gb=0,
|
||||
payment_plan_traffic_reset_period="NO_RESET",
|
||||
referral_discount_percent=5,
|
||||
referral_bonus_days=7,
|
||||
),
|
||||
session_factory=session_factory,
|
||||
remnawave_client=remnawave_client,
|
||||
sync_service=_StubSyncService(),
|
||||
)
|
||||
|
||||
order = await service.create_order(
|
||||
telegram_id=1001,
|
||||
username="buyer",
|
||||
first_name="Buyer",
|
||||
last_name=None,
|
||||
language_code="ru",
|
||||
plan_code="30d",
|
||||
)
|
||||
|
||||
assert order.extends_existing_access is True
|
||||
assert order.provision_username == "custom-external"
|
||||
|
||||
async with session_factory() as session:
|
||||
stored_order = await session.scalar(
|
||||
select(PaymentOrder).where(PaymentOrder.order_uuid == order.order_uuid)
|
||||
)
|
||||
|
||||
assert stored_order is not None
|
||||
assert stored_order.remnawave_user_uuid == str(existing_remote_user.uuid)
|
||||
|
||||
await service.mark_order_under_review(order_uuid=order.order_uuid, telegram_id=1001)
|
||||
access = await service.approve_order(order_uuid=order.order_uuid)
|
||||
|
||||
assert access.is_renewal is True
|
||||
assert access.username == "custom-external"
|
||||
assert remnawave_client.created_bodies == []
|
||||
assert remnawave_client.updated_bodies[-1]["username"] == "custom-external"
|
||||
|
||||
|
||||
async def test_approve_order_extends_existing_user_found_by_telegram_id(session_factory) -> None:
|
||||
existing_remote_user = make_remote_user(
|
||||
user_uuid="88888888-8888-8888-8888-888888888888",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import pytest
|
||||
from app.services.remnawave_identity import load_legacy_user_ids, validate_cached_identities, deterministic_user_uuid
|
||||
|
||||
LEGACY = 'a1111111-1111-4111-8111-111111111111'
|
||||
|
||||
@pytest.mark.parametrize('entries', [[], {}, [{'id': 42, 'uuid': LEGACY}, {'id': 42, 'uuid': LEGACY}], [{'id': 42, 'uuid': LEGACY}, {'id': 43, 'uuid': LEGACY}], [{'id': True, 'uuid': LEGACY}]])
|
||||
def test_legacy_map_rejects_ambiguous_or_empty_data(tmp_path, entries):
|
||||
path = tmp_path / 'map.json'
|
||||
path.write_text(json.dumps(entries))
|
||||
with pytest.raises(ValueError):
|
||||
load_legacy_user_ids(path)
|
||||
|
||||
def test_missing_legacy_cache_mapping_fails_closed():
|
||||
with pytest.raises(ValueError, match='cache'):
|
||||
validate_cached_identities({}, [(42, LEGACY)])
|
||||
|
||||
def test_cache_allows_legacy_and_new_deterministic_identity_after_restart():
|
||||
validate_cached_identities({42: LEGACY}, [(42, LEGACY), (99, deterministic_user_uuid(99))])
|
||||
|
||||
def test_deleted_history_requires_complete_snapshot_and_is_preserved():
|
||||
assert validate_cached_identities({42: LEGACY}, [(7, 'b2222222-2222-4222-8222-222222222222')],
|
||||
remote_identities=[(42, LEGACY)]) == 1
|
||||
|
||||
|
||||
def test_reused_remote_id_rejects_historical_uuid():
|
||||
with pytest.raises(ValueError, match='cache'):
|
||||
validate_cached_identities({}, [(7, LEGACY)], remote_identities=[(7, deterministic_user_uuid(7))])
|
||||
|
||||
|
||||
def test_deleted_cache_uuid_cannot_belong_to_another_legacy_id():
|
||||
with pytest.raises(ValueError, match='another'):
|
||||
validate_cached_identities({42: LEGACY}, [(7, LEGACY)], remote_identities=[])
|
||||
|
||||
|
||||
def test_deleted_cache_uuid_cannot_belong_to_another_live_id():
|
||||
with pytest.raises(ValueError, match='another'):
|
||||
validate_cached_identities({}, [(7, LEGACY)], remote_identities=[(99, LEGACY)])
|
||||
|
||||
|
||||
def test_matching_seed_does_not_override_conflicting_live_identity():
|
||||
with pytest.raises(ValueError, match='conflicts'):
|
||||
validate_cached_identities({42: LEGACY}, [(42, LEGACY)],
|
||||
remote_identities=[(42, deterministic_user_uuid(42))])
|
||||
|
||||
def test_reused_seeded_id_cannot_hide_changed_short_uuid_behind_legacy_alias():
|
||||
with pytest.raises(ValueError, match='short UUID'):
|
||||
validate_cached_identities({42: LEGACY}, [(42, LEGACY, 'old-short')],
|
||||
remote_identities=[(42, LEGACY, 'new-short')])
|
||||
@@ -0,0 +1,196 @@
|
||||
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')])
|
||||
Reference in New Issue
Block a user