rewrite v3

This commit is contained in:
2026-10-28 13:01:59 +03:00
parent da24ed8ba7
commit 372781b956
15 changed files with 527 additions and 1158 deletions
-7
View File
@@ -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),
+4 -4
View File
@@ -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
-13
View File
@@ -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 "&lt;важно&gt;" in text
def test_format_issued_access_for_renewal_mentions_extension() -> None:
text = format_issued_access(
subscription_url="https://example.com/sub",
-110
View File
@@ -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",
+49
View File
@@ -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')])
+196
View File
@@ -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')])