From 67e091197606e068c238ccdba5eacd9d765fcce3 Mon Sep 17 00:00:00 2001 From: Dimitri Pisarev Date: Thu, 19 Feb 2026 23:45:25 +0100 Subject: [PATCH] Fix critical limiter, daily usage sync, and callback routing issues --- .../qr/generate_qr_handler.py | 74 +++++- bot/application/ports/cache.py | 17 ++ .../services/qr_generation_service.py | 35 ++- bot/di/modules/enhanced_cache_module.py | 12 + .../repositories/qr_history_repository.py | 6 + bot/infrastructure/cache.py | 19 ++ .../cache/enhanced_cache_service.py | 30 +++ .../repositories/qr_history_repository.py | 27 +++ bot/presentation/handlers/qr/__init__.py | 3 - .../handlers/qr/input_handlers.py | 29 ++- .../keyboards/eye_style_keyboards.py | 3 +- bot/presentation/keyboards/qr_keyboards.py | 3 +- db_schema.sql | 6 +- .../qr/test_generate_qr_handler.py | 224 ++++++++++++++++++ 14 files changed, 461 insertions(+), 27 deletions(-) diff --git a/bot/application/command_handlers/qr/generate_qr_handler.py b/bot/application/command_handlers/qr/generate_qr_handler.py index 307b72c..e011cb8 100644 --- a/bot/application/command_handlers/qr/generate_qr_handler.py +++ b/bot/application/command_handlers/qr/generate_qr_handler.py @@ -89,6 +89,21 @@ def __init__( self.dynamic_setup = dynamic_setup +class _LimitReservation: + """Tracks pre-allocated atomic limiter counters for rollback.""" + + __slots__ = ("keys",) + + def __init__(self) -> None: + self.keys: list[str] = [] + + def add(self, key: str) -> None: + self.keys.append(key) + + def is_empty(self) -> bool: + return len(self.keys) == 0 + + class GenerateQRHandler(ICommandHandler[GenerateQRCommand, tuple[bytes, int | None]]): """ Command handler for QR code generation. @@ -146,6 +161,8 @@ async def handle( RuntimeError: If unique short code cannot be generated """ start_time = time.time() + limit_reservation: _LimitReservation | None = None + persisted_successfully = False try: # ============================================ @@ -153,7 +170,7 @@ async def handle( # ============================================ # Limits MUST be checked here (not only in QRGenerationService) # to prevent bypass via direct mediator.send(GenerateQRCommand) - await self._enforce_limits(command.user_id) + limit_reservation = await self._enforce_limits(command.user_id) # ============================================ # PHASE 1: Validation and Preparation (no I/O) @@ -172,6 +189,7 @@ async def handle( qr_data, qr_code_id, pending_events = await self._persist_with_retry( command, content, qr_data, generation_time_ms ) + persisted_successfully = True # ============================================ # PHASE 4: Side effects (AFTER successful commit) @@ -194,11 +212,23 @@ async def handle( return qr_data, qr_code_id except ValueError: + if ( + limit_reservation + and not persisted_successfully + and not limit_reservation.is_empty() + ): + await self._rollback_limit_reservation(limit_reservation) logger.error( f"❌ Validation error in GenerateQRCommand for user {command.user_id}" ) raise except Exception: + if ( + limit_reservation + and not persisted_successfully + and not limit_reservation.is_empty() + ): + await self._rollback_limit_reservation(limit_reservation) logger.error( f"❌ Error handling GenerateQRCommand for user {command.user_id}" ) @@ -208,7 +238,7 @@ async def handle( # Private: Phase 0 — Limit Enforcement (SECURITY) # ------------------------------------------------------------------ - async def _enforce_limits(self, user_id: int) -> None: + async def _enforce_limits(self, user_id: int) -> _LimitReservation: """ Enforce QR generation limits at the handler level. @@ -225,6 +255,8 @@ async def _enforce_limits(self, user_id: int) -> None: PermissionError: If user is banned or limits exceeded ValueError: If user not found """ + reservation = _LimitReservation() + user = await self._user_repo.get_by_telegram_id(user_id) if not user: raise ValueError(f"User {user_id} not found") @@ -255,7 +287,13 @@ async def _enforce_limits(self, user_id: int) -> None: daily_key, ttl=86400, # Expire at end of day (24h max) ) - if daily_count > 0 and daily_count > tariff.daily_limit: + if daily_count <= 0: + await self._rollback_limit_reservation(reservation) + raise PermissionError("Rate limiter is unavailable. Try again shortly.") + + reservation.add(daily_key) + if daily_count > tariff.daily_limit: + await self._rollback_limit_reservation(reservation) raise PermissionError( f"Daily QR generation limit exceeded ({tariff.daily_limit})" ) @@ -267,7 +305,13 @@ async def _enforce_limits(self, user_id: int) -> None: monthly_key, ttl=86400 * 31, # ~1 month ) - if monthly_count > 0 and monthly_count > tariff.monthly_limit: + if monthly_count <= 0: + await self._rollback_limit_reservation(reservation) + raise PermissionError("Rate limiter is unavailable. Try again shortly.") + + reservation.add(monthly_key) + if monthly_count > tariff.monthly_limit: + await self._rollback_limit_reservation(reservation) raise PermissionError( f"Monthly QR generation limit exceeded ({tariff.monthly_limit})" ) @@ -292,6 +336,24 @@ async def _enforce_limits(self, user_id: int) -> None: f"Monthly QR generation limit exceeded ({tariff.monthly_limit})" ) + return reservation + + async def _rollback_limit_reservation(self, reservation: _LimitReservation) -> None: + """Rollback pre-allocated limiter counters. + + Called when generation fails after successful pre-allocation. + """ + if not self._cache or reservation.is_empty(): + return + + for key in reversed(reservation.keys): + try: + await self._cache.decrement(key) + except Exception: + logger.exception( + "Failed to rollback limiter reservation for key '%s'", key + ) + # ------------------------------------------------------------------ # Private: Phase 1 — Preparation (pure logic, no I/O) # ------------------------------------------------------------------ @@ -522,6 +584,10 @@ async def _do_persist( event = event.with_updated(qr_code_id=qr_code_id) pending_events.append(event) + # Keep daily_usage in sync with successful generation inside the same + # transaction so limit checks have a consistent source of truth. + await uow.qr_codes.increment_daily_usage(command.user_id, date.today()) + # Increment user's QR count (Rich Domain Model) user = await uow.users.get_by_telegram_id(command.user_id) if user: diff --git a/bot/application/ports/cache.py b/bot/application/ports/cache.py index 6811297..460c558 100644 --- a/bot/application/ports/cache.py +++ b/bot/application/ports/cache.py @@ -101,3 +101,20 @@ async def increment(self, key: str, ttl: int | None = None) -> int: Returns: New counter value after increment """ + + @abstractmethod + async def decrement(self, key: str) -> int: + """Decrement a counter and return the new value. + + Used to rollback quota reservations if generation fails after + a successful pre-allocation. + + Implementations must be resilient: if key is missing or operation + fails, they should return 0 and avoid negative counters. + + Args: + key: Counter key + + Returns: + New counter value after decrement, or 0 if key is absent/failure + """ diff --git a/bot/application/services/qr_generation_service.py b/bot/application/services/qr_generation_service.py index 5f8c830..d604df4 100644 --- a/bot/application/services/qr_generation_service.py +++ b/bot/application/services/qr_generation_service.py @@ -241,9 +241,7 @@ async def generate( except Exception as e: logger.error(f"QR generation error: {e}", exc_info=True) - return GenerationResult.internal_error( - "Service temporarily unavailable. Please try again." - ) + return GenerationResult.internal_error(str(e)) async def _check_limits( self, @@ -352,20 +350,39 @@ async def _get_remaining_quota( limit_query = CheckUserLimitQuery(user_id=user_id, is_admin=is_admin) limit_check = await self._mediator.send(limit_query) + daily_used = self._extract_limit_usage( + limit_check, "daily_used", "used_today" + ) + monthly_used = self._extract_limit_usage( + limit_check, "monthly_used", "used_this_month" + ) + remaining_daily = None if limit_check.daily_limit is not None: - remaining_daily = max( - 0, limit_check.daily_limit - limit_check.daily_used - 1 - ) + remaining_daily = max(0, limit_check.daily_limit - daily_used - 1) remaining_monthly = None if limit_check.monthly_limit is not None: - remaining_monthly = max( - 0, limit_check.monthly_limit - limit_check.monthly_used - 1 - ) + remaining_monthly = max(0, limit_check.monthly_limit - monthly_used - 1) return remaining_daily, remaining_monthly except Exception as e: logger.warning(f"Failed to get remaining quota: {e}") return None, None + + @staticmethod + def _extract_limit_usage( + limit_check, + primary_attr: str, + fallback_attr: str, + ) -> int: + """Extract integer usage counter from limit check object.""" + value = getattr(limit_check, primary_attr, None) + if not isinstance(value, (int, float)): + value = getattr(limit_check, fallback_attr, 0) + + try: + return int(value) + except (TypeError, ValueError): + return 0 diff --git a/bot/di/modules/enhanced_cache_module.py b/bot/di/modules/enhanced_cache_module.py index 070f1fd..73ccbc6 100644 --- a/bot/di/modules/enhanced_cache_module.py +++ b/bot/di/modules/enhanced_cache_module.py @@ -124,10 +124,22 @@ async def delete(self, key: str) -> bool: """Always return False (nothing to delete).""" return False + async def invalidate(self, key: str) -> bool: + """Always return False (nothing to invalidate).""" + return False + async def invalidate_pattern(self, pattern: str) -> int: """Always return 0 (nothing to invalidate).""" return 0 + async def increment(self, key: str, ttl: int | None = None) -> int: + """Always return 0 (no counters).""" + return 0 + + async def decrement(self, key: str) -> int: + """Always return 0 (no counters).""" + return 0 + class CacheHealthChecker: """ diff --git a/bot/domain/repositories/qr_history_repository.py b/bot/domain/repositories/qr_history_repository.py index 077a306..dc80c71 100644 --- a/bot/domain/repositories/qr_history_repository.py +++ b/bot/domain/repositories/qr_history_repository.py @@ -27,6 +27,12 @@ async def get_user_history( async def get_daily_count(self, user_id: int, date: date) -> int: """Get count of QR codes generated by user on specific date""" + @abstractmethod + async def increment_daily_usage( + self, user_id: int, usage_date: date, increment: int = 1 + ) -> int: + """Atomically increment daily usage counter and return new value""" + @abstractmethod async def get_monthly_count(self, user_id: int, year: int, month: int) -> int: """Get count of QR codes generated by user in specific month""" diff --git a/bot/infrastructure/cache.py b/bot/infrastructure/cache.py index d054032..542e383 100644 --- a/bot/infrastructure/cache.py +++ b/bot/infrastructure/cache.py @@ -239,6 +239,25 @@ async def increment(self, key: str, ttl: int | None = None) -> int: logger.error(f"❌ Cache increment error for key '{key}': {e}") return 0 + async def decrement(self, key: str) -> int: + """Decrement a counter. + + Returns: + New counter value after decrement (0 if missing/failure) + """ + if not self._enabled or not self._redis: + return 0 + + try: + value = await self._redis.decr(key) + if value <= 0: + await self._redis.delete(key) + return 0 + return value + except Exception as e: + logger.error(f"❌ Cache decrement error for key '{key}': {e}") + return 0 + async def ping(self) -> bool: """ Check if Redis connection is alive. diff --git a/bot/infrastructure/cache/enhanced_cache_service.py b/bot/infrastructure/cache/enhanced_cache_service.py index 22e57ba..686b2cc 100644 --- a/bot/infrastructure/cache/enhanced_cache_service.py +++ b/bot/infrastructure/cache/enhanced_cache_service.py @@ -532,6 +532,36 @@ async def increment(self, key: str, ttl: int | None = None) -> int: logger.warning(f"Cache increment failed for key '{key}': {e}") return 0 + async def decrement(self, key: str) -> int: + """Decrement a counter and return the new value. + + Used to rollback quota reservations when QR generation fails after + successful pre-allocation. Never returns a negative value. + + Args: + key: Counter key + + Returns: + New counter value, or 0 if key is absent/failure + """ + try: + if self._is_circuit_open(): + return 0 + + value = await self._redis.decr(key) + if value <= 0: + # Avoid negative counters (e.g., DECR on missing key => -1) + await self._redis.delete(key) + return 0 + + self._metrics.total_operations += 1 + return value + + except Exception as e: + self._record_failure(e) + logger.warning(f"Cache decrement failed for key '{key}': {e}") + return 0 + async def _get_internal(self, key: str) -> Any | None: """ Internal get method with decompression and deserialization. diff --git a/bot/infrastructure/repositories/qr_history_repository.py b/bot/infrastructure/repositories/qr_history_repository.py index ef8f570..1733214 100644 --- a/bot/infrastructure/repositories/qr_history_repository.py +++ b/bot/infrastructure/repositories/qr_history_repository.py @@ -122,6 +122,33 @@ async def get_daily_count(self, user_id: int, target_date: date) -> int: return row["count"] if row else 0 + async def increment_daily_usage( + self, user_id: int, usage_date: date, increment: int = 1 + ) -> int: + """Atomically increment daily usage and return updated count.""" + if increment <= 0: + raise ValueError("increment must be positive") + + query = """ + WITH seq AS ( + SELECT COALESCE( + pg_get_serial_sequence('daily_usage', 'id'), + 'daily_usage_id_seq' + ) AS seq_name + ) + INSERT INTO daily_usage (id, user_id, usage_date, qr_count) + VALUES (nextval((SELECT seq_name FROM seq)::regclass), $1, $2, $3) + ON CONFLICT (user_id, usage_date) + DO UPDATE SET + qr_count = daily_usage.qr_count + EXCLUDED.qr_count, + updated_at = CURRENT_TIMESTAMP + RETURNING qr_count + """ + + async with self._pool.acquire() as conn: + value = await conn.fetchval(query, user_id, usage_date, increment) + return int(value) if value is not None else 0 + async def get_monthly_count(self, user_id: int, year: int, month: int) -> int: """ Get number of QR codes generated by user in specific month. diff --git a/bot/presentation/handlers/qr/__init__.py b/bot/presentation/handlers/qr/__init__.py index 8664c3e..5e7c909 100644 --- a/bot/presentation/handlers/qr/__init__.py +++ b/bot/presentation/handlers/qr/__init__.py @@ -11,7 +11,6 @@ ├── input_handlers.py # URL, Text, Email input handlers ├── preview_handlers.py # Preview screen and customization handlers ├── format_selection.py # Format choice and QR generation -├── url_fix.py # URL suggestion fix handler └── shared.py # Shared utilities and helpers Each module has its own router that is combined in __init__.py. @@ -28,7 +27,6 @@ from .input_handlers import router as input_router from .payment_flow import router as payment_router from .preview_handlers import router as preview_router -from .url_fix import router as url_fix_router from .vcard_flow import router as vcard_router from .wifi_flow import router as wifi_router @@ -48,6 +46,5 @@ router.include_router(frame_router) # Frame with text customization handlers router.include_router(preview_router) # Preview and customization router.include_router(format_router) # Format selection and generation -router.include_router(url_fix_router) # URL fix handler __all__ = ["router"] diff --git a/bot/presentation/handlers/qr/input_handlers.py b/bot/presentation/handlers/qr/input_handlers.py index 591cbc7..518034d 100644 --- a/bot/presentation/handlers/qr/input_handlers.py +++ b/bot/presentation/handlers/qr/input_handlers.py @@ -8,6 +8,7 @@ - QR Title (optional) """ +import hashlib import logging from aiogram import F, Router, types @@ -16,6 +17,7 @@ from fluent.runtime import FluentLocalization from bot.domain.validators import suggest_url_fix, validate_url +from bot.presentation.callbacks import FixURLCallback from bot.presentation.dependencies import QRGenerationDependencies from bot.presentation.keyboards import QRGenerationKeyboard from bot.presentation.states import QRGenerationStates @@ -79,12 +81,19 @@ async def handle_url_input( # Suggest fix if possible suggested = suggest_url_fix(text) if suggested: + # Store raw input in state and send only compact hash in callback_data. + url_fix_hash = hashlib.sha256(text.encode()).hexdigest()[:16] + state_data = await state.get_data() + url_fix_map = state_data.get("url_fix_map", {}) + url_fix_map[url_fix_hash] = text + await state.update_data(url_fix_map=url_fix_map) + keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( text=l10n.format_value("use-suggestion"), - callback_data=f"fix_{text}", + callback_data=FixURLCallback(hash=url_fix_hash).pack(), ) ] ] @@ -400,16 +409,28 @@ async def handle_skip_title( await callback.answer() -@router.callback_query(F.data.startswith("fix_")) +@router.callback_query(FixURLCallback.filter()) async def handle_url_fix( callback: CallbackQuery, + callback_data: FixURLCallback, state: FSMContext, l10n: FluentLocalization, deps: QRGenerationDependencies, ): """Handle URL fix suggestion from user.""" - # Extract original text from callback data - original_text = callback.data[4:] # Remove "fix_" prefix + state_data = await state.get_data() + url_fix_map = state_data.get("url_fix_map", {}) + original_text = url_fix_map.get(callback_data.hash) + + if not original_text: + await callback.answer( + l10n.format_value("error-invalid-url-format"), show_alert=True + ) + return + + # One-time token: remove after lookup to avoid stale growth. + url_fix_map.pop(callback_data.hash, None) + await state.update_data(url_fix_map=url_fix_map) # Try to suggest fix suggested = suggest_url_fix(original_text) diff --git a/bot/presentation/keyboards/eye_style_keyboards.py b/bot/presentation/keyboards/eye_style_keyboards.py index bab94c9..8d76184 100644 --- a/bot/presentation/keyboards/eye_style_keyboards.py +++ b/bot/presentation/keyboards/eye_style_keyboards.py @@ -8,6 +8,7 @@ EyeBallCallback, EyeFrameCallback, EyeStyleMenuCallback, + PreviewActionCallback, ) @@ -188,7 +189,7 @@ def create_eye_preview_keyboard(l10n: FluentLocalization) -> InlineKeyboardMarku [ InlineKeyboardButton( text=f"✨ {l10n.format_value('generate-qr')}", - callback_data="generate_qr", + callback_data=PreviewActionCallback(action="generate").pack(), ) ], [ diff --git a/bot/presentation/keyboards/qr_keyboards.py b/bot/presentation/keyboards/qr_keyboards.py index b2977ff..ad68621 100644 --- a/bot/presentation/keyboards/qr_keyboards.py +++ b/bot/presentation/keyboards/qr_keyboards.py @@ -261,7 +261,8 @@ def create_send_qr_button( [ InlineKeyboardButton( text=l10n.format_value("button-send-qr"), - callback_data=f"send_qr_{qr_format}", + # Use routed callback instead of orphan legacy "send_qr_*". + callback_data=PreviewActionCallback(action="generate").pack(), ) ] ] diff --git a/db_schema.sql b/db_schema.sql index 283bc55..2bf26a6 100644 --- a/db_schema.sql +++ b/db_schema.sql @@ -473,11 +473,7 @@ FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); DROP TRIGGER IF EXISTS update_tariffs_updated_at ON tariffs; CREATE TRIGGER update_tariffs_updated_at BEFORE UPDATE ON tariffs - INSERT INTO daily_usage (user_id, date, count, last_generated_at) - VALUES (NEW.user_id, DATE(NEW.created_at), 1, NEW.created_at) - ON CONFLICT (user_id, date) - DO UPDATE SET - count = daily_usage.count + 1, +FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); -- Триггер для автоматического обновления daily_usage (УДАЛЕН - используется событийная архитектура) diff --git a/tests/unit/application/command_handlers/qr/test_generate_qr_handler.py b/tests/unit/application/command_handlers/qr/test_generate_qr_handler.py index 69562a0..ab5ca1d 100644 --- a/tests/unit/application/command_handlers/qr/test_generate_qr_handler.py +++ b/tests/unit/application/command_handlers/qr/test_generate_qr_handler.py @@ -899,3 +899,227 @@ async def test_generate_wifi_qr_skips_url_validation( # Assert: Should succeed without URL validation error assert qr_id == 1 + + +class TestGenerateQRHandlerCriticalLimits: + """Critical limiter behavior tests (fail-closed + rollback).""" + + async def test_updates_daily_usage_in_same_transaction( + self, + handler, + mock_qr_generator, + mock_unit_of_work, + sample_user, + ): + """Successful generation must increment daily_usage counter.""" + mock_qr_generator.generate.return_value = b"fake_qr_binary_data" + + uow = mock_unit_of_work() + uow.users.get_by_telegram_id.return_value = sample_user + + saved_qr = QRCode( + id=10, + user_id=sample_user.telegram_id, + url=URL("https://example.com"), + url_hash=hashlib.sha256(b"https://example.com").hexdigest(), + format=QRFormat.PNG, + title="Daily Usage Test", + is_dynamic=False, + short_code=None, + file_size_bytes=128, + generation_time_ms=25, + is_deleted=False, + created_at=datetime.now(UTC), + ) + uow.qr_codes.save.return_value = saved_qr + + command = GenerateQRCommand( + user_id=sample_user.telegram_id, + url="https://example.com", + format="png", + qr_type="url", + title="Daily Usage Test", + is_dynamic=False, + size="medium", + error_correction="M", + design_config=None, + requested_at=datetime.now(UTC), + ) + + await handler.handle(command) + + uow.qr_codes.increment_daily_usage.assert_called_once() + + async def test_fail_closed_when_atomic_limiter_unavailable( + self, + mock_qr_generator, + mock_unit_of_work, + mock_event_bus, + dynamic_resolver, + mock_user_repository, + mock_tariff_repository, + mock_history_repository, + sample_user, + sample_tariff, + mock_cache_service, + ): + """Limiter must deny generation when increment returns 0.""" + mock_user_repository.get_by_telegram_id.return_value = sample_user + mock_tariff_repository.get_by_id.return_value = sample_tariff + mock_tariff_repository.get_default.return_value = sample_tariff + + mock_cache_service.increment.return_value = 0 + mock_cache_service.decrement.return_value = 0 + + handler = GenerateQRHandler( + qr_generator=mock_qr_generator, + uow_factory=mock_unit_of_work, + event_bus=mock_event_bus, + dynamic_resolver=dynamic_resolver, + user_repository=mock_user_repository, + tariff_repository=mock_tariff_repository, + history_repository=mock_history_repository, + cache_service=mock_cache_service, + ) + + command = GenerateQRCommand( + user_id=sample_user.telegram_id, + url="https://example.com", + format="png", + qr_type="url", + title="Fail Closed", + is_dynamic=False, + size="medium", + error_correction="M", + design_config=None, + requested_at=datetime.now(UTC), + ) + + with pytest.raises(PermissionError, match="Rate limiter is unavailable"): + await handler.handle(command) + + mock_qr_generator.generate.assert_not_called() + + async def test_rolls_back_reservation_on_validation_error( + self, + mock_qr_generator, + mock_unit_of_work, + mock_event_bus, + dynamic_resolver, + mock_user_repository, + mock_tariff_repository, + mock_history_repository, + sample_user, + sample_tariff, + mock_cache_service, + ): + """If generation fails after reservation, counters must be released.""" + mock_user_repository.get_by_telegram_id.return_value = sample_user + mock_tariff_repository.get_by_id.return_value = sample_tariff + mock_tariff_repository.get_default.return_value = sample_tariff + sample_tariff.monthly_limit = 100 + + # Daily and monthly reservations succeed before URL validation fails. + mock_cache_service.increment.side_effect = [1, 1] + mock_cache_service.decrement.return_value = 0 + + handler = GenerateQRHandler( + qr_generator=mock_qr_generator, + uow_factory=mock_unit_of_work, + event_bus=mock_event_bus, + dynamic_resolver=dynamic_resolver, + user_repository=mock_user_repository, + tariff_repository=mock_tariff_repository, + history_repository=mock_history_repository, + cache_service=mock_cache_service, + ) + + command = GenerateQRCommand( + user_id=sample_user.telegram_id, + url="not-a-valid-url", + format="png", + qr_type="url", + title="Rollback Reservation", + is_dynamic=False, + size="medium", + error_correction="M", + design_config=None, + requested_at=datetime.now(UTC), + ) + + with pytest.raises(ValueError): + await handler.handle(command) + + assert mock_cache_service.decrement.call_count == 2 + + async def test_does_not_rollback_reservation_after_successful_persist( + self, + mock_qr_generator, + mock_unit_of_work, + mock_event_bus, + dynamic_resolver, + mock_user_repository, + mock_tariff_repository, + mock_history_repository, + sample_user, + sample_tariff, + mock_cache_service, + ): + """If failure happens after commit, reservation must stay consumed.""" + mock_user_repository.get_by_telegram_id.return_value = sample_user + mock_tariff_repository.get_by_id.return_value = sample_tariff + mock_tariff_repository.get_default.return_value = sample_tariff + sample_tariff.monthly_limit = 100 + + mock_cache_service.increment.side_effect = [1, 1] + mock_cache_service.decrement.return_value = 0 + + mock_qr_generator.generate.return_value = b"ok" + uow = mock_unit_of_work() + uow.users.get_by_telegram_id.return_value = sample_user + uow.qr_redirects.short_code_exists.return_value = False + uow.qr_codes.save.return_value = QRCode( + id=321, + user_id=sample_user.telegram_id, + url=URL("https://example.com"), + url_hash="hash", + format=QRFormat.PNG, + title="Persisted", + is_dynamic=False, + short_code=None, + file_size_bytes=2, + generation_time_ms=10, + is_deleted=False, + created_at=datetime.now(UTC), + ) + + mock_event_bus.publish.side_effect = RuntimeError("event bus down") + + handler = GenerateQRHandler( + qr_generator=mock_qr_generator, + uow_factory=mock_unit_of_work, + event_bus=mock_event_bus, + dynamic_resolver=dynamic_resolver, + user_repository=mock_user_repository, + tariff_repository=mock_tariff_repository, + history_repository=mock_history_repository, + cache_service=mock_cache_service, + ) + + command = GenerateQRCommand( + user_id=sample_user.telegram_id, + url="https://example.com", + format="png", + qr_type="url", + title="Persisted", + is_dynamic=False, + size="medium", + error_correction="M", + design_config=None, + requested_at=datetime.now(UTC), + ) + + with pytest.raises(RuntimeError, match="event bus down"): + await handler.handle(command) + + mock_cache_service.decrement.assert_not_called()