1) Отображение скидки на кнопках (красивое!)

2) У промогрупп появится приоритет
3) У пользователя может быть несколько промогрупп, но влиять будет только с наивысшим приоритетом
4) К промокодам можно будет добавить промогруппу. Все активировавшие промокод получат её
5) При выводе пользователей с промогруппой будет также выводиться ссылка на каждого. Можно будет отследить сливы промокодов "для своих". Я в целом это добавлю во все места, где пользователь выводится в админке
6) Исправить баг исчезновения триалки при пополнении
7) Исправить падающие тесты и добавить новых
8) Трафик: 0 ГБ в тестовой подписке исправить на Трафик: Безлимит
This commit is contained in:
Pavel Stryuk
2025-11-04 13:05:02 +01:00
parent 16f4285948
commit 427011fe41
39 changed files with 3263 additions and 213 deletions

View File

@@ -6,9 +6,14 @@ import os
import sys
import types
from datetime import datetime, timezone
from pathlib import Path
import pytest
# Add project root to Python path for imports
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# Подменяем параметры подключения к БД, чтобы SQLAlchemy не требовал aiosqlite.
os.environ.setdefault("DATABASE_MODE", "postgresql")
os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://user:pass@localhost/test_db")

0
tests/crud/__init__.py Normal file
View File

View File

