Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
549 changes: 331 additions & 218 deletions bot/application/command_handlers/qr/generate_qr_handler.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions bot/application/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Чёткое разделение ответственности
"""

from .dynamic_qr_resolver import DynamicQRCodeResolver, DynamicQRSetup
from .qr_generation_service import (
GenerationResult,
GenerationStatus,
Expand All @@ -22,6 +23,8 @@
from .user_stats_service import UserStatsService

__all__ = [
"DynamicQRCodeResolver",
"DynamicQRSetup",
"QRGenerationService",
"GenerationResult",
"GenerationStatus",
Expand Down
133 changes: 133 additions & 0 deletions bot/application/services/dynamic_qr_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""
Dynamic QR Code Resolver service.

Encapsulates the logic for generating unique short codes
and building redirect URLs for dynamic QR codes.

Extracted from GenerateQRHandler to follow SRP:
- Short code generation and collision resolution
- Redirect URL construction
- No I/O — purely stateless preparation logic

Clean Architecture: This service lives in the application layer
because it depends on RedirectURLBuilder (configuration-aware).
"""

from dataclasses import dataclass

from bot.application.services.redirect_url_builder import RedirectURLBuilder
from bot.domain.value_objects import URL, ShortCode

# Maximum number of short code generation attempts before giving up.
# After this many collisions, we assume something is fundamentally wrong.
MAX_SHORT_CODE_RETRIES = 10


@dataclass(frozen=True)
class DynamicQRSetup:
"""Immutable result of dynamic QR preparation.

Contains all data needed to generate a dynamic QR code image
and persist the redirect mapping.

Attributes:
short_code: Generated short code string (e.g., "xK9mP2Lq")
redirect_url: URL to encode in the QR image (APP_URL/{short_code})
target_url: Original URL the user wants the QR to point to
"""

short_code: str
redirect_url: URL
target_url: URL


class DynamicQRCodeResolver:
"""Resolves unique short codes for dynamic QR codes.

Responsibilities:
- Generate initial short code + redirect URL pair
- Regenerate on collision (new random short code)
- Verify uniqueness against repository (inside transaction)

Usage:
resolver = DynamicQRCodeResolver(redirect_url_builder)

# Phase 1: Prepare (outside transaction)
setup = resolver.prepare(target_url)

# Phase 2: Ensure uniqueness (inside transaction)
final_setup = await resolver.ensure_unique(uow, setup)
"""

def __init__(self, redirect_url_builder: RedirectURLBuilder) -> None:
self._url_builder = redirect_url_builder

def prepare(self, target_url: URL) -> DynamicQRSetup:
"""Generate initial short code and redirect URL.

This is a pure function (no I/O) — safe to call outside transaction.

Args:
target_url: The URL the user wants the QR to redirect to

Returns:
DynamicQRSetup with generated short code and redirect URL
"""
short_code = str(ShortCode.generate())
redirect_url = URL(self._url_builder.build(short_code))
return DynamicQRSetup(
short_code=short_code,
redirect_url=redirect_url,
target_url=target_url,
)

def regenerate(self, target_url: URL) -> DynamicQRSetup:
"""Generate a new short code after collision.

Same as prepare() — generates a fresh random short code.

Args:
target_url: The URL the user wants the QR to redirect to

Returns:
DynamicQRSetup with a new random short code
"""
return self.prepare(target_url)

async def ensure_unique(
self,
uow,
setup: DynamicQRSetup,
) -> DynamicQRSetup:
"""Check short code uniqueness, regenerate on collision.

This method MUST be called inside an active UoW transaction
to guarantee consistency (short_code_exists is a DB query).

The method only regenerates the short code string — it does NOT
regenerate the QR image. The caller is responsible for detecting
that the short code changed and regenerating the image accordingly.

Args:
uow: Active Unit of Work with qr_redirects repository
setup: Initial DynamicQRSetup to verify

Returns:
Original setup if unique, or new setup with different short code

Raises:
RuntimeError: If unique code cannot be found after max retries
"""
retries = 0
current = setup

while await uow.qr_redirects.short_code_exists(ShortCode(current.short_code)):
retries += 1
if retries > MAX_SHORT_CODE_RETRIES:
raise RuntimeError(
f"Failed to generate unique short code "
f"after {MAX_SHORT_CODE_RETRIES} attempts"
)
current = self.regenerate(setup.target_url)

return current
14 changes: 14 additions & 0 deletions bot/di/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@
GetUserStatsHandler,
)
from bot.application.services import (
DynamicQRCodeResolver,
QRGenerationService,
QRPreviewService,
RedirectURLBuilder,
UserStatsService,
)
from bot.config import Config
Expand Down Expand Up @@ -309,6 +311,18 @@ def qr_preview_service(self) -> QRPreviewService:
"""Get QR preview application service (lazy)."""
return self._lazy_deps["qr_preview_service"].get_sync()

# Shared Application Services (used by command handlers)

@property
def redirect_url_builder(self) -> RedirectURLBuilder:
"""Get redirect URL builder (lazy)."""
return self._lazy_deps["redirect_url_builder"].get_sync()

@property
def dynamic_qr_resolver(self) -> DynamicQRCodeResolver:
"""Get dynamic QR code resolver (lazy)."""
return self._lazy_deps["dynamic_qr_resolver"].get_sync()

# CQRS Command Handlers

@property
Expand Down
19 changes: 17 additions & 2 deletions bot/di/modules/command_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
UpdateUserLanguageHandler,
UpgradeUserTariffHandler,
)
from bot.application.services.dynamic_qr_resolver import DynamicQRCodeResolver
from bot.application.services.redirect_url_builder import RedirectURLBuilder

