830e64afe0
* fix: устранить MissingGreenlet в автоплатежах и починить traceback в логах - subtract_user_balance: пишем promo_offer_log в отдельной сессии вместо rollback после commit, который экспайрил объекты основной сессии и ломал последующие обращения к subscription/user attrs - monitoring_service._process_autopayments: перезагружаем subscription с eager-load user/tariff после списания, оборачиваем каждую итерацию в try/except + rollback, чтобы одна ошибка не валила весь батч - logging_config: новый processor _auto_capture_exc_info автоматически подтягивает traceback из sys.exc_info() или error-kwarg → полный traceback в файле, консоли и Telegram без exc_info=True на каждом вызове - logging_handler: дублирующая логика захвата exc_info в TelegramNotifierProcessor как резерв * fix: устранить root cause MissingGreenlet в автоплатежах через refetch по id Трейс показал: subscription.user падает на lazy-load → pool._checkout → do_ping → await_ → MissingGreenlet. SQLAlchemy 2.0 async session не поддерживает sync-lazy-load для relationships. Причина рассинхрона: lock_user_for_pricing делает populate_existing=True + selectinload( User.subscriptions).selectinload(Subscription.tariff), что разгружает Subscription.user backref для сестринских подписок того же user. Последующее обращение sub.user у другой подписки падает. Фикс: захватываем (sub_id, user_id) пары ДО цикла, каждую итерацию делаем fresh refetch через async select с eager load user+tariff+ promo_group. Никаких lazy access в горячем пути. В except используем локально захваченные id вместо getattr(subscription, ...), чтобы логирование не падало каскадом на expired объекте. * fix: grant all available squads for unrestricted trials (#2897) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook (#2894) * feat: add WEBHOOK_IP to allow Telegram bypass DNS lookup for webhook * style: ruff format main.py --------- Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not update first_name/last_name from OIDC claims (#2892) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: do not reset subscription_crypto_link when cryptoLink absent in webhook (#2891) Co-authored-by: Dmitry Lunin <br@slack.ru> * fix: FSM state loss on balance topup, PayPear confirmation_url, hidden trial tariff in renewal - balance/platega: re-set FSM state after min/max validation errors, set state before pending_amount path, use balance_topup callback for back button - balance/main: set FSM state and payment_method in handle_topup_amount_callback for all providers before routing, use balance_topup callback in validation errors - payment/paypear: fix confirmation_url key (was 'url'), add fallback, store charged amount with commission for correct webhook amount comparison - tariff_purchase: redirect to active tariff list when current tariff is inactive (hidden trial after promo code activation) - cabinet/renewal: check tariff.is_active in both GET and POST endpoints to prevent hidden trial tariff periods from appearing * fix: tariff switch pricing showing free for upgrades, admin duplicate subscription guard - pricing_engine: use shortest period for daily rate comparison instead of period closest to remaining_days — fixes incorrect free/zero cost for upgrades when tariffs have different period sets - pricing_engine: remove unused target_days parameter from get_tariff_daily_rate_fraction - admin_users: add duplicate subscription check before create, change_tariff and activate actions to prevent UniqueViolationError on uq_subscriptions_user_tariff_active constraint - admin_users: add IntegrityError fallback on create as TOCTOU safety net * feat: tariff switch direction control, fix device pricing within tariff limit Tariff switch direction: - Add TARIFF_SWITCH_UPGRADE_ENABLED and TARIFF_SWITCH_DOWNGRADE_ENABLED settings to control allowed switch directions - Guard all 10 entry points: instant switch (list, preview, confirm), legacy switch (list, select, confirm, daily confirm), cabinet (preview, execute), purchase-options API - Filter tariff lists by allowed direction, show "unavailable" when both directions disabled - Expose settings in cabinet purchase-options response for frontend Device pricing fix: - Devices within tariff.device_limit are now free when restoring (was charging for all devices regardless of tariff inclusion) - Fix max(100, price) minimum enforcing 1 RUB even when chargeable_devices is 0 - Apply fix across all endpoints: bot handlers (confirm_change, execute_change, confirm_add), cabinet API (legacy purchase, modern purchase, get-price, save-cart), inline keyboard display * fix: classic mode renewal resets device_limit to 1 via cart key mismatch - Fix cart key mismatch: extend cart saved 'device_limit' but confirm_purchase read 'devices' key, falling back to DEFAULT=1. Now both keys are saved in both cart-save paths - Fix confirm_purchase device resolution: use explicit is None checks instead of or-chain to avoid falsy-zero trap - Fix return_to_saved_cart display: fall back to 'device_limit' and 'traffic_limit_gb' keys when 'devices'/'traffic_gb' are absent - Fix second cart-save path in _extend_existing_subscription with same dual-key pattern - Fix RemnaWaveService import path in renewal service - Add RESET_DEVICES_ON_RENEWAL setting: resets all connected devices (hwid) via RemnaWave API on each subscription renewal * fix: menu layout schema icon limit, traffic_topup_enabled condition, shadowing imports - Increase icon max_length from 10 to 100 in all three schemas (MenuButtonConfig, ButtonUpdateRequest, AddCustomButtonRequest) to support Telegram Custom Emoji IDs - Add traffic_topup_enabled condition to ButtonConditions schema - Remove shadowing local imports of MenuLayoutService in routes/menu_layout.py (top-level import already provides access) * feat(tickets): multi-media message gallery (media_items JSONB) - Add media_items JSONB column to TicketMessage model for multi-media gallery support (photos/videos/documents in one bubble) - Add TicketMediaItem schema with type validation and shared _validate_media_bundle helper (max 10 items, legacy field compat) - Update admin and user ticket handlers to store media_items and back-fill legacy media_type/media_file_id/media_caption from first item for backward compatibility - Update _message_to_response in both admin and user routes to include media_items in API responses - Allow empty message text when media is attached (message field now defaults to empty string with model validator ensuring text or media) - Add migration 0061 with idempotent column check Based on PR #2869 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: ticket media_items review fixes - Add if has_media else None guards in user-side ticket handlers (create_ticket, add_message) matching admin handler pattern - Fix Telegram notification using resolved primary_file_id/primary_type instead of raw request fields for gallery messages - Narrow except Exception to (TypeError, KeyError, ValueError) in _message_to_response with warning log for debugging - Add media_items parameter to TicketCRUD.create_ticket and TicketCRUD.add_message for CRUD layer parity - Add TicketMediaItemResponse and media_items field to webapi TicketMessageResponse to prevent data loss on read * feat: landing page analytics goals and sticky pay button - Add sticky_pay_button, analytics_view_enabled, analytics_view_goal, analytics_click_enabled, analytics_click_goal columns to LandingPage - Add fields to CRUD updatable fields, admin create/update/detail schemas, create_landing() kwargs, _landing_to_detail() response - Expose sticky_pay_button and analytics fields in public landing config response for frontend Yandex Metrika integration - Add migration 0062 with idempotent column checks Based on PR #2852 by @smediainfo — CI/CD workflow changes excluded (hardcoded version strings would regress dynamic manifest reading) * fix: validate analytics goal is set when analytics is enabled on landing Prevent enabling analytics_view/click without providing the corresponding goal identifier, which would result in empty Yandex Metrika calls on the frontend. * feat: Yandex Metrika offline conversions + S2S postbacks - Add YandexClientIdMap model for user → yandex_cid mapping with upsert-safe CRUD (ON CONFLICT DO UPDATE) - Add yandex_cid, subid, referrer columns to GuestPurchase - Add yandex_offline_conv_service: Measurement Protocol integration with mc.yandex.ru/collect (registration, trial, purchase events), background task management, CID parsing from /start params - Add s2s_postback_service: server-to-server affiliate postbacks with URL template placeholders and URL-safe encoding - Add analytics offline conversion info to branding API (masked secret) - Add POST /analytics/yandex-cid endpoint for cabinet CID capture - Add 11 config settings (YANDEX_OFFLINE_CONV_*, S2S_POSTBACK_*) - Add migration 0063 (yandex_client_id_map table + guest_purchases cols) - Fix: mask measurement secret aggressively (show only last 4 chars) - Fix: always replace {user_id} placeholder in S2S postback URLs - Fix: use structlog kwargs instead of f-strings with LOG_PREFIX Based on PR #2851 by @smediainfo — CI/CD workflow changes excluded --------- Co-authored-by: c0mrade <killmy666@gmail.com> Co-authored-by: Danila Yudin <danyayudin2012@gmail.com> Co-authored-by: Dmitry V. Lunin <49199230+BlackRaincoat@users.noreply.github.com> Co-authored-by: Dmitry Lunin <br@slack.ru>
474 lines
18 KiB
Python
474 lines
18 KiB
Python
"""
|
|
Tests for PromoCodeService - focus on promo group integration
|
|
"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
from app.database.models import PromoCodeType
|
|
from app.services.promocode_service import PromoCodeService
|
|
|
|
|
|
# Import fixtures
|
|
|
|
|
|
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
|
|
|
|
|
|
async def test_activate_trial_promocode_uses_all_available_squads_when_tariff_has_no_restrictions(
|
|
monkeypatch,
|
|
):
|
|
sample_user = 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,
|
|
)
|
|
mock_db_session = AsyncMock()
|
|
mock_db_session.commit = AsyncMock()
|
|
mock_db_session.rollback = AsyncMock()
|
|
mock_db_session.refresh = AsyncMock()
|
|
mock_db_session.delete = AsyncMock()
|
|
|
|
promocode = SimpleNamespace(
|
|
id=10,
|
|
code='KRTN14',
|
|
type=PromoCodeType.TRIAL_SUBSCRIPTION.value,
|
|
balance_bonus_kopeks=0,
|
|
subscription_days=14,
|
|
tariff_id=7,
|
|
promo_group_id=None,
|
|
promo_group=None,
|
|
first_purchase_only=False,
|
|
max_uses=20,
|
|
current_uses=0,
|
|
is_active=True,
|
|
is_valid=True,
|
|
valid_until=None,
|
|
)
|
|
trial_tariff = SimpleNamespace(
|
|
id=7,
|
|
name='Trial',
|
|
traffic_limit_gb=100,
|
|
device_limit=5,
|
|
allowed_squads=[],
|
|
trial_duration_days=14,
|
|
)
|
|
created_subscription = SimpleNamespace(id=99)
|
|
|
|
monkeypatch.setattr('app.services.promocode_service.RemnaWaveService', lambda: SimpleNamespace())
|
|
create_remnawave_user_mock = AsyncMock()
|
|
monkeypatch.setattr(
|
|
'app.services.promocode_service.SubscriptionService',
|
|
lambda: SimpleNamespace(create_remnawave_user=create_remnawave_user_mock),
|
|
)
|
|
monkeypatch.setattr('app.services.promocode_service.get_user_by_id', AsyncMock(return_value=sample_user))
|
|
monkeypatch.setattr('app.services.promocode_service.get_promocode_by_code', AsyncMock(return_value=promocode))
|
|
monkeypatch.setattr('app.services.promocode_service.check_user_promocode_usage', AsyncMock(return_value=False))
|
|
monkeypatch.setattr('app.database.crud.promocode.count_user_recent_activations', AsyncMock(return_value=0))
|
|
monkeypatch.setattr('app.services.promocode_service.get_subscription_by_user_id', AsyncMock(return_value=None))
|
|
monkeypatch.setattr('app.services.promocode_service.create_promocode_use', AsyncMock(return_value=object()))
|
|
monkeypatch.setattr('app.database.crud.tariff.get_tariff_by_id', AsyncMock(return_value=trial_tariff))
|
|
monkeypatch.setattr('app.database.crud.tariff.get_trial_tariff', AsyncMock(return_value=None))
|
|
monkeypatch.setattr(
|
|
'app.database.crud.server_squad.get_available_server_squads',
|
|
AsyncMock(
|
|
return_value=[
|
|
SimpleNamespace(squad_uuid='fi-uuid'),
|
|
SimpleNamespace(squad_uuid='ru-uuid'),
|
|
]
|
|
),
|
|
)
|
|
create_trial_subscription_mock = AsyncMock(return_value=created_subscription)
|
|
monkeypatch.setattr('app.database.crud.subscription.create_trial_subscription', create_trial_subscription_mock)
|
|
|
|
service = PromoCodeService()
|
|
result = await service.activate_promocode(mock_db_session, sample_user.id, promocode.code)
|
|
|
|
assert result['success'] is True
|
|
create_trial_subscription_mock.assert_awaited_once_with(
|
|
mock_db_session,
|
|
sample_user.id,
|
|
duration_days=14,
|
|
traffic_limit_gb=100,
|
|
device_limit=5,
|
|
connected_squads=['fi-uuid', 'ru-uuid'],
|
|
tariff_id=7,
|
|
)
|
|
create_remnawave_user_mock.assert_awaited_once_with(mock_db_session, created_subscription)
|