@@ -0,0 +1,142 @@
"""
Tests for Promocode CRUD operations - focus on promo_group_id integration
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from types import SimpleNamespace
from app.database.crud.promocode import (
create_promocode,
get_promocode_by_code,
get_promocodes_list,
)
from app.database.models import PromoCodeType, PromoCode
# Import fixtures
from tests.fixtures.promocode_fixtures import (
sample_promo_group,
sample_promocode_promo_group,
mock_db_session,
)
async def test_create_promocode_with_promo_group_id(
sample_promo_group,
mock_db_session,
):
"""
Test creating a promocode with promo_group_id
Scenario:
- Create PROMO_GROUP type promocode
- promo_group_id should be saved
- Database operations should be called correctly
"""
# Execute
promocode = await create_promocode(
db=mock_db_session,
code="TESTGROUP",
type=PromoCodeType.PROMO_GROUP,
balance_bonus_kopeks=0,
subscription_days=0,
max_uses=100,
valid_until=None,
created_by=1,
promo_group_id=sample_promo_group.id
)
# Assertions
assert promocode.code == "TESTGROUP"
assert promocode.type == PromoCodeType.PROMO_GROUP.value
assert promocode.promo_group_id == sample_promo_group.id
# Verify database operations
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_awaited_once()
mock_db_session.refresh.assert_awaited_once()
async def test_create_promocode_without_promo_group_id(mock_db_session):
"""
Test creating a promocode without promo_group_id (other types)
Scenario:
- Create BALANCE type promocode
- promo_group_id should be None
"""
# Execute
promocode = await create_promocode(
db=mock_db_session,
code="BALANCE100",
type=PromoCodeType.BALANCE,
balance_bonus_kopeks=10000,
subscription_days=0,
max_uses=50,
valid_until=None,
created_by=1,
promo_group_id=None
)
# Assertions
assert promocode.code == "BALANCE100"
assert promocode.type == PromoCodeType.BALANCE.value
assert promocode.promo_group_id is None
async def test_get_promocode_by_code_loads_promo_group(
sample_promocode_promo_group,
mock_db_session,
):
"""
Test that get_promocode_by_code loads promo_group relationship
Scenario:
- Query promocode by code
- Verify selectinload was used for promo_group
- Verify promo_group data is accessible
"""
# Setup mock result
mock_result = AsyncMock()
mock_result.scalar_one_or_none = lambda: sample_promocode_promo_group
mock_db_session.execute = AsyncMock(return_value=mock_result)
# Execute
promocode = await get_promocode_by_code(mock_db_session, "VIPGROUP")
# Assertions
assert promocode is not None
assert promocode.code == "VIPGROUP"
assert promocode.promo_group is not None
assert promocode.promo_group.name == "Test VIP Group"
# Verify execute was called (query was executed)
mock_db_session.execute.assert_awaited_once()
async def test_get_promocodes_list_loads_promo_groups(
sample_promocode_promo_group,
mock_db_session,
):
"""
Test that get_promocodes_list loads promo_group relationships
Scenario:
- Query list of promocodes
- Verify selectinload was used for promo_group
- Verify all promocodes have accessible promo_group data
"""
# Setup mock result
mock_result = AsyncMock()
mock_result.scalars = MagicMock(return_value=MagicMock(all=MagicMock(return_value=[sample_promocode_promo_group])))
mock_db_session.execute = AsyncMock(return_value=mock_result)
# Execute
promocodes = await get_promocodes_list(mock_db_session, offset=0, limit=10)
# Assertions
assert len(promocodes) == 1
assert promocodes[0].promo_group is not None
assert promocodes[0].promo_group.name == "Test VIP Group"
# Verify execute was called
mock_db_session.execute.assert_awaited_once()

1
tests/fixtures/__init__.py vendored Normal file
View File

@@ -0,0 +1 @@
"""Test fixtures package"""

206
tests/fixtures/promocode_fixtures.py vendored Normal file
View File

@@ -0,0 +1,206 @@
"""
Fixtures for promocode and promo group testing
"""
import pytest
from types import SimpleNamespace
from unittest.mock import AsyncMock
from datetime import datetime, timedelta
from app.database.models import PromoCodeType
@pytest.fixture
def sample_promo_group():
"""Sample PromoGroup object for testing"""
return SimpleNamespace(
id=1,
name="Test VIP Group",
priority=50,
server_discount_percent=20,
traffic_discount_percent=15,
device_discount_percent=10,
period_discounts={30: 10, 60: 15, 90: 20},
is_default=False,
auto_assign_total_spent_kopeks=None,
auto_assign_enabled=False,
addon_discount_enabled=True
)
@pytest.fixture
def sample_user():
"""Sample User object for testing"""
return SimpleNamespace(
id=1,
telegram_id=123456789,
username="testuser",
full_name="Test User",
balance_kopeks=0,
language="ru",
has_had_paid_subscription=False,
total_spent_kopeks=0
)
@pytest.fixture
def sample_promocode_balance():
"""Balance type promocode"""
return SimpleNamespace(
id=1,
code="BALANCE100",
type=PromoCodeType.BALANCE.value,
balance_bonus_kopeks=10000, # 100 rubles
subscription_days=0,
max_uses=100,
current_uses=10,
is_active=True,
promo_group_id=None,
promo_group=None,
valid_until=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
created_by=1
)
@pytest.fixture
def sample_promocode_subscription():
"""Subscription days type promocode"""
return SimpleNamespace(
id=2,
code="SUB30",
type=PromoCodeType.SUBSCRIPTION_DAYS.value,
balance_bonus_kopeks=0,
subscription_days=30,
max_uses=50,
current_uses=5,
is_active=True,
promo_group_id=None,
promo_group=None,
valid_until=datetime.utcnow() + timedelta(days=60),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
created_by=1
)
@pytest.fixture
def sample_promocode_promo_group(sample_promo_group):
"""Promo group type promocode"""
return SimpleNamespace(
id=3,
code="VIPGROUP",
type=PromoCodeType.PROMO_GROUP.value,
balance_bonus_kopeks=0,
subscription_days=0,
max_uses=100,
current_uses=20,
is_active=True,
promo_group_id=sample_promo_group.id,
promo_group=sample_promo_group,
valid_until=None,
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
created_by=1
)
@pytest.fixture
def sample_promocode_invalid():
"""Invalid/expired promocode"""
return SimpleNamespace(
id=4,
code="EXPIRED",
type=PromoCodeType.BALANCE.value,
balance_bonus_kopeks=5000,
subscription_days=0,
max_uses=10,
current_uses=10, # Used up
is_active=False,
promo_group_id=None,
promo_group=None,
valid_until=datetime.utcnow() - timedelta(days=1), # Expired
created_at=datetime.utcnow() - timedelta(days=30),
updated_at=datetime.utcnow(),
created_by=1
)
@pytest.fixture
def mock_db_session():
"""Mock AsyncSession"""
db = AsyncMock()
db.commit = AsyncMock()
db.rollback = AsyncMock()
db.refresh = AsyncMock()
db.get = AsyncMock()
db.execute = AsyncMock()
db.add = AsyncMock()
return db
@pytest.fixture
def mock_has_user_promo_group():
"""Mock has_user_promo_group function"""
return AsyncMock(return_value=False)
@pytest.fixture
def mock_add_user_to_promo_group():
"""Mock add_user_to_promo_group function"""
return AsyncMock()
@pytest.fixture
def mock_get_promo_group_by_id(sample_promo_group):
"""Mock get_promo_group_by_id function"""
return AsyncMock(return_value=sample_promo_group)
@pytest.fixture
def mock_get_user_by_id(sample_user):
"""Mock get_user_by_id function"""
return AsyncMock(return_value=sample_user)
@pytest.fixture
def mock_get_promocode_by_code():
"""Mock get_promocode_by_code function"""
return AsyncMock()
@pytest.fixture
def mock_check_user_promocode_usage():
"""Mock check_user_promocode_usage function"""
return AsyncMock(return_value=False)
@pytest.fixture
def mock_create_promocode_use():
"""Mock create_promocode_use function"""
return AsyncMock()
@pytest.fixture
def mock_remnawave_service():
"""Mock RemnaWaveService"""
service = AsyncMock()
service.create_remnawave_user = AsyncMock()
service.update_remnawave_user = AsyncMock()
return service
@pytest.fixture
def mock_subscription_service():
"""Mock SubscriptionService"""
service = AsyncMock()
service.create_remnawave_user = AsyncMock()
service.update_remnawave_user = AsyncMock()
return service
# Helper function to create a valid promocode property mock
def make_promocode_valid(promocode):
"""Helper to make promocode appear valid (is_valid property)"""
promocode.is_valid = True
return promocode

View File

View File

@@ -0,0 +1,338 @@
"""
Integration tests for promo code with promo group full workflow
These tests validate the complete flow from creating a promo group,
creating a promocode, to activating it and verifying the user receives
the promo group assignment.
"""
import pytest
from unittest.mock import AsyncMock, patch
from types import SimpleNamespace
from app.services.promocode_service import PromoCodeService
from app.database.models import PromoCodeType
# Import fixtures
from tests.fixtures.promocode_fixtures import (
sample_promo_group,
sample_user,
sample_promocode_promo_group,
mock_db_session,
)
async def test_promo_group_promocode_full_workflow(
monkeypatch,
sample_user,
sample_promo_group,
mock_db_session,
):
"""
Integration test: Full workflow of promo group promocode
Flow:
1. Promo group exists (VIP Group, priority 50)
2. Admin creates PROMO_GROUP type promocode
3. User activates promocode
4. User is added to promo group
5. Usage is recorded
6. Counter is incremented
This test validates the entire integration between:
- Promocode CRUD
- Promo group CRUD
- User promo group CRUD
- Promocode service
"""
# Setup: Create a PROMO_GROUP promocode
promocode = SimpleNamespace(
id=1,
code="INTEGRATIONTEST",
type=PromoCodeType.PROMO_GROUP.value,
balance_bonus_kopeks=0,
subscription_days=0,
max_uses=100,
current_uses=0,
is_active=True,
is_valid=True,
promo_group_id=sample_promo_group.id,
promo_group=sample_promo_group,
valid_until=None
)
# Mock all CRUD operations
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=promocode)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
get_promo_group_mock = AsyncMock(return_value=sample_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute: User activates promocode
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"INTEGRATIONTEST"
)
# Verify: Activation successful
assert result["success"] is True
assert "Test VIP Group" in result["description"]
# Verify: All steps were executed in correct order
get_user_mock.assert_awaited_once_with(mock_db_session, sample_user.id)
get_promocode_mock.assert_awaited_once_with(mock_db_session, "INTEGRATIONTEST")
check_usage_mock.assert_awaited_once_with(mock_db_session, sample_user.id, promocode.id)
# Verify: Promo group assignment flow
get_promo_group_mock.assert_awaited_once_with(mock_db_session, sample_promo_group.id)
has_promo_group_mock.assert_awaited_once_with(
mock_db_session,
sample_user.id,
sample_promo_group.id
)
add_promo_group_mock.assert_awaited_once_with(
mock_db_session,
sample_user.id,
sample_promo_group.id,
assigned_by="promocode"
)
# Verify: Usage recorded
create_usage_mock.assert_awaited_once_with(
mock_db_session,
promocode.id,
sample_user.id
)
# Verify: Counter incremented
assert promocode.current_uses == 1
# Verify: Database committed
mock_db_session.commit.assert_awaited()
async def test_duplicate_promo_group_assignment_edge_case(
monkeypatch,
sample_user,
sample_promo_group,
mock_db_session,
):
"""
Edge case: User already has promo group from previous promocode
Scenario:
1. User previously activated a promo group promocode
2. User already has the VIP Group
3. User activates another promocode for same group
4. System should not duplicate the assignment
5. Activation should still succeed
"""
promocode = SimpleNamespace(
id=2,
code="DUPLICATE",
type=PromoCodeType.PROMO_GROUP.value,
balance_bonus_kopeks=0,
subscription_days=0,
max_uses=100,
current_uses=5,
is_active=True,
is_valid=True,
promo_group_id=sample_promo_group.id,
promo_group=sample_promo_group,
valid_until=None
)
# Mock CRUD operations
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=promocode)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
# User ALREADY HAS this promo group
has_promo_group_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"DUPLICATE"
)
# Verify: Activation still successful
assert result["success"] is True
# Verify: add_user_to_promo_group was NOT called (no duplicate)
add_promo_group_mock.assert_not_awaited()
# Verify: Usage was still recorded
create_usage_mock.assert_awaited_once()
# Verify: Counter still incremented
assert promocode.current_uses == 6
async def test_missing_promo_group_graceful_failure(
monkeypatch,
sample_user,
mock_db_session,
):
"""
Edge case: Promocode references deleted/non-existent promo group
Scenario:
1. Promocode was created with promo_group_id=999
2. Promo group was later deleted
3. User activates promocode
4. System should handle gracefully (log warning, continue)
5. Promocode effects should still apply
6. No promo group is assigned (can't assign non-existent group)
"""
# Promocode with non-existent promo_group_id
promocode = SimpleNamespace(
id=3,
code="ORPHANED",
type=PromoCodeType.PROMO_GROUP.value,
balance_bonus_kopeks=0,
subscription_days=0,
max_uses=10,
current_uses=0,
is_active=True,
is_valid=True,
promo_group_id=999, # Non-existent
promo_group=None,
valid_until=None
)
# Mock CRUD operations
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=promocode)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
# Promo group NOT FOUND
get_promo_group_mock = AsyncMock(return_value=None)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"ORPHANED"
)
# Verify: Activation STILL successful (graceful degradation)
assert result["success"] is True
# Verify: Attempted to fetch promo group
get_promo_group_mock.assert_awaited_once_with(mock_db_session, 999)
# Verify: add_user_to_promo_group was NOT called (group doesn't exist)
add_promo_group_mock.assert_not_awaited()
# Verify: Usage was still recorded (promocode still works)
create_usage_mock.assert_awaited_once()
# Verify: Counter still incremented
assert promocode.current_uses == 1

View File

@@ -0,0 +1,584 @@
"""
Tests for PromoCodeService - focus on promo group integration
"""
import pytest
from unittest.mock import AsyncMock, patch
from types import SimpleNamespace
from app.services.promocode_service import PromoCodeService
from app.database.models import PromoCodeType
# Import fixtures
from tests.fixtures.promocode_fixtures import (
sample_promo_group,
sample_user,
sample_promocode_promo_group,
mock_db_session,
mock_has_user_promo_group,
mock_add_user_to_promo_group,
mock_get_promo_group_by_id,
mock_get_user_by_id,
mock_get_promocode_by_code,
mock_check_user_promocode_usage,
mock_create_promocode_use,
)
async def test_activate_promo_group_promocode_success(
monkeypatch,
sample_user,
sample_promo_group,
sample_promocode_promo_group,
mock_db_session,
):
"""
Test successful activation of PROMO_GROUP type promocode
Scenario:
- User activates valid promo group promocode
- User doesn't have this promo group yet
- User is successfully added to promo group
- Result includes promo group name
"""
# Make promocode valid
sample_promocode_promo_group.is_valid = True
# Mock CRUD functions
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
get_promo_group_mock = AsyncMock(return_value=sample_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"VIPGROUP"
)
# Assertions
assert result["success"] is True
assert "Test VIP Group" in result["description"]
assert result["promocode"]["promo_group_id"] == sample_promo_group.id
# Verify promo group was fetched
get_promo_group_mock.assert_awaited_once_with(
mock_db_session,
sample_promo_group.id
)
# Verify user promo group check
has_promo_group_mock.assert_awaited_once_with(
mock_db_session,
sample_user.id,
sample_promo_group.id
)
# Verify promo group assignment
add_promo_group_mock.assert_awaited_once_with(
mock_db_session,
sample_user.id,
sample_promo_group.id,
assigned_by="promocode"
)
# Verify usage recorded
create_usage_mock.assert_awaited_once_with(
mock_db_session,
sample_promocode_promo_group.id,
sample_user.id
)
# Verify counter incremented
assert sample_promocode_promo_group.current_uses == 21
mock_db_session.commit.assert_awaited()
async def test_activate_promo_group_user_already_has_group(
monkeypatch,
sample_user,
sample_promo_group,
sample_promocode_promo_group,
mock_db_session,
):
"""
Test activation when user already has the promo group
Scenario:
- User activates promo group promocode
- User already has this promo group
- add_user_to_promo_group should NOT be called
- Activation still succeeds
"""
sample_promocode_promo_group.is_valid = True
# Mock CRUD functions
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
# User ALREADY HAS the promo group
has_promo_group_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"VIPGROUP"
)
# Assertions
assert result["success"] is True
# Verify promo group assignment was NOT called
add_promo_group_mock.assert_not_awaited()
# But usage was still recorded
create_usage_mock.assert_awaited_once()
async def test_activate_promo_group_group_not_found(
monkeypatch,
sample_user,
sample_promocode_promo_group,
mock_db_session,
):
"""
Test activation when promo group doesn't exist (deleted/invalid)
Scenario:
- Promocode references non-existent promo_group_id
- get_promo_group_by_id returns None
- Warning is logged but activation doesn't fail
- Promocode effects still apply (graceful degradation)
"""
sample_promocode_promo_group.is_valid = True
# Mock CRUD functions
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
# Promo group NOT FOUND
get_promo_group_mock = AsyncMock(return_value=None)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"VIPGROUP"
)
# Assertions
assert result["success"] is True # Still succeeds!
# Verify promo group was attempted to fetch
get_promo_group_mock.assert_awaited_once()
# Verify promo group assignment was NOT called (because group not found)
add_promo_group_mock.assert_not_awaited()
# But usage was still recorded
create_usage_mock.assert_awaited_once()
async def test_activate_promo_group_assignment_error(
monkeypatch,
sample_user,
sample_promo_group,
sample_promocode_promo_group,
mock_db_session,
):
"""
Test activation when promo group assignment fails
Scenario:
- add_user_to_promo_group raises exception
- Error is logged but activation doesn't fail
- Promocode usage is still recorded (graceful degradation)
"""
sample_promocode_promo_group.is_valid = True
# Mock CRUD functions
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
get_promo_group_mock = AsyncMock(return_value=sample_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
# add_user_to_promo_group RAISES EXCEPTION
add_promo_group_mock = AsyncMock(side_effect=Exception("Database error"))
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"VIPGROUP"
)
# Assertions
assert result["success"] is True # Still succeeds!
# Verify promo group assignment was attempted
add_promo_group_mock.assert_awaited_once()
# But usage was still recorded
create_usage_mock.assert_awaited_once()
async def test_activate_promo_group_assigned_by_value(
monkeypatch,
sample_user,
sample_promo_group,
sample_promocode_promo_group,
mock_db_session,
):
"""
Test that assigned_by parameter is correctly set to 'promocode'
Scenario:
- Verify add_user_to_promo_group is called with assigned_by="promocode"
"""
sample_promocode_promo_group.is_valid = True
# Mock CRUD functions
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
get_promo_group_mock = AsyncMock(return_value=sample_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
await service.activate_promocode(
mock_db_session,
sample_user.id,
"VIPGROUP"
)
# Verify assigned_by="promocode"
add_promo_group_mock.assert_awaited_once_with(
mock_db_session,
sample_user.id,
sample_promo_group.id,
assigned_by="promocode" # Critical assertion
)
async def test_activate_promo_group_description_includes_group_name(
monkeypatch,
sample_user,
sample_promo_group,
sample_promocode_promo_group,
mock_db_session,
):
"""
Test that result description includes promo group name
Scenario:
- When promo group is assigned, description should include group name
"""
sample_promocode_promo_group.is_valid = True
# Mock CRUD functions
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
get_promo_group_mock = AsyncMock(return_value=sample_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"VIPGROUP"
)
# Verify description includes promo group name
assert "Назначена промогруппа: Test VIP Group" in result["description"]
async def test_promocode_data_includes_promo_group_id(
monkeypatch,
sample_user,
sample_promo_group,
sample_promocode_promo_group,
mock_db_session,
):
"""
Test that returned promocode data includes promo_group_id
Scenario:
- Verify result["promocode"]["promo_group_id"] is present
"""
sample_promocode_promo_group.is_valid = True
# Mock CRUD functions
get_user_mock = AsyncMock(return_value=sample_user)
monkeypatch.setattr(
'app.services.promocode_service.get_user_by_id',
get_user_mock
)
get_promocode_mock = AsyncMock(return_value=sample_promocode_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promocode_by_code',
get_promocode_mock
)
check_usage_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.check_user_promocode_usage',
check_usage_mock
)
get_promo_group_mock = AsyncMock(return_value=sample_promo_group)
monkeypatch.setattr(
'app.services.promocode_service.get_promo_group_by_id',
get_promo_group_mock
)
has_promo_group_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
'app.services.promocode_service.has_user_promo_group',
has_promo_group_mock
)
add_promo_group_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.add_user_to_promo_group',
add_promo_group_mock
)
create_usage_mock = AsyncMock()
monkeypatch.setattr(
'app.services.promocode_service.create_promocode_use',
create_usage_mock
)
# Execute
service = PromoCodeService()
result = await service.activate_promocode(
mock_db_session,
sample_user.id,
"VIPGROUP"
)
# Verify promocode data structure
assert "promocode" in result
assert "promo_group_id" in result["promocode"]
assert result["promocode"]["promo_group_id"] == sample_promo_group.id

View File

@@ -12,7 +12,6 @@ if str(ROOT_DIR) not in sys.path:
from app.services import referral_service # noqa: E402
@pytest.mark.asyncio
async def test_commission_accrues_before_minimum_first_topup(monkeypatch):
user = SimpleNamespace(
id=1,

View File

@@ -66,7 +66,6 @@ def test_deduplicate_ignores_records_without_expire_date():
assert deduplicated[telegram_id] is valid
@pytest.mark.asyncio
async def test_get_or_create_user_handles_unique_violation(monkeypatch):
service = _create_service()
db = AsyncMock()
@@ -97,7 +96,6 @@ async def test_get_or_create_user_handles_unique_violation(monkeypatch):
rollback_mock.assert_awaited()
@pytest.mark.asyncio
async def test_get_or_create_user_creates_new(monkeypatch):
service = _create_service()
db = AsyncMock()

View File

@@ -25,7 +25,6 @@ class DummyTexts:
return f"{value / 100:.0f}"
@pytest.mark.asyncio
async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
@@ -185,7 +184,6 @@ async def test_auto_purchase_saved_cart_after_topup_success(monkeypatch):
admin_service_mock.send_subscription_purchase_notification.assert_awaited()
@pytest.mark.asyncio
async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
@@ -298,3 +296,379 @@ async def test_auto_purchase_saved_cart_after_topup_extension(monkeypatch):
bot.send_message.assert_awaited()
service_mock.update_remnawave_user.assert_awaited()
create_transaction_mock.assert_awaited()
@pytest.mark.asyncio
async def test_auto_purchase_trial_preserved_on_insufficient_balance(monkeypatch):
"""Тест: триал сохраняется, если не хватает денег для автопокупки"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
subscription = MagicMock()
subscription.id = 123
subscription.is_trial = True # Триальная подписка!
subscription.status = "active"
subscription.end_date = datetime.utcnow() + timedelta(days=2) # Осталось 2 дня
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 99
user.telegram_id = 9999
# ИСПРАВЛЕНО: Баланс достаточный для первой проверки (строка 243),
# но subtract_user_balance вернёт False (симуляция неудачи списания)
user.balance_kopeks = 60_000
user.language = "ru"
user.subscription = subscription
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30,
"total_price": 50_000,
"description": "Продление на 30 дней",
"device_limit": 1,
"traffic_limit_gb": 100,
"squad_uuid": None,
"consume_promo_offer": False,
}
# Mock: недостаточно денег, списание не удалось
subtract_mock = AsyncMock(return_value=False)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
db_session = AsyncMock(spec=AsyncSession)
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is False # Автопокупка не удалась
assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН!
subtract_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_auto_purchase_trial_converted_after_successful_extension(monkeypatch):
"""Тест: триал конвертируется в платную подписку ТОЛЬКО после успешного продления"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
subscription = MagicMock()
subscription.id = 456
subscription.is_trial = True # Триальная подписка!
subscription.status = "active"
subscription.end_date = datetime.utcnow() + timedelta(days=1)
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 88
user.telegram_id = 8888
user.balance_kopeks = 200_000 # Достаточно денег
user.language = "ru"
user.subscription = subscription
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30,
"total_price": 100_000,
"description": "Продление на 30 дней",
"device_limit": 2,
"traffic_limit_gb": 500,
"squad_uuid": None,
"consume_promo_offer": False,
}
# Mock: деньги списались успешно
subtract_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
# Mock: продление успешно
async def extend_stub(db, current_subscription, days):
current_subscription.end_date = current_subscription.end_date + timedelta(days=days)
return current_subscription
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.extend_subscription",
extend_stub,
)
create_transaction_mock = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.create_transaction",
create_transaction_mock,
)
service_mock = MagicMock()
service_mock.update_remnawave_user = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.SubscriptionService",
lambda: service_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
# ИСПРАВЛЕНО: Добавлен мок для format_local_datetime
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_local_datetime",
lambda dt, fmt: dt.strftime(fmt) if dt else "",
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_extension_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
db_session = AsyncMock(spec=AsyncSession)
db_session.commit = AsyncMock() # Важно! Отслеживаем commit
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is True # Автопокупка успешна
assert subscription.is_trial is False # ТРИАЛ КОНВЕРТИРОВАН!
assert subscription.status == "active"
db_session.commit.assert_awaited() # Commit был вызван
@pytest.mark.asyncio
async def test_auto_purchase_trial_preserved_on_extension_failure(monkeypatch):
"""Тест: триал НЕ конвертируется и вызывается rollback при ошибке в extend_subscription"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
subscription = MagicMock()
subscription.id = 789
subscription.is_trial = True # Триальная подписка!
subscription.status = "active"
subscription.end_date = datetime.utcnow() + timedelta(days=3)
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 77
user.telegram_id = 7777
user.balance_kopeks = 200_000 # Достаточно денег
user.language = "ru"
user.subscription = subscription
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30,
"total_price": 100_000,
"description": "Продление на 30 дней",
"device_limit": 1,
"traffic_limit_gb": 100,
"squad_uuid": None,
"consume_promo_offer": False,
}
# Mock: деньги списались успешно
subtract_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
# Mock: extend_subscription выбрасывает ошибку!
async def extend_error(db, current_subscription, days):
raise Exception("Database connection error")
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.extend_subscription",
extend_error,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
# ИСПРАВЛЕНО: Добавлены недостающие моки
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_local_datetime",
lambda dt, fmt: dt.strftime(fmt) if dt else "",
)
db_session = AsyncMock(spec=AsyncSession)
db_session.rollback = AsyncMock() # Важно! Отслеживаем rollback
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is False # Автопокупка не удалась
assert subscription.is_trial is True # ТРИАЛ СОХРАНЁН!
db_session.rollback.assert_awaited() # ROLLBACK БЫЛ ВЫЗВАН!
@pytest.mark.asyncio
async def test_auto_purchase_trial_remaining_days_transferred(monkeypatch):
"""Тест: остаток триала переносится на платную подписку при TRIAL_ADD_REMAINING_DAYS_TO_PAID=True"""
monkeypatch.setattr(settings, "AUTO_PURCHASE_AFTER_TOPUP_ENABLED", True)
monkeypatch.setattr(settings, "TRIAL_ADD_REMAINING_DAYS_TO_PAID", True) # Включено!
now = datetime.utcnow()
trial_end = now + timedelta(days=2) # Осталось 2 дня триала
subscription = MagicMock()
subscription.id = 321
subscription.is_trial = True
subscription.status = "active"
subscription.end_date = trial_end
subscription.start_date = now - timedelta(days=1) # Триал начался вчера
subscription.device_limit = 1
subscription.traffic_limit_gb = 10
subscription.connected_squads = []
user = MagicMock(spec=User)
user.id = 66
user.telegram_id = 6666
user.balance_kopeks = 200_000
user.language = "ru"
user.subscription = subscription
cart_data = {
"cart_mode": "extend",
"subscription_id": subscription.id,
"period_days": 30, # Покупает 30 дней
"total_price": 100_000,
"description": "Продление на 30 дней",
"device_limit": 1,
"traffic_limit_gb": 100,
"squad_uuid": None,
"consume_promo_offer": False,
}
subtract_mock = AsyncMock(return_value=True)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.subtract_user_balance",
subtract_mock,
)
# Mock: extend_subscription с логикой переноса бонусных дней
# Имитируем нашу новую логику из extend_subscription()
async def extend_with_bonus(db, current_subscription, days):
# Вычисляем бонусные дни (как в нашем коде)
bonus_days = 0
if current_subscription.is_trial and settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID:
if current_subscription.end_date and current_subscription.end_date > now:
remaining = current_subscription.end_date - now
if remaining.total_seconds() > 0:
bonus_days = max(0, remaining.days)
total_days = days + bonus_days
current_subscription.end_date = current_subscription.end_date + timedelta(days=total_days)
return current_subscription
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.extend_subscription",
extend_with_bonus,
)
create_transaction_mock = AsyncMock(return_value=MagicMock())
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.create_transaction",
create_transaction_mock,
)
service_mock = MagicMock()
service_mock.update_remnawave_user = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.SubscriptionService",
lambda: service_mock,
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.get_user_cart",
AsyncMock(return_value=cart_data),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.user_cart_service.delete_user_cart",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.clear_subscription_checkout_draft",
AsyncMock(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.get_texts",
lambda lang: DummyTexts(),
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_period_description",
lambda days, lang: f"{days} дней",
)
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.format_local_datetime",
lambda dt, fmt: dt.strftime(fmt),
)
admin_service_mock = MagicMock()
admin_service_mock.send_subscription_extension_notification = AsyncMock()
monkeypatch.setattr(
"app.services.subscription_auto_purchase_service.AdminNotificationService",
lambda bot: admin_service_mock,
)
db_session = AsyncMock(spec=AsyncSession)
db_session.commit = AsyncMock()
db_session.refresh = AsyncMock() # ИСПРАВЛЕНО: Добавлен мок для refresh
bot = AsyncMock()
result = await auto_purchase_saved_cart_after_topup(db_session, user, bot=bot)
# Проверки
assert result is True
assert subscription.is_trial is False # Триал конвертирован
# Проверяем, что подписка продлена на 32 дня (30 + 2 бонусных)
# end_date должна быть примерно на 32 дня от оригинального trial_end
expected_end = trial_end + timedelta(days=32) # trial_end + (30 + 2)
actual_delta = (subscription.end_date - trial_end).days
assert actual_delta == 32, f"Expected 32 days extension (30 + 2 bonus), got {actual_delta}"

