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:
+19
-32
@@ -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 тесты
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user