Files
remnawave-bedolaga-telegram…/tests/services/test_modem_service.py
gy9vin da46e39c61 refactor(modem): рефакторинг модуля управления модемом
Рефакторинг архитектуры управления модемом:

- Создан сервис app/services/modem_service.py:
  - ModemService с бизнес-логикой подключения/отключения
  - ModemError enum для типизации ошибок
  - ModemPriceInfo, ModemOperationResult dataclass'ы
  - Константы MODEM_WARNING_DAYS_* для уровней предупреждений
2025-12-25 18:44:27 +03:00

419 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Тесты для ModemService - управление модемом в подписке.
"""
import pytest
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
from types import SimpleNamespace
from app.services.modem_service import (
ModemService,
ModemError,
ModemAvailabilityResult,
ModemPriceResult,
ModemEnableResult,
ModemDisableResult,
get_modem_service,
MODEM_WARNING_DAYS_CRITICAL,
MODEM_WARNING_DAYS_INFO,
)
def create_mock_settings():
"""Создаёт мок настроек приложения."""
settings = MagicMock()
settings.is_modem_enabled.return_value = True
settings.get_modem_price_per_month.return_value = 10000 # 100 рублей
settings.get_modem_period_discount.return_value = 0
return settings
def create_sample_user():
"""Создаёт пример пользователя."""
user = SimpleNamespace(
id=1,
telegram_id=123456789,
balance_kopeks=50000, # 500 рублей
language="ru",
subscription=None,
)
return user
def create_sample_subscription():
"""Создаёт пример подписки."""
subscription = SimpleNamespace(
id=1,
user_id=1,
is_trial=False,
modem_enabled=False,
device_limit=2,
end_date=datetime.utcnow() + timedelta(days=30),
updated_at=datetime.utcnow(),
)
return subscription
def create_trial_subscription():
"""Создаёт триальную подписку."""
subscription = SimpleNamespace(
id=2,
user_id=1,
is_trial=True,
modem_enabled=False,
device_limit=1,
end_date=datetime.utcnow() + timedelta(days=7),
updated_at=datetime.utcnow(),
)
return subscription
def create_modem_service(monkeypatch):
"""Создаёт ModemService с замоканными настройками."""
mock_settings = create_mock_settings()
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
return ModemService(), mock_settings
class TestModemServiceAvailability:
"""Тесты проверки доступности модема."""
def test_check_availability_no_subscription(self, monkeypatch):
"""Модем недоступен без подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.subscription = None
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.NO_SUBSCRIPTION
assert not result.modem_enabled
def test_check_availability_trial_subscription(self, monkeypatch):
"""Модем недоступен для триальной подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
trial_subscription = create_trial_subscription()
sample_user.subscription = trial_subscription
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.TRIAL_SUBSCRIPTION
assert not result.modem_enabled
def test_check_availability_modem_disabled_in_settings(self, monkeypatch):
"""Модем недоступен, если отключён в настройках."""
modem_service, mock_settings = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
mock_settings.is_modem_enabled.return_value = False
result = modem_service.check_availability(sample_user)
assert not result.available
assert result.error == ModemError.MODEM_DISABLED
def test_check_availability_success(self, monkeypatch):
"""Модем доступен для платной подписки."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user)
assert result.available
assert result.error is None
assert not result.modem_enabled
def test_check_availability_for_enable_already_enabled(self, monkeypatch):
"""Нельзя подключить уже подключенный модем."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user, for_enable=True)
assert not result.available
assert result.error == ModemError.ALREADY_ENABLED
assert result.modem_enabled
def test_check_availability_for_disable_not_enabled(self, monkeypatch):
"""Нельзя отключить неподключенный модем."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = False
sample_user.subscription = sample_subscription
result = modem_service.check_availability(sample_user, for_disable=True)
assert not result.available
assert result.error == ModemError.NOT_ENABLED
assert not result.modem_enabled
class TestModemServicePricing:
"""Тесты расчёта цены модема."""
def test_calculate_price_one_month(self, monkeypatch):
"""Расчёт цены на 1 месяц."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=30)
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 10000
assert result.final_price == 10000
assert result.charged_months == 1
assert result.discount_percent == 0
assert not result.has_discount
def test_calculate_price_three_months(self, monkeypatch):
"""Расчёт цены на 3 месяца."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 30000 # 3 * 10000
assert result.charged_months == 3
def test_calculate_price_with_discount(self, monkeypatch):
"""Расчёт цены со скидкой."""
modem_service, mock_settings = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.end_date = datetime.utcnow() + timedelta(days=90)
mock_settings.get_modem_period_discount.return_value = 10 # 10% скидка
result = modem_service.calculate_price(sample_subscription)
assert result.base_price == 30000
assert result.discount_percent == 10
assert result.discount_amount == 3000
assert result.final_price == 27000
assert result.has_discount
class TestModemServiceBalance:
"""Тесты проверки баланса."""
def test_check_balance_sufficient(self, monkeypatch):
"""Баланса достаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 50000
has_funds, missing = modem_service.check_balance(sample_user, 10000)
assert has_funds
assert missing == 0
def test_check_balance_insufficient(self, monkeypatch):
"""Баланса недостаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 5000
has_funds, missing = modem_service.check_balance(sample_user, 10000)
assert not has_funds
assert missing == 5000
def test_check_balance_zero_price(self, monkeypatch):
"""Нулевая цена - всегда достаточно."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_user.balance_kopeks = 0
has_funds, missing = modem_service.check_balance(sample_user, 0)
assert has_funds
assert missing == 0
class TestModemServicePeriodWarning:
"""Тесты предупреждений о сроке действия."""
def test_warning_critical(self, monkeypatch):
"""Критическое предупреждение при <= 7 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(7) == "critical"
assert modem_service.get_period_warning_level(5) == "critical"
assert modem_service.get_period_warning_level(1) == "critical"
def test_warning_info(self, monkeypatch):
"""Информационное предупреждение при <= 30 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(30) == "info"
assert modem_service.get_period_warning_level(15) == "info"
assert modem_service.get_period_warning_level(8) == "info"
def test_warning_none(self, monkeypatch):
"""Нет предупреждения при > 30 днях."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_period_warning_level(31) is None
assert modem_service.get_period_warning_level(60) is None
assert modem_service.get_period_warning_level(90) is None
class TestModemServiceEnable:
"""Тесты подключения модема."""
async def test_enable_modem_success(self, monkeypatch):
"""Успешное подключение модема."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 50000
mock_db = AsyncMock()
mock_subtract = AsyncMock(return_value=True)
mock_create_transaction = AsyncMock()
mock_update_remnawave = AsyncMock()
monkeypatch.setattr(
'app.services.modem_service.subtract_user_balance',
mock_subtract
)
monkeypatch.setattr(
'app.services.modem_service.create_transaction',
mock_create_transaction
)
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
result = await modem_service.enable_modem(
mock_db, sample_user, sample_subscription
)
assert result.success
assert result.error is None
assert result.charged_amount == 10000
assert sample_subscription.modem_enabled is True
assert sample_subscription.device_limit == 3 # было 2, стало 3
async def test_enable_modem_insufficient_funds(self, monkeypatch):
"""Недостаточно средств для подключения."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 1000 # недостаточно
mock_db = AsyncMock()
result = await modem_service.enable_modem(
mock_db, sample_user, sample_subscription
)
assert not result.success
assert result.error == ModemError.INSUFFICIENT_FUNDS
async def test_enable_modem_charge_error(self, monkeypatch):
"""Ошибка списания средств."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_user.subscription = sample_subscription
sample_user.balance_kopeks = 50000
mock_db = AsyncMock()
mock_subtract = AsyncMock(return_value=False) # ошибка списания
monkeypatch.setattr(
'app.services.modem_service.subtract_user_balance',
mock_subtract
)
result = await modem_service.enable_modem(
mock_db, sample_user, sample_subscription
)
assert not result.success
assert result.error == ModemError.CHARGE_ERROR
class TestModemServiceDisable:
"""Тесты отключения модема."""
async def test_disable_modem_success(self, monkeypatch):
"""Успешное отключение модема."""
modem_service, _ = create_modem_service(monkeypatch)
sample_user = create_sample_user()
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
sample_subscription.device_limit = 3
sample_user.subscription = sample_subscription
mock_db = AsyncMock()
mock_update_remnawave = AsyncMock()
modem_service._subscription_service.update_remnawave_user = mock_update_remnawave
result = await modem_service.disable_modem(
mock_db, sample_user, sample_subscription
)
assert result.success
assert result.error is None
assert sample_subscription.modem_enabled is False
assert sample_subscription.device_limit == 2 # было 3, стало 2
class TestModemServiceSingleton:
"""Тесты singleton паттерна."""
def test_get_modem_service_returns_same_instance(self, monkeypatch):
"""get_modem_service возвращает один и тот же экземпляр."""
# Сбрасываем глобальный экземпляр
import app.services.modem_service as modem_module
modem_module._modem_service = None
mock_settings = create_mock_settings()
monkeypatch.setattr('app.services.modem_service.settings', mock_settings)
service1 = get_modem_service()
service2 = get_modem_service()
assert service1 is service2
class TestModemEnabledGetter:
"""Тесты безопасного получения статуса модема."""
def test_get_modem_enabled_true(self, monkeypatch):
"""Модем включён."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = True
assert modem_service.get_modem_enabled(sample_subscription) is True
def test_get_modem_enabled_false(self, monkeypatch):
"""Модем выключен."""
modem_service, _ = create_modem_service(monkeypatch)
sample_subscription = create_sample_subscription()
sample_subscription.modem_enabled = False
assert modem_service.get_modem_enabled(sample_subscription) is False
def test_get_modem_enabled_none_subscription(self, monkeypatch):
"""Подписка None."""
modem_service, _ = create_modem_service(monkeypatch)
assert modem_service.get_modem_enabled(None) is False
def test_get_modem_enabled_no_attribute(self, monkeypatch):
"""У подписки нет атрибута modem_enabled."""
modem_service, _ = create_modem_service(monkeypatch)
subscription = SimpleNamespace(id=1) # без modem_enabled
assert modem_service.get_modem_enabled(subscription) is False