test: expand PricingEngine tests + update CryptoBot payment tests

- Add 45 unit tests covering tariff/classic modes, discounts, edge cases
- Update CryptoBot payment tests for new PricingEngine integration
- Add original_total identity tests for both pricing modes
This commit is contained in:
Fringg
2026-03-13 05:12:47 +03:00
parent 75dbd2b4fc
commit 3a3bd9d499
3 changed files with 534 additions and 60 deletions
+19 -32
View File
@@ -197,28 +197,17 @@ async def create_subscription(
### Документация кода ### Документация кода
```python ```python
async def calculate_subscription_price( from app.services.pricing_engine import PricingEngine
period_days: int,
traffic_gb: int, pricing = PricingEngine.calculate_renewal_price(
devices_count: int, subscription=subscription,
servers_count: int period_days=30,
) -> int: user=user,
""" )
Рассчитывает стоимость подписки. # pricing.final_total — стоимость в копейках
# pricing.original_total — цена до скидок
Args: # pricing.promo_group_discount — скидка промогруппы
period_days: Период подписки в днях # pricing.promo_offer_discount — скидка промо-оффера
traffic_gb: Лимит трафика в ГБ (0 = безлимит)
devices_count: Количество устройств
servers_count: Количество серверов
Returns:
Стоимость в копейках
Raises:
ValueError: Если переданы некорректные параметры
"""
# implementation
``` ```
### Обработка ошибок ### Обработка ошибок
@@ -341,20 +330,18 @@ python main.py
### Тестирование компонентов ### Тестирование компонентов
```python ```python
# tests/test_subscription_service.py # tests/services/test_pricing_engine.py
import pytest import pytest
from app.services.subscription_service import SubscriptionService from app.services.pricing_engine import PricingEngine
@pytest.mark.asyncio def test_calculate_renewal_price():
async def test_calculate_price(): pricing = PricingEngine.calculate_renewal_price(
price = await SubscriptionService.calculate_subscription_price( subscription=mock_subscription,
period_days=30, period_days=30,
traffic_gb=100, user=mock_user,
devices_count=3,
servers_count=1
) )
assert price > 0 assert pricing.final_total > 0
assert isinstance(price, int) assert isinstance(pricing.final_total, int)
``` ```
### Integration тесты ### Integration тесты
+19 -15
View File
@@ -20,6 +20,7 @@ os.environ.setdefault('BOT_TOKEN', 'test-token')
from app.config import settings from app.config import settings
from app.database.models import PaymentMethod from app.database.models import PaymentMethod
from app.services.payment.cryptobot import CryptoBotPaymentMixin from app.services.payment.cryptobot import CryptoBotPaymentMixin
from app.services.pricing_engine import PricingEngine
from app.services.subscription_renewal_service import ( from app.services.subscription_renewal_service import (
SubscriptionRenewalPricing, SubscriptionRenewalPricing,
SubscriptionRenewalResult, SubscriptionRenewalResult,
@@ -348,7 +349,7 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
module = sys.modules['app.services.payment.cryptobot'] module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin() mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5) subscription = types.SimpleNamespace(id=77, connected_squads=[], traffic_limit_gb=100, device_limit=5, tariff=None, tariff_id=None)
user = types.SimpleNamespace(id=5, balance_kopeks=7000, subscription=subscription) user = types.SimpleNamespace(id=5, balance_kopeks=7000, subscription=subscription)
pricing_model = SubscriptionRenewalPricing( pricing_model = SubscriptionRenewalPricing(
@@ -385,9 +386,9 @@ async def test_cryptobot_renewal_uses_pricing_snapshot(monkeypatch):
) )
async def fail_calculate(*args, **kwargs): async def fail_calculate(*args, **kwargs):
raise AssertionError('calculate_pricing should not be called when snapshot is present') raise AssertionError('PricingEngine.calculate_renewal_price should not be called when snapshot is present')
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate) monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fail_calculate)
captured: dict[str, Any] = {} captured: dict[str, Any] = {}
@@ -431,7 +432,7 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
module = sys.modules['app.services.payment.cryptobot'] module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin() mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3) subscription = types.SimpleNamespace(id=55, connected_squads=[], traffic_limit_gb=50, device_limit=3, tariff=None, tariff_id=None)
user = types.SimpleNamespace(id=8, balance_kopeks=4000, subscription=subscription) user = types.SimpleNamespace(id=8, balance_kopeks=4000, subscription=subscription)
descriptor = build_payment_descriptor( descriptor = build_payment_descriptor(
@@ -451,25 +452,26 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id) sys.modules, 'app.services.payment_service', types.SimpleNamespace(get_user_by_id=fake_get_user_by_id)
) )
# Recalculated price is LOWER than descriptor — user benefits, should proceed
recalculated_pricing = SubscriptionRenewalPricing( recalculated_pricing = SubscriptionRenewalPricing(
period_days=30, period_days=30,
period_id='days:30', period_id='days:30',
months=1, months=1,
base_original_total=5200, base_original_total=4800,
discounted_total=5200, discounted_total=4800,
final_total=5200, final_total=4800,
promo_discount_value=0, promo_discount_value=0,
promo_discount_percent=0, promo_discount_percent=0,
overall_discount_percent=0, overall_discount_percent=0,
per_month=5200, per_month=4800,
server_ids=[], server_ids=[],
details={}, details={},
) )
async def fake_calculate(db, u, sub, period): async def fake_calculate(self, db, sub, period_days, *, user=None):
return recalculated_pricing return recalculated_pricing
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fake_calculate) monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fake_calculate)
captured: dict[str, Any] = {} captured: dict[str, Any] = {}
@@ -499,8 +501,10 @@ async def test_cryptobot_renewal_accepts_changed_pricing_without_snapshot(monkey
) )
assert result is True assert result is True
assert captured['pricing'].final_total == 5000 # With C-4 fix: recalculated price (4800) < descriptor (5000) → use recalculated
assert captured['charge'] == 4000 assert captured['pricing'].final_total == 4800
# M-5 fix: required_balance = max(0, final_total - missing) = max(0, 4800 - 1000) = 3800
assert captured['charge'] == 3800
@pytest.mark.anyio('asyncio') @pytest.mark.anyio('asyncio')
@@ -508,7 +512,7 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch
module = sys.modules['app.services.payment.cryptobot'] module = sys.modules['app.services.payment.cryptobot']
mixin = CryptoBotPaymentMixin() mixin = CryptoBotPaymentMixin()
subscription = types.SimpleNamespace(id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4) subscription = types.SimpleNamespace(id=91, connected_squads=[], traffic_limit_gb=80, device_limit=4, tariff=None, tariff_id=None)
user = types.SimpleNamespace(id=21, balance_kopeks=6000, subscription=subscription) user = types.SimpleNamespace(id=21, balance_kopeks=6000, subscription=subscription)
pricing_model = SubscriptionRenewalPricing( pricing_model = SubscriptionRenewalPricing(
@@ -560,9 +564,9 @@ async def test_cryptobot_webhook_uses_inline_payload_when_db_missing(monkeypatch
) )
async def fail_calculate(*args, **kwargs): async def fail_calculate(*args, **kwargs):
raise AssertionError('calculate_pricing should not be called') raise AssertionError('PricingEngine.calculate_renewal_price should not be called')
monkeypatch.setattr(module.renewal_service, 'calculate_pricing', fail_calculate) monkeypatch.setattr(PricingEngine, 'calculate_renewal_price', fail_calculate)
captured: dict[str, Any] = {} captured: dict[str, Any] = {}
+496 -13
View File
@@ -59,12 +59,35 @@ class TestStackedDiscounts:
assert g_val == 0 assert g_val == 0
assert o_val == 1500 assert o_val == 1500
def test_only_group(self):
result, gd, od = PricingEngine.apply_stacked_discounts(10000, 20, 0)
assert result == 8000
assert gd == 2000
assert od == 0
def test_both_100_percent(self):
result, gd, od = PricingEngine.apply_stacked_discounts(10000, 100, 100)
assert result == 0
assert gd == 10000
assert od == 0 # offer discount on 0 is 0
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
def _make_server(price_kopeks=5000, is_available=True, is_full=False, allowed_promo_groups=None): _server_id_counter = 0
def _make_server(
price_kopeks=5000, is_available=True, is_full=False, allowed_promo_groups=None, server_id=None, squad_uuid=None
):
global _server_id_counter
if server_id is None:
_server_id_counter += 1
server_id = _server_id_counter
server = MagicMock() server = MagicMock()
server.id = server_id
server.squad_uuid = squad_uuid
server.price_kopeks = price_kopeks server.price_kopeks = price_kopeks
server.is_available = is_available server.is_available = is_available
server.is_full = is_full server.is_full = is_full
@@ -77,8 +100,8 @@ class TestCalculateServersPrice:
async def test_available_server(self): async def test_available_server(self):
engine = PricingEngine() engine = PricingEngine()
db = AsyncMock() db = AsyncMock()
server = _make_server(price_kopeks=5000) server = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server): with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None) total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 5000 assert total == 5000
assert len(details) == 1 assert len(details) == 1
@@ -88,8 +111,8 @@ class TestCalculateServersPrice:
async def test_unavailable_server_uses_real_price(self): async def test_unavailable_server_uses_real_price(self):
engine = PricingEngine() engine = PricingEngine()
db = AsyncMock() db = AsyncMock()
server = _make_server(price_kopeks=7000, is_available=False) server = _make_server(price_kopeks=7000, is_available=False, squad_uuid='uuid-1')
with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server): with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None) total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 7000 # NOT 0! assert total == 7000 # NOT 0!
assert details[0]['status'] == 'unavailable' assert details[0]['status'] == 'unavailable'
@@ -98,8 +121,8 @@ class TestCalculateServersPrice:
async def test_full_server_uses_real_price(self): async def test_full_server_uses_real_price(self):
engine = PricingEngine() engine = PricingEngine()
db = AsyncMock() db = AsyncMock()
server = _make_server(price_kopeks=3000, is_full=True) server = _make_server(price_kopeks=3000, is_full=True, squad_uuid='uuid-1')
with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server): with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None) total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 3000 # NOT 0! assert total == 3000 # NOT 0!
@@ -107,7 +130,7 @@ class TestCalculateServersPrice:
async def test_server_not_found_zero_price(self): async def test_server_not_found_zero_price(self):
engine = PricingEngine() engine = PricingEngine()
db = AsyncMock() db = AsyncMock()
with patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=None): with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[]):
total, details = await engine._calculate_servers_price(['uuid-orphan'], db, promo_group_id=None) total, details = await engine._calculate_servers_price(['uuid-orphan'], db, promo_group_id=None)
assert total == 0 assert total == 0
assert details[0]['status'] == 'not_found' assert details[0]['status'] == 'not_found'
@@ -116,12 +139,51 @@ class TestCalculateServersPrice:
async def test_multiple_servers(self): async def test_multiple_servers(self):
engine = PricingEngine() engine = PricingEngine()
db = AsyncMock() db = AsyncMock()
s1 = _make_server(price_kopeks=5000) s1 = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
s2 = _make_server(price_kopeks=3000, is_available=False) s2 = _make_server(price_kopeks=3000, is_available=False, squad_uuid='uuid-2')
with patch('app.services.pricing_engine.get_server_squad_by_uuid', side_effect=[s1, s2]): with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[s1, s2]):
total, details = await engine._calculate_servers_price(['uuid-1', 'uuid-2'], db, promo_group_id=None) total, details = await engine._calculate_servers_price(['uuid-1', 'uuid-2'], db, promo_group_id=None)
assert total == 8000 assert total == 8000
@pytest.mark.asyncio
async def test_server_ids_and_prices_alignment_with_not_found(self):
"""Verify server_ids and servers_individual_prices have same length when some servers are not found."""
engine = PricingEngine()
db = AsyncMock()
s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-1')
s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-3')
# uuid-orphan not in batch result — should be excluded from BOTH lists
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[s1, s3]):
total, details = await engine._calculate_servers_price(
['uuid-1', 'uuid-orphan', 'uuid-3'], db, promo_group_id=None
)
assert total == 8000 # 5000 + 0 + 3000
assert len(details) == 3
# Verify id fields
assert details[0]['id'] == 10
assert details[1]['id'] is None
assert details[2]['id'] == 30
@pytest.mark.asyncio
async def test_db_exception_path(self):
"""Verify batch DB exception returns price=0 and status=error for all UUIDs."""
engine = PricingEngine()
db = AsyncMock()
with patch('app.services.pricing_engine.get_server_squads_by_uuids', side_effect=RuntimeError('DB error')):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=None)
assert total == 0
assert details[0]['status'] == 'error'
assert details[0]['id'] is None
@pytest.mark.asyncio
async def test_empty_uuids_returns_empty(self):
"""Verify empty UUIDs list returns 0 total and empty details."""
engine = PricingEngine()
db = AsyncMock()
total, details = await engine._calculate_servers_price([], db, promo_group_id=None)
assert total == 0
assert details == []
class TestCalculateTrafficPrice: class TestCalculateTrafficPrice:
def test_base_only(self): def test_base_only(self):
@@ -163,6 +225,7 @@ class TestCalculateRenewalPriceTariffMode:
subscription.tariff = MagicMock() subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 19000} subscription.tariff.period_prices = {'30': 19000}
subscription.tariff.device_limit = 2 subscription.tariff.device_limit = 2
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 2 subscription.tariff.id = 2
subscription.device_limit = 2 subscription.device_limit = 2
subscription.connected_squads = [] subscription.connected_squads = []
@@ -190,6 +253,7 @@ class TestCalculateRenewalPriceTariffMode:
subscription.tariff = MagicMock() subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 19000} subscription.tariff.period_prices = {'30': 19000}
subscription.tariff.device_limit = 2 subscription.tariff.device_limit = 2
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 2 subscription.tariff.id = 2
subscription.device_limit = 4 subscription.device_limit = 4
subscription.connected_squads = [] subscription.connected_squads = []
@@ -208,6 +272,30 @@ class TestCalculateRenewalPriceTariffMode:
assert result.devices_price == 10000 assert result.devices_price == 10000
assert result.final_total == 29000 assert result.final_total == 29000
@pytest.mark.asyncio
async def test_tariff_device_price_from_tariff(self):
"""When tariff has device_price_kopeks set, use it instead of settings."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = 2
subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 10000}
subscription.tariff.device_limit = 2
subscription.tariff.device_price_kopeks = 3000 # tariff-specific price
subscription.tariff.id = 2
subscription.device_limit = 4 # 2 extra devices
user = MagicMock()
user.promo_group = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
):
ms.PRICE_PER_DEVICE = 5000 # should NOT be used
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.devices_price == 6000 # 2 extra × 3000 (tariff price)
assert result.final_total == 16000 # 10000 + 6000
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_tariff_with_discounts(self): async def test_tariff_with_discounts(self):
engine = PricingEngine() engine = PricingEngine()
@@ -217,6 +305,7 @@ class TestCalculateRenewalPriceTariffMode:
subscription.tariff = MagicMock() subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 20000} subscription.tariff.period_prices = {'30': 20000}
subscription.tariff.device_limit = 1 subscription.tariff.device_limit = 1
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 1 subscription.tariff.id = 1
subscription.device_limit = 1 subscription.device_limit = 1
promo_group = MagicMock() promo_group = MagicMock()
@@ -244,6 +333,7 @@ class TestCalculateRenewalPriceTariffMode:
subscription.tariff = MagicMock() subscription.tariff = MagicMock()
subscription.tariff.period_prices = {'30': 19000} subscription.tariff.period_prices = {'30': 19000}
subscription.tariff.device_limit = 1 subscription.tariff.device_limit = 1
subscription.tariff.device_price_kopeks = None
subscription.tariff.id = 1 subscription.tariff.id = 1
subscription.device_limit = 1 subscription.device_limit = 1
user = MagicMock() user = MagicMock()
@@ -257,6 +347,30 @@ class TestCalculateRenewalPriceTariffMode:
assert result.base_price == 0 assert result.base_price == 0
assert result.final_total == 0 assert result.final_total == 0
@pytest.mark.asyncio
async def test_tariff_device_limit_below_tariff_included(self):
"""When subscription device_limit < tariff device_limit, extra_devices is 0 (not negative)."""
engine = PricingEngine()
db = AsyncMock()
tariff = MagicMock()
tariff.id = 1
tariff.period_prices = {'30': 10000}
tariff.device_price_kopeks = 5000
tariff.device_limit = 5
sub = MagicMock()
sub.tariff_id = 1
sub.tariff = tariff
sub.device_limit = 2 # less than tariff's 5
user = MagicMock()
user.promo_group = None
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0):
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
assert result.devices_price == 0
assert result.final_total == 10000
assert result.breakdown.get('extra_devices') == 0
class TestCalculateRenewalPriceClassicMode: class TestCalculateRenewalPriceClassicMode:
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -275,9 +389,9 @@ class TestCalculateRenewalPriceClassicMode:
user.promo_group_id = None user.promo_group_id = None
user.promo_offer_discount_percent = 0 user.promo_offer_discount_percent = 0
user.promo_offer_expires_at = None user.promo_offer_expires_at = None
server = _make_server(price_kopeks=5000) server = _make_server(price_kopeks=5000, squad_uuid='uuid-1')
with ( with (
patch('app.services.pricing_engine.get_server_squad_by_uuid', return_value=server), patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0), patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms, patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 29000}), patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 29000}),
@@ -286,6 +400,7 @@ class TestCalculateRenewalPriceClassicMode:
ms.get_traffic_price.return_value = 3000 ms.get_traffic_price.return_value = 3000
ms.PRICE_PER_DEVICE = 0 ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 2 ms.DEFAULT_DEVICE_LIMIT = 2
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user) result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.is_tariff_mode is False assert result.is_tariff_mode is False
assert result.base_price == 29000 assert result.base_price == 29000
@@ -321,6 +436,7 @@ class TestCalculateRenewalPriceClassicMode:
ms.get_traffic_price.return_value = 0 ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 0 ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 2 ms.DEFAULT_DEVICE_LIMIT = 2
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user) result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.final_total == 7200 assert result.final_total == 7200
assert result.promo_group_discount == 2000 assert result.promo_group_discount == 2000
@@ -350,6 +466,7 @@ class TestCalculateRenewalPriceClassicMode:
ms.get_traffic_price.return_value = 0 ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 0 ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1 ms.DEFAULT_DEVICE_LIMIT = 1
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user) result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.base_price == 99000 assert result.base_price == 99000
assert result.final_total == 99000 assert result.final_total == 99000
@@ -377,7 +494,373 @@ class TestCalculateRenewalPriceClassicMode:
ms.get_traffic_price.return_value = 0 ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 3000 ms.PRICE_PER_DEVICE = 3000
ms.DEFAULT_DEVICE_LIMIT = 2 ms.DEFAULT_DEVICE_LIMIT = 2
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user) result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
# 5 - 2 = 3 extra devices * 3000 = 9000 # 5 - 2 = 3 extra devices * 3000 = 9000
assert result.devices_price == 9000 assert result.devices_price == 9000
assert result.final_total == 19000 assert result.final_total == 19000
@pytest.mark.asyncio
async def test_classic_breakdown_server_ids_and_prices_alignment(self):
"""Verify server_ids and servers_individual_prices have same length when orphaned UUIDs present."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = ['uuid-found', 'uuid-orphan', 'uuid-found2']
subscription.traffic_limit_gb = 0
subscription.purchased_traffic_gb = 0
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
s1 = _make_server(price_kopeks=5000, server_id=10, squad_uuid='uuid-found')
s3 = _make_server(price_kopeks=3000, server_id=30, squad_uuid='uuid-found2')
with (
patch(
'app.services.pricing_engine.get_server_squads_by_uuids',
return_value=[s1, s3],
),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.get_traffic_price.return_value = 0
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
ms.is_traffic_fixed.return_value = False
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
# Verify breakdown alignment — both lists must have same length
ids = result.breakdown['server_ids']
prices = result.breakdown['servers_individual_prices']
assert len(ids) == len(prices), f'server_ids({len(ids)}) != prices({len(prices)})'
assert ids == [10, 30]
assert prices == [5000, 3000]
# Total servers_price includes only found servers
assert result.servers_price == 8000
@pytest.mark.asyncio
async def test_classic_fixed_traffic_ignores_subscription_values(self):
"""When is_traffic_fixed() is True, use fixed limit and zero purchased."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = 999 # should be ignored
subscription.purchased_traffic_gb = 500 # should be ignored
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.is_traffic_fixed.return_value = True
ms.get_fixed_traffic_limit.return_value = 50
ms.get_traffic_price.side_effect = lambda gb: {50: 4000}.get(gb, 0)
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.traffic_price == 4000
assert result.breakdown['purchased_traffic_gb'] == 0
@pytest.mark.asyncio
async def test_classic_default_traffic_limit_when_none(self):
"""When subscription.traffic_limit_gb is None, use DEFAULT_TRAFFIC_LIMIT_GB."""
engine = PricingEngine()
db = AsyncMock()
subscription = MagicMock()
subscription.tariff_id = None
subscription.tariff = None
subscription.connected_squads = []
subscription.traffic_limit_gb = None # should fallback to default
subscription.purchased_traffic_gb = 0
subscription.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
with (
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
ms.get_traffic_price.side_effect = lambda gb: {50: 4000}.get(gb, 0)
ms.PRICE_PER_DEVICE = 0
ms.DEFAULT_DEVICE_LIMIT = 1
result = await engine.calculate_renewal_price(db, subscription, 30, user=user)
assert result.traffic_price == 4000
@pytest.mark.asyncio
async def test_classic_multi_month_period(self):
"""90-day period multiplies monthly prices by 3."""
engine = PricingEngine()
db = AsyncMock()
sub = MagicMock()
sub.tariff_id = None
sub.tariff = None
sub.connected_squads = ['uuid-s1']
sub.traffic_limit_gb = 50
sub.purchased_traffic_gb = 0
sub.device_limit = 1
user = MagicMock()
user.promo_group = None
user.promo_group_id = None
server = _make_server(price_kopeks=3000, squad_uuid='uuid-s1')
with (
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {90: 27000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.DEFAULT_DEVICE_LIMIT = 1
ms.PRICE_PER_DEVICE = 5000
ms.get_traffic_price.return_value = 2000
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
result = await engine.calculate_renewal_price(db, sub, 90, user=user)
assert result.period_days == 90
assert result.base_price == 27000
# Servers and traffic are monthly x 3 months
assert result.servers_price == 3000 * 3
assert result.traffic_price == 2000 * 3
assert result.devices_price == 0 # no extra devices
assert result.final_total == 27000 + 9000 + 6000
@pytest.mark.asyncio
async def test_classic_per_category_different_discounts(self):
"""Different discount percents per category (period=10%, servers=20%, traffic=30%, devices=0%)."""
engine = PricingEngine()
db = AsyncMock()
sub = MagicMock()
sub.tariff_id = None
sub.tariff = None
sub.connected_squads = ['uuid-s1']
sub.traffic_limit_gb = 100
sub.purchased_traffic_gb = 0
sub.device_limit = 3 # 2 extra devices
user = MagicMock()
promo_group = MagicMock()
def discount_by_category(category, period_days):
return {'period': 10, 'servers': 20, 'traffic': 30, 'devices': 0}[category]
promo_group.get_discount_percent = MagicMock(side_effect=discount_by_category)
user.promo_group = promo_group
user.promo_group_id = 1
server = _make_server(price_kopeks=6000, squad_uuid='uuid-s1')
with (
patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=0),
patch('app.services.pricing_engine.settings') as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.DEFAULT_DEVICE_LIMIT = 1
ms.PRICE_PER_DEVICE = 4000
ms.get_traffic_price.return_value = 5000
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 100
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
# period: 10000 * 10% = 1000 discount -> 9000
assert result.base_price == 9000
# servers: 6000 * 20% = 1200 discount -> 4800 per month x 1
assert result.servers_price == 4800
# traffic: 5000 * 30% = 1500 discount -> 3500 per month x 1
assert result.traffic_price == 3500
# devices: 2 extra x 4000 = 8000, 0% discount -> 8000
assert result.devices_price == 8000
# total group discount = 1000 + 1200 + 1500 + 0 = 3700
assert result.promo_group_discount == 3700
assert result.final_total == 9000 + 4800 + 3500 + 8000
class TestServerPromoGroupFiltering:
@pytest.mark.asyncio
async def test_server_not_allowed_for_promo_group(self):
"""Server with restricted promo groups still charges real price."""
engine = PricingEngine()
db = AsyncMock()
pg_mock = MagicMock()
pg_mock.id = 99
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1', allowed_promo_groups=[pg_mock])
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=5)
assert total == 5000 # real price still charged
assert details[0]['status'] == 'not_allowed'
@pytest.mark.asyncio
async def test_server_empty_allowed_groups_is_open(self):
"""Server with empty allowed_promo_groups is available to all."""
engine = PricingEngine()
db = AsyncMock()
server = _make_server(price_kopeks=5000, squad_uuid='uuid-1', allowed_promo_groups=[])
with patch('app.services.pricing_engine.get_server_squads_by_uuids', return_value=[server]):
total, details = await engine._calculate_servers_price(['uuid-1'], db, promo_group_id=5)
assert total == 5000
assert details[0]['status'] == 'available'
class TestFromPayloadRoundTrip:
def test_renewal_pricing_snapshot_roundtrip(self):
"""RenewalPricing serialized via asdict() is correctly restored by from_payload()."""
import dataclasses
from app.services.subscription_renewal_service import SubscriptionRenewalPricing
pricing = RenewalPricing(
base_price=29000,
servers_price=5000,
traffic_price=3000,
devices_price=0,
promo_group_discount=2000,
promo_offer_discount=800,
final_total=34200,
period_days=30,
is_tariff_mode=False,
breakdown={
'server_ids': [1, 2],
'servers_individual_prices': [5000, 3000],
'offer_discount_pct': 5,
},
)
payload = dataclasses.asdict(pricing)
restored = SubscriptionRenewalPricing.from_payload(payload)
assert restored.final_total == 34200
assert restored.period_days == 30
assert restored.promo_discount_value == 800 # mapped from promo_offer_discount
assert restored.server_ids == [1, 2]
assert restored.details.get('servers_individual_prices') == [5000, 3000]
assert restored.months == 1
assert restored.per_month == 34200
# ---------------------------------------------------------------------------
# Patch-target constants used in new tests below
# ---------------------------------------------------------------------------
SERVERS_BATCH_PATH = 'app.services.pricing_engine.get_server_squads_by_uuids'
SETTINGS_PATH = 'app.services.pricing_engine.settings'
class TestFromPayloadLegacyRoundTrip:
def test_legacy_to_payload_roundtrip(self):
"""Legacy SubscriptionRenewalPricing.to_payload() -> from_payload() preserves all fields."""
from app.services.subscription_renewal_service import SubscriptionRenewalPricing, build_renewal_period_id
original = SubscriptionRenewalPricing(
period_days=30,
period_id=build_renewal_period_id(30),
months=1,
base_original_total=15000,
discounted_total=12000,
final_total=10800,
promo_discount_value=1200,
promo_discount_percent=10,
overall_discount_percent=28,
per_month=10800,
server_ids=[1, 2, 3],
details={'servers_individual_prices': [5000, 3000, 2000]},
)
payload = original.to_payload()
restored = SubscriptionRenewalPricing.from_payload(payload)
assert restored.period_days == original.period_days
assert restored.period_id == original.period_id
assert restored.months == original.months
assert restored.base_original_total == original.base_original_total
assert restored.discounted_total == original.discounted_total
assert restored.final_total == original.final_total
assert restored.promo_discount_value == original.promo_discount_value
assert restored.promo_discount_percent == original.promo_discount_percent
assert restored.overall_discount_percent == original.overall_discount_percent
assert restored.per_month == original.per_month
assert restored.server_ids == original.server_ids
class TestOriginalPriceIdentity:
@pytest.mark.asyncio
async def test_tariff_mode_identity(self):
"""final_total + promo_group_discount + promo_offer_discount == undiscounted subtotal."""
engine = PricingEngine()
db = AsyncMock()
tariff = MagicMock()
tariff.id = 1
tariff.period_prices = {'30': 20000}
tariff.device_price_kopeks = 3000
tariff.device_limit = 1
sub = MagicMock()
sub.tariff_id = 1
sub.tariff = tariff
sub.device_limit = 3 # 2 extra
user = MagicMock()
promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=25)
user.promo_group = promo_group
with patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=15):
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
subtotal = 20000 + 2 * 3000 # 26000
assert result.final_total + result.promo_group_discount + result.promo_offer_discount == subtotal
@pytest.mark.asyncio
async def test_classic_mode_identity(self):
"""final_total + promo_group_discount + promo_offer_discount == undiscounted total in classic mode."""
engine = PricingEngine()
db = AsyncMock()
sub = MagicMock()
sub.tariff_id = None
sub.tariff = None
sub.connected_squads = ['uuid-s1']
sub.traffic_limit_gb = 50
sub.purchased_traffic_gb = 0
sub.device_limit = 2 # 1 extra
user = MagicMock()
promo_group = MagicMock()
promo_group.get_discount_percent = MagicMock(return_value=20)
user.promo_group = promo_group
user.promo_group_id = 1
server = _make_server(price_kopeks=4000, squad_uuid='uuid-s1')
with (
patch(SERVERS_BATCH_PATH, return_value=[server]),
patch('app.services.pricing_engine.get_user_active_promo_discount_percent', return_value=10),
patch(SETTINGS_PATH) as ms,
patch('app.services.pricing_engine.CLASSIC_PERIOD_PRICES', {30: 10000}),
patch('app.services.pricing_engine.PERIOD_PRICES', {}),
):
ms.PRICE_PER_DEVICE = 5000
ms.DEFAULT_DEVICE_LIMIT = 1
ms.get_traffic_price.return_value = 3000
ms.is_traffic_fixed.return_value = False
ms.DEFAULT_TRAFFIC_LIMIT_GB = 50
result = await engine.calculate_renewal_price(db, sub, 30, user=user)
# Reconstruct original undiscounted total
original = result.final_total + result.promo_group_discount + result.promo_offer_discount
# original should equal base_original + servers_original + traffic_original + devices_original
expected_original = 10000 + 4000 + 3000 + 5000 # 22000
assert original == expected_original