View File

@@ -12,7 +12,6 @@ from app.config import settings
from app.services.system_settings_service import bot_configuration_service
@pytest.mark.asyncio
async def test_env_override_prevents_set_value(monkeypatch):
bot_configuration_service.initialize_definitions()
@@ -45,7 +44,6 @@ async def test_env_override_prevents_set_value(monkeypatch):
assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
@pytest.mark.asyncio
async def test_env_override_prevents_reset_value(monkeypatch):
bot_configuration_service.initialize_definitions()
@@ -77,7 +75,6 @@ async def test_env_override_prevents_reset_value(monkeypatch):
assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
@pytest.mark.asyncio
async def test_initialize_skips_db_value_for_env_override(monkeypatch):
bot_configuration_service.initialize_definitions()
@@ -130,7 +127,6 @@ async def test_initialize_skips_db_value_for_env_override(monkeypatch):
assert not bot_configuration_service.has_override("SUPPORT_USERNAME")
@pytest.mark.asyncio
async def test_set_value_applies_without_env_override(monkeypatch):
bot_configuration_service.initialize_definitions()

View File

@@ -54,7 +54,6 @@ def mock_state():
state.clear = AsyncMock()
return state
@pytest.mark.asyncio
async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест сохранения корзины и перенаправления к пополнению"""
# Мокаем все зависимости
@@ -102,7 +101,6 @@ async def test_save_cart_and_redirect_to_topup(mock_callback_query, mock_state,
# mock_callback_query.answer не должен быть вызван
mock_callback_query.answer.assert_not_called()
@pytest.mark.asyncio
async def test_return_to_saved_cart_success(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест возврата к сохраненной корзине с достаточным балансом"""
# Подготовим данные корзины
@@ -153,7 +151,6 @@ async def test_return_to_saved_cart_success(mock_callback_query, mock_state, moc
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_return_to_saved_cart_skips_edit_when_message_matches(
mock_callback_query,
mock_state,
@@ -224,7 +221,6 @@ async def test_return_to_saved_cart_skips_edit_when_message_matches(
mock_cart_service.save_user_cart.assert_not_called()
@pytest.mark.asyncio
async def test_return_to_saved_cart_normalizes_devices_when_disabled(
mock_callback_query,
mock_state,
@@ -299,7 +295,6 @@ async def test_return_to_saved_cart_normalizes_devices_when_disabled(
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест возврата к сохраненной корзине с недостаточным балансом"""
# Подготовим данные корзины
@@ -347,7 +342,6 @@ async def test_return_to_saved_cart_insufficient_funds(mock_callback_query, mock
# (ответ отправляется через return до вызова callback.answer())
mock_callback_query.answer.assert_not_called()
@pytest.mark.asyncio
async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
"""Тест очистки сохраненной корзины"""
# Мокаем все зависимости
@@ -369,7 +363,6 @@ async def test_clear_saved_cart(mock_callback_query, mock_state, mock_user, mock
# Проверяем, что вызван answer
mock_callback_query.answer.assert_called_once()
@pytest.mark.asyncio
async def test_handle_subscription_cancel_clears_saved_cart(mock_callback_query, mock_state, mock_user, mock_db):
"""Отмена покупки должна очищать сохраненную корзину"""
mock_clear_draft = AsyncMock()

View File

@@ -34,7 +34,6 @@ def user_cart_service(mock_redis):
service.redis_client = mock_redis
return service
@pytest.mark.asyncio
async def test_save_user_cart(user_cart_service, mock_redis):
"""Тест сохранения корзины пользователя"""
user_id = 12345
@@ -52,7 +51,6 @@ async def test_save_user_cart(user_cart_service, mock_redis):
assert f"user_cart:{user_id}" in mock_redis.storage
assert cart_data == eval(mock_redis.storage[f"user_cart:{user_id}"])
@pytest.mark.asyncio
async def test_get_user_cart(user_cart_service, mock_redis):
"""Тест получения корзины пользователя"""
user_id = 12345
@@ -72,7 +70,6 @@ async def test_get_user_cart(user_cart_service, mock_redis):
assert result == cart_data
@pytest.mark.asyncio
async def test_get_user_cart_not_found(user_cart_service):
"""Тест получения несуществующей корзины пользователя"""
user_id = 99999
@@ -81,7 +78,6 @@ async def test_get_user_cart_not_found(user_cart_service):
assert result is None
@pytest.mark.asyncio
async def test_delete_user_cart(user_cart_service, mock_redis):
"""Тест удаления корзины пользователя"""
user_id = 12345
@@ -103,7 +99,6 @@ async def test_delete_user_cart(user_cart_service, mock_redis):
assert result is True
assert f"user_cart:{user_id}" not in mock_redis.storage
@pytest.mark.asyncio
async def test_delete_user_cart_not_found(user_cart_service):
"""Тест удаления несуществующей корзины пользователя"""
user_id = 99999
@@ -113,7 +108,6 @@ async def test_delete_user_cart_not_found(user_cart_service):
assert result is False
@pytest.mark.asyncio
async def test_has_user_cart(user_cart_service, mock_redis):
"""Тест проверки наличия корзины пользователя"""
user_id = 12345
@@ -136,7 +130,6 @@ async def test_has_user_cart(user_cart_service, mock_redis):
result = await user_cart_service.has_user_cart(user_id)
assert result is True
@pytest.mark.asyncio
async def test_has_user_cart_not_found(user_cart_service):
"""Тест проверки отсутствия корзины пользователя"""
user_id = 99999

View File

@@ -0,0 +1,405 @@
"""
Тесты для утилит ценообразования и форматирования цен.
Этот модуль тестирует функции из app/utils/pricing_utils.py и app/localization/texts.py,
особенно функции отображения цен со скидками на кнопках подписки.
"""
import pytest
from unittest.mock import patch, MagicMock
from typing import Dict, Any
from app.utils.pricing_utils import format_period_option_label
from app.localization.texts import _build_dynamic_values
class TestFormatPeriodOptionLabel:
"""Тесты для функции format_period_option_label."""
def test_format_with_price_only_no_discount(self) -> None:
"""Цена без скидки должна отображаться в простом формате."""
result = format_period_option_label("📅 30 дней", 99000)
assert result == "📅 30 дней - 990 ₽"
def test_format_with_discount_shows_strikethrough(self) -> None:
"""Цена со скидкой должна показывать зачёркнутую оригинальную цену."""
result = format_period_option_label(
"📅 30 дней",
price=69300,
original_price=99000,
discount_percent=30
)
assert result == "📅 30 дней - <s>990 ₽</s> 693 ₽ (-30%)"
def test_format_with_zero_price_returns_label_only(self) -> None:
"""Нулевая цена должна возвращать только метку без цены."""
result = format_period_option_label("📅 30 дней", 0)
assert result == "📅 30 дней"
def test_format_with_negative_price_returns_label_only(self) -> None:
"""Отрицательная цена должна возвращать только метку."""
result = format_period_option_label("📅 30 дней", -1000)
assert result == "📅 30 дней"
def test_format_with_zero_discount_percent_shows_simple_price(self) -> None:
"""Нулевая скидка должна отображать простую цену без зачёркивания."""
result = format_period_option_label(
"📅 30 дней",
price=99000,
original_price=99000,
discount_percent=0
)
assert result == "📅 30 дней - 990 ₽"
def test_format_with_original_price_equal_to_final_shows_simple(self) -> None:
"""Если оригинальная цена равна финальной, показывать простой формат."""
result = format_period_option_label(
"📅 30 дней",
price=99000,
original_price=99000,
discount_percent=10 # Указана скидка, но цены равны
)
assert result == "📅 30 дней - 990 ₽"
def test_format_with_original_price_less_than_final_shows_simple(self) -> None:
"""Если оригинальная цена меньше финальной (некорректно), показывать простой формат."""
result = format_period_option_label(
"📅 30 дней",
price=99000,
original_price=50000,
discount_percent=10
)
assert result == "📅 30 дней - 990 ₽"
@pytest.mark.parametrize(
"label,price,original,discount,expected",
[
# Базовые случаи
("📅 14 дней", 50000, 0, 0, "📅 14 дней - 500 ₽"),
("📅 30 дней", 99000, 0, 0, "📅 30 дней - 990 ₽"),
("📅 360 дней", 899000, 0, 0, "📅 360 дней - 8990 ₽"),
# Со скидками
("📅 30 дней", 69300, 99000, 30, "📅 30 дней - <s>990 ₽</s> 693 ₽ (-30%)"),
("📅 90 дней", 188300, 269000, 30, "📅 90 дней - <s>2690 ₽</s> 1883 ₽ (-30%)"),
("📅 360 дней", 629300, 899000, 30, "📅 360 дней - <s>8990 ₽</s> 6293 ₽ (-30%)"),
# Разные проценты скидок
("📅 30 дней", 89100, 99000, 10, "📅 30 дней - <s>990 ₽</s> 891 ₽ (-10%)"),
("📅 30 дней", 49500, 99000, 50, "📅 30 дней - <s>990 ₽</s> 495 ₽ (-50%)"),
# Цены с копейками
("📅 7 дней", 12345, 0, 0, "📅 7 дней - 123.45 ₽"),
("📅 7 дней", 12350, 0, 0, "📅 7 дней - 123.5 ₽"),
],
)
def test_format_various_scenarios(
self,
label: str,
price: int,
original: int,
discount: int,
expected: str
) -> None:
"""Различные сценарии форматирования должны работать корректно."""
result = format_period_option_label(label, price, original, discount)
assert result == expected
def test_format_with_100_percent_discount(self) -> None:
"""100% скидка должна корректно отображаться."""
result = format_period_option_label(
"📅 30 дней",
price=0,
original_price=99000,
discount_percent=100
)
# Цена 0, поэтому возвращается только label
assert result == "📅 30 дней"
def test_format_preserves_label_emojis(self) -> None:
"""Эмодзи в метке должны сохраняться."""
result = format_period_option_label("🔥 📅 360 дней 🔥", 899000)
assert result == "🔥 📅 360 дней 🔥 - 8990 ₽"
def test_format_with_large_prices(self) -> None:
"""Большие цены должны корректно форматироваться."""
result = format_period_option_label(
"📅 720 дней",
price=150000000, # 1,500,000 рублей
original_price=200000000,
discount_percent=25
)
assert result == "📅 720 дней - <s>2000000 ₽</s> 1500000 ₽ (-25%)"
def test_format_with_small_prices_kopeks(self) -> None:
"""Маленькие цены с копейками должны корректно отображаться."""
result = format_period_option_label(
"📅 1 день",
price=5050, # 50.50 рублей
original_price=10000,
discount_percent=50
)
assert result == "📅 1 день - <s>100 ₽</s> 50.5 ₽ (-50%)"
def test_format_without_optional_params_uses_defaults(self) -> None:
"""Вызов без опциональных параметров должен использовать значения по умолчанию."""
result = format_period_option_label("📅 30 дней", 99000)
assert result == "📅 30 дней - 990 ₽"
class TestBuildDynamicValues:
"""Тесты для функции _build_dynamic_values из texts.py."""
@patch('app.localization.texts.settings')
def test_russian_language_generates_period_keys(self, mock_settings: MagicMock) -> None:
"""Русский язык должен генерировать все ключи периодов."""
# Настройка моков
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_30_DAYS = 99000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_360_DAYS = 899000
mock_settings.get_base_promo_group_period_discount.return_value = 0
mock_settings.format_price = lambda x: f"{x // 100}"
# Мок для traffic цен
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("ru-RU")
assert "PERIOD_14_DAYS" in result
assert "PERIOD_30_DAYS" in result
assert "PERIOD_60_DAYS" in result
assert "PERIOD_90_DAYS" in result
assert "PERIOD_180_DAYS" in result
assert "PERIOD_360_DAYS" in result
@patch('app.localization.texts.settings')
def test_english_language_generates_period_keys(self, mock_settings: MagicMock) -> None:
"""Английский язык должен генерировать все ключи периодов."""
# Настройка моков
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_30_DAYS = 99000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_360_DAYS = 899000
mock_settings.get_base_promo_group_period_discount.return_value = 0
mock_settings.format_price = lambda x: f"{x // 100}"
# Мок для traffic цен
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("en-US")
assert "PERIOD_14_DAYS" in result
assert "PERIOD_30_DAYS" in result
assert "PERIOD_360_DAYS" in result
# Проверяем, что используется "days" а не "дней"
assert "days" in result["PERIOD_30_DAYS"]
@patch('app.localization.texts.settings')
@patch('app.utils.pricing_utils.apply_percentage_discount')
def test_period_with_discount_shows_strikethrough(
self,
mock_apply_discount: MagicMock,
mock_settings: MagicMock
) -> None:
"""Период со скидкой должен показывать зачёркнутую цену."""
# Настройка моков
mock_settings.PRICE_30_DAYS = 99000
mock_settings.get_base_promo_group_period_discount.return_value = 30
mock_apply_discount.return_value = (69300, 29700) # 30% скидка
mock_settings.format_price = lambda x: f"{x // 100}"
# Остальные цены
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_360_DAYS = 899000
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("ru-RU")
# Проверяем, что есть зачёркивание и процент скидки
assert "<s>990 ₽</s>" in result["PERIOD_30_DAYS"]
assert "(-30%)" in result["PERIOD_30_DAYS"]
@patch('app.localization.texts.settings')
def test_period_360_with_discount_has_fire_emojis(self, mock_settings: MagicMock) -> None:
"""Период 360 дней со скидкой должен иметь огоньки 🔥."""
# Настройка моков для 360 дней со скидкой
mock_settings.PRICE_360_DAYS = 899000
def get_discount(period_days: int) -> int:
return 30 if period_days == 360 else 0
mock_settings.get_base_promo_group_period_discount.side_effect = get_discount
mock_settings.format_price = lambda x: f"{x // 100}"
# Остальные цены
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_30_DAYS = 99000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("ru-RU")
# Проверяем наличие огоньков
assert result["PERIOD_360_DAYS"].startswith("🔥")
assert result["PERIOD_360_DAYS"].endswith("🔥")
assert result["PERIOD_360_DAYS"].count("🔥") == 2
@patch('app.localization.texts.settings')
def test_period_360_without_discount_no_fire_emojis(self, mock_settings: MagicMock) -> None:
"""Период 360 дней без скидки НЕ должен иметь огоньки 🔥."""
# Настройка моков для 360 дней БЕЗ скидки
mock_settings.PRICE_360_DAYS = 899000
mock_settings.get_base_promo_group_period_discount.return_value = 0 # Нет скидки
mock_settings.format_price = lambda x: f"{x // 100}"
# Остальные цены
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_30_DAYS = 99000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("ru-RU")
# Проверяем отсутствие огоньков
assert "🔥" not in result["PERIOD_360_DAYS"]
# Но должна быть просто цена
assert "8990 ₽" in result["PERIOD_360_DAYS"]
@patch('app.localization.texts.settings')
def test_other_periods_never_have_fire_emojis(self, mock_settings: MagicMock) -> None:
"""Другие периоды (не 360) никогда не должны иметь огоньки, даже со скидкой."""
# Настройка моков - 30 дней со скидкой
mock_settings.PRICE_30_DAYS = 99000
def get_discount(period_days: int) -> int:
return 30 if period_days == 30 else 0
mock_settings.get_base_promo_group_period_discount.side_effect = get_discount
mock_settings.format_price = lambda x: f"{x // 100}"
# Остальные цены
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_360_DAYS = 899000
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("ru-RU")
# 30 дней со скидкой не должно иметь огоньков
assert "🔥" not in result["PERIOD_30_DAYS"]
# Но должна быть скидка
assert "<s>" in result["PERIOD_30_DAYS"]
@patch('app.localization.texts.settings')
def test_returns_empty_dict_for_unknown_language(self, mock_settings: MagicMock) -> None:
"""Неизвестный язык должен возвращать пустой словарь."""
result = _build_dynamic_values("fr-FR") # Французский не поддерживается
assert result == {}
@patch('app.localization.texts.settings')
def test_language_code_extraction_works(self, mock_settings: MagicMock) -> None:
"""Должна корректно извлекаться языковая часть из locale."""
# Настройка моков
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_30_DAYS = 99000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_360_DAYS = 899000
mock_settings.get_base_promo_group_period_discount.return_value = 0
mock_settings.format_price = lambda x: f"{x // 100}"
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
# Тест с полным locale кодом
result1 = _build_dynamic_values("ru-RU")
result2 = _build_dynamic_values("ru")
result3 = _build_dynamic_values("RU-ru")
# Все должны вернуть русские значения
assert "дней" in result1["PERIOD_30_DAYS"]
assert "дней" in result2["PERIOD_30_DAYS"]
assert "дней" in result3["PERIOD_30_DAYS"]
@patch('app.localization.texts.settings')
def test_traffic_keys_also_generated(self, mock_settings: MagicMock) -> None:
"""Должны генерироваться не только периоды, но и ключи трафика."""
# Настройка моков
mock_settings.PRICE_14_DAYS = 50000
mock_settings.PRICE_30_DAYS = 99000
mock_settings.PRICE_60_DAYS = 189000
mock_settings.PRICE_90_DAYS = 269000
mock_settings.PRICE_180_DAYS = 499000
mock_settings.PRICE_360_DAYS = 899000
mock_settings.get_base_promo_group_period_discount.return_value = 0
mock_settings.format_price = lambda x: f"{x // 100}"
mock_settings.PRICE_TRAFFIC_5GB = 10000
mock_settings.PRICE_TRAFFIC_10GB = 20000
mock_settings.PRICE_TRAFFIC_25GB = 30000
mock_settings.PRICE_TRAFFIC_50GB = 40000
mock_settings.PRICE_TRAFFIC_100GB = 50000
mock_settings.PRICE_TRAFFIC_250GB = 60000
mock_settings.PRICE_TRAFFIC_UNLIMITED = 70000
result = _build_dynamic_values("ru-RU")
# Проверяем наличие ключей трафика
assert "TRAFFIC_5GB" in result
assert "TRAFFIC_10GB" in result
assert "TRAFFIC_UNLIMITED" in result
assert "SUPPORT_INFO" in result