from .base import DIModule
Expand All @@ -29,15 +30,29 @@ class CommandHandlersModule(DIModule):
"""

def register(self, container) -> None:
# Shared services for QR handlers
container.register_lazy(
"redirect_url_builder",
lambda: RedirectURLBuilder(container.config.app_url),
singleton=True,
)

container.register_lazy(
"dynamic_qr_resolver",
lambda: DynamicQRCodeResolver(
redirect_url_builder=container.redirect_url_builder,
),
singleton=True,
)

# QR Handlers
container.register_lazy(
"generate_qr_handler",
lambda: GenerateQRHandler(
qr_generator=container.qr_generator,
uow_factory=container.create_unit_of_work,
event_bus=container.event_bus,
config=container.config,
redirect_url_builder=RedirectURLBuilder(container.config.app_url),
dynamic_resolver=container.dynamic_qr_resolver,
),
singleton=True,
)
Expand Down
20 changes: 20 additions & 0 deletions bot/domain/events/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,26 @@ def _get_payload(self) -> dict[str, Any]:
if k not in base_fields
}

def with_updated(self, **kwargs: Any) -> BaseEvent:
"""Create a copy of this event with updated fields.

Uses dataclasses.replace() under the hood to produce a new
frozen instance. This is safe for frozen dataclasses and avoids
fragile manual reconstruction when only one or two fields change.

Usage:
event = event.with_updated(qr_code_id=saved_id)

Args:
**kwargs: Fields to update in the new instance

Returns:
New event instance with updated fields
"""
import dataclasses

return dataclasses.replace(self, **kwargs)

@staticmethod
def _serialize_value(value: Any) -> Any:
"""Serialize a single value for JSON compatibility."""
Expand Down
20 changes: 14 additions & 6 deletions tests/infrastructure/test_unit_of_work.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,15 @@ async def test_handler_uses_uow_for_all_db_ops(self):
"""Test that all DB operations happen inside UoW context."""
from bot.application.command_handlers import GenerateQRHandler
from bot.application.commands import GenerateQRCommand
from bot.application.services.dynamic_qr_resolver import DynamicQRCodeResolver
from bot.application.services.redirect_url_builder import RedirectURLBuilder

# Mock dependencies
event_bus = AsyncMock()
config = MagicMock()
config.app_url = "https://example.com"
redirect_url_builder = RedirectURLBuilder("https://example.com")
dynamic_resolver = DynamicQRCodeResolver(
redirect_url_builder=redirect_url_builder
)

# Track UoW usage
uow_entered = False
Expand Down Expand Up @@ -147,7 +151,7 @@ def uow_factory():
qr_generator=qr_generator,
uow_factory=uow_factory,
event_bus=event_bus,
config=config,
dynamic_resolver=dynamic_resolver,
)

command = GenerateQRCommand(
Expand All @@ -173,13 +177,17 @@ async def test_events_published_after_commit(self):
"""Test that events are published only after UoW commits."""
from bot.application.command_handlers import GenerateQRHandler
from bot.application.commands import GenerateQRCommand
from bot.application.services.dynamic_qr_resolver import DynamicQRCodeResolver
from bot.application.services.redirect_url_builder import RedirectURLBuilder

qr_generator = AsyncMock()
qr_generator.generate = AsyncMock(return_value=b"qr_data")

event_bus = AsyncMock()
config = MagicMock()
config.app_url = "https://example.com"
redirect_url_builder = RedirectURLBuilder("https://example.com")
dynamic_resolver = DynamicQRCodeResolver(
redirect_url_builder=redirect_url_builder
)

events_published = []
commit_called = False
Expand Down Expand Up @@ -216,7 +224,7 @@ async def on_exit(self_mock, exc_type, exc_val, exc_tb):
qr_generator=qr_generator,
uow_factory=lambda: mock_uow,
event_bus=event_bus,
config=config,
dynamic_resolver=dynamic_resolver,
)

command = GenerateQRCommand(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from bot.application.command_handlers.qr.generate_qr_handler import GenerateQRHandler
from bot.application.commands import GenerateQRCommand
from bot.application.services.dynamic_qr_resolver import DynamicQRCodeResolver
from bot.application.services.redirect_url_builder import RedirectURLBuilder
from bot.domain.entities import QRCode
from bot.domain.value_objects import URL, QRFormat
Expand All @@ -29,21 +30,25 @@ def redirect_url_builder(mock_config):
return RedirectURLBuilder(mock_config.app_url)


@pytest.fixture
def dynamic_resolver(redirect_url_builder):
"""Create DynamicQRCodeResolver with redirect_url_builder."""
return DynamicQRCodeResolver(redirect_url_builder=redirect_url_builder)


@pytest.fixture
def handler(
mock_qr_generator,
mock_unit_of_work,
mock_event_bus,
mock_config,
redirect_url_builder,
dynamic_resolver,
):
"""Create handler with mocked dependencies."""
return GenerateQRHandler(
qr_generator=mock_qr_generator,
uow_factory=mock_unit_of_work,
event_bus=mock_event_bus,
config=mock_config,
redirect_url_builder=redirect_url_builder,
dynamic_resolver=dynamic_resolver,
)


Expand Down Expand Up @@ -474,7 +479,7 @@ async def test_max_collision_retries_raises_error(
)

# Act & Assert
with pytest.raises(RuntimeError, match="Failed to generate unique short code"):
with pytest.raises(RuntimeError, match="Failed to persist QR code"):
await handler.handle(command)

async def test_dynamic_wifi_falls_back_to_static(
Expand Down
Loading