diff --git a/.claude/CLAUDE.local.md b/.claude/CLAUDE.local.md new file mode 100644 index 0000000..420546a --- /dev/null +++ b/.claude/CLAUDE.local.md @@ -0,0 +1,132 @@ +# Personal Development Preferences + +This file is for YOUR personal preferences when working on this project. +It's automatically gitignored, so your teammates won't see these. + +## My Preferred Patterns + +### Error Messages + +I prefer verbose error messages during development: + +```python +# Instead of: +raise ValueError("Invalid email") + +# I prefer: +raise ValueError( + f"Invalid email format: '{self.value}'. " + f"Expected format: user@domain.com" +) +``` + +### Logging + +Add debug logging to all service methods: + +```python +import logging + +logger = logging.getLogger(__name__) + +async def login(self, email: Email, password: Password) -> LoginResult: + logger.debug(f"Attempting login for email: {email.value}") + # ... implementation + logger.debug(f"Login successful for email: {email.value}") +``` + +### Test Data + +When creating test fixtures, I prefer these test users: + +```python +TEST_USER_EMAIL = "pablo.test@homecomp.dev" +TEST_USER_PASSWORD = "TestPass123!" +``` + +## My Development Workflow + +### Before Starting Work + +1. Pull latest changes from `dev` branch +2. Run `just migrate` to ensure DB is up to date +3. Check `docker-compose ps` to verify PostgreSQL is running + +### Before Committing + +1. Run tests (when test suite exists) +2. Run linter/formatter +3. Review the diff + +### Commit Message Style + +I prefer conventional commits format: + +``` +feat(auth): add login throttling service +fix(user): correct email validation regex +refactor(shared): extract password hashing to value object +test(auth): add login service unit tests +``` + +## Code Review Preferences + +When reviewing my code: + +- Flag any missing type hints +- Check for proper use of value objects (no raw strings/ints) +- Verify async/await usage +- Ensure repositories return DTOs, not models +- Look for potential N+1 query issues + +## Quick Commands I Use + +```bash +# Start everything +docker-compose up -d && just run + +# Reset database (DESTRUCTIVE!) +docker-compose down -v && docker-compose up -d && just migrate + +# Check recent migrations +uv run alembic history | head -n 5 + +# Database shell +just pgcli +``` + +## Import Aliases I Like + +```python +# Shared value objects +from app.shared.domain.value_objects.shared_email import Email as SharedEmail +from app.shared.domain.value_objects.shared_password import Password as SharedPassword + +# Context-specific (when importing across contexts) +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user.domain.dto.user_dto import UserDTO +``` + +## Notes to Self + +- Remember to update CLAUDE.md if architectural patterns change +- Keep `/docs/login-throttle-implementation.md` updated +- Consider adding OpenAPI tags to routes for better docs organization +- Eventually add health check endpoint +- Set up CI/CD when ready + +## Personal Testing Preferences + +- Always test the happy path first +- Then test validation failures +- Then test edge cases +- Mock at the contract level, never the implementation + +## Documentation Standards + +When adding new features: + +1. Update CLAUDE.md if it affects architecture +2. Add inline comments only for non-obvious business logic +3. Update API docs if adding/changing endpoints +4. Consider adding examples to `/docs/` for complex features diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md new file mode 100644 index 0000000..2408c84 --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,871 @@ +--- +paths: app/**/*.py +--- + +# Python Code Style Guidelines + +## General Python Style + +- Follow PEP 8 with line length limit of 100 characters +- Use Python 3.13+ features where beneficial +- Prefer `dataclass` over manual `__init__` methods +- Use `frozen=True` for immutable data structures +- Use type hints for ALL function signatures and class attributes + +## Type Hints + +### Always Annotate + +```python +# Good +async def find_user(self, email: Email) -> Optional[UserDTO]: + pass + +# Bad +async def find_user(self, email): + pass +``` + +### Use Domain Types + +```python +# Good - uses value objects +def create_user(email: Email, password: Password) -> UserID: + pass + +# Bad - uses primitives +def create_user(email: str, password: str) -> int: + pass +``` + +### SQLAlchemy Type Annotations + +```python +from sqlalchemy.orm import Mapped, mapped_column + +class UserModel(BaseModel): + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True) +``` + +## Async/Await Conventions + +### Always Use Async + +All application code should be async: + +```python +# Good +async def login(self, email: Email, password: Password) -> LoginResult: + user = await self._user_repo.find_user(email=email) + return result + +# Bad - blocking in async context +async def login(self, email: Email, password: Password) -> LoginResult: + user = self._user_repo.find_user_sync(email=email) # Blocks! + return result +``` + +### Await Database Operations + +```python +# Good +result = await db.execute(select(UserModel).where(...)) + +# Bad +result = db.execute(select(UserModel).where(...)) # Missing await! +``` + +## Dataclasses and Immutability + +### Value Objects + +Always frozen, with validation in `__post_init__`: + +```python +from dataclasses import dataclass, field + +@dataclass(frozen=True) +class Email: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + # Only validate if not from trusted source + if not self._validated and not self._is_valid(): + raise ValueError(f"Invalid email: {self.value}") + + @classmethod + def from_trusted_source(cls, value: str) -> "Email": + """ + Create Email from trusted source (e.g., database) - skips validation. + Use this to avoid performance overhead when data is already validated. + """ + return cls(value, _validated=True) + + def _is_valid(self) -> bool: + # Validation logic + return True +``` + +**Usage:** + +```python +# From user input - validates +user_email = Email("user@example.com") # Runs validation + +# From database - skips validation for performance +class UserMapper: + @staticmethod + def toDTO(model: UserModel) -> UserDTO: + return UserDTO( + user_id=UserID(model.id), + email=Email.from_trusted_source(model.email), # No validation overhead + password=Password.from_hash(model.password) + ) +``` + +### Context-Specific Value Objects + +**Rule:** To maintain bounded context isolation, **always create context-specific value objects** by extending shared value objects. Never use shared value objects directly in domain models or DTOs. + +**Pattern:** + +```python +# Shared Kernel - validation logic +# app/shared/domain/value_objects/shared_currency.py +@dataclass(frozen=True) +class SharedCurrency: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + # Validation logic + if len(self.value) != 3: + raise ValueError("Currency must be 3 characters") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + return cls(value, _validated=True) + +# Context-specific wrapper +# app/context/user_account/domain/value_objects/account_currency.py +@dataclass(frozen=True) +class UserAccountCurrency(SharedCurrency): + pass # Inherits all validation from SharedCurrency + +# Another context-specific wrapper +# app/context/credit_card/domain/value_objects/credit_card_currency.py +@dataclass(frozen=True) +class CreditCardCurrency(SharedCurrency): + pass # Can add context-specific behavior later if needed +``` + +**Usage in Domain Models:** + +```python +# Good - uses context-specific type +@dataclass(frozen=True) +class UserAccountDTO: + account_id: AccountID + currency: UserAccountCurrency # ✅ Context-specific + +# Bad - uses shared type directly +@dataclass(frozen=True) +class UserAccountDTO: + account_id: AccountID + currency: SharedCurrency # ❌ Breaks context isolation + +# Bad - uses wrong context's type +@dataclass(frozen=True) +class UserAccountDTO: + account_id: AccountID + currency: CreditCardCurrency # ❌ Cross-context dependency +``` + +**Rationale:** +- Maintains strict bounded context boundaries +- Prevents accidental cross-context dependencies +- Allows future context-specific behavior without breaking changes +- Makes code explicitly show which context owns the value +- Type system enforces architectural boundaries + +### DTOs + +Always frozen, minimal logic: + +```python +@dataclass(frozen=True) +class UserDTO: + user_id: UserID + email: Email + password: Password +``` + +### Pydantic Models + +Use for request validation only (interface layer): + +```python +class LoginRequest(BaseModel): + model_config = ConfigDict(frozen=True) + email: EmailStr + password: str +``` + +## Error Handling + +### Custom Exceptions for Each Case + +**ALWAYS create specific custom exceptions** instead of raising standard exceptions (ValueError, RuntimeError, etc.). Each exceptional case should have its own exception class. + +**Rationale:** +- Makes error handling more explicit and type-safe +- Allows different handling for different error cases +- Self-documenting code (exception name describes what went wrong) +- Easier to catch and handle specific errors in controllers + +### Exception Organization + +Create exceptions in `domain/exceptions/`: + +```python +# app/context/user_account/domain/exceptions/exceptions.py +class UserAccountMapperError(Exception): + pass + +class UserAccountNameAlreadyExistError(Exception): + pass + +class UserAccountNotFoundError(Exception): + pass +``` + +```python +# app/context/user_account/domain/exceptions/__init__.py +from .exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) + +__all__ = [ + "UserAccountMapperError", + "UserAccountNameAlreadyExistError", + "UserAccountNotFoundError", +] +``` + +### Naming Convention + +Exception names should clearly describe the error condition: + +- `{Entity}NotFoundError` - Entity doesn't exist +- `{Entity}{Field}AlreadyExistError` - Duplicate/unique constraint violation +- `{Entity}{Operation}Error` - Operation-specific failures +- `Invalid{Entity}{Field}Error` - Validation failures for specific fields + +```python +# Good - specific exceptions +class UserNotFoundError(Exception): + pass + +class UserEmailAlreadyExistError(Exception): + pass + +class InvalidUserPasswordError(Exception): + pass + +# Bad - generic exceptions +raise ValueError("User not found") # Don't do this! +raise RuntimeError("Email already exists") # Don't do this! +``` + +### Value Objects + +Create specific validation exceptions: + +```python +# app/context/user/domain/exceptions/exceptions.py +class InvalidEmailFormatError(Exception): + pass + +class InvalidPasswordLengthError(Exception): + pass + +# app/context/user/domain/value_objects/email.py +@dataclass(frozen=True) +class Email: + value: str + + def __post_init__(self): + if not self._is_valid(): + raise InvalidEmailFormatError( + f"Invalid email format: '{self.value}'. " + f"Expected format: user@domain.com" + ) +``` + +### Domain Services + +Raise specific domain exceptions: + +```python +# app/context/auth/domain/exceptions/exceptions.py +class InvalidCredentialsError(Exception): + pass + +class AccountLockedError(Exception): + pass + +# app/context/auth/domain/services/login_service.py +class LoginService: + async def login(...) -> LoginResult: + user = await self._user_repo.find_user(email=email) + if not user: + raise InvalidCredentialsError("Invalid email or password") + + if user.is_locked: + raise AccountLockedError(f"Account locked until {user.locked_until}") +``` + +### Controllers + +Map domain exceptions to HTTP status codes: + +```python +from fastapi import HTTPException +from app.context.auth.domain.exceptions import ( + InvalidCredentialsError, + AccountLockedError, +) + +@router.post("/login") +async def login(request: LoginRequest): + try: + result = await handler.handle(...) + return result + except InvalidCredentialsError as e: + raise HTTPException(status_code=401, detail=str(e)) + except AccountLockedError as e: + raise HTTPException(status_code=403, detail=str(e)) +``` + +## Import Organization + +Group imports in this order: + +1. Standard library +2. Third-party packages (FastAPI, SQLAlchemy, etc.) +3. Local application imports (absolute imports from `app.*`) + +```python +# Standard library +from dataclasses import dataclass +from typing import Optional + +# Third-party +from fastapi import APIRouter, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +# Local application +from app.context.auth.domain.contracts.login_service_contract import LoginServiceContract +from app.context.auth.application.commands.login_command import LoginCommand +from app.shared.domain.value_objects.shared_email import Email as SharedEmail +``` + +## Module Initialization Pattern + +**All `__init__.py` files** must follow this pattern to provide a clean, refactorable public API. + +### Pattern + +```python +# app/context/user_account/domain/value_objects/__init__.py +from .account_id import AccountID +from .account_name import AccountName +from .balance import Balance +from .currency import Currency + +__all__ = ["AccountName", "AccountID", "Balance", "Currency"] +``` + +### Rules + +1. Use **relative imports** (`.module_name`) to import from individual files within the package +2. Define an explicit **`__all__` list** to declare the public API +3. **Always import from the module directory**, never from individual files + +### Usage + +```python +# Good - import from module +from app.context.user_account.domain.value_objects import AccountID, Balance + +# Bad - import directly from file +from app.context.user_account.domain.value_objects.account_id import AccountID +``` + +### Benefits + +- **Cleaner imports** - Shorter, more readable import statements +- **Easier refactoring** - Internal file structure can change without breaking imports +- **Explicit public API** - `__all__` makes it clear what's meant to be used externally +- **Consistency** - Same pattern across the entire codebase + +### Where to Apply + +Apply this pattern to **all directories** containing multiple Python modules: + +- `value_objects/` +- `dto/` +- `contracts/` +- `services/` +- `handlers/` +- `commands/` +- `queries/` +- `repositories/` +- `mappers/` +- `schemas/` +- Any other package with multiple modules + +## Commands and Queries (CQRS) + +### Use Primitives Only + +Commands and queries should **only use primitive types** (str, int, float, bool, etc.). They should NOT use value objects or domain types. + +**Rationale:** +- Commands/queries are application-layer DTOs for transferring data from controllers to handlers +- Value object validation and construction happens in the handler, not at the boundary +- Keeps commands/queries simple and framework-agnostic +- Allows handlers to control when and how validation occurs + +```python +# Good - uses primitives +@dataclass(frozen=True) +class LoginCommand: + email: str + password: str + +# Bad - uses value objects +@dataclass(frozen=True) +class LoginCommand: + email: Email # Don't do this! + password: Password # Don't do this! +``` + +**Handler converts primitives to value objects:** + +```python +class LoginHandler: + async def handle(self, command: LoginCommand) -> LoginResult: + # Handler constructs value objects from primitives + email = SharedEmail(command.email) # Validation happens here + password = SharedPassword.from_plain_text(command.password) + + # Now use value objects with domain service + result = await self._login_service.login(email, password) + return result +``` + +**Complete data flow:** + +``` +Controller (Pydantic model with primitives) + ↓ +Command/Query (primitives) + ↓ +Handler (converts to value objects) + ↓ +Domain Service (uses value objects) +``` + +## Application Layer Handlers + +### Exception Handling and Result Pattern + +**Rule:** Handlers MUST catch all domain exceptions and convert them to Result objects. Handlers should NEVER let exceptions propagate to the controller layer. + +**Rationale:** +- Keeps handlers HTTP-agnostic and framework-independent +- Makes handlers easier to test (no exception handling needed in tests) +- Centralizes error-to-message mapping in the handler +- Controllers can map error codes to HTTP status codes programmatically (no string parsing!) + +### Result DTO Pattern with Error Codes + +All handler result DTOs should follow this pattern: + +```python +# app/context/user_account/application/dto/create_account_result.py +from dataclasses import dataclass +from enum import Enum +from typing import Optional + +class CreateAccountErrorCode(str, Enum): + """Error codes for account creation""" + NAME_ALREADY_EXISTS = "NAME_ALREADY_EXISTS" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + +@dataclass(frozen=True) +class CreateAccountResult: + """Result of account creation operation""" + + # Success fields - populated when operation succeeds + account_id: Optional[int] = None + account_name: Optional[str] = None + account_balance: Optional[float] = None + + # Error fields - populated when operation fails + error_code: Optional[CreateAccountErrorCode] = None + error_message: Optional[str] = None +``` + +**Pattern rules:** +- Define a context-specific error code enum (inheriting from `str, Enum`) +- Use `Optional` for all fields +- Success data fields default to `None` +- Include both `error_code` and `error_message` fields +- On success: populate data fields, leave error fields as None +- On failure: populate both error_code (for logic) and error_message (for users) + +### Handler Implementation Pattern + +**All handlers MUST follow this exception handling pattern:** + +```python +class CreateAccountHandler(CreateAccountHandlerContract): + """Handler for create account command""" + + def __init__(self, service: CreateAccountServiceContract): + self._service = service + + async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: + """Execute the create account command""" + + try: + # 1. Convert command primitives to value objects + account_dto = await self._service.create_account( + user_id=UserAccountUserID(command.user_id), + name=AccountName(command.name), + currency=UserAccountCurrency(command.currency), + balance=UserAccountBalance.from_float(command.balance), + ) + + # 2. Validate operation succeeded + if account_dto.account_id is None: + return CreateAccountResult( + error_code=CreateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Error creating account", + ) + + # 3. Convert domain DTO to result with primitives + return CreateAccountResult( + account_id=account_dto.account_id.value, + account_name=account_dto.name.value, + account_balance=float(account_dto.balance.value), + ) + + # 4. Catch specific domain exceptions and return error code + message + except UserAccountNameAlreadyExistError: + return CreateAccountResult( + error_code=CreateAccountErrorCode.NAME_ALREADY_EXISTS, + error_message="Account name already exist", + ) + except UserAccountMapperError: + return CreateAccountResult( + error_code=CreateAccountErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) + + # 5. Always catch generic Exception as final fallback + except Exception: + return CreateAccountResult( + error_code=CreateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) +``` + +**Handler Exception Handling Rules:** + +1. **Wrap entire handler logic in try/except** +2. **Catch specific domain exceptions first** - Map each to error code + user-friendly message +3. **Always catch `Exception` as final fallback** - Prevents any exception from escaping the handler +4. **Return Result object with error_code and error_message** - Never re-raise exceptions +5. **Use user-friendly error messages** - These go directly to the API response + +**Exception Ordering:** + +```python +try: + # Handler logic + pass +except SpecificDomainException1: # Most specific first + return Result( + error_code=ErrorCode.SPECIFIC_ERROR_1, + error_message="Specific error message 1", + ) +except SpecificDomainException2: + return Result( + error_code=ErrorCode.SPECIFIC_ERROR_2, + error_message="Specific error message 2", + ) +except Exception: # Generic catch-all last + return Result( + error_code=ErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) +``` + +### Controller Integration with Error Codes + +Controllers check the result.error_code field and map to HTTP status codes: + +```python +from fastapi import APIRouter, HTTPException, Depends + +@router.post("/accounts", status_code=201) +async def create_account( + request: CreateAccountRequest, + handler: CreateAccountHandlerContract = Depends(get_create_account_handler), + user_id: int = Depends(get_current_user_id), +): + """Create a new user account""" + command = CreateAccountCommand( + user_id=user_id, + name=request.name, + currency=request.currency, + balance=request.balance, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + # Map error codes to status codes (no string parsing!) + status_code_map = { + CreateAccountErrorCode.NAME_ALREADY_EXISTS: 409, # Conflict + CreateAccountErrorCode.MAPPER_ERROR: 500, # Internal Server Error + CreateAccountErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + return CreateAccountResponse( + id=result.account_id, + name=result.account_name, + balance=result.account_balance, + ) +``` + +**Benefits of Error Code Pattern:** + +- **Type Safety**: Error codes are enums, preventing typos +- **No String Parsing**: Controllers use error codes for logic, not string matching +- **Refactor-Friendly**: Can change error messages without breaking controller logic +- **Explicit Mapping**: Clear mapping between domain errors and HTTP status codes +- **IDE Support**: Autocomplete and type checking for error codes +- **Documentation**: Error codes serve as documentation of possible failures + +## Dependency Injection + +### Define Contract-Based Factories + +```python +# infrastructure/dependencies.py +def get_user_repository( + db: AsyncSession = Depends(get_db), +) -> UserRepositoryContract: + return UserRepository(db) + +def get_login_handler( + service: LoginServiceContract = Depends(get_login_service), +) -> LoginHandlerContract: + return LoginHandler(service) +``` + +### Inject in Controllers + +```python +@router.post("/login") +async def login( + request: LoginRequest, + handler: LoginHandlerContract = Depends(get_login_handler), +): + command = LoginCommand(...) + return await handler.handle(command) +``` + +### Authenticating Requests + +**Rule:** Always inject the authenticated user ID using the shared middleware dependency. Pass user_id as a **primitive** (int) to commands/queries. + +**Pattern:** + +```python +from fastapi import APIRouter, Depends +from app.shared.infrastructure.middleware import get_current_user_id + +@router.post("/accounts", status_code=201) +async def create_account( + request: CreateAccountRequest, + handler: CreateAccountHandlerContract = Depends(get_create_account_handler), + user_id: int = Depends(get_current_user_id), # ✅ Inject authenticated user +): + """Create a new user account""" + command = CreateAccountCommand( + user_id=user_id, # ✅ Pass primitive to command + name=request.name, + currency=request.currency, + balance=request.balance, + ) + result = await handler.handle(command) + return result +``` + +**Available Middleware Functions:** + +```python +# Required authentication - raises 401 if missing/invalid +from app.shared.infrastructure.middleware import get_current_user_id + +async def protected_route(user_id: int = Depends(get_current_user_id)): + # user_id is always present, or 401 was raised + pass + +# Optional authentication - returns None if not authenticated +from app.shared.infrastructure.middleware import get_current_user_id_optional + +async def public_route(user_id: Optional[int] = Depends(get_current_user_id_optional)): + # user_id might be None (for personalized public content) + if user_id: + # Show personalized content + pass + else: + # Show default content + pass +``` + +**Command includes user_id as primitive:** + +```python +@dataclass(frozen=True) +class CreateAccountCommand: + user_id: int # ✅ Primitive (consistent with CQRS pattern) + name: str + currency: str + balance: float +``` + +**Handler converts to value object:** + +```python +class CreateAccountHandler: + async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: + # Convert primitive to value object + user_id = UserID(command.user_id) # ✅ Handler responsibility + + # Use value object in domain layer + result = await self._service.create_account(user_id=user_id, ...) + return result +``` + +**Important:** +- Never manually extract tokens or validate sessions in controllers +- Never pass `UserID` value objects in commands/queries +- Middleware handles all authentication logic (extraction, validation, session lookup) +- Controllers receive clean primitive `int` user_id +- Handlers convert primitives to value objects for domain layer + +## Naming Conventions + +### Files + +- Contracts: `{name}_contract.py` +- Implementations: `{name}.py` +- DTOs: `{name}_dto.py` +- Commands: `{action}_command.py` +- Queries: `{action}_query.py` +- Handlers: `{action}_handler.py` + +### Classes + +- Contracts: `{Name}Contract` (e.g., `LoginServiceContract`) +- Implementations: `{Name}` (e.g., `LoginService`) +- DTOs: `{Name}DTO` (e.g., `UserDTO`) +- Value Objects: `{Name}` (e.g., `Email`, `Password`) + +### Variables + +- Use descriptive names: `user_repository` not `repo` +- Private attributes: `self._db`, `self._user_repo` +- Constants: `MAX_LOGIN_ATTEMPTS = 5` + +## Documentation + +### Docstrings + +Use for public APIs and complex business logic: + +```python +async def find_user(self, email: Email) -> Optional[UserDTO]: + """ + Find a user by email address. + + Args: + email: The user's email address + + Returns: + UserDTO if found, None otherwise + """ + pass +``` + +### Comments + +Only when the "why" isn't obvious from the code: + +```python +# Bad - states the obvious +# Increment the counter +counter += 1 + +# Good - explains the business reason +# Argon2 recommends time_cost=2 for interactive logins +hasher = PasswordHasher(time_cost=2) +``` + +## Code Organization Within Files + +Standard order within a class: + +1. Class variables +2. `__init__` or dataclass fields +3. Public methods +4. Private methods +5. Static/class methods + +```python +@dataclass(frozen=True) +class Example: + # Fields + field: str + + # Public methods + def public_method(self): + pass + + # Private methods + def _private_helper(self): + pass + + # Static methods + @staticmethod + def static_helper(): + pass +``` diff --git a/.claude/rules/database.md b/.claude/rules/database.md new file mode 100644 index 0000000..b23a976 --- /dev/null +++ b/.claude/rules/database.md @@ -0,0 +1,492 @@ +--- +paths: "{app/**/infrastructure/**/*.py,migrations/**/*.py}" +--- + +# Database and Migration Guidelines + +## SQLAlchemy Async Patterns + +### Session Management + +Always use dependency injection for database sessions: + +```python +from app.shared.infrastructure.database import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +async def my_handler(db: AsyncSession = Depends(get_db)): + # Session is automatically managed + result = await db.execute(...) + await db.commit() # If needed +``` + +Never create sessions manually in application code. + +### Model Registration + +**CRITICAL**: All SQLAlchemy models must be imported to register them with the metadata **before** any database operations occur. This is especially important for models with foreign key relationships. + +**Pattern**: Import all models at the **end** of `app/shared/infrastructure/database.py`: + +```python +# app/shared/infrastructure/database.py + +# ... database setup code ... + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session + + +# ────────────────────────────────────────────────────────────────────────────── +# Import all models to register them with SQLAlchemy metadata +# IMPORTANT: Order matters! Parent tables must be imported before child tables +# ────────────────────────────────────────────────────────────────────────────── + +from app.context.user.infrastructure.models.user_model import UserModel # noqa: F401, E402 +from app.context.user_account.infrastructure.models.user_account_model import ( # noqa: F401, E402 + UserAccountModel, +) +from app.context.auth.infrastructure.models.session_model import SessionModel # noqa: F401, E402 +from app.context.credit_card.infrastructure.models.credit_card_model import ( # noqa: F401, E402 + CreditCardModel, +) +``` + +**Why this works**: +1. Models import `BaseDBModel` from `app.shared.infrastructure.models` (just the base class) +2. `database.py` imports model classes directly at the end (after all setup is complete) +3. No circular imports because dependency flows one way +4. Models are automatically registered when `database.py` is imported (which happens via `get_db()`) + +**Import Order Rules**: +- Parent tables (referenced by foreign keys) must come **before** child tables +- Example: `users` → `user_accounts` → `credit_cards` (since credit_cards references user_accounts) + +**Why NOT in other places**: +- ❌ **NOT in `models/__init__.py`** - causes circular imports (models import BaseDBModel from there) +- ❌ **NOT in `main.py`** - pollutes application entry point, not the right responsibility +- ❌ **NOT in individual model files** - would require every model to know about all other models +- ✅ **YES in `database.py`** - centralized, runs automatically, no circular dependency + +**When adding new models**: +1. Add import to `database.py` at the end +2. Place it in correct order based on foreign key dependencies +3. Use `# noqa: F401, E402` to suppress linter warnings (F401=unused import, E402=import not at top) + +### Query Execution + +Use async patterns with proper await: + +```python +# SELECT queries +stmt = select(UserModel).where(UserModel.email == email) +result = await db.execute(stmt) +user = result.scalar_one_or_none() + +# INSERT +db.add(user_model) +await db.commit() +await db.refresh(user_model) + +# UPDATE +stmt = update(UserModel).where(UserModel.id == user_id).values(email=new_email) +await db.execute(stmt) +await db.commit() + +# DELETE +stmt = delete(UserModel).where(UserModel.id == user_id) +await db.execute(stmt) +await db.commit() +``` + +### Result Handling + +```python +# Single result (raises if not found) +user = result.scalar_one() + +# Single result (returns None if not found) +user = result.scalar_one_or_none() + +# Multiple results +users = result.scalars().all() + +# First result +user = result.scalars().first() +``` + +## Database Models + +### Base Model Usage + +All models must inherit from `BaseModel`: + +```python +from app.shared.infrastructure.models.base_model import BaseModel +from sqlalchemy.orm import Mapped, mapped_column +from datetime import datetime, UTC + +class UserModel(BaseModel): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) +``` + +### Type Annotations + +Use `Mapped[type]` for all columns: + +```python +# Basic types +id: Mapped[int] +email: Mapped[str] +is_active: Mapped[bool] + +# Optional/nullable +phone: Mapped[Optional[str]] = mapped_column(nullable=True) + +# With defaults (see Timezone-Aware Dates section below for datetime) +created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) + +# Relationships +addresses: Mapped[list["AddressModel"]] = relationship(back_populates="user") +``` + +### Timezone-Aware Dates + +**CRITICAL**: Always use timezone-aware datetime objects in database models to prevent timezone-related bugs. + +**Pattern**: + +```python +from datetime import datetime, UTC +from sqlalchemy.orm import Mapped, mapped_column + +class UserModel(BaseModel): + __tablename__ = "users" + + # ✅ CORRECT: Timezone-aware with UTC + created_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(UTC), + nullable=False + ) + + updated_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + nullable=False + ) + + # For nullable timestamps + deleted_at: Mapped[Optional[datetime]] = mapped_column( + default=None, + nullable=True + ) +``` + +**Why use `datetime.now(UTC)` wrapped in lambda**: +- `UTC` is a constant from `datetime` module (Python 3.11+) +- Lambda ensures the function is called at insertion time (not model definition time) +- Without lambda, the default would be evaluated once when the class is defined + +**Common Mistakes to Avoid**: + +```python +# ❌ WRONG: Not timezone-aware +created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) + +# ❌ WRONG: datetime.utcnow is deprecated and naive +created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.utcnow()) + +# ❌ WRONG: Missing lambda (evaluates at class definition) +created_at: Mapped[datetime] = mapped_column(default=datetime.now(UTC)) + +# ✅ CORRECT: Timezone-aware UTC with lambda +created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) +``` + +**Database Column Type**: + +PostgreSQL stores timezone-aware timestamps in `TIMESTAMP WITH TIME ZONE`: + +```python +# In migrations, Alembic will use TIMESTAMP WITH TIME ZONE automatically +op.add_column('users', + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False) +) +``` + +**For Python < 3.11**: + +If using Python versions before 3.11, use `timezone.utc`: + +```python +from datetime import datetime, timezone + +created_at: Mapped[datetime] = mapped_column( + default=lambda: datetime.now(timezone.utc) +) +``` + +**Soft Delete Pattern**: + +For soft deletes, use nullable `deleted_at`: + +```python +class UserModel(BaseModel): + deleted_at: Mapped[Optional[datetime]] = mapped_column( + default=None, + nullable=True + ) + + # Query helper properties + @property + def is_deleted(self) -> bool: + return self.deleted_at is not None +``` + +**Benefits of Timezone-Aware Dates**: +- Prevents ambiguity when displaying dates to users in different timezones +- Ensures correct date arithmetic (no DST issues) +- Makes it explicit that all times are stored in UTC +- Required for proper international application support +- Avoids Python warnings about naive datetime comparisons + +### Naming Conventions + +- Table names: plural snake_case (`users`, `login_attempts`) +- Column names: snake_case (`email`, `created_at`, `is_active`) +- Foreign keys: `{table}_id` (e.g., `user_id`) + +## Repository Pattern + +### Contract Definition + +```python +from abc import ABC, abstractmethod + +class UserRepositoryContract(ABC): + @abstractmethod + async def find_user( + self, + user_id: Optional[UserID] = None, + email: Optional[Email] = None, + ) -> Optional[UserDTO]: + pass + + @abstractmethod + async def save_user(self, user: UserDTO) -> UserID: + pass +``` + +### Implementation + +```python +class UserRepository(UserRepositoryContract): + def __init__(self, db: AsyncSession): + self._db = db + + async def find_user( + self, + user_id: Optional[UserID] = None, + email: Optional[Email] = None, + ) -> Optional[UserDTO]: + stmt = select(UserModel) + + if user_id: + stmt = stmt.where(UserModel.id == user_id.value) + if email: + stmt = stmt.where(UserModel.email == email.value) + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return UserMapper.toDTO(model) if model else None +``` + +### Important Rules + +- Never return SQLAlchemy models from repositories +- Always use mappers to convert models to DTOs +- Accept domain value objects as parameters (not primitives) +- Return domain DTOs (not database models) +- Keep repositories thin - no business logic + +## Mapper Pattern + +### Standard Mapper Structure + +```python +class UserMapper: + @staticmethod + def toDTO(model: UserModel) -> UserDTO: + """Convert database model to domain DTO""" + return UserDTO( + user_id=UserID(model.id), + email=Email(model.email), + password=Password.from_hash(model.password), + ) + + @staticmethod + def toModel(dto: UserDTO) -> UserModel: + """Convert domain DTO to database model""" + return UserModel( + id=dto.user_id.value, + email=dto.email.value, + password=dto.password.value, + ) +``` + +### Mapper Rules + +- One mapper per aggregate root +- Static methods only (no state) +- Handle value object conversion +- Place in `infrastructure/mappers/` directory + +## Alembic Migrations + +### Creating Migrations + +Use the Just command: + +```bash +# Generate new migration +just migration-generate "add user email verification" + +# This runs: +# uv run alembic revision --autogenerate -m "description" +``` + +### Migration File Structure + +```python +"""add user email verification + +Revision ID: abc123def456 +Revises: previous_revision +Create Date: 2025-12-24 10:30:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers +revision = 'abc123def456' +down_revision = 'previous_revision' +branch_labels = None +depends_on = None + +def upgrade() -> None: + op.add_column('users', + sa.Column('email_verified', sa.Boolean(), nullable=False, server_default='false') + ) + +def downgrade() -> None: + op.drop_column('users', 'email_verified') +``` + +### Migration Best Practices + +- Always provide both `upgrade()` and `downgrade()` +- Use descriptive migration messages +- Review autogenerated migrations before committing +- Test migrations on a copy of production data +- Keep migrations small and focused +- Add indexes in separate migrations for large tables + +### Common Migration Operations + +```python +# Add column +op.add_column('users', sa.Column('phone', sa.String(20), nullable=True)) + +# Drop column +op.drop_column('users', 'phone') + +# Create table +op.create_table( + 'login_attempts', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('email', sa.String(255), nullable=False), + sa.Column('attempted_at', sa.DateTime(), nullable=False), +) + +# Add foreign key +op.create_foreign_key('fk_user_profile', 'profiles', 'users', ['user_id'], ['id']) + +# Create index +op.create_index('ix_users_email', 'users', ['email'], unique=True) + +# Execute raw SQL (use sparingly) +op.execute("UPDATE users SET is_active = true WHERE created_at > '2025-01-01'") +``` + +### Running Migrations + +```bash +# Apply all pending migrations +just migrate + +# This runs: +# uv run alembic upgrade head + +# Rollback one migration +uv run alembic downgrade -1 + +# See migration history +uv run alembic history + +# See current revision +uv run alembic current +``` + +## Database Connection + +### Configuration + +Connection details in `.env`: + +``` +DB_HOST=localhost +DB_PORT=5432 +DB_USER=uhomecomp +DB_PASS=homecomppass +DB_NAME=homecomp +``` + +### Connection Pool Settings + +In `app/shared/infrastructure/database.py`: + +```python +engine = create_async_engine( + DATABASE_URL, + echo=False, # Set True for SQL logging + pool_size=5, # Max persistent connections + max_overflow=10, # Max overflow connections + pool_pre_ping=True, # Verify connections before use +) +``` + +## Common Pitfalls + +1. **Forgetting await** - All SQLAlchemy async operations need `await` +2. **Returning models from repos** - Always use mappers +3. **N+1 queries** - Use `selectinload()` or `joinedload()` for relationships +4. **Missing transactions** - Use `await db.commit()` for writes +5. **Hardcoded values** - Use value objects, not raw strings/ints +6. **Circular imports** - Forward reference relationships with string `"ModelName"` +7. **Naive datetime objects** - Always use timezone-aware dates with `datetime.now(UTC)` + +## Performance Tips + +- Use `scalar_one_or_none()` instead of `all()[0]` +- Add indexes on frequently queried columns +- Use `defer()` to skip loading heavy columns +- Use `selectinload()` for one-to-many relationships +- Batch operations when possible +- Consider read replicas for heavy read workloads diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md new file mode 100644 index 0000000..e5a6903 --- /dev/null +++ b/.claude/rules/logging.md @@ -0,0 +1,627 @@ +# Logging Guidelines + +## Overview + +This application uses **structlog** for structured logging with automatic routing to different backends based on environment: +- **Development** (`APP_ENV=dev`): Console (colored) + Loki (structured JSON) +- **Test** (`APP_ENV=test`): NullLogger (no output) +- **Production** (`APP_ENV=production`): Console (JSON) → Docker logs → Promtail → Loki + +## Core Principles + +### 1. Always Use Dependency Injection + +**NEVER** instantiate loggers directly. Always inject via `get_logger()` dependency. + +```python +# Good - dependency injection +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger + +class LoginHandler: + def __init__(self, logger: LoggerContract): + self._logger = logger + +# Bad - direct instantiation +import structlog +logger = structlog.get_logger() # Don't do this! +``` + +### 2. Logger is Environment-Aware + +The `get_logger()` factory automatically returns: +- **StructlogLogger** in dev/production (logs to console/Loki) +- **NullLogger** in tests (silent) + +No need to check environment in your code - the dependency injection handles it. + +### 3. Use Structured Logging + +Always pass context as keyword arguments, never in the message string. + +```python +# Good - structured data +self._logger.info("Login attempt", email=email, user_id=user_id) +self._logger.warning("Account blocked", user_id=user_id, blocked_until=timestamp) + +# Bad - unstructured string formatting +self._logger.info(f"Login attempt for {email}") # Can't query by email in Loki! +self._logger.warning(f"User {user_id} blocked until {timestamp}") # Can't filter! +``` + +**Benefits of structured logging:** +- Queryable in Grafana: `{app="homecomp-api"} | json | email="user@example.com"` +- Type-safe: Loki indexes fields automatically +- Machine-readable: Easy to aggregate, alert, visualize + +## Log Levels + +Use appropriate log levels according to severity: + +### debug +**When**: Detailed diagnostic information useful during development +**Examples**: +- Method entry/exit +- Internal state transitions +- Query parameters + +```python +self._logger.debug("Handling login command", email=command.email) +self._logger.debug("No existing session, creating new session", user_id=user_id) +self._logger.debug("Applying login delay", delay_seconds=delay) +``` + +### info +**When**: Normal application flow, significant events +**Examples**: +- Successful operations +- User actions +- State changes + +```python +self._logger.info("Login attempt", email=email) +self._logger.info("Login successful", email=email, user_id=user_id) +self._logger.info("Password verified successfully", user_id=user_id) +``` + +### warning +**When**: Recoverable errors, security events, degraded functionality +**Examples**: +- Failed authentication (expected behavior) +- Rate limiting triggered +- Retryable failures + +```python +self._logger.warning("Login failed - invalid credentials", email=email) +self._logger.warning("Account blocked", user_id=user_id, blocked_until=timestamp) +self._logger.warning("Max login attempts reached", user_id=user_id) +``` + +### error +**When**: Unexpected errors that need investigation +**Examples**: +- Database errors +- Invalid data from trusted sources +- Unhandled exceptions + +```python +self._logger.error("Token generation failed", email=email) +self._logger.error("Invalid user data retrieved from database", email=email) +self._logger.error("Unexpected error during login", email=email, error=str(e)) +``` + +### critical +**When**: System failures requiring immediate attention +**Examples**: +- Database connection lost +- Critical service unavailable +- Data corruption detected + +```python +self._logger.critical("Database connection pool exhausted") +self._logger.critical("Unable to connect to authentication service", error=str(e)) +``` + +## Logging Strategy by Layer + +### CRITICAL RULE: Avoid Redundant Success Logs + +**DO NOT log successful operations at multiple layers.** This creates log bloat and makes debugging harder. + +**Pattern**: Log success **ONLY at the controller layer** for audit trail purposes. + +```python +# ✅ GOOD - Success logged only at controller +@router.post("/cards") +async def create_credit_card( + request: CreateCreditCardRequest, + handler: CreateCreditCardHandlerContract = Depends(...), + logger: LoggerContract = Depends(get_logger), +): + logger.info("Create credit card request", user_id=user_id, name=request.name) + result = await handler.handle(command) + + # Log success at controller level (audit trail) + logger.info("Credit card created successfully", user_id=user_id, credit_card_id=result.id) + return response + +# ✅ GOOD - Handler logs only errors/warnings, NOT success +class CreateCreditCardHandler: + async def handle(self, command): + try: + card = await self._service.create(...) + return CreateCreditCardResult(credit_card_id=card.id) # No success log + except CreditCardNameAlreadyExistError: + self._logger.debug("Card name already exists", user_id=command.user_id) + return CreateCreditCardResult(error_code=...) + +# ✅ GOOD - Service logs only business events, NOT success +class CreateCreditCardService: + async def create(self, user_id, name, ...): + self._logger.debug("Creating credit card", user_id=user_id.value, name=name.value) + # ... business logic ... + return await self._repository.save(card) # No success log +``` + +```python +# ❌ BAD - Success logged at all layers (redundant!) +@router.post("/cards") +async def create_credit_card(...): + logger.info("Create credit card request", ...) + result = await handler.handle(command) + logger.info("Credit card created successfully", ...) # ✅ Keep this one + return response + +class CreateCreditCardHandler: + async def handle(self, command): + card = await self._service.create(...) + self._logger.info("Handler succeeded", ...) # ❌ Remove - redundant! + return CreateCreditCardResult(...) + +class CreateCreditCardService: + async def create(...): + result = await self._repository.save(card) + self._logger.info("Card created", ...) # ❌ Remove - redundant! + return result +``` + +**Why this matters**: +- **Audit trail**: Controller logs capture what happened (one log = one operation) +- **Less noise**: Easier to find errors when not buried in success logs +- **Better performance**: Fewer logs = lower overhead +- **Cleaner queries**: `{severity="error"}` shows real problems, not buried in success logs + +**What to log at each layer**: +- **Controllers**: Success (info), errors/warnings for HTTP outcomes +- **Handlers**: Only errors, business rule violations, cross-context calls (debug) +- **Services**: Only business events (warnings), errors, debug flow + +### Controller Layer (Interface/REST) + +**Purpose**: Log HTTP-level events and user-facing outcomes + +**What to log**: +- Incoming requests (info) +- Success responses (info) +- Client errors 4xx (warning) +- Server errors 5xx (error) + +**What NOT to log**: +- Passwords or sensitive data +- Internal implementation details (use handler/service for that) + +```python +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger + +@router.post("/login") +async def login( + request: LoginRequest, + handler: Annotated[LoginHandlerContract, Depends(get_login_handler)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + logger.info("Login attempt", email=str(request.email)) + + result = await handler.handle(LoginCommand(...)) + + if result.status == LoginHandlerResultStatus.SUCCESS: + logger.info("Login successful", email=str(request.email), user_id=result.user_id) + return LoginResponse(message="Login successful") + + if result.status == LoginHandlerResultStatus.INVALID_CREDENTIALS: + logger.warning("Login failed - invalid credentials", email=str(request.email)) + raise HTTPException(status_code=401, detail=result.error_msg) + + if result.status == LoginHandlerResultStatus.ACCOUNT_BLOCKED: + logger.warning( + "Login failed - account blocked", + email=str(request.email), + retry_after=result.retry_after.isoformat() if result.retry_after else None, + ) + raise HTTPException(status_code=429, detail=result.error_msg) + + logger.error("Login failed - unexpected error", email=str(request.email), status=result.status.value) + raise HTTPException(status_code=500, detail=result.error_msg) +``` + +### Handler Layer (Application) + +**Purpose**: Log orchestration logic and business flow + +**What to log**: +- Handler execution start (debug) +- Cross-context calls (debug) +- Business rule violations (info/warning) +- Exception handling (error) + +```python +from app.shared.domain.contracts import LoggerContract + +class LoginHandler(LoginHandlerContract): + def __init__( + self, + user_handler: FindUserHandlerContract, + login_service: LoginServiceContract, + logger: LoggerContract, + ): + self._user_handler = user_handler + self._login_service = login_service + self._logger = logger + + async def handle(self, command: LoginCommand) -> LoginHandlerResultDTO: + self._logger.debug("Handling login command", email=command.email) + + user = await self._user_handler.handle(FindUserQuery(email=command.email)) + if user is None or user.error_code is not None: + self._logger.debug("User not found", email=command.email) + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.INVALID_CREDENTIALS, + error_msg="Invalid username or password", + ) + + if user.user_id is None or user.email is None or user.password is None: + self._logger.error("Invalid user data retrieved from database", email=command.email) + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.UNEXPECTED_ERROR, + error_msg="Invalid user data", + ) + + try: + user_token = await self._login_service.handle(...) + self._logger.debug("Login service succeeded", email=command.email, user_id=user.user_id) + return LoginHandlerResultDTO(status=LoginHandlerResultStatus.SUCCESS, ...) + + except AccountBlockedException as abe: + self._logger.info( + "Account blocked", + email=command.email, + blocked_until=abe.blocked_until.isoformat() if abe.blocked_until else None, + ) + return LoginHandlerResultDTO(status=LoginHandlerResultStatus.ACCOUNT_BLOCKED, ...) + + except InvalidCredentialsException: + self._logger.debug("Invalid credentials provided", email=command.email) + return LoginHandlerResultDTO(status=LoginHandlerResultStatus.INVALID_CREDENTIALS, ...) + + except Exception as e: + self._logger.error("Unexpected error during login", email=command.email, error=str(e)) + return LoginHandlerResultDTO(status=LoginHandlerResultStatus.UNEXPECTED_ERROR, ...) +``` + +### Service Layer (Domain) + +**Purpose**: Log domain logic execution and business rule enforcement + +**What to log**: +- Service method entry (debug) +- Business rule evaluations (info) +- Domain exceptions (warning/error) +- State changes (info) + +```python +from app.shared.domain.contracts import LoggerContract + +class LoginService(LoginServiceContract): + def __init__(self, session_repo: SessionRepositoryContract, logger: LoggerContract): + self._session_repo = session_repo + self._logger = logger + + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> SessionToken: + self._logger.debug("Login service started", user_id=db_user.user_id.value, email=db_user.email.value) + + session = await self._session_repo.getSession(user_id=db_user.user_id) + + if session is None: + self._logger.debug("No existing session, creating new session", user_id=db_user.user_id.value) + session = await self._session_repo.createSession(...) + + if session.blocked_until is not None and not session.blocked_until.isOver(): + self._logger.warning( + "Account is blocked", + user_id=db_user.user_id.value, + blocked_until=session.blocked_until.value.isoformat(), + ) + raise AccountBlockedException(session.blocked_until.value) + + if not db_user.password.verify(user_password.value): + new_attempts = FailedLoginAttempts(session.failed_attempts.value + 1) + + self._logger.info( + "Password verification failed", + user_id=db_user.user_id.value, + failed_attempts=new_attempts.value, + ) + + if new_attempts.hasReachMaxAttempts(): + blocked_until = BlockedTime.setBlocked() + self._logger.warning( + "Max login attempts reached, blocking account", + user_id=db_user.user_id.value, + blocked_until=blocked_until.value.isoformat(), + ) + + await self._session_repo.updateSession(...) + + delay = new_attempts.getAttemptDelay() + self._logger.debug("Applying login delay", delay_seconds=delay) + await asyncio.sleep(delay) + + raise InvalidCredentialsException() + + new_token = SessionToken.generate() + self._logger.info( + "Password verified successfully, creating session token", + user_id=db_user.user_id.value, + ) + + await self._session_repo.updateSession(...) + return new_token +``` + +## Dependency Injection Setup + +### Step 1: Add Logger to Constructor + +```python +from app.shared.domain.contracts import LoggerContract + +class MyService: + def __init__( + self, + some_repo: SomeRepositoryContract, + logger: LoggerContract, # Add logger parameter + ): + self._some_repo = some_repo + self._logger = logger +``` + +### Step 2: Update Dependency Factory + +```python +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger + +def get_my_service( + some_repo: Annotated[SomeRepositoryContract, Depends(get_some_repo)], + logger: Annotated[LoggerContract, Depends(get_logger)], # Add logger dependency +) -> MyServiceContract: + return MyService(some_repo, logger) +``` + +### Step 3: Controllers Get Logger Directly + +Controllers inject logger as a parameter (not passed through handlers): + +```python +@router.post("/endpoint") +async def my_endpoint( + request: MyRequest, + handler: Annotated[MyHandlerContract, Depends(get_my_handler)], + logger: Annotated[LoggerContract, Depends(get_logger)], # Inject logger +): + logger.info("Request received", some_field=request.some_field) + result = await handler.handle(...) + logger.info("Request completed", result_status=result.status) + return result +``` + +## Security Considerations + +### Never Log Sensitive Data + +**Never log**: +- Passwords (plain text or hashed) +- Session tokens +- API keys +- Credit card numbers +- Personal identifiable information (unless absolutely necessary) + +```python +# Bad - logs password +self._logger.info("User login", email=email, password=password) # NEVER! + +# Bad - logs session token +self._logger.info("Session created", token=token.value) # NEVER! + +# Good - logs only non-sensitive data +self._logger.info("Login successful", email=email, user_id=user_id) +``` + +### Log Security Events + +Always log security-relevant events: +- Authentication attempts (success and failure) +- Authorization failures +- Rate limiting triggers +- Account lockouts +- Suspicious activity + +```python +self._logger.warning("Login failed - invalid credentials", email=email) +self._logger.warning("Account blocked due to max attempts", user_id=user_id) +self._logger.warning("Unauthorized access attempt", user_id=user_id, resource=resource) +``` + +## Performance Considerations + +### Use Appropriate Log Levels + +In production, set log level to `INFO` to avoid debug overhead: +- Debug logs are skipped entirely (no string formatting) +- Info/warning/error logs are processed + +### Avoid Expensive Operations in Log Calls + +```python +# Bad - expensive operation even if debug is disabled +self._logger.debug("User data", user_data=json.dumps(expensive_serialize(user))) + +# Good - expensive operation only if debug enabled +if self._logger._logger.isEnabledFor(logging.DEBUG): + self._logger.debug("User data", user_data=json.dumps(expensive_serialize(user))) + +# Best - keep it simple +self._logger.debug("User loaded", user_id=user.id) +``` + +## Querying Logs in Grafana + +### Basic Queries + +```logql +# All application logs +{app="homecomp-api"} + +# Filter by severity +{app="homecomp-api", severity="warning"} +{app="homecomp-api", severity="error"} + +# Search for specific events +{app="homecomp-api"} |= "Login attempt" +{app="homecomp-api"} |= "Account blocked" +``` + +### Structured Queries + +```logql +# Parse JSON and filter by field +{app="homecomp-api"} | json | email="user@example.com" +{app="homecomp-api"} | json | user_id="123" +{app="homecomp-api"} | json | level="error" + +# Count events +count_over_time({app="homecomp-api"} |= "Login attempt" [5m]) + +# Aggregate by field +sum by (email) (count_over_time({app="homecomp-api"} |= "Login failed" [1h])) +``` + +### Advanced Queries + +```logql +# Failed logins in last hour +{app="homecomp-api", severity="warning"} |= "Login failed" [1h] + +# Errors by logger +sum by (logger) (count_over_time({app="homecomp-api", severity="error"} [1h])) + +# Login attempts per email +topk(10, sum by (email) (count_over_time({app="homecomp-api"} |= "Login attempt" [24h]))) +``` + +## Testing + +### Unit Tests + +Logger is automatically replaced with `NullLogger` when `APP_ENV=test`: + +```python +# In tests, logger does nothing - no output, no setup needed +def test_login_handler(): + logger = get_logger() # Returns NullLogger in test environment + handler = LoginHandler(user_handler, login_service, logger) + # Logger calls are no-ops, tests run silently +``` + +### Integration Tests + +If you need to verify logging behavior: + +```python +from app.shared.infrastructure.logging import StructlogLogger + +def test_login_logs_attempt(): + # Create real logger for verification + logger = StructlogLogger() + handler = LoginHandler(user_handler, login_service, logger) + + # Use caplog fixture to capture logs + result = handler.handle(LoginCommand(email="test@example.com", password="wrong")) + + # Verify log was called (implementation depends on test framework) + # Usually not necessary - focus on behavior, not logging +``` + +**Recommendation**: Don't test logging in unit tests. Logging is a cross-cutting concern - verify business logic instead. + +## Common Patterns + +### Login/Authentication Flow + +```python +# Controller +logger.info("Login attempt", email=email) +# Handler +logger.debug("Handling login command", email=email) +# Service +logger.info("Password verification failed", user_id=user_id, failed_attempts=3) +logger.warning("Max login attempts reached, blocking account", user_id=user_id) +``` + +### CRUD Operations + +```python +# Create +logger.info("Creating account", user_id=user_id, account_name=name) +logger.info("Account created", account_id=account_id) + +# Read +logger.debug("Fetching account", account_id=account_id) + +# Update +logger.info("Updating account", account_id=account_id, fields=["balance"]) +logger.info("Account updated", account_id=account_id) + +# Delete +logger.warning("Deleting account", account_id=account_id, user_id=user_id) +logger.info("Account deleted", account_id=account_id) +``` + +### Error Handling + +```python +try: + result = await some_operation() + logger.info("Operation succeeded", operation="some_operation", result_id=result.id) +except SpecificException as e: + logger.warning("Expected failure", operation="some_operation", error=str(e)) + raise +except Exception as e: + logger.error("Unexpected error", operation="some_operation", error=str(e)) + raise +``` + +## Summary Checklist + +When adding logging to a new feature: + +- [ ] Inject `LoggerContract` via dependency injection +- [ ] Add logger to dependency factory functions +- [ ] Log at controller layer (user-facing events) +- [ ] Log at handler layer (orchestration flow) +- [ ] Log at service layer (domain logic) +- [ ] Use appropriate log levels (debug/info/warning/error/critical) +- [ ] Pass context as keyword arguments (structured logging) +- [ ] Never log sensitive data (passwords, tokens, etc.) +- [ ] Log security events (auth failures, rate limits, etc.) +- [ ] Test in dev environment - verify logs appear in console and Grafana diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..cd56552 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,289 @@ +--- +paths: tests/**/*.py +--- + +# Testing Guidelines + +## Test Organization + +Structure tests to mirror the DDD architecture: + +``` +tests/ +├── unit/ +│ ├── context/ +│ │ ├── auth/ +│ │ │ ├── domain/ # Value objects, domain services +│ │ │ ├── application/ # Handlers with mocked dependencies +│ │ │ └── infrastructure/ # Repositories with test DB +│ │ └── user/ +│ └── shared/ +└── integration/ + └── test_*.py # API endpoint tests +``` + +### One File Per Test Class + +**IMPORTANT**: Each test class must be in its own file. Never combine multiple test classes in a single file. + +**File Naming Convention**: +- **REQUIRED**: Test files MUST end with `_test.py` (never `test_*.py`) +- Pattern: `{name_of_thing_being_tested}_test.py` +- Examples: + - `user_account_id_test.py` (tests `UserAccountID`) + - `create_account_service_test.py` (tests `CreateAccountService`) + - `create_account_handler_test.py` (tests `CreateAccountHandler`) + - `user_account_mapper_test.py` (tests `UserAccountMapper`) + - `login_service_test.py` (tests `LoginService`) + +**Why `*_test.py` instead of `test_*.py`**: +- Consistency with snake_case naming throughout the codebase +- Test files sort alphabetically next to the files they test +- Easier to identify what's being tested at a glance +- Modern Python testing convention (used by many projects) + +**Rationale**: +- Easier to locate tests for specific components +- Clearer git history (changes to one component don't affect other test files) +- Prevents merge conflicts when multiple developers work on different components +- Matches the one-class-per-file pattern used in the main codebase +- Makes test discovery more intuitive + +**Example Structure**: + +``` +tests/unit/context/user_account/ +├── domain/ +│ ├── user_account_id_test.py # TestUserAccountID +│ ├── account_name_test.py # TestAccountName +│ ├── create_account_service_test.py # TestCreateAccountService +│ └── update_account_service_test.py # TestUpdateAccountService +├── application/ +│ ├── create_account_handler_test.py # TestCreateAccountHandler +│ └── update_account_handler_test.py # TestUpdateAccountHandler +└── infrastructure/ + └── user_account_mapper_test.py # TestUserAccountMapper +``` + +**Good**: +```python +# create_account_service_test.py +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateAccountService: + """Tests for CreateAccountService""" + # ... test methods +``` + +**Bad**: +```python +# test_services.py ❌ Wrong naming convention (should end with _test.py) +# AND multiple classes in one file ❌ +@pytest.mark.unit +class TestCreateAccountService: + # ... test methods + +@pytest.mark.unit +class TestUpdateAccountService: # ❌ Second class in same file + # ... test methods +``` + +## Framework and Tools + +- Use `pytest` with `pytest-asyncio` for async test support +- Use `pytest-cov` for coverage reporting +- Use `httpx.AsyncClient` for integration testing FastAPI endpoints +- Use `pytest.mark.asyncio` decorator for all async tests +- **REQUIRED**: Test files MUST end with `_test.py` for consistency + +## Unit Test Patterns + +### Test Class Markers + +**REQUIRED**: All unit test classes must be marked with `@pytest.mark.unit`. For async test classes, also add `@pytest.mark.asyncio` at the class level. + +```python +# Sync tests (value objects, DTOs) +@pytest.mark.unit +class TestUserAccountID: + """Tests for UserAccountID value object""" + + def test_valid_id_creation(self): + # ... test code + +# Async tests (services, handlers) +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateAccountService: + """Tests for CreateAccountService""" + + @pytest.mark.asyncio # Also mark individual async methods + async def test_create_account_success(self): + # ... test code +``` + +**Benefits**: +- Run only unit tests: `pytest -m unit` +- Run only integration tests: `pytest -m integration` +- Separate fast tests from slow tests + +### Testing Value Objects + +Always test validation logic extensively: + +```python +def test_email_validation_rejects_invalid_format(): + with pytest.raises(ValueError, match="Invalid email"): + Email("not-an-email") + +def test_email_validation_accepts_valid_format(): + email = Email("user@example.com") + assert email.value == "user@example.com" +``` + +### Testing Domain Services + +Mock repository contracts, never implementations: + +```python +@pytest.mark.asyncio +async def test_login_service_with_valid_credentials(): + # Arrange + mock_repo = Mock(spec=UserRepositoryContract) + mock_repo.find_user.return_value = UserDTO(...) + + service = LoginService(mock_repo) + + # Act + result = await service.login(email, password) + + # Assert + assert result.is_success + mock_repo.find_user.assert_called_once() +``` + +### Testing Handlers + +Inject mocked service contracts: + +```python +@pytest.mark.asyncio +async def test_login_handler_returns_token_on_success(): + # Arrange + mock_service = Mock(spec=LoginServiceContract) + mock_service.login.return_value = LoginResultDTO(...) + + handler = LoginHandler(mock_service) + command = LoginCommand(email=Email(...), password=Password(...)) + + # Act + result = await handler.handle(command) + + # Assert + assert result.token is not None +``` + +## Integration Test Patterns + +### Testing Endpoints + +Use FastAPI's test client with async support: + +```python +@pytest.mark.asyncio +async def test_login_endpoint_returns_200_with_valid_credentials(client: AsyncClient): + response = await client.post( + "/auth/login", + json={"email": "test@example.com", "password": "ValidPass123"} + ) + + assert response.status_code == 200 + assert "token" in response.json() +``` + +### Testing Repository Implementations + +Use a test database (not mocks): + +```python +@pytest.mark.asyncio +async def test_user_repository_finds_user_by_email(test_db: AsyncSession): + # Arrange + repo = UserRepository(test_db) + # Insert test data... + + # Act + user = await repo.find_user(email=Email("test@example.com")) + + # Assert + assert user is not None + assert user.email.value == "test@example.com" +``` + +## Test Fixtures + +Create reusable fixtures in `conftest.py`: + +```python +# tests/conftest.py +@pytest.fixture +async def test_db(): + """Provide clean test database session""" + # Setup test database + # Yield session + # Teardown/rollback + +@pytest.fixture +async def client(): + """Provide FastAPI test client""" + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac +``` + +## Coverage Requirements + +- Aim for 80%+ coverage on domain and application layers +- 100% coverage on value object validation logic +- Infrastructure can have lower coverage (e.g., 60%) +- Don't test framework code (FastAPI internals) + +## Test Naming + +Use descriptive names following the pattern: +- `test_{unit}__{scenario}__{expected_behavior}` +- Example: `test_login_service__invalid_password__raises_authentication_error` + +## What NOT to Test + +- SQLAlchemy's ORM functionality +- FastAPI's request parsing +- Third-party library internals +- Trivial property getters on dataclasses + +## Running Tests + +```bash +# Run all tests +pytest + +# Run only unit tests (fast) +pytest -m unit + +# Run only integration tests (slower) +pytest -m integration + +# Run with coverage +pytest --cov=app --cov-report=html + +# Run specific test file +pytest tests/unit/context/auth/domain/login_service_test.py + +# Run tests matching pattern +pytest -k "login" + +# Run unit tests for specific context +pytest tests/unit/context/user_account/ -m unit + +# List all tests without running +pytest --collect-only +``` diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ecb08c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +.venv/ +venv/ +env/ +ENV/ + +# UV +.uv/ + +# Tests +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Environment files +.env +.env.* +!.env.example + +# Git +.git/ +.gitignore +.gitattributes + +# Documentation +docs/ +*.md +!README.md + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# CI/CD +.github/ +.gitlab-ci.yml + +# Database +*.db +*.sqlite + +# Logs +logs/ +*.log + +# Development +justfile +.ruff_cache/ + +# Claude +.claude/ +CLAUDE.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f8a9315 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +APP_ENV=dev + +DB_HOST=localhost +DB_PORT=5432 +DB_USER=uhomecomp +DB_PASS=homecomppass +DB_NAME=homecomp + +TEST_DB_HOST=localhost +TEST_DB_PORT=5433 +TEST_DB_USER=uhomecomp +TEST_DB_PASS=homecomppass +TEST_DB_NAME=homecomp_test + +VALKEY_HOST=localhost +VALKEY_PORT=6379 + +# Grafana Configuration (optional, for monitoring) +GRAFANA_USER=admin +GRAFANA_PASSWORD=admin diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..6e73dbb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,141 @@ +name: Tests + +on: + pull_request: + branches: [main, dev] + types: [opened, synchronize, reopened, ready_for_review] + workflow_dispatch: # Allow manual trigger from GitHub UI + +# Cancel in-progress runs when a new one starts +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + # Run on all non-draft PRs and manual triggers + if: (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --frozen + + - name: Run Ruff linter + run: uv run ruff check . + + - name: Run Ruff formatter check + run: uv run ruff format --check . + + unit-tests: + # Run on all non-draft PRs (both dev and main) and manual triggers + if: (github.event_name == 'pull_request' && github.event.pull_request.draft == false) || github.event_name == 'workflow_dispatch' + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --frozen + + - name: Run unit tests + run: uv run pytest -m unit --ignore=tests/integration --tb=short + + - name: Upload coverage to artifacts (optional) + if: always() + run: | + uv run pytest -m unit --ignore=tests/integration --cov --cov-report=xml --cov-report=term + continue-on-error: true + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml + retention-days: 7 + continue-on-error: true + + integration-tests: + # Run ONLY on PRs targeting main (not dev) or manual triggers + # Only runs if lint and unit-tests pass successfully + needs: [lint, unit-tests] + if: | + (github.event_name == 'pull_request' && + github.event.pull_request.draft == false && + github.base_ref == 'main') || + github.event_name == 'workflow_dispatch' + + runs-on: ubuntu-latest + + env: + APP_ENV: test + TEST_DB_HOST: localhost + TEST_DB_PORT: 5432 + TEST_DB_USER: uhomecomp + TEST_DB_PASS: homecomppass + TEST_DB_NAME: homecomp_test + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: uhomecomp + POSTGRES_PASSWORD: homecomppass + POSTGRES_DB: homecomp_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Install dependencies + run: uv sync --frozen + + - name: Run migrations + run: uv run alembic upgrade head + + - name: Run integration tests + run: uv run pytest -m integration --tb=short diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cd73e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python + +# Virtual Environment +venv/ +env/ +ENV/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Environment variables +.env +.env.local + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +.claude/settings.local.json +htmlcov +.coverage + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4f1246f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,665 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**HomeComp API** is a FastAPI-based REST API following **Domain-Driven Design (DDD)** principles. The application is structured around bounded contexts with clear separation between domain logic, application orchestration, infrastructure, and interface layers. + +## Technology Stack + +- **Python 3.13+** (required) +- **FastAPI 0.125.0** - Async web framework +- **SQLAlchemy 2.0.45** - Async ORM +- **PostgreSQL 16** - Database +- **Alembic 1.17.2** - Database migrations +- **Argon2** - Password hashing +- **UV** - Python package manager +- **Just** - Command runner + +## Development Commands + +### Running the Application + +```bash +# Start development server (with auto-reload) +just run +# Runs: uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 +``` + +The API will be available at: +- **API**: http://localhost:8080 +- **Docs**: http://localhost:8080/docs (Swagger UI) +- **Redoc**: http://localhost:8080/redoc + +### Database Operations + +```bash +# Generate a new migration +just migration-generate "description of changes" + +# Run pending migrations +just migrate + +# Connect to PostgreSQL CLI +just pgcli +``` + +### Docker Services + +```bash +# Start PostgreSQL container +docker-compose up -d + +# Stop all services +docker-compose down +``` + +## Architecture + +### DDD Layered Structure + +The codebase follows a strict 4-layer DDD architecture within each bounded context: + +``` +app/context/{context_name}/ +├── domain/ # Core business logic (framework-agnostic) +│ ├── contracts/ # Service interfaces (dependency inversion) +│ ├── services/ # Domain services (business rules) +│ ├── value_objects/ # Immutable validated objects +│ └── dto/ # Domain data transfer objects +│ +├── application/ # Use cases and orchestration +│ ├── commands/ # Write operations (CQRS) +│ ├── queries/ # Read operations (CQRS) +│ ├── handlers/ # Command/query handler implementations +│ ├── contracts/ # Handler interfaces +│ └── dto/ # Application DTOs +│ +├── infrastructure/ # External concerns and implementations +│ ├── repositories/ # Data access implementations +│ ├── models/ # SQLAlchemy ORM models +│ ├── mappers/ # Domain DTO ↔ Database model mappers +│ └── dependencies.py # Dependency injection setup +│ +└── interface/ # External interfaces + └── rest/ # REST API layer + ├── controllers/ # FastAPI route handlers + ├── schemas/ # Pydantic request/response models + └── routes.py # Router registration +``` + +### Current Bounded Contexts + +1. **Auth Context** (`app/context/auth/`) - Authentication and authorization +2. **User Context** (`app/context/user/`) - User management + +### Shared Kernel + +Located at `app/shared/`, contains cross-cutting concerns: + +- **Infrastructure**: + - `database.py` - Async SQLAlchemy engine and session factory + - `models/base_model.py` - Base database model + +- **Domain**: + - `value_objects/shared_email.py` - Email validation + - `value_objects/shared_password.py` - Argon2 password hashing + +## Key Architectural Patterns + +**IMPORTANT**: For comprehensive details on implementing DDD and CQRS patterns, see `/docs/ddd-patterns.md`. This includes: +- When to use Commands vs Queries +- Complete data flow diagrams for both +- Interface (contract) definitions and placement +- Dependency injection setup and rules +- Cross-context communication patterns +- Common anti-patterns to avoid + +### 1. Dependency Injection via FastAPI + +Dependencies are defined as factory functions in `infrastructure/dependencies.py`: + +```python +# Pattern: Contract-based injection +def get_service() -> ServiceContract: + return ConcreteService() + +def get_handler( + service: ServiceContract = Depends(get_service), +) -> HandlerContract: + return ConcreteHandler(service) +``` + +**Important**: Always program to contracts (interfaces), not implementations. + +### 2. CQRS (Command Query Responsibility Segregation) + +- **Commands** - Represent write operations (e.g., `LoginCommand`) +- **Queries** - Represent read operations (e.g., `FindUserQuery`) +- **Handlers** - Process commands/queries and coordinate domain services + +### 3. Repository Pattern + +All data access goes through repository contracts: + +```python +# Contract defines interface +class UserRepositoryContract(ABC): + async def find_user(self, user_id: Optional[UserID] = None) -> Optional[UserDTO]: + pass + +# Implementation uses SQLAlchemy +class UserRepository(UserRepositoryContract): + def __init__(self, db: AsyncSession): + self._db = db +``` + +### 4. Mapper Pattern + +Separate database models from domain DTOs using mappers: + +```python +class UserMapper: + @staticmethod + def toDTO(model: UserModel) -> UserDTO: + return UserDTO( + user_id=UserID(model.id), + email=Email(model.email), + password=Password.from_hash(model.password) + ) +``` + +**Rule**: Never pass SQLAlchemy models outside the infrastructure layer. + +### 5. Value Objects + +Use immutable, validated value objects for domain concepts: + +```python +@dataclass(frozen=True) +class Email: + value: str + + def __post_init__(self): + # Validation logic here + if not self._is_valid(): + raise ValueError(f"Invalid email: {self.value}") +``` + +#### Context-Specific Value Objects + +**Rule**: To maintain bounded context isolation, **always create context-specific value objects** even when they share identical validation logic. + +**Pattern**: +1. Define validation logic once in the **Shared Kernel** (`app/shared/domain/value_objects/`) +2. Create **context-specific wrappers** that extend the shared value object +3. Each context uses **only its own value object types**, never shared or cross-context types + +**Example**: + +```python +# Shared Kernel - contains validation logic +# app/shared/domain/value_objects/shared_currency.py +@dataclass(frozen=True) +class SharedCurrency: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if len(self.value) != 3: + raise ValueError("Currency code must be 3 characters") + if not self.value.isupper(): + raise ValueError("Currency code must be uppercase") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + return cls(value, _validated=True) + +# User Account Context - extends shared validation +# app/context/user_account/domain/value_objects/account_currency.py +@dataclass(frozen=True) +class UserAccountCurrency(SharedCurrency): + pass + +# Credit Card Context - extends shared validation +# app/context/credit_card/domain/value_objects/credit_card_currency.py +@dataclass(frozen=True) +class CreditCardCurrency(SharedCurrency): + pass +``` + +**Usage**: + +```python +# Good - each context uses its own type +class UserAccountDTO: + currency: UserAccountCurrency # ✅ Context-specific type + +class CreditCardDTO: + currency: CreditCardCurrency # ✅ Context-specific type + +# Bad - using shared type directly +class UserAccountDTO: + currency: SharedCurrency # ❌ Breaks context isolation + +# Bad - cross-context usage +class UserAccountDTO: + currency: CreditCardCurrency # ❌ Wrong context! +``` + +**Benefits**: +- **Context Isolation**: Maintains clear bounded context boundaries +- **No Code Duplication**: Validation logic lives in one place (shared kernel) +- **Type Safety**: Prevents accidental mixing of types from different contexts +- **Future Flexibility**: Contexts can add specific behavior later without affecting others +- **Explicit Domain Modeling**: Code clearly shows which context a value belongs to + +**When to Use**: +- Apply this pattern to **all value objects that appear in multiple contexts** +- Common examples: Currency, Money, Quantity, Percentage, Date/Time ranges +- Even if contexts share identical validation today, use context-specific types for future flexibility + +### 6. Authentication in Controllers + +**Rule**: Controllers obtain the authenticated user ID via dependency injection from shared middleware. The user ID is passed as a **primitive** (int) to commands/queries, following CQRS principles. + +**Pattern**: + +```python +# Controller - app/context/user_account/interface/rest/controllers/create_account_controller.py +from fastapi import APIRouter, Depends +from app.shared.infrastructure.middleware import get_current_user_id + +@router.post("/accounts", status_code=201) +async def create_account( + request: CreateAccountRequest, + handler: CreateAccountHandlerContract = Depends(get_create_account_handler), + user_id: int = Depends(get_current_user_id), # ✅ Inject authenticated user ID +): + # Pass primitive user_id to command + command = CreateAccountCommand( + user_id=user_id, # ✅ Primitive in command + name=request.name, + currency=request.currency, + balance=request.balance, + ) + result = await handler.handle(command) + return result +``` + +**Middleware Implementation**: + +The shared middleware (`app/shared/infrastructure/middleware/session_auth_dependency.py`) provides two authentication dependencies: + +```python +async def get_current_user_id( + access_token: Optional[str] = Cookie(default=None), + session_repo: SessionRepositoryContract = Depends(get_session_repository_for_auth), +) -> int: + """ + Required authentication - raises 401 if not authenticated. + Returns: user_id as int + """ + if not access_token: + raise HTTPException(status_code=401, detail="Not authenticated") + + token = SessionToken(access_token) + session = await session_repo.getSession(token=token) + + if not session: + raise HTTPException(status_code=401, detail="Invalid or expired session") + + return session.user_id.value # Extract primitive from value object + + +async def get_current_user_id_optional( + access_token: Optional[str] = Cookie(default=None), + session_repo: SessionRepositoryContract = Depends(get_session_repository_for_auth), +) -> Optional[int]: + """ + Optional authentication - returns None if not authenticated. + Returns: Optional[int] + """ + if not access_token: + return None + + # ... validation logic ... + return session.user_id.value if session else None +``` + +**Command Structure**: + +Commands receive user_id as a primitive: + +```python +# app/context/user_account/application/commands/create_account_command.py +@dataclass(frozen=True) +class CreateAccountCommand: + user_id: int # ✅ Primitive type (not UserID value object) + name: str + currency: str + balance: float +``` + +**Handler Converts to Value Objects**: + +```python +# app/context/user_account/application/handlers/create_account_handler.py +class CreateAccountHandler: + async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: + # Convert primitives to value objects + user_id = UserID(command.user_id) # ✅ Handler creates value objects + name = AccountName(command.name) + currency = UserAccountCurrency(command.currency) + + # Use value objects in domain service + account_dto = await self._service.create_account( + user_id=user_id, + name=name, + currency=currency, + ) + return result +``` + +**Authentication Flow**: + +``` +1. HTTP Request with Cookie + ↓ +2. get_current_user_id dependency + → Extracts access_token from cookie + → Validates SessionToken value object + → Queries SessionRepository + → Returns int (user_id.value) + ↓ +3. Controller receives user_id: int + → Creates Command with primitive user_id + ↓ +4. Handler receives Command + → Converts user_id to UserID value object + → Passes to domain service +``` + +**When to Use**: + +- **Required Authentication**: Use `get_current_user_id` - raises 401 if not authenticated +- **Optional Authentication**: Use `get_current_user_id_optional` - returns None if not authenticated (e.g., personalized public content) + +**Benefits**: + +- **Centralized Authentication**: All auth logic in one place (middleware) +- **Separation of Concerns**: Controllers don't handle token validation +- **CQRS Compliance**: Commands use primitives, handlers use value objects +- **Type Safety**: FastAPI validates dependency types automatically +- **Testability**: Easy to mock user_id in tests + +## Database Configuration + +### Connection Details + +Configuration in `.env`: +``` +DB_HOST=localhost +DB_PORT=5432 +DB_USER=uhomecomp +DB_PASS=homecomppass +DB_NAME=homecomp +``` + +### Session Management + +Get async database sessions via dependency injection: + +```python +from app.shared.infrastructure.database import get_db +from sqlalchemy.ext.asyncio import AsyncSession + +async def my_handler(db: AsyncSession = Depends(get_db)): + # Use db session here +``` + +### Migrations + +- All migrations live in `/migrations/` +- Use Alembic for schema changes +- Migration environment imports from `app.shared.infrastructure.models.base_model` + +## Security Considerations + +### Password Hashing + +Always use `SharedPassword` value object for password operations: + +```python +# Hashing +hashed = SharedPassword.from_plain_text("user_password") + +# Verification +is_valid = hashed_password.verify("input_password") +``` + +Uses **Argon2** algorithm (modern, secure). + +### Login Throttling + +Comprehensive implementation guide available at `/docs/login-throttle-implementation.md`. + +**Recommended approach**: +- **Development**: In-memory throttle service +- **Production**: Redis-based throttle service (distributed, persistent) + +Throttle by **email** (not just IP) to prevent account-specific brute-force attacks. + +## Code Organization Rules + +### Layer Dependencies + +Dependencies flow in ONE direction only: + +``` +Interface → Application → Domain + ↓ ↓ +Infrastructure ←┘ +``` + +**Rules**: +- Domain layer has NO dependencies on other layers +- Application layer depends ONLY on domain +- Infrastructure implements domain contracts +- Interface layer orchestrates via application handlers + +### Cross-Context Communication + +When one context needs data from another: + +1. Import the **query handler contract** from the other context's application layer +2. Inject via dependency injection +3. Never access repositories directly across contexts + +**Example**: Auth context uses `FindUserHandlerContract` from User context. + +### File Naming Conventions + +- **Contracts/Interfaces**: `{name}_contract.py` +- **Implementations**: `{name}.py` +- **DTOs**: `{name}_dto.py` +- **Value Objects**: `{name}.py` (e.g., `email.py`, `password.py`) +- **Commands**: `{action}_command.py` +- **Queries**: `{action}_query.py` +- **Handlers**: `{action}_handler.py` + +## Data Flow Example + +Typical request flow through the system: + +``` +1. HTTP Request + ↓ +2. Pydantic Schema (interface/rest/schemas) + ↓ +3. Controller (interface/rest/controllers) + ↓ +4. Handler (application/handlers) + → Converts to Command/Query + ↓ +5. Domain Service (domain/services) + → Contains business logic + ↓ +6. Repository (infrastructure/repositories) + → Data access via SQLAlchemy + → Returns Domain DTO (via Mapper) + ↓ +7. Handler returns Application DTO + ↓ +8. Controller returns Pydantic response +``` + +## Important Implementation Notes + +### Async/Await + +This is an **async-first** codebase: +- All route handlers are `async def` +- All repository methods are `async` +- Use `await` for database operations +- Database sessions are async (`AsyncSession`) + +### Type Hints + +Extensive type hints are used throughout: +- All function parameters and return types should be annotated +- Use domain types (Value Objects, DTOs) instead of primitives +- SQLAlchemy uses `Mapped[]` type annotations + +### REST Schemas + +When creating schemas for the REST interface layer: + +**Request Schemas** (incoming data): +- Use **Pydantic `BaseModel`** for validation +- Set `model_config = ConfigDict(frozen=True)` for immutability +- Leverage Pydantic validators (`EmailStr`, custom validators, etc.) + +**Response Schemas** (outgoing data): +- Use **Python `@dataclass(frozen=True)`** for simplicity +- No validation needed (we control the data) +- Avoids unnecessary Pydantic overhead +- FastAPI can serialize dataclasses automatically + +```python +# Request schema - use Pydantic for validation +class LoginRequest(BaseModel): + model_config = ConfigDict(frozen=True) + email: EmailStr + password: str + +# Response schema - use dataclass for performance +@dataclass(frozen=True) +class LoginResponse: + message: str +``` + +### Error Handling + +When implementing new features: +- Raise `ValueError` in value objects for validation failures +- Raise `HTTPException` in controllers for HTTP errors +- Domain services should raise domain-specific exceptions +- Let FastAPI handle exception-to-HTTP conversion + +## Current Implementation Status + +### Completed +- User context with repository and query handler +- Auth context scaffolding +- Database models and migrations for users +- Dependency injection setup +- Password hashing with Argon2 +- Value objects for Email, Password, UserID + +### In Progress +- Login service implementation (see `app/context/auth/domain/services/login_service.py:10-15`) +- Login throttling (see `/docs/login-throttle-implementation.md`) +- Token generation for authentication + +## Testing Guidelines + +No test suite currently exists. When implementing tests: + +### Structure +``` +tests/ +├── unit/ +│ ├── context/ +│ │ ├── auth/ +│ │ │ ├── domain/ # Test domain services, value objects +│ │ │ ├── application/ # Test handlers +│ │ │ └── infrastructure/ # Test repositories (with test DB) +│ │ └── user/ +│ └── shared/ +└── integration/ + └── test_api_endpoints.py +``` + +### Recommendations +- Use `pytest` with `pytest-asyncio` +- Mock repository contracts for unit tests +- Use test database for integration tests +- Test value object validation extensively +- Test CQRS handlers with mocked dependencies + +## Common Pitfalls to Avoid + +1. **Don't bypass value objects** - Always use `Email`, `Password`, etc., never raw strings +2. **Don't skip the mapper layer** - Never return SQLAlchemy models from repositories +3. **Don't mix contexts directly** - Use query handlers for cross-context communication +4. **Don't put business logic in controllers** - Keep it in domain services +5. **Don't forget dependency injection** - Use `Depends()` for all dependencies +6. **Don't use sync database operations** - Everything must be async +7. **Don't violate layer dependencies** - Domain should never import from infrastructure + +## Adding New Features + +When implementing a new feature: + +1. **Identify the bounded context** - Does it fit in Auth, User, or need a new context? +2. **Design domain layer first**: + - Value objects + - Domain DTOs + - Service contracts + - Domain services +3. **Create application layer**: + - Commands/Queries + - Handler contracts + - Handler implementations +4. **Implement infrastructure**: + - Database models (if needed) + - Repositories + - Mappers + - Dependencies +5. **Add interface layer**: + - Pydantic schemas + - Controllers + - Route registration in `app/main.py` +6. **Create database migration**: + - `just migration-generate "description"` + - `just migrate` + +## Package Management + +This project uses **UV** (not pip): + +```bash +# Install dependencies +uv sync + +# Add new dependency +uv add package-name + +# Add dev dependency +uv add --dev package-name +``` + +Dependencies are locked in `uv.lock` and declared in `pyproject.toml`. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aefa869 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,52 @@ +# Stage 1: Builder - Install dependencies using UV +FROM python:3.13-slim AS builder + +# Install UV package manager +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Set working directory +WORKDIR /app + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies to a virtual environment +RUN uv sync --frozen --no-dev --no-install-project + +# Stage 2: Runtime - Minimal production image +FROM python:3.13-slim + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/app/.venv/bin:$PATH" + +# Create non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Set working directory +WORKDIR /app + +# Copy virtual environment from builder +COPY --from=builder /app/.venv /app/.venv + +# Copy application code +COPY app/ /app/app/ +COPY alembic.ini /app/ +COPY migrations/ /app/migrations/ + +# Change ownership to non-root user +RUN chown -R appuser:appuser /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 8090 + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8090/').read()" + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090"] diff --git a/README.md b/README.md index ee8db46..0f29f30 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,425 @@ -# homecomp-api -HomeCompanion API +# HomeComp API + +A production-ready RESTful API for personal finance management built with **FastAPI** and **Domain-Driven Design** principles. This project demonstrates modern Python backend development practices, including clean architecture, CQRS patterns, and comprehensive structured logging. + +## Overview + +HomeComp API is a backend service designed to help users track household finances, including bank accounts, credit cards, recurring entries (income/expenses), and household management. The application follows strict architectural boundaries through DDD, ensuring maintainable and testable code. + +## Key Features + +- **Authentication System**: Session-based authentication with login throttling and account lockout protection +- **User Account Management**: CRUD operations for bank accounts with multi-currency support +- **Credit Card Tracking**: Manage credit cards with payment limits and billing cycles +- **Entry Management**: Track income and expenses across accounts and credit cards +- **Household Management**: Organize accounts and cards by household +- **Structured Logging**: Production-ready logging with Loki integration and Grafana visualization + +## Technology Stack + +### Core Technologies +- **Python 3.13+** - Modern Python with latest features +- **FastAPI 0.125** - High-performance async web framework +- **SQLAlchemy 2.0** - Async ORM with type safety +- **PostgreSQL 16** - Production database +- **Alembic** - Database migration management + +### Security & Infrastructure +- **Argon2** - Modern password hashing algorithm +- **Structlog** - Structured logging for observability +- **UV** - Fast Python package manager +- **Docker Compose** - Local development environment + +## Architecture Highlights + +### Domain-Driven Design (DDD) + +The codebase is organized into **bounded contexts**, each with a strict 4-layer architecture: + +``` +📁 app/context/{context_name}/ +├── 📂 domain/ # Core business logic (framework-agnostic) +│ ├── contracts/ # Service interfaces +│ ├── services/ # Business rules +│ ├── value_objects/ # Immutable validated objects +│ └── dto/ # Domain data transfer objects +│ +├── 📂 application/ # Use case orchestration (CQRS) +│ ├── commands/ # Write operations +│ ├── queries/ # Read operations +│ └── handlers/ # Command/query handlers +│ +├── 📂 infrastructure/ # External concerns +│ ├── repositories/ # Data access implementations +│ ├── models/ # SQLAlchemy ORM models +│ └── mappers/ # DTO ↔ Model conversion +│ +└── 📂 interface/ # External interfaces + └── rest/ # REST API layer + ├── controllers/ # Route handlers + └── schemas/ # Request/response models +``` + +### CQRS (Command Query Responsibility Segregation) + +- **Commands** - Write operations (CreateAccountCommand, UpdateCardCommand) +- **Queries** - Read operations (FindAccountQuery, ListCardsQuery) +- **Handlers** - Process commands/queries with error handling via Result pattern + +### Key Patterns + +- **Repository Pattern** - All data access abstracted behind contracts +- **Mapper Pattern** - Clean separation between domain and persistence layers +- **Value Objects** - Type-safe, validated domain primitives (Email, Currency, Money) +- **Dependency Injection** - FastAPI's DI system for loose coupling +- **Result Pattern** - Type-safe error handling without exceptions in application layer + +## Project Structure + +``` +homecomp-api/ +├── app/ +│ ├── context/ # Bounded contexts +│ │ ├── auth/ # Authentication & authorization +│ │ ├── user_account/ # Bank account management +│ │ ├── credit_card/ # Credit card tracking +│ │ ├── entry/ # Income/expense entries +│ │ └── household/ # Household management +│ │ +│ ├── shared/ # Shared kernel +│ │ ├── domain/ # Shared value objects & contracts +│ │ └── infrastructure/ # Database, logging, middleware +│ │ +│ └── main.py # Application entry point +│ +├── migrations/ # Alembic database migrations +├── tests/ # Unit and integration tests +├── docs/ # Architecture documentation +├── docker/ # Docker configurations +│ ├── loki/ # Loki config +│ ├── promtail/ # Promtail config +│ └── grafana/ # Grafana dashboards +├── Dockerfile # Multi-stage production image +├── docker-compose.yml # Complete development stack +├── .dockerignore # Docker build exclusions +├── justfile # Development commands +└── pyproject.toml # Project dependencies (UV) +``` + +## Getting Started + +### Prerequisites + +- Python 3.13 or higher +- Docker & Docker Compose +- UV package manager ([installation guide](https://github.com/astral-sh/uv)) +- Just command runner (optional) ([installation guide](https://github.com/casey/just)) + +### Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/yourusername/homecomp-api.git + cd homecomp-api + ``` + +2. **Install dependencies** + ```bash + uv sync + ``` + +3. **Start PostgreSQL** + ```bash + docker-compose up -d + ``` + +4. **Run database migrations** + ```bash + just migrate + # or: uv run alembic upgrade head + ``` + +5. **Start the development server** + ```bash + just run + # or: uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 + ``` + +The API will be available at: +- **API**: http://localhost:8080 +- **Interactive Docs**: http://localhost:8080/docs (Swagger UI) +- **ReDoc**: http://localhost:8080/redoc + +### Docker Deployment (Recommended) + +The easiest way to run the entire stack (API + PostgreSQL + Valkey + Logging) is with Docker Compose: + +1. **Clone the repository** + ```bash + git clone https://github.com/polivera/homecomp-api.git + cd homecomp-api + ``` + +2. **Create environment file** + ```bash + cp .env.example .env + # Edit .env with your configuration + ``` + +3. **Build and start all services** + ```bash + docker-compose up -d + ``` + +4. **Run database migrations** + ```bash + docker-compose exec api uv run alembic upgrade head + ``` + +The application stack will be available at: +- **API**: http://localhost:8090 +- **Interactive Docs**: http://localhost:8090/docs +- **Grafana**: http://localhost:3000 (admin/admin) +- **PostgreSQL**: localhost:5432 + +**Useful Docker commands:** +```bash +# View logs +docker-compose logs -f api + +# Rebuild after code changes +docker-compose up -d --build api + +# Stop all services +docker-compose down + +# Stop and remove volumes (reset database) +docker-compose down -v +``` + +### Environment Configuration + +Create a `.env` file in the project root: + +```env +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_USER=uhomecomp +DB_PASS=homecomppass +DB_NAME=homecomp + +# Application +APP_ENV=dev # dev, test, or production +``` + +## API Documentation + +Once the server is running, explore the API through: + +- **Swagger UI**: http://localhost:8080/docs - Interactive API testing +- **ReDoc**: http://localhost:8080/redoc - Clean API documentation + +### Available Endpoints + +#### Authentication (`/api/auth`) +- `POST /api/auth/login` - User login with session creation + +#### User Accounts (`/api/user-accounts`) +- `POST /api/user-accounts` - Create a new account +- `GET /api/user-accounts` - List all user accounts +- `GET /api/user-accounts/{id}` - Get account details +- `PUT /api/user-accounts/{id}` - Update account information +- `DELETE /api/user-accounts/{id}` - Delete an account + +#### Credit Cards (`/api/credit-cards`) +- Full CRUD operations for credit card management + +#### Entries (`/api/entries`) +- Income and expense tracking across accounts and cards + +#### Households (`/api/households`) +- Household organization and management + +## Development + +### Local Development (Without Docker) + +For local development without Docker, ensure PostgreSQL is running separately: + +```bash +# Start development server with hot reload +just run +# or: uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 + +# Run tests +uv run pytest + +# Run tests with coverage +uv run pytest --cov + +# Generate new migration +just migration-generate "description of changes" + +# Run pending migrations +just migrate + +# Connect to PostgreSQL CLI +just pgcli + +# Format code +uv run ruff format + +# Lint code +uv run ruff check +``` + +### Docker Development + +When using Docker Compose, the API has hot-reload enabled via volume mounting: + +```bash +# Start all services in development mode +docker-compose up -d + +# View API logs (with hot-reload feedback) +docker-compose logs -f api + +# Run migrations inside container +docker-compose exec api uv run alembic upgrade head + +# Run tests inside container +docker-compose exec api uv run pytest + +# Access Python shell inside container +docker-compose exec api uv run python + +# Rebuild after dependency changes +docker-compose up -d --build api +``` + +### Database Migrations + +```bash +# Create a new migration +just migration-generate "add_user_preferences_table" + +# Apply migrations +just migrate + +# Rollback one migration +uv run alembic downgrade -1 +``` + +## Testing + +The project uses pytest with async support: + +```bash +# Run all tests +uv run pytest + +# Run unit tests only +uv run pytest -m unit + +# Run integration tests only +uv run pytest -m integration + +# Run with coverage report +uv run pytest --cov --cov-report=html +``` + +## Architecture Decisions + +### Why DDD? + +Domain-Driven Design provides: +- **Clear boundaries** - Each context owns its data and logic +- **Testability** - Domain logic is framework-agnostic +- **Maintainability** - Changes are localized to bounded contexts +- **Team scalability** - Contexts can be worked on independently + +### Why CQRS? + +Command Query Responsibility Segregation offers: +- **Separation of concerns** - Read and write models can evolve independently +- **Optimized queries** - Read operations don't need full domain logic +- **Clear intent** - Commands vs queries make code easier to understand + +### Why Value Objects? + +Type-safe value objects provide: +- **Compile-time validation** - Invalid data can't exist in the domain +- **Self-documenting code** - `Email` is clearer than `str` +- **Encapsulation** - Validation logic lives in one place + +### Why Repository Pattern? + +Repositories offer: +- **Testability** - Easy to mock data access +- **Flexibility** - Can swap ORMs or databases without changing domain +- **Abstraction** - Domain doesn't depend on SQLAlchemy + +## Logging & Observability + +The application uses **structlog** with: +- Structured JSON logging for production +- Colored console logs for development +- Loki integration for log aggregation +- Grafana dashboards for visualization + +All logs include contextual information (user_id, email, operation) making debugging and monitoring straightforward. + +## Security Features + +- **Argon2 password hashing** - Memory-hard algorithm resistant to GPU attacks +- **Session-based authentication** - Secure token management with expiration +- **Login throttling** - Progressive delays to prevent brute-force attacks +- **Account lockout** - Temporary blocks after failed login attempts +- **Input validation** - Pydantic models validate all request data +- **SQL injection protection** - SQLAlchemy ORM with parameterized queries + +## Contributing + +This is a personal portfolio project, but suggestions and feedback are welcome! + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## What I Learned + +Building this project taught me: + +- **Clean Architecture** - Strict layer dependencies and separation of concerns +- **Async Python** - Modern async/await patterns with FastAPI and SQLAlchemy 2.0 +- **Type Safety** - Comprehensive type hints and domain-driven value objects +- **Production Practices** - Structured logging, error handling, and observability +- **Database Design** - Alembic migrations, PostgreSQL optimization, and async sessions +- **Testing Strategy** - Unit vs integration tests with proper mocking +- **API Design** - RESTful conventions and comprehensive OpenAPI documentation + +## Future Enhancements + +- [ ] Credit card expenses tracking +- [ ] JWT-based authentication as an alternative to sessions +- [ ] Real-time notifications via WebSockets +- [ ] Budget planning and forecasting features +- [ ] Data export/import (CSV, JSON) +- [ ] Multi-tenancy support for household sharing +- [ ] Mobile app integration +- [ ] Shared account for household + +## License + +This project is open source and available under the MIT License. + +## Contact + +**Pablo** - [GitHub](https://github.com/polivera) + +--- + +Built with ❤️ using FastAPI, DDD, and modern Python practices. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..94f812e --- /dev/null +++ b/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/auth/__init__.py b/app/context/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/auth/application/__init__.py b/app/context/auth/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/auth/application/commands/__init__.py b/app/context/auth/application/commands/__init__.py new file mode 100644 index 0000000..d4731e0 --- /dev/null +++ b/app/context/auth/application/commands/__init__.py @@ -0,0 +1,3 @@ +from .login_command import LoginCommand + +__all__ = ["LoginCommand"] diff --git a/app/context/auth/application/commands/login_command.py b/app/context/auth/application/commands/login_command.py new file mode 100644 index 0000000..386116c --- /dev/null +++ b/app/context/auth/application/commands/login_command.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class LoginCommand: + email: str + password: str diff --git a/app/context/auth/application/contracts/__init__.py b/app/context/auth/application/contracts/__init__.py new file mode 100644 index 0000000..32dd255 --- /dev/null +++ b/app/context/auth/application/contracts/__init__.py @@ -0,0 +1,4 @@ +from .get_session_handler_contract import GetSessionHandlerContract +from .login_handler_contract import LoginHandlerContract + +__all__ = ["LoginHandlerContract", "GetSessionHandlerContract"] diff --git a/app/context/auth/application/contracts/get_session_handler_contract.py b/app/context/auth/application/contracts/get_session_handler_contract.py new file mode 100644 index 0000000..dafc6c6 --- /dev/null +++ b/app/context/auth/application/contracts/get_session_handler_contract.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +from app.context.auth.application.dto.get_session_result_dto import GetSessionResultDTO +from app.context.auth.application.queries import GetSessionQuery + + +class GetSessionHandlerContract(ABC): + @abstractmethod + async def handle(self, query: GetSessionQuery) -> GetSessionResultDTO | None: + pass diff --git a/app/context/auth/application/contracts/login_handler_contract.py b/app/context/auth/application/contracts/login_handler_contract.py new file mode 100644 index 0000000..d6c71f0 --- /dev/null +++ b/app/context/auth/application/contracts/login_handler_contract.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod + +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.dto import LoginHandlerResultDTO + + +class LoginHandlerContract(ABC): + @abstractmethod + async def handle(self, command: LoginCommand) -> LoginHandlerResultDTO: + pass diff --git a/app/context/auth/application/dto/__init__.py b/app/context/auth/application/dto/__init__.py new file mode 100644 index 0000000..b2b403b --- /dev/null +++ b/app/context/auth/application/dto/__init__.py @@ -0,0 +1,3 @@ +from .login_handler_result_dto import LoginHandlerResultDTO, LoginHandlerResultStatus + +__all__ = ["LoginHandlerResultDTO", "LoginHandlerResultStatus"] diff --git a/app/context/auth/application/dto/get_session_result_dto.py b/app/context/auth/application/dto/get_session_result_dto.py new file mode 100644 index 0000000..2583d9e --- /dev/null +++ b/app/context/auth/application/dto/get_session_result_dto.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GetSessionResultDTO: + user_id: int + token: str | None + failed_attempts: int + blocked_until: str | None diff --git a/app/context/auth/application/dto/login_handler_result_dto.py b/app/context/auth/application/dto/login_handler_result_dto.py new file mode 100644 index 0000000..bc860c1 --- /dev/null +++ b/app/context/auth/application/dto/login_handler_result_dto.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class LoginHandlerResultStatus(Enum): + SUCCESS = "success" + INVALID_CREDENTIALS = "invalid" + ACCOUNT_BLOCKED = "blocked" + UNEXPECTED_ERROR = "unexpected_error" + + +@dataclass(frozen=True) +class LoginHandlerResultDTO: + status: LoginHandlerResultStatus + token: str | None = None + user_id: int | None = None + error_msg: str | None = None + retry_after: datetime | None = None diff --git a/app/context/auth/application/handlers/__init__.py b/app/context/auth/application/handlers/__init__.py new file mode 100644 index 0000000..b7118eb --- /dev/null +++ b/app/context/auth/application/handlers/__init__.py @@ -0,0 +1,4 @@ +from .get_session_handler import GetSessionHandler +from .login_handler import LoginHandler + +__all__ = ["LoginHandler", "GetSessionHandler"] diff --git a/app/context/auth/application/handlers/get_session_handler.py b/app/context/auth/application/handlers/get_session_handler.py new file mode 100644 index 0000000..34a073e --- /dev/null +++ b/app/context/auth/application/handlers/get_session_handler.py @@ -0,0 +1,28 @@ +from app.context.auth.application.contracts import GetSessionHandlerContract +from app.context.auth.application.dto.get_session_result_dto import GetSessionResultDTO +from app.context.auth.application.queries import GetSessionQuery +from app.context.auth.domain.contracts import SessionRepositoryContract +from app.context.auth.domain.value_objects import AuthUserID, SessionToken + + +class GetSessionHandler(GetSessionHandlerContract): + _session_repo: SessionRepositoryContract + + def __init__(self, session_repo: SessionRepositoryContract): + self._session_repo = session_repo + + async def handle(self, query: GetSessionQuery) -> GetSessionResultDTO | None: + session = await self._session_repo.getSession( + user_id=AuthUserID(query.user_id) if query.user_id is not None else None, + token=SessionToken(query.token) if query.token is not None else None, + ) + return ( + GetSessionResultDTO( + user_id=session.user_id.value, + token=session.token.value if session.token is not None else None, + failed_attempts=session.failed_attempts.value, + blocked_until=session.blocked_until.toString() if session.blocked_until is not None else None, + ) + if session is not None + else None + ) diff --git a/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py new file mode 100644 index 0000000..6a8b05f --- /dev/null +++ b/app/context/auth/application/handlers/login_handler.py @@ -0,0 +1,93 @@ +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.contracts import ( + LoginHandlerContract, +) +from app.context.auth.application.dto import ( + LoginHandlerResultDTO, + LoginHandlerResultStatus, +) +from app.context.auth.domain.contracts import LoginServiceContract +from app.context.auth.domain.dto import AuthUserDTO +from app.context.auth.domain.exceptions import ( + AccountBlockedException, + InvalidCredentialsException, +) +from app.context.auth.domain.value_objects import AuthEmail, AuthPassword, AuthUserID +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.queries import FindUserQuery +from app.shared.domain.contracts import LoggerContract + + +class LoginHandler(LoginHandlerContract): + _user_handler: FindUserHandlerContract + _login_service: LoginServiceContract + _logger: LoggerContract + + def __init__( + self, + user_handler: FindUserHandlerContract, + login_service: LoginServiceContract, + logger: LoggerContract, + ): + self._user_handler = user_handler + self._login_service = login_service + self._logger = logger + + async def handle(self, command: LoginCommand) -> LoginHandlerResultDTO: + self._logger.debug("Handling login command", email=command.email) + + user = await self._user_handler.handle(FindUserQuery(email=command.email)) + if user is None or user.error_code is not None: + self._logger.debug("User not found", email=command.email) + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.INVALID_CREDENTIALS, + error_msg="Invalid username or password", + ) + + if user.user_id is None or user.email is None or user.password is None: + self._logger.error("Invalid user data retrieved from database", email=command.email) + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.UNEXPECTED_ERROR, + error_msg="Invalid user data", + ) + + try: + user_token = await self._login_service.handle( + user_password=AuthPassword(command.password), + db_user=AuthUserDTO( + user_id=AuthUserID(user.user_id), + email=AuthEmail(user.email), + password=AuthPassword.from_hash(user.password), + ), + ) + + self._logger.debug("Login service succeeded", email=command.email, user_id=user.user_id) + + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.SUCCESS, + token=user_token.value, + user_id=user.user_id, + ) + except AccountBlockedException as abe: + self._logger.info( + "Account blocked", + email=command.email, + blocked_until=abe.blocked_until.isoformat() if abe.blocked_until else None, + ) + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.ACCOUNT_BLOCKED, + retry_after=abe.blocked_until, + error_msg="Account is blocked, try again later", + ) + except InvalidCredentialsException: + self._logger.debug("Invalid credentials provided", email=command.email) + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.INVALID_CREDENTIALS, + error_msg="Invalid username or password", + ) + except Exception as e: + self._logger.error("Unexpected error during login", email=command.email, error=str(e)) + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.UNEXPECTED_ERROR, + error_msg="Unexpected error", + ) diff --git a/app/context/auth/application/queries/__init__.py b/app/context/auth/application/queries/__init__.py new file mode 100644 index 0000000..677fdf5 --- /dev/null +++ b/app/context/auth/application/queries/__init__.py @@ -0,0 +1,3 @@ +from .get_session_query import GetSessionQuery + +__all__ = ["GetSessionQuery"] diff --git a/app/context/auth/application/queries/get_session_query.py b/app/context/auth/application/queries/get_session_query.py new file mode 100644 index 0000000..9d29e84 --- /dev/null +++ b/app/context/auth/application/queries/get_session_query.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GetSessionQuery: + user_id: int | None = None + token: str | None = None diff --git a/app/context/auth/domain/__init__.py b/app/context/auth/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/auth/domain/contracts/__init__.py b/app/context/auth/domain/contracts/__init__.py new file mode 100644 index 0000000..facf8b5 --- /dev/null +++ b/app/context/auth/domain/contracts/__init__.py @@ -0,0 +1,9 @@ +from .login_attempts_service_contract import LoginAttemptsServiceContract +from .login_service_contract import LoginServiceContract +from .session_repository_contract import SessionRepositoryContract + +__all__ = [ + "SessionRepositoryContract", + "LoginAttemptsServiceContract", + "LoginServiceContract", +] diff --git a/app/context/auth/domain/contracts/login_attempts_service_contract.py b/app/context/auth/domain/contracts/login_attempts_service_contract.py new file mode 100644 index 0000000..3e3017b --- /dev/null +++ b/app/context/auth/domain/contracts/login_attempts_service_contract.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from app.context.user.domain.value_objects import UserEmail + + +class LoginAttemptsServiceContract(ABC): + @abstractmethod + async def handle(self, email: UserEmail): + pass diff --git a/app/context/auth/domain/contracts/login_service_contract.py b/app/context/auth/domain/contracts/login_service_contract.py new file mode 100644 index 0000000..fa1cc56 --- /dev/null +++ b/app/context/auth/domain/contracts/login_service_contract.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod + +from app.context.auth.domain.dto import AuthUserDTO +from app.context.auth.domain.value_objects import AuthPassword, SessionToken + + +class LoginServiceContract(ABC): + @abstractmethod + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> SessionToken: + """ + Handle user login. + + Args: + user_password: The plaintext password to verify + db_user: The user attempting to login + + Returns: + SessionToken: A new session token on successful login + + Raises: + InvalidCredentialsException: If password is incorrect + AccountBlockedException: If account is temporarily blocked + """ + pass diff --git a/app/context/auth/domain/contracts/session_repository_contract.py b/app/context/auth/domain/contracts/session_repository_contract.py new file mode 100644 index 0000000..675ab71 --- /dev/null +++ b/app/context/auth/domain/contracts/session_repository_contract.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod + +from app.context.auth.domain.dto.session_dto import SessionDTO +from app.context.auth.domain.value_objects import ( + AuthUserID, + SessionToken, +) + + +class SessionRepositoryContract(ABC): + @abstractmethod + async def getSession( + self, user_id: AuthUserID | None = None, token: SessionToken | None = None + ) -> SessionDTO | None: + pass + + @abstractmethod + async def createSession(self, session: SessionDTO) -> SessionDTO: + """Create a new session.""" + pass + + @abstractmethod + async def updateSession(self, session: SessionDTO) -> SessionDTO: + """Update an existing session.""" + pass diff --git a/app/context/auth/domain/dto/__init__.py b/app/context/auth/domain/dto/__init__.py new file mode 100644 index 0000000..b4a3b18 --- /dev/null +++ b/app/context/auth/domain/dto/__init__.py @@ -0,0 +1,4 @@ +from .auth_user_dto import AuthUserDTO +from .session_dto import SessionDTO + +__all__ = ["SessionDTO", "AuthUserDTO"] diff --git a/app/context/auth/domain/dto/auth_user_dto.py b/app/context/auth/domain/dto/auth_user_dto.py new file mode 100644 index 0000000..aa9f6cd --- /dev/null +++ b/app/context/auth/domain/dto/auth_user_dto.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.context.auth.domain.value_objects import AuthEmail, AuthPassword, AuthUserID + + +@dataclass(frozen=True) +class AuthUserDTO: + user_id: AuthUserID + email: AuthEmail + password: AuthPassword diff --git a/app/context/auth/domain/dto/session_dto.py b/app/context/auth/domain/dto/session_dto.py new file mode 100644 index 0000000..a5e6732 --- /dev/null +++ b/app/context/auth/domain/dto/session_dto.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from app.context.auth.domain.value_objects import ( + AuthUserID, + FailedLoginAttempts, + SessionToken, +) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime + + +@dataclass(frozen=True) +class SessionDTO: + user_id: AuthUserID + token: SessionToken | None + failed_attempts: FailedLoginAttempts + blocked_until: BlockedTime | None diff --git a/app/context/auth/domain/exceptions.py b/app/context/auth/domain/exceptions.py new file mode 100644 index 0000000..db0da7d --- /dev/null +++ b/app/context/auth/domain/exceptions.py @@ -0,0 +1,23 @@ +"""Domain exceptions for Auth context.""" + +from datetime import datetime + + +class AuthDomainException(Exception): + """Base exception for Auth domain errors.""" + + pass + + +class InvalidCredentialsException(AuthDomainException): + """Raised when login credentials are invalid.""" + + pass + + +class AccountBlockedException(AuthDomainException): + """Raised when account is temporarily blocked due to failed login attempts.""" + + def __init__(self, blocked_until: datetime): + self.blocked_until = blocked_until + super().__init__(f"Account is blocked until {blocked_until.isoformat()}") diff --git a/app/context/auth/domain/services/__init__.py b/app/context/auth/domain/services/__init__.py new file mode 100644 index 0000000..4e8173c --- /dev/null +++ b/app/context/auth/domain/services/__init__.py @@ -0,0 +1,3 @@ +from .login_service import LoginService + +__all__ = ["LoginService"] diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py new file mode 100644 index 0000000..badb66e --- /dev/null +++ b/app/context/auth/domain/services/login_service.py @@ -0,0 +1,108 @@ +import asyncio + +from app.context.auth.domain.contracts import ( + LoginServiceContract, + SessionRepositoryContract, +) +from app.context.auth.domain.dto import AuthUserDTO, SessionDTO +from app.context.auth.domain.exceptions import ( + AccountBlockedException, + InvalidCredentialsException, +) +from app.context.auth.domain.value_objects import ( + AuthPassword, + FailedLoginAttempts, + SessionToken, +) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime +from app.shared.domain.contracts import LoggerContract + + +class LoginService(LoginServiceContract): + _session_repo: SessionRepositoryContract + _logger: LoggerContract + + def __init__(self, session_repo: SessionRepositoryContract, logger: LoggerContract): + self._session_repo = session_repo + self._logger = logger + + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> SessionToken: + self._logger.debug("Login service started", user_id=db_user.user_id.value, email=db_user.email.value) + + session = await self._session_repo.getSession(user_id=db_user.user_id) + + if session is None: + self._logger.debug("No existing session, creating new session", user_id=db_user.user_id.value) + # Create session for first login attempt + session = await self._session_repo.createSession( + SessionDTO( + user_id=db_user.user_id, + token=None, + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + ) + + if session.blocked_until is not None and not session.blocked_until.isOver(): + self._logger.warning( + "Account is blocked", + user_id=db_user.user_id.value, + blocked_until=session.blocked_until.value.isoformat(), + ) + # Account is blocked + raise AccountBlockedException(session.blocked_until.value) + + if not db_user.password.verify(user_password.value): + # Increment failed attempts + new_attempts = FailedLoginAttempts(session.failed_attempts.value + 1) + + self._logger.info( + "Password verification failed", + user_id=db_user.user_id.value, + failed_attempts=new_attempts.value, + ) + + # Block account if max attempts reached + blocked_until = None + if new_attempts.hasReachMaxAttempts(): + blocked_until = BlockedTime.setBlocked() + self._logger.warning( + "Max login attempts reached, blocking account", + user_id=db_user.user_id.value, + blocked_until=blocked_until.value.isoformat(), + ) + + await self._session_repo.updateSession( + SessionDTO( + user_id=db_user.user_id, + token=None, + failed_attempts=new_attempts, + blocked_until=blocked_until, + ) + ) + + # Sleep to avoid brute force attempts + delay = new_attempts.getAttemptDelay() + self._logger.debug("Applying login delay", delay_seconds=delay) + await asyncio.sleep(delay) + + raise InvalidCredentialsException() + + # Create token and reset failed attempts + new_token = SessionToken.generate() + + self._logger.info( + "Password verified successfully, creating session token", + user_id=db_user.user_id.value, + ) + + await self._session_repo.updateSession( + SessionDTO( + user_id=db_user.user_id, + token=new_token, + failed_attempts=FailedLoginAttempts.reset(), + blocked_until=None, + ) + ) + + return new_token diff --git a/app/context/auth/domain/value_objects/__init__.py b/app/context/auth/domain/value_objects/__init__.py new file mode 100644 index 0000000..5c63288 --- /dev/null +++ b/app/context/auth/domain/value_objects/__init__.py @@ -0,0 +1,15 @@ +from .auth_email import AuthEmail +from .auth_password import AuthPassword +from .auth_user_id import AuthUserID +from .failed_login_attempts import FailedLoginAttempts +from .session_token import SessionToken +from .throttle_time import ThrottleTime + +__all__ = [ + "FailedLoginAttempts", + "ThrottleTime", + "AuthEmail", + "AuthPassword", + "AuthUserID", + "SessionToken", +] diff --git a/app/context/auth/domain/value_objects/auth_email.py b/app/context/auth/domain/value_objects/auth_email.py new file mode 100644 index 0000000..4802571 --- /dev/null +++ b/app/context/auth/domain/value_objects/auth_email.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedEmail + + +@dataclass(frozen=True) +class AuthEmail(SharedEmail): + pass diff --git a/app/context/auth/domain/value_objects/auth_password.py b/app/context/auth/domain/value_objects/auth_password.py new file mode 100644 index 0000000..75d59d7 --- /dev/null +++ b/app/context/auth/domain/value_objects/auth_password.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedPassword + + +@dataclass(frozen=True) +class AuthPassword(SharedPassword): + pass diff --git a/app/context/auth/domain/value_objects/auth_user_id.py b/app/context/auth/domain/value_objects/auth_user_id.py new file mode 100644 index 0000000..3a2ab16 --- /dev/null +++ b/app/context/auth/domain/value_objects/auth_user_id.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AuthUserID: + value: int diff --git a/app/context/auth/domain/value_objects/blocked_time.py b/app/context/auth/domain/value_objects/blocked_time.py new file mode 100644 index 0000000..973b83d --- /dev/null +++ b/app/context/auth/domain/value_objects/blocked_time.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Final, Self + + +@dataclass +class BlockedTime: + value: datetime + + BLOCK_MINUTES: Final = 15 + + def toString(self) -> str: + return self.value.isoformat() + + def isOver(self) -> bool: + return self.value < datetime.now() + + @classmethod + def setBlocked(cls) -> Self: + return cls(datetime.now() + timedelta(minutes=cls.BLOCK_MINUTES)) diff --git a/app/context/auth/domain/value_objects/failed_login_attempts.py b/app/context/auth/domain/value_objects/failed_login_attempts.py new file mode 100644 index 0000000..2d4de98 --- /dev/null +++ b/app/context/auth/domain/value_objects/failed_login_attempts.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class FailedLoginAttempts: + value: int + _max_attempts: int = 4 + _wait_attempts: list[float] = field(default_factory=lambda: [0, 0, 2, 4]) + + def hasReachMaxAttempts(self) -> bool: + return self.value >= self._max_attempts + + def getAttemptDelay(self) -> float: + return self._wait_attempts[self.value] if self.value < len(self._wait_attempts) else 4 + + @classmethod + def reset(cls) -> Self: + return cls(0) diff --git a/app/context/auth/domain/value_objects/session_token.py b/app/context/auth/domain/value_objects/session_token.py new file mode 100644 index 0000000..d951205 --- /dev/null +++ b/app/context/auth/domain/value_objects/session_token.py @@ -0,0 +1,23 @@ +import secrets +from dataclasses import dataclass +from typing import Self + + +@dataclass(frozen=True) +class SessionToken: + """ + Session token value object for session-based authentication. + Represents a cryptographically secure random token. + """ + + value: str + + @classmethod + def generate(cls) -> Self: + """Generate a new cryptographically secure session token.""" + return cls(value=secrets.token_urlsafe(32)) + + @classmethod + def from_string(cls, token: str) -> Self: + """Create a SessionToken from an existing token string.""" + return cls(value=token) diff --git a/app/context/auth/domain/value_objects/throttle_time.py b/app/context/auth/domain/value_objects/throttle_time.py new file mode 100644 index 0000000..d7123d4 --- /dev/null +++ b/app/context/auth/domain/value_objects/throttle_time.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from app.context.auth.domain.value_objects import FailedLoginAttempts + + +@dataclass(frozen=True) +class ThrottleTime: + value: int + _throttleTimeSeconds: tuple[int, ...] = (0, 2, 4, 8) + + @classmethod + def fromAttempts(cls, attempts: FailedLoginAttempts) -> "ThrottleTime": + return cls(value=cls._throttleTimeSeconds[attempts.value]) diff --git a/app/context/auth/infrastructure/__init__.py b/app/context/auth/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py new file mode 100644 index 0000000..2dbfbb4 --- /dev/null +++ b/app/context/auth/infrastructure/dependencies.py @@ -0,0 +1,61 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.auth.application.contracts import ( + LoginHandlerContract, +) +from app.context.auth.application.contracts.get_session_handler_contract import ( + GetSessionHandlerContract, +) +from app.context.auth.application.handlers import LoginHandler +from app.context.auth.application.handlers.get_session_handler import GetSessionHandler +from app.context.auth.domain.contracts import ( + LoginServiceContract, + SessionRepositoryContract, +) +from app.context.auth.domain.services import LoginService +from app.context.auth.infrastructure.repositories import SessionRepository +from app.context.user.application.contracts import ( + FindUserHandlerContract, +) +from app.context.user.infrastructure.dependencies import ( + get_find_user_query_handler, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.database import get_db +from app.shared.infrastructure.dependencies import get_logger + + +def get_session_repository( + db: Annotated[AsyncSession, Depends(get_db)], +) -> SessionRepositoryContract: + return SessionRepository(db) + + +def get_login_service( + session_repo: Annotated[SessionRepositoryContract, Depends(get_session_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> LoginServiceContract: + """ + LoginService dependency injection + """ + return LoginService(session_repo, logger) + + +def get_session_handler( + session_repo: Annotated[SessionRepository, Depends(get_session_repository)], +) -> GetSessionHandlerContract: + return GetSessionHandler(session_repo) + + +def get_login_handler( + user_query_handler: Annotated[FindUserHandlerContract, Depends(get_find_user_query_handler)], + login_service: Annotated[LoginServiceContract, Depends(get_login_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> LoginHandlerContract: + """ + LoginHandler dependency injection + """ + return LoginHandler(user_query_handler, login_service, logger) diff --git a/app/context/auth/infrastructure/mappers/__init__.py b/app/context/auth/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..bf52cf2 --- /dev/null +++ b/app/context/auth/infrastructure/mappers/__init__.py @@ -0,0 +1,3 @@ +from .session_mapper import SessionMapper + +__all__ = ["SessionMapper"] diff --git a/app/context/auth/infrastructure/mappers/session_mapper.py b/app/context/auth/infrastructure/mappers/session_mapper.py new file mode 100644 index 0000000..7ad9ecf --- /dev/null +++ b/app/context/auth/infrastructure/mappers/session_mapper.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass + +from app.context.auth.domain.dto import SessionDTO +from app.context.auth.domain.value_objects import ( + AuthUserID, + FailedLoginAttempts, + SessionToken, +) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime +from app.context.auth.infrastructure.models import SessionModel + + +@dataclass(frozen=True) +class SessionMapper: + @staticmethod + def toDTO(model: SessionModel | None) -> SessionDTO | None: + if model is None: + return None + + return SessionDTO( + user_id=AuthUserID(model.user_id), + token=SessionToken.from_string(model.token) if model.token else None, + failed_attempts=FailedLoginAttempts(model.failed_attempts), + blocked_until=BlockedTime(model.blocked_until) if model.blocked_until is not None else None, + ) + + @staticmethod + def toModel(dto: SessionDTO) -> SessionModel: + return SessionModel( + user_id=dto.user_id.value, + token=dto.token.value if dto.token else None, + failed_attempts=dto.failed_attempts.value, + blocked_until=dto.blocked_until, + ) diff --git a/app/context/auth/infrastructure/models/__init__.py b/app/context/auth/infrastructure/models/__init__.py new file mode 100644 index 0000000..e95fb9f --- /dev/null +++ b/app/context/auth/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .session_model import SessionModel + +__all__ = ["SessionModel"] diff --git a/app/context/auth/infrastructure/models/session_model.py b/app/context/auth/infrastructure/models/session_model.py new file mode 100644 index 0000000..610cc64 --- /dev/null +++ b/app/context/auth/infrastructure/models/session_model.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class SessionModel(BaseDBModel): + __tablename__ = "sessions" + + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) + token: Mapped[str | None] = mapped_column(String(100), unique=True, index=True) + failed_attempts: Mapped[int] = mapped_column(Integer, default=0) + blocked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/context/auth/infrastructure/repositories/__init__.py b/app/context/auth/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..ee3420e --- /dev/null +++ b/app/context/auth/infrastructure/repositories/__init__.py @@ -0,0 +1,3 @@ +from .session_repository import SessionRepository + +__all__ = ["SessionRepository"] diff --git a/app/context/auth/infrastructure/repositories/session_repository.py b/app/context/auth/infrastructure/repositories/session_repository.py new file mode 100644 index 0000000..95e419b --- /dev/null +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -0,0 +1,60 @@ +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.auth.domain.contracts import SessionRepositoryContract +from app.context.auth.domain.dto.session_dto import SessionDTO +from app.context.auth.domain.value_objects import AuthUserID, SessionToken +from app.context.auth.infrastructure.mappers import SessionMapper +from app.context.auth.infrastructure.models import SessionModel + + +class SessionRepository(SessionRepositoryContract): + _db: AsyncSession + + def __init__(self, db: AsyncSession): + self._db = db + + async def getSession( + self, user_id: AuthUserID | None = None, token: SessionToken | None = None + ) -> SessionDTO | None: + stmt = select(SessionModel) + if user_id is not None: + stmt = stmt.where(SessionModel.user_id == user_id.value) + if token is not None: + stmt = stmt.where(SessionModel.token == token.value) + + res = await self._db.execute(stmt) + return SessionMapper.toDTO(res.scalar_one_or_none()) + + async def createSession(self, session: SessionDTO) -> SessionDTO: + session_model = SessionModel( + user_id=session.user_id.value, + token=session.token.value if session.token is not None else None, + failed_attempts=session.failed_attempts.value, + blocked_until=session.blocked_until.value if session.blocked_until is not None else None, + ) + + self._db.add(session_model) + await self._db.commit() + await self._db.refresh(session_model) + dto = SessionMapper.toDTO(session_model) + if dto is None: + # TODO: Valid exception + raise Exception("send a valid exception here") + + return dto + + async def updateSession(self, session: SessionDTO) -> SessionDTO: + stmt = ( + update(SessionModel) + .where(SessionModel.user_id == session.user_id.value) + .values( + token=session.token.value if session.token is not None else None, + failed_attempts=session.failed_attempts.value, + blocked_until=session.blocked_until.value if session.blocked_until is not None else None, + ) + ) + await self._db.execute(stmt) + await self._db.commit() + + return session diff --git a/app/context/auth/interface/__init__.py b/app/context/auth/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/auth/interface/rest/__init__.py b/app/context/auth/interface/rest/__init__.py new file mode 100644 index 0000000..05f0b83 --- /dev/null +++ b/app/context/auth/interface/rest/__init__.py @@ -0,0 +1,3 @@ +from .routes import auth_routes + +__all__ = ["auth_routes"] diff --git a/app/context/auth/interface/rest/controllers/__init__.py b/app/context/auth/interface/rest/controllers/__init__.py new file mode 100644 index 0000000..528e57b --- /dev/null +++ b/app/context/auth/interface/rest/controllers/__init__.py @@ -0,0 +1,3 @@ +from .login_rest_controller import router as login_router + +__all__ = ["login_router"] diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py new file mode 100644 index 0000000..0eae356 --- /dev/null +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -0,0 +1,65 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Response + +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.contracts import LoginHandlerContract +from app.context.auth.application.dto import LoginHandlerResultStatus +from app.context.auth.infrastructure.dependencies import get_login_handler +from app.context.auth.interface.rest.schemas import LoginRequest, LoginResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.domain.value_objects import SharedAppEnv +from app.shared.infrastructure.dependencies import get_logger + +router = APIRouter(prefix="/login") + + +@router.post("", response_model=LoginResponse) +async def login( + response: Response, + request: LoginRequest, + handler: Annotated[LoginHandlerContract, Depends(get_login_handler)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """User login endpoint""" + logger.info("Login attempt", email=str(request.email)) + + login_result = await handler.handle(LoginCommand(email=str(request.email), password=request.password)) + + if login_result.status == LoginHandlerResultStatus.SUCCESS: + if login_result.token is None: + logger.error("Token generation failed", email=str(request.email)) + raise HTTPException(status_code=500, detail="Token generation failed") + + logger.info("Login successful", email=str(request.email), user_id=login_result.user_id) + + # Set session token as HTTP-only secure cookie + response.set_cookie( + key="access_token", + value=login_result.token, + httponly=True, + secure=SharedAppEnv.isProd(), + samesite="lax", + max_age=3600, + ) + + return LoginResponse(message="Login successful") + + if login_result.status == LoginHandlerResultStatus.INVALID_CREDENTIALS: + logger.warning("Login failed - invalid credentials", email=str(request.email)) + raise HTTPException(status_code=401, detail=login_result.error_msg) + + if login_result.status == LoginHandlerResultStatus.ACCOUNT_BLOCKED: + logger.warning( + "Login failed - account blocked", + email=str(request.email), + retry_after=login_result.retry_after.isoformat() if login_result.retry_after else None, + ) + headers = {} + if login_result.retry_after: + headers["Retry-After"] = login_result.retry_after.isoformat() + raise HTTPException(status_code=429, detail=login_result.error_msg, headers=headers) + + # UNEXPECTED_ERROR or any other status + logger.error("Login failed - unexpected error", email=str(request.email), status=login_result.status.value) + raise HTTPException(status_code=500, detail=login_result.error_msg) diff --git a/app/context/auth/interface/rest/routes.py b/app/context/auth/interface/rest/routes.py new file mode 100644 index 0000000..0dac66d --- /dev/null +++ b/app/context/auth/interface/rest/routes.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.context.auth.interface.rest.controllers import login_router + +auth_routes = APIRouter(prefix="/api/auth", tags=["auth"]) + +# Include all controller routers +auth_routes.include_router(login_router) diff --git a/app/context/auth/interface/rest/schemas/__init__.py b/app/context/auth/interface/rest/schemas/__init__.py new file mode 100644 index 0000000..fe9dc67 --- /dev/null +++ b/app/context/auth/interface/rest/schemas/__init__.py @@ -0,0 +1,3 @@ +from .login_rest_schema import LoginRequest, LoginResponse + +__all__ = ["LoginRequest", "LoginResponse"] diff --git a/app/context/auth/interface/rest/schemas/login_rest_schema.py b/app/context/auth/interface/rest/schemas/login_rest_schema.py new file mode 100644 index 0000000..26a2b70 --- /dev/null +++ b/app/context/auth/interface/rest/schemas/login_rest_schema.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict, EmailStr + + +class LoginRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + email: EmailStr + password: str + + +@dataclass(frozen=True) +class LoginResponse: + message: str diff --git a/app/context/category/infrastructure/models/__init__.py b/app/context/category/infrastructure/models/__init__.py new file mode 100644 index 0000000..cc4442c --- /dev/null +++ b/app/context/category/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .category_model import CategoryModel + +__all__ = ["CategoryModel"] diff --git a/app/context/category/infrastructure/models/category_model.py b/app/context/category/infrastructure/models/category_model.py new file mode 100644 index 0000000..8f7b7ee --- /dev/null +++ b/app/context/category/infrastructure/models/category_model.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class CategoryModel(BaseDBModel): + """Minimal category model for validation queries""" + + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + color: Mapped[str] = mapped_column(String(7), nullable=False) + household_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("households.id", ondelete="SET NULL"), + nullable=True, + default=None, + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + ) diff --git a/app/context/credit_card/__init__.py b/app/context/credit_card/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/credit_card/application/__init__.py b/app/context/credit_card/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/credit_card/application/commands/__init__.py b/app/context/credit_card/application/commands/__init__.py new file mode 100644 index 0000000..7ab72a4 --- /dev/null +++ b/app/context/credit_card/application/commands/__init__.py @@ -0,0 +1,9 @@ +from .create_credit_card_command import CreateCreditCardCommand +from .delete_credit_card_command import DeleteCreditCardCommand +from .update_credit_card_command import UpdateCreditCardCommand + +__all__ = [ + "CreateCreditCardCommand", + "DeleteCreditCardCommand", + "UpdateCreditCardCommand", +] diff --git a/app/context/credit_card/application/commands/create_credit_card_command.py b/app/context/credit_card/application/commands/create_credit_card_command.py new file mode 100644 index 0000000..1100c86 --- /dev/null +++ b/app/context/credit_card/application/commands/create_credit_card_command.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CreateCreditCardCommand: + """Command to create a new credit card""" + + user_id: int + account_id: int + name: str + currency: str + limit: float diff --git a/app/context/credit_card/application/commands/delete_credit_card_command.py b/app/context/credit_card/application/commands/delete_credit_card_command.py new file mode 100644 index 0000000..8f90ec2 --- /dev/null +++ b/app/context/credit_card/application/commands/delete_credit_card_command.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeleteCreditCardCommand: + """Command to delete a credit card""" + + credit_card_id: int + user_id: int diff --git a/app/context/credit_card/application/commands/update_credit_card_command.py b/app/context/credit_card/application/commands/update_credit_card_command.py new file mode 100644 index 0000000..4d79407 --- /dev/null +++ b/app/context/credit_card/application/commands/update_credit_card_command.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UpdateCreditCardCommand: + """Command to update an existing credit card""" + + credit_card_id: int + user_id: int + name: str | None = None + limit: float | None = None + used: float | None = None + currency: str | None = None diff --git a/app/context/credit_card/application/contracts/__init__.py b/app/context/credit_card/application/contracts/__init__.py new file mode 100644 index 0000000..a5bc905 --- /dev/null +++ b/app/context/credit_card/application/contracts/__init__.py @@ -0,0 +1,13 @@ +from .create_credit_card_handler_contract import CreateCreditCardHandlerContract +from .delete_credit_card_handler_contract import DeleteCreditCardHandlerContract +from .find_credit_card_by_id_handler_contract import FindCreditCardByIdHandlerContract +from .find_credit_cards_by_user_handler_contract import FindCreditCardsByUserHandlerContract +from .update_credit_card_handler_contract import UpdateCreditCardHandlerContract + +__all__ = [ + "CreateCreditCardHandlerContract", + "DeleteCreditCardHandlerContract", + "FindCreditCardByIdHandlerContract", + "FindCreditCardsByUserHandlerContract", + "UpdateCreditCardHandlerContract", +] diff --git a/app/context/credit_card/application/contracts/create_credit_card_handler_contract.py b/app/context/credit_card/application/contracts/create_credit_card_handler_contract.py new file mode 100644 index 0000000..c672cf3 --- /dev/null +++ b/app/context/credit_card/application/contracts/create_credit_card_handler_contract.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.application.commands.create_credit_card_command import ( + CreateCreditCardCommand, +) +from app.context.credit_card.application.dto.create_credit_card_result import ( + CreateCreditCardResult, +) + + +class CreateCreditCardHandlerContract(ABC): + """Contract for create credit card command handler""" + + @abstractmethod + async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResult: + """Handle the create credit card command""" + pass diff --git a/app/context/credit_card/application/contracts/delete_credit_card_handler_contract.py b/app/context/credit_card/application/contracts/delete_credit_card_handler_contract.py new file mode 100644 index 0000000..0938244 --- /dev/null +++ b/app/context/credit_card/application/contracts/delete_credit_card_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.application.commands import DeleteCreditCardCommand +from app.context.credit_card.application.dto import DeleteCreditCardResult + + +class DeleteCreditCardHandlerContract(ABC): + """Contract for delete credit card command handler""" + + @abstractmethod + async def handle(self, command: DeleteCreditCardCommand) -> DeleteCreditCardResult: + """Handle the delete credit card command""" + pass diff --git a/app/context/credit_card/application/contracts/find_credit_card_by_id_handler_contract.py b/app/context/credit_card/application/contracts/find_credit_card_by_id_handler_contract.py new file mode 100644 index 0000000..177e035 --- /dev/null +++ b/app/context/credit_card/application/contracts/find_credit_card_by_id_handler_contract.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.application.dto.credit_card_response_dto import ( + CreditCardResponseDTO, +) +from app.context.credit_card.application.queries.find_credit_card_by_id_query import ( + FindCreditCardByIdQuery, +) + + +class FindCreditCardByIdHandlerContract(ABC): + """Contract for find credit card by ID query handler""" + + @abstractmethod + async def handle(self, query: FindCreditCardByIdQuery) -> CreditCardResponseDTO | None: + """Handle the find credit card by ID query""" + pass diff --git a/app/context/credit_card/application/contracts/find_credit_cards_by_user_handler_contract.py b/app/context/credit_card/application/contracts/find_credit_cards_by_user_handler_contract.py new file mode 100644 index 0000000..38899c2 --- /dev/null +++ b/app/context/credit_card/application/contracts/find_credit_cards_by_user_handler_contract.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.application.dto.credit_card_response_dto import ( + CreditCardResponseDTO, +) +from app.context.credit_card.application.queries.find_credit_cards_by_user_query import ( + FindCreditCardsByUserQuery, +) + + +class FindCreditCardsByUserHandlerContract(ABC): + """Contract for find credit cards by user query handler""" + + @abstractmethod + async def handle(self, query: FindCreditCardsByUserQuery) -> list[CreditCardResponseDTO]: + """Handle the find credit cards by user query""" + pass diff --git a/app/context/credit_card/application/contracts/update_credit_card_handler_contract.py b/app/context/credit_card/application/contracts/update_credit_card_handler_contract.py new file mode 100644 index 0000000..61555e3 --- /dev/null +++ b/app/context/credit_card/application/contracts/update_credit_card_handler_contract.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.application.commands.update_credit_card_command import ( + UpdateCreditCardCommand, +) +from app.context.credit_card.application.dto.update_credit_card_result import ( + UpdateCreditCardResult, +) + + +class UpdateCreditCardHandlerContract(ABC): + """Contract for update credit card command handler""" + + @abstractmethod + async def handle(self, command: UpdateCreditCardCommand) -> UpdateCreditCardResult: + """Handle the update credit card command""" + pass diff --git a/app/context/credit_card/application/dto/__init__.py b/app/context/credit_card/application/dto/__init__.py new file mode 100644 index 0000000..2e62779 --- /dev/null +++ b/app/context/credit_card/application/dto/__init__.py @@ -0,0 +1,14 @@ +from .create_credit_card_result import CreateCreditCardErrorCode, CreateCreditCardResult +from .credit_card_response_dto import CreditCardResponseDTO +from .delete_credit_card_result import DeleteCreditCardErrorCode, DeleteCreditCardResult +from .update_credit_card_result import UpdateCreditCardErrorCode, UpdateCreditCardResult + +__all__ = [ + "CreateCreditCardErrorCode", + "CreateCreditCardResult", + "CreditCardResponseDTO", + "DeleteCreditCardErrorCode", + "DeleteCreditCardResult", + "UpdateCreditCardErrorCode", + "UpdateCreditCardResult", +] diff --git a/app/context/credit_card/application/dto/create_credit_card_result.py b/app/context/credit_card/application/dto/create_credit_card_result.py new file mode 100644 index 0000000..e7747fc --- /dev/null +++ b/app/context/credit_card/application/dto/create_credit_card_result.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from enum import Enum + + +class CreateCreditCardErrorCode(str, Enum): + """Error codes for credit card creation""" + + NAME_ALREADY_EXISTS = "NAME_ALREADY_EXISTS" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class CreateCreditCardResult: + """Result of credit card creation operation""" + + # Success fields - populated when operation succeeds + credit_card_id: int | None = None + + # Error fields - populated when operation fails + error_code: CreateCreditCardErrorCode | None = None + error_message: str | None = None diff --git a/app/context/credit_card/application/dto/credit_card_response_dto.py b/app/context/credit_card/application/dto/credit_card_response_dto.py new file mode 100644 index 0000000..4aa048b --- /dev/null +++ b/app/context/credit_card/application/dto/credit_card_response_dto.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from decimal import Decimal + +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO + + +@dataclass(frozen=True) +class CreditCardResponseDTO: + """Application layer DTO for credit card responses""" + + credit_card_id: int + user_id: int + account_id: int + name: str + currency: str + limit: Decimal + used: Decimal + + @classmethod + def from_domain_dto(cls, domain_dto: CreditCardDTO) -> "CreditCardResponseDTO": + """Convert from domain DTO to application response DTO""" + return cls( + credit_card_id=domain_dto.credit_card_id.value, + user_id=domain_dto.user_id.value, + account_id=domain_dto.account_id.value, + name=domain_dto.name.value, + currency=domain_dto.currency.value, + limit=domain_dto.limit.value, + used=domain_dto.used.value, + ) diff --git a/app/context/credit_card/application/dto/delete_credit_card_result.py b/app/context/credit_card/application/dto/delete_credit_card_result.py new file mode 100644 index 0000000..328f520 --- /dev/null +++ b/app/context/credit_card/application/dto/delete_credit_card_result.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from enum import Enum + + +class DeleteCreditCardErrorCode(str, Enum): + """Error codes for credit card deletion""" + + NOT_FOUND = "NOT_FOUND" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class DeleteCreditCardResult: + """Result of credit card deletion operation""" + + success: bool = False + error_code: DeleteCreditCardErrorCode | None = None + error_message: str | None = None diff --git a/app/context/credit_card/application/dto/update_credit_card_result.py b/app/context/credit_card/application/dto/update_credit_card_result.py new file mode 100644 index 0000000..83fc957 --- /dev/null +++ b/app/context/credit_card/application/dto/update_credit_card_result.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from enum import Enum + + +class UpdateCreditCardErrorCode(str, Enum): + """Error codes for credit card update""" + + NOT_FOUND = "NOT_FOUND" + NAME_ALREADY_EXISTS = "NAME_ALREADY_EXISTS" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class UpdateCreditCardResult: + """Result of credit card update operation""" + + # Success fields - populated when operation succeeds + credit_card_id: int | None = None + credit_card_name: str | None = None + + # Error fields - populated when operation fails + error_code: UpdateCreditCardErrorCode | None = None + error_message: str | None = None diff --git a/app/context/credit_card/application/handlers/__init__.py b/app/context/credit_card/application/handlers/__init__.py new file mode 100644 index 0000000..da5bbd4 --- /dev/null +++ b/app/context/credit_card/application/handlers/__init__.py @@ -0,0 +1,13 @@ +from .create_credit_card_handler import CreateCreditCardHandler +from .delete_credit_card_handler import DeleteCreditCardHandler +from .find_credit_card_by_id_handler import FindCreditCardByIdHandler +from .find_credit_cards_by_user_handler import FindCreditCardsByUserHandler +from .update_credit_card_handler import UpdateCreditCardHandler + +__all__ = [ + "CreateCreditCardHandler", + "DeleteCreditCardHandler", + "FindCreditCardByIdHandler", + "FindCreditCardsByUserHandler", + "UpdateCreditCardHandler", +] diff --git a/app/context/credit_card/application/handlers/create_credit_card_handler.py b/app/context/credit_card/application/handlers/create_credit_card_handler.py new file mode 100644 index 0000000..75c27ce --- /dev/null +++ b/app/context/credit_card/application/handlers/create_credit_card_handler.py @@ -0,0 +1,88 @@ +from app.context.credit_card.application.commands import CreateCreditCardCommand +from app.context.credit_card.application.contracts import ( + CreateCreditCardHandlerContract, +) +from app.context.credit_card.application.dto import ( + CreateCreditCardErrorCode, + CreateCreditCardResult, +) +from app.context.credit_card.domain.contracts.services.create_credit_card_service_contract import ( + CreateCreditCardServiceContract, +) +from app.context.credit_card.domain.exceptions import ( + CreditCardMapperError, + CreditCardNameAlreadyExistError, +) +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CreditCardAccountID, + CreditCardCurrency, + CreditCardName, + CreditCardUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class CreateCreditCardHandler(CreateCreditCardHandlerContract): + """Handler for create credit card command""" + + def __init__(self, service: CreateCreditCardServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResult: + """Execute the create credit card command""" + + self._logger.debug( + "Handling create credit card command", + user_id=command.user_id, + account_id=command.account_id, + name=command.name, + ) + + try: + # Convert command primitives to value objects + card_dto = await self._service.create_credit_card( + user_id=CreditCardUserID(command.user_id), + account_id=CreditCardAccountID(command.account_id), + name=CreditCardName(command.name), + currency=CreditCardCurrency(command.currency), + limit=CardLimit.from_float(command.limit), + ) + + # Validate operation succeeded + if card_dto.credit_card_id is None: + self._logger.error( + "Credit card created but ID is None", + user_id=command.user_id, + account_id=command.account_id, + ) + return CreateCreditCardResult( + error_code=CreateCreditCardErrorCode.UNEXPECTED_ERROR, + error_message="Error creating credit card", + ) + + # Return success result + return CreateCreditCardResult(credit_card_id=card_dto.credit_card_id.value) + + # Catch specific domain exceptions and return error codes + except CreditCardNameAlreadyExistError: + self._logger.debug("Credit card name already exists", user_id=command.user_id, name=command.name) + return CreateCreditCardResult( + error_code=CreateCreditCardErrorCode.NAME_ALREADY_EXISTS, + error_message="Credit card name already exists", + ) + except CreditCardMapperError: + self._logger.error("Credit card mapper error", user_id=command.user_id) + return CreateCreditCardResult( + error_code=CreateCreditCardErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) + + # Always catch generic Exception as final fallback + except Exception as e: + self._logger.error("Unexpected error creating credit card", user_id=command.user_id, error=str(e)) + return CreateCreditCardResult( + error_code=CreateCreditCardErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/credit_card/application/handlers/delete_credit_card_handler.py b/app/context/credit_card/application/handlers/delete_credit_card_handler.py new file mode 100644 index 0000000..cdc04dd --- /dev/null +++ b/app/context/credit_card/application/handlers/delete_credit_card_handler.py @@ -0,0 +1,72 @@ +from app.context.credit_card.application.commands import DeleteCreditCardCommand +from app.context.credit_card.application.contracts import ( + DeleteCreditCardHandlerContract, +) +from app.context.credit_card.application.dto import ( + DeleteCreditCardErrorCode, + DeleteCreditCardResult, +) +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.exceptions import CreditCardNotFoundError +from app.context.credit_card.domain.value_objects import CreditCardID, CreditCardUserID +from app.shared.domain.contracts import LoggerContract + + +class DeleteCreditCardHandler(DeleteCreditCardHandlerContract): + """Handler for delete credit card command""" + + def __init__(self, repository: CreditCardRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, command: DeleteCreditCardCommand) -> DeleteCreditCardResult: + """Execute the delete credit card command""" + + self._logger.debug( + "Handling delete credit card command", + credit_card_id=command.credit_card_id, + user_id=command.user_id, + ) + + try: + # Convert command primitives to value objects + success = await self._repository.delete_credit_card( + card_id=CreditCardID(command.credit_card_id), + user_id=CreditCardUserID(command.user_id), + ) + + if not success: + self._logger.warning( + "Credit card not found for deletion", + credit_card_id=command.credit_card_id, + user_id=command.user_id, + ) + return DeleteCreditCardResult( + error_code=DeleteCreditCardErrorCode.NOT_FOUND, + error_message="Credit card not found", + ) + + return DeleteCreditCardResult(success=True) + + # Catch specific domain exceptions and return error codes + except CreditCardNotFoundError: + self._logger.debug("Credit card not found", credit_card_id=command.credit_card_id, user_id=command.user_id) + return DeleteCreditCardResult( + error_code=DeleteCreditCardErrorCode.NOT_FOUND, + error_message="Credit card not found", + ) + + # Always catch generic Exception as final fallback + except Exception as e: + self._logger.error( + "Unexpected error deleting credit card", + credit_card_id=command.credit_card_id, + user_id=command.user_id, + error=str(e), + ) + return DeleteCreditCardResult( + error_code=DeleteCreditCardErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/credit_card/application/handlers/find_credit_card_by_id_handler.py b/app/context/credit_card/application/handlers/find_credit_card_by_id_handler.py new file mode 100644 index 0000000..cae342b --- /dev/null +++ b/app/context/credit_card/application/handlers/find_credit_card_by_id_handler.py @@ -0,0 +1,45 @@ +from app.context.credit_card.application.contracts import ( + FindCreditCardByIdHandlerContract, +) +from app.context.credit_card.application.dto import CreditCardResponseDTO +from app.context.credit_card.application.queries import FindCreditCardByIdQuery +from app.context.credit_card.domain.contracts.infrastructure import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.value_objects import CreditCardID +from app.shared.domain.contracts import LoggerContract + + +class FindCreditCardByIdHandler(FindCreditCardByIdHandlerContract): + """Handler for find credit card by ID query""" + + def __init__(self, repository: CreditCardRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: FindCreditCardByIdQuery) -> CreditCardResponseDTO | None: + """Execute the find credit card by ID query""" + + self._logger.debug( + "Finding credit card by ID", + credit_card_id=query.credit_card_id, + user_id=query.user_id, + ) + + # Convert query primitives to value objects and find card for user + from app.context.credit_card.domain.value_objects import CreditCardUserID + + card_dto = await self._repository.find_user_credit_card_by_id( + user_id=CreditCardUserID(query.user_id), + card_id=CreditCardID(query.credit_card_id), + ) + + if not card_dto: + self._logger.debug( + "Credit card not found", + credit_card_id=query.credit_card_id, + user_id=query.user_id, + ) + return None + + return CreditCardResponseDTO.from_domain_dto(card_dto) diff --git a/app/context/credit_card/application/handlers/find_credit_cards_by_user_handler.py b/app/context/credit_card/application/handlers/find_credit_cards_by_user_handler.py new file mode 100644 index 0000000..bbfb4ab --- /dev/null +++ b/app/context/credit_card/application/handlers/find_credit_cards_by_user_handler.py @@ -0,0 +1,28 @@ +from app.context.credit_card.application.contracts import ( + FindCreditCardsByUserHandlerContract, +) +from app.context.credit_card.application.dto import CreditCardResponseDTO +from app.context.credit_card.application.queries import FindCreditCardsByUserQuery +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.value_objects import CreditCardUserID +from app.shared.domain.contracts import LoggerContract + + +class FindCreditCardsByUserHandler(FindCreditCardsByUserHandlerContract): + """Handler for find credit cards by user query""" + + def __init__(self, repository: CreditCardRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: FindCreditCardsByUserQuery) -> list[CreditCardResponseDTO]: + """Execute the find credit cards by user query""" + + self._logger.debug("Finding credit cards for user", user_id=query.user_id) + + # Convert query primitive to value object + card_dtos = await self._repository.find_user_credit_cards(user_id=CreditCardUserID(query.user_id)) + + return [CreditCardResponseDTO.from_domain_dto(dto) for dto in card_dtos or []] diff --git a/app/context/credit_card/application/handlers/update_credit_card_handler.py b/app/context/credit_card/application/handlers/update_credit_card_handler.py new file mode 100644 index 0000000..c255d81 --- /dev/null +++ b/app/context/credit_card/application/handlers/update_credit_card_handler.py @@ -0,0 +1,109 @@ +from app.context.credit_card.application.commands import UpdateCreditCardCommand +from app.context.credit_card.application.contracts import ( + UpdateCreditCardHandlerContract, +) +from app.context.credit_card.application.dto import ( + UpdateCreditCardErrorCode, + UpdateCreditCardResult, +) +from app.context.credit_card.domain.contracts.services.update_credit_card_service_contract import ( + UpdateCreditCardServiceContract, +) +from app.context.credit_card.domain.exceptions import ( + CreditCardMapperError, + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, +) +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class UpdateCreditCardHandler(UpdateCreditCardHandlerContract): + """Handler for update credit card command""" + + def __init__(self, service: UpdateCreditCardServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: UpdateCreditCardCommand) -> UpdateCreditCardResult: + """Execute the update credit card command""" + + self._logger.debug( + "Handling update credit card command", + credit_card_id=command.credit_card_id, + user_id=command.user_id, + name=command.name, + ) + + try: + # Convert command primitives to value objects + credit_card_id = CreditCardID(command.credit_card_id) + user_id = CreditCardUserID(command.user_id) + name = CreditCardName(command.name) if command.name else None + currency = CreditCardCurrency(command.currency) if command.currency else None + limit = CardLimit.from_float(command.limit) if command.limit is not None else None + used = CardUsed.from_float(command.used) if command.used is not None else None + + # Call service with value objects + updated_dto = await self._service.update_credit_card( + credit_card_id=credit_card_id, + user_id=user_id, + currency=currency, + name=name, + limit=limit, + used=used, + ) + + # Return success result with updated data + return UpdateCreditCardResult( + credit_card_id=updated_dto.credit_card_id.value, + credit_card_name=updated_dto.name.value, + ) + + # Catch specific domain exceptions and return error codes + except CreditCardNotFoundError: + self._logger.debug("Credit card not found", credit_card_id=command.credit_card_id, user_id=command.user_id) + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.NOT_FOUND, + error_message="Credit card not found", + ) + except CreditCardNameAlreadyExistError: + self._logger.debug( + "Credit card name already exists", + user_id=command.user_id, + name=command.name, + ) + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.NAME_ALREADY_EXISTS, + error_message="Credit card name already exists", + ) + except CreditCardMapperError: + self._logger.error( + "Credit card mapper error", + credit_card_id=command.credit_card_id, + user_id=command.user_id, + ) + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) + + # Always catch generic Exception as final fallback + except Exception as e: + self._logger.error( + "Unexpected error updating credit card", + credit_card_id=command.credit_card_id, + user_id=command.user_id, + error=str(e), + ) + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/credit_card/application/queries/__init__.py b/app/context/credit_card/application/queries/__init__.py new file mode 100644 index 0000000..32ee62b --- /dev/null +++ b/app/context/credit_card/application/queries/__init__.py @@ -0,0 +1,7 @@ +from .find_credit_card_by_id_query import FindCreditCardByIdQuery +from .find_credit_cards_by_user_query import FindCreditCardsByUserQuery + +__all__ = [ + "FindCreditCardByIdQuery", + "FindCreditCardsByUserQuery", +] diff --git a/app/context/credit_card/application/queries/find_credit_card_by_id_query.py b/app/context/credit_card/application/queries/find_credit_card_by_id_query.py new file mode 100644 index 0000000..1f1dc7d --- /dev/null +++ b/app/context/credit_card/application/queries/find_credit_card_by_id_query.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FindCreditCardByIdQuery: + """Query to find a credit card by ID""" + + credit_card_id: int + user_id: int diff --git a/app/context/credit_card/application/queries/find_credit_cards_by_user_query.py b/app/context/credit_card/application/queries/find_credit_cards_by_user_query.py new file mode 100644 index 0000000..7c40b6c --- /dev/null +++ b/app/context/credit_card/application/queries/find_credit_cards_by_user_query.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FindCreditCardsByUserQuery: + """Query to find all credit cards for a user""" + + user_id: int diff --git a/app/context/credit_card/domain/__init__.py b/app/context/credit_card/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/credit_card/domain/contracts/__init__.py b/app/context/credit_card/domain/contracts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/credit_card/domain/contracts/infrastructure/__init__.py b/app/context/credit_card/domain/contracts/infrastructure/__init__.py new file mode 100644 index 0000000..d55e90d --- /dev/null +++ b/app/context/credit_card/domain/contracts/infrastructure/__init__.py @@ -0,0 +1,3 @@ +from .credit_card_repository_contract import CreditCardRepositoryContract + +__all__ = ["CreditCardRepositoryContract"] diff --git a/app/context/credit_card/domain/contracts/infrastructure/credit_card_repository_contract.py b/app/context/credit_card/domain/contracts/infrastructure/credit_card_repository_contract.py new file mode 100644 index 0000000..9ff964e --- /dev/null +++ b/app/context/credit_card/domain/contracts/infrastructure/credit_card_repository_contract.py @@ -0,0 +1,123 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import CreditCardUserID +from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID +from app.context.credit_card.domain.value_objects.credit_card_name import ( + CreditCardName, +) + + +class CreditCardRepositoryContract(ABC): + """Contract for credit card repository operations""" + + @abstractmethod + async def save_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: + """ + Create a new credit card + + Args: + card: The credit card DTO to save + + Returns: + CreditCardDTO of the created credit card + + Raises: + CreditCardMapperError if cannot map model to dto + CreditCardNameAlreadyExistError if card name already exists for user + """ + pass + + @abstractmethod + async def find_credit_card( + self, + card_id: CreditCardID | None = None, + user_id: CreditCardUserID | None = None, + name: CreditCardName | None = None, + only_active: bool | None = True, + ) -> CreditCardDTO | None: + """ + Find a credit card by ID or by user_id and name (admin/unrestricted usage) + + Args: + card_id: Credit card ID to search for + user_id: User ID to search for (combined with name) + name: Credit card name to search for (combined with user_id) + only_active: Whether to exclude soft-deleted cards (default: True) + + Returns: + CreditCardDTO if found, None otherwise + """ + pass + + @abstractmethod + async def find_user_credit_cards( + self, + user_id: CreditCardUserID, + card_id: CreditCardID | None = None, + name: CreditCardName | None = None, + only_active: bool | None = True, + ) -> list[CreditCardDTO] | None: + """ + Find user credit cards always filtering by user_id (for user-scoped queries) + + Args: + user_id: User ID to filter cards for + card_id: Optional card ID to find specific card + name: Optional card name for partial match search + only_active: Whether to exclude soft-deleted cards (default: True) + + Returns: + List of CreditCardDTO, empty list if none found + """ + pass + + @abstractmethod + async def find_user_credit_card_by_id( + self, + user_id: CreditCardUserID, + card_id: CreditCardID, + only_active: bool | None = True, + ) -> CreditCardDTO | None: + """ + Find a specific credit card by ID for a user + + Args: + user_id: User ID who owns the card + card_id: Credit card ID to find + only_active: Whether to exclude soft-deleted cards (default: True) + + Returns: + CreditCardDTO if found, None otherwise + """ + pass + + @abstractmethod + async def update_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: + """ + Update an existing credit card + + Args: + card: The credit card DTO with updated values + + Returns: + Updated CreditCardDTO + + Raises: + ValueError: If card not found or already deleted + """ + pass + + @abstractmethod + async def delete_credit_card(self, card_id: CreditCardID, user_id: CreditCardUserID) -> bool: + """ + Soft delete a credit card. Returns True if deleted, False if not found/unauthorized + + Args: + card_id: Credit card ID to delete + user_id: User ID (for authorization check) + + Returns: + True if successfully deleted, False if not found or unauthorized + """ + pass diff --git a/app/context/credit_card/domain/contracts/services/__init__.py b/app/context/credit_card/domain/contracts/services/__init__.py new file mode 100644 index 0000000..ffb88f2 --- /dev/null +++ b/app/context/credit_card/domain/contracts/services/__init__.py @@ -0,0 +1,7 @@ +from .create_credit_card_service_contract import CreateCreditCardServiceContract +from .update_credit_card_service_contract import UpdateCreditCardServiceContract + +__all__ = [ + "CreateCreditCardServiceContract", + "UpdateCreditCardServiceContract", +] diff --git a/app/context/credit_card/domain/contracts/services/create_credit_card_service_contract.py b/app/context/credit_card/domain/contracts/services/create_credit_card_service_contract.py new file mode 100644 index 0000000..cba8433 --- /dev/null +++ b/app/context/credit_card/domain/contracts/services/create_credit_card_service_contract.py @@ -0,0 +1,28 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CreditCardAccountID, + CreditCardCurrency, + CreditCardUserID, +) +from app.context.credit_card.domain.value_objects.card_limit import CardLimit +from app.context.credit_card.domain.value_objects.credit_card_name import ( + CreditCardName, +) + + +class CreateCreditCardServiceContract(ABC): + """Contract for create credit card service""" + + @abstractmethod + async def create_credit_card( + self, + user_id: CreditCardUserID, + account_id: CreditCardAccountID, + name: CreditCardName, + currency: CreditCardCurrency, + limit: CardLimit, + ) -> CreditCardDTO: + """Create a new credit card""" + pass diff --git a/app/context/credit_card/domain/contracts/services/update_credit_card_service_contract.py b/app/context/credit_card/domain/contracts/services/update_credit_card_service_contract.py new file mode 100644 index 0000000..e8e0e8f --- /dev/null +++ b/app/context/credit_card/domain/contracts/services/update_credit_card_service_contract.py @@ -0,0 +1,30 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CreditCardCurrency, + CreditCardUserID, +) +from app.context.credit_card.domain.value_objects.card_limit import CardLimit +from app.context.credit_card.domain.value_objects.card_used import CardUsed +from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID +from app.context.credit_card.domain.value_objects.credit_card_name import ( + CreditCardName, +) + + +class UpdateCreditCardServiceContract(ABC): + """Contract for update credit card service""" + + @abstractmethod + async def update_credit_card( + self, + credit_card_id: CreditCardID, + user_id: CreditCardUserID, + name: CreditCardName | None = None, + limit: CardLimit | None = None, + used: CardUsed | None = None, + currency: CreditCardCurrency | None = None, + ) -> CreditCardDTO: + """Update an existing credit card""" + pass diff --git a/app/context/credit_card/domain/dto/__init__.py b/app/context/credit_card/domain/dto/__init__.py new file mode 100644 index 0000000..bf37ecd --- /dev/null +++ b/app/context/credit_card/domain/dto/__init__.py @@ -0,0 +1,3 @@ +from .credit_card_dto import CreditCardDTO + +__all__ = ["CreditCardDTO"] diff --git a/app/context/credit_card/domain/dto/credit_card_dto.py b/app/context/credit_card/domain/dto/credit_card_dto.py new file mode 100644 index 0000000..68a78e2 --- /dev/null +++ b/app/context/credit_card/domain/dto/credit_card_dto.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass + +from app.context.credit_card.domain.value_objects import ( + CreditCardAccountID, + CreditCardCurrency, + CreditCardDeletedAt, + CreditCardUserID, +) +from app.context.credit_card.domain.value_objects.card_limit import CardLimit +from app.context.credit_card.domain.value_objects.card_used import CardUsed +from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID +from app.context.credit_card.domain.value_objects.credit_card_name import ( + CreditCardName, +) + + +@dataclass(frozen=True) +class CreditCardDTO: + """Domain DTO for credit card entity""" + + user_id: CreditCardUserID + account_id: CreditCardAccountID + name: CreditCardName + currency: CreditCardCurrency + limit: CardLimit + used: CardUsed | None = None + credit_card_id: CreditCardID | None = None + deleted_at: CreditCardDeletedAt | None = None + + @property + def is_deleted(self) -> bool: + """Check if the credit card is soft deleted""" + return self.deleted_at is not None diff --git a/app/context/credit_card/domain/exceptions/__init__.py b/app/context/credit_card/domain/exceptions/__init__.py new file mode 100644 index 0000000..cb58d1d --- /dev/null +++ b/app/context/credit_card/domain/exceptions/__init__.py @@ -0,0 +1,49 @@ +from .exceptions import ( + CreditCardCreationError, + CreditCardDatabaseError, + CreditCardMapperError, + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, + CreditCardRepositoryInvalidParametersError, + CreditCardUnauthorizedAccessError, + CreditCardUpdateError, + CreditCardUpdateWithoutIdError, + CreditCardUsedExceedsLimitError, + InvalidCardLimitFormatError, + InvalidCardLimitPrecisionError, + InvalidCardLimitTypeError, + InvalidCardLimitValueError, + InvalidCardUsedFormatError, + InvalidCardUsedPrecisionError, + InvalidCardUsedTypeError, + InvalidCardUsedValueError, + InvalidCreditCardIdTypeError, + InvalidCreditCardIdValueError, + InvalidCreditCardNameLengthError, + InvalidCreditCardNameTypeError, +) + +__all__ = [ + "CreditCardCreationError", + "CreditCardDatabaseError", + "CreditCardMapperError", + "CreditCardNameAlreadyExistError", + "CreditCardNotFoundError", + "CreditCardRepositoryInvalidParametersError", + "CreditCardUnauthorizedAccessError", + "CreditCardUpdateError", + "CreditCardUpdateWithoutIdError", + "CreditCardUsedExceedsLimitError", + "InvalidCardLimitFormatError", + "InvalidCardLimitPrecisionError", + "InvalidCardLimitTypeError", + "InvalidCardLimitValueError", + "InvalidCardUsedFormatError", + "InvalidCardUsedPrecisionError", + "InvalidCardUsedTypeError", + "InvalidCardUsedValueError", + "InvalidCreditCardIdTypeError", + "InvalidCreditCardIdValueError", + "InvalidCreditCardNameLengthError", + "InvalidCreditCardNameTypeError", +] diff --git a/app/context/credit_card/domain/exceptions/exceptions.py b/app/context/credit_card/domain/exceptions/exceptions.py new file mode 100644 index 0000000..66276bc --- /dev/null +++ b/app/context/credit_card/domain/exceptions/exceptions.py @@ -0,0 +1,95 @@ +# Value Object Exceptions + + +class InvalidCardLimitTypeError(Exception): + pass + + +class InvalidCardLimitValueError(Exception): + pass + + +class InvalidCardLimitPrecisionError(Exception): + pass + + +class InvalidCardLimitFormatError(Exception): + pass + + +class InvalidCardUsedTypeError(Exception): + pass + + +class InvalidCardUsedValueError(Exception): + pass + + +class InvalidCardUsedPrecisionError(Exception): + pass + + +class InvalidCardUsedFormatError(Exception): + pass + + +class InvalidCreditCardNameTypeError(Exception): + pass + + +class InvalidCreditCardNameLengthError(Exception): + pass + + +class InvalidCreditCardIdTypeError(Exception): + pass + + +class InvalidCreditCardIdValueError(Exception): + pass + + +# Domain Service Exceptions + + +class CreditCardNotFoundError(Exception): + pass + + +class CreditCardUnauthorizedAccessError(Exception): + pass + + +class CreditCardNameAlreadyExistError(Exception): + pass + + +class CreditCardUsedExceedsLimitError(Exception): + pass + + +# Repository Exceptions + + +class CreditCardCreationError(Exception): + pass + + +class CreditCardRepositoryInvalidParametersError(Exception): + pass + + +class CreditCardUpdateWithoutIdError(Exception): + pass + + +class CreditCardUpdateError(Exception): + pass + + +class CreditCardMapperError(Exception): + pass + + +class CreditCardDatabaseError(Exception): + pass diff --git a/app/context/credit_card/domain/services/__init__.py b/app/context/credit_card/domain/services/__init__.py new file mode 100644 index 0000000..ba1a366 --- /dev/null +++ b/app/context/credit_card/domain/services/__init__.py @@ -0,0 +1,7 @@ +from .create_credit_card_service import CreateCreditCardService +from .update_credit_card_service import UpdateCreditCardService + +__all__ = [ + "CreateCreditCardService", + "UpdateCreditCardService", +] diff --git a/app/context/credit_card/domain/services/create_credit_card_service.py b/app/context/credit_card/domain/services/create_credit_card_service.py new file mode 100644 index 0000000..ad2b26e --- /dev/null +++ b/app/context/credit_card/domain/services/create_credit_card_service.py @@ -0,0 +1,55 @@ +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.contracts.services.create_credit_card_service_contract import ( + CreateCreditCardServiceContract, +) +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CreditCardAccountID, + CreditCardCurrency, + CreditCardUserID, +) +from app.context.credit_card.domain.value_objects.card_limit import CardLimit +from app.context.credit_card.domain.value_objects.credit_card_name import ( + CreditCardName, +) +from app.shared.domain.contracts import LoggerContract + + +class CreateCreditCardService(CreateCreditCardServiceContract): + """Service for creating credit cards""" + + def __init__(self, card_repository: CreditCardRepositoryContract, logger: LoggerContract): + self._card_repository = card_repository + self._logger = logger + + async def create_credit_card( + self, + user_id: CreditCardUserID, + account_id: CreditCardAccountID, + name: CreditCardName, + currency: CreditCardCurrency, + limit: CardLimit, + ) -> CreditCardDTO: + """Create a new credit card with validation""" + + self._logger.debug( + "Creating credit card", + user_id=user_id.value, + account_id=account_id.value, + name=name.value, + currency=currency.value, + limit=float(limit.value), + ) + + card_dto = CreditCardDTO( + user_id=user_id, + account_id=account_id, + name=name, + currency=currency, + limit=limit, + ) + + # Save and return the new credit card + return await self._card_repository.save_credit_card(card_dto) diff --git a/app/context/credit_card/domain/services/update_credit_card_service.py b/app/context/credit_card/domain/services/update_credit_card_service.py new file mode 100644 index 0000000..ec6d261 --- /dev/null +++ b/app/context/credit_card/domain/services/update_credit_card_service.py @@ -0,0 +1,117 @@ +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.contracts.services.update_credit_card_service_contract import ( + UpdateCreditCardServiceContract, +) +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import ( + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, + CreditCardUnauthorizedAccessError, + CreditCardUsedExceedsLimitError, +) +from app.context.credit_card.domain.value_objects import ( + CreditCardCurrency, + CreditCardUserID, +) +from app.context.credit_card.domain.value_objects.card_limit import CardLimit +from app.context.credit_card.domain.value_objects.card_used import CardUsed +from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID +from app.context.credit_card.domain.value_objects.credit_card_name import ( + CreditCardName, +) +from app.shared.domain.contracts import LoggerContract + + +class UpdateCreditCardService(UpdateCreditCardServiceContract): + """Service for updating credit cards""" + + def __init__(self, repository: CreditCardRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def update_credit_card( + self, + credit_card_id: CreditCardID, + user_id: CreditCardUserID, + name: CreditCardName | None = None, + limit: CardLimit | None = None, + used: CardUsed | None = None, + currency: CreditCardCurrency | None = None, + ) -> CreditCardDTO: + """Update an existing credit card with validation""" + + self._logger.debug( + "Updating credit card", + credit_card_id=credit_card_id.value, + user_id=user_id.value, + name=name.value if name else None, + limit=float(limit.value) if limit else None, + used=float(used.value) if used else None, + ) + + # Find the existing card + existing_card = await self._repository.find_credit_card(card_id=credit_card_id) + + if not existing_card: + self._logger.warning("Credit card not found", credit_card_id=credit_card_id.value, user_id=user_id.value) + raise CreditCardNotFoundError(f"Credit card with ID {credit_card_id.value} not found") + + # Verify ownership + if existing_card.user_id.value != user_id.value: + self._logger.warning( + "Unauthorized credit card access attempt", + credit_card_id=credit_card_id.value, + user_id=user_id.value, + owner_id=existing_card.user_id.value, + ) + raise CreditCardUnauthorizedAccessError( + f"User {user_id.value} is not authorized to update credit card {credit_card_id.value}" + ) + + # If name is being changed, check for duplicates + if name and name.value != existing_card.name.value: + duplicate_card = await self._repository.find_credit_card(user_id=user_id, name=name) + if duplicate_card: + self._logger.warning( + "Credit card name already exists", + user_id=user_id.value, + name=name.value, + existing_card_id=duplicate_card.credit_card_id.value if duplicate_card.credit_card_id else None, + ) + raise CreditCardNameAlreadyExistError( + f"Credit card with name '{name.value}' already exists for this user" + ) + + # Build updated card DTO with new values or existing ones + updated_name = name if name else existing_card.name + updated_limit = limit if limit else existing_card.limit + updated_used = used if used is not None else existing_card.used + + # Business rule: ensure used <= limit + if updated_used.value > updated_limit.value: + self._logger.warning( + "Credit card used amount exceeds limit", + credit_card_id=credit_card_id.value, + user_id=user_id.value, + used=float(updated_used.value), + limit=float(updated_limit.value), + ) + raise CreditCardUsedExceedsLimitError( + f"Used amount ({updated_used.value}) cannot exceed limit ({updated_limit.value})" + ) + + # Create updated DTO + updated_card = CreditCardDTO( + credit_card_id=existing_card.credit_card_id, + user_id=existing_card.user_id, + account_id=existing_card.account_id, + name=updated_name, + currency=existing_card.currency, + limit=updated_limit, + used=updated_used, + ) + + # Save and return + return await self._repository.update_credit_card(updated_card) diff --git a/app/context/credit_card/domain/value_objects/__init__.py b/app/context/credit_card/domain/value_objects/__init__.py new file mode 100644 index 0000000..f9fa78e --- /dev/null +++ b/app/context/credit_card/domain/value_objects/__init__.py @@ -0,0 +1,19 @@ +from .card_limit import CardLimit +from .card_used import CardUsed +from .credit_card_account_id import CreditCardAccountID +from .credit_card_currency import CreditCardCurrency +from .credit_card_deleted_at import CreditCardDeletedAt +from .credit_card_id import CreditCardID +from .credit_card_name import CreditCardName +from .credit_card_user_id import CreditCardUserID + +__all__ = [ + "CardLimit", + "CardUsed", + "CreditCardDeletedAt", + "CreditCardID", + "CreditCardName", + "CreditCardAccountID", + "CreditCardCurrency", + "CreditCardUserID", +] diff --git a/app/context/credit_card/domain/value_objects/card_limit.py b/app/context/credit_card/domain/value_objects/card_limit.py new file mode 100644 index 0000000..cae3a5f --- /dev/null +++ b/app/context/credit_card/domain/value_objects/card_limit.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation + +from app.context.credit_card.domain.exceptions import ( + InvalidCardLimitFormatError, + InvalidCardLimitPrecisionError, + InvalidCardLimitTypeError, + InvalidCardLimitValueError, +) + + +@dataclass(frozen=True) +class CardLimit: + """Value object for credit card limit""" + + value: Decimal + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, Decimal): + raise InvalidCardLimitTypeError(f"CardLimit must be a Decimal, got {type(self.value)}") + if self.value <= 0: + raise InvalidCardLimitValueError(f"CardLimit must be positive, got {self.value}") + + # Check for max 2 decimal places + if self.value.as_tuple().exponent < -2: + raise InvalidCardLimitPrecisionError(f"CardLimit must have at most 2 decimal places, got {self.value}") + + @classmethod + def from_float(cls, value: float) -> "CardLimit": + """Create CardLimit from float value""" + try: + # Round to 2 decimal places + decimal_value = Decimal(str(value)).quantize(Decimal("0.01")) + return cls(decimal_value) + except (InvalidOperation, ValueError) as e: + raise InvalidCardLimitFormatError(f"Invalid CardLimit value: {value}") from e + + @classmethod + def from_trusted_source(cls, value: Decimal) -> "CardLimit": + """Create CardLimit from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/credit_card/domain/value_objects/card_used.py b/app/context/credit_card/domain/value_objects/card_used.py new file mode 100644 index 0000000..6892749 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/card_used.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass, field +from decimal import Decimal, InvalidOperation + +from app.context.credit_card.domain.exceptions import ( + InvalidCardUsedFormatError, + InvalidCardUsedPrecisionError, + InvalidCardUsedTypeError, + InvalidCardUsedValueError, +) + + +@dataclass(frozen=True) +class CardUsed: + """Value object for credit card used amount""" + + value: Decimal + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, Decimal): + raise InvalidCardUsedTypeError(f"CardUsed must be a Decimal, got {type(self.value)}") + if self.value < 0: + raise InvalidCardUsedValueError(f"CardUsed must be non-negative, got {self.value}") + + # Check for max 2 decimal places + if self.value.as_tuple().exponent < -2: + raise InvalidCardUsedPrecisionError(f"CardUsed must have at most 2 decimal places, got {self.value}") + + @classmethod + def from_float(cls, value: float) -> "CardUsed": + """Create CardUsed from float value""" + try: + # Round to 2 decimal places + decimal_value = Decimal(str(value)).quantize(Decimal("0.01")) + return cls(decimal_value) + except (InvalidOperation, ValueError) as e: + raise InvalidCardUsedFormatError(f"Invalid CardUsed value: {value}") from e + + @classmethod + def from_trusted_source(cls, value: Decimal) -> "CardUsed": + """Create CardUsed from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/credit_card/domain/value_objects/credit_card_account_id.py b/app/context/credit_card/domain/value_objects/credit_card_account_id.py new file mode 100644 index 0000000..2711b2e --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_account_id.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedAccountID + + +@dataclass(frozen=True) +class CreditCardAccountID(SharedAccountID): + pass diff --git a/app/context/credit_card/domain/value_objects/credit_card_currency.py b/app/context/credit_card/domain/value_objects/credit_card_currency.py new file mode 100644 index 0000000..0e0bbf9 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_currency.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedCurrency + + +@dataclass(frozen=True) +class CreditCardCurrency(SharedCurrency): + pass diff --git a/app/context/credit_card/domain/value_objects/credit_card_deleted_at.py b/app/context/credit_card/domain/value_objects/credit_card_deleted_at.py new file mode 100644 index 0000000..78d2fe3 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_deleted_at.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedDeletedAt + + +@dataclass(frozen=True) +class CreditCardDeletedAt(SharedDeletedAt): + pass diff --git a/app/context/credit_card/domain/value_objects/credit_card_id.py b/app/context/credit_card/domain/value_objects/credit_card_id.py new file mode 100644 index 0000000..435c51c --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_id.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field + +from app.context.credit_card.domain.exceptions import ( + InvalidCreditCardIdTypeError, + InvalidCreditCardIdValueError, +) + + +@dataclass(frozen=True) +class CreditCardID: + """Value object for credit card identifier""" + + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and not isinstance(self.value, int): + raise InvalidCreditCardIdTypeError(f"CreditCardID must be an integer, got {type(self.value)}") + if not self._validated and self.value <= 0: + raise InvalidCreditCardIdValueError(f"CreditCardID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> "CreditCardID": + """Create CreditCardID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/credit_card/domain/value_objects/credit_card_name.py b/app/context/credit_card/domain/value_objects/credit_card_name.py new file mode 100644 index 0000000..1d95992 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_name.py @@ -0,0 +1,32 @@ +from dataclasses import dataclass, field + +from app.context.credit_card.domain.exceptions import ( + InvalidCreditCardNameLengthError, + InvalidCreditCardNameTypeError, +) + + +@dataclass(frozen=True) +class CreditCardName: + """Value object for credit card name""" + + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, str): + raise InvalidCreditCardNameTypeError(f"CreditCardName must be a string, got {type(self.value)}") + if len(self.value) < 3: + raise InvalidCreditCardNameLengthError( + f"CreditCardName must be at least 3 characters, got {len(self.value)}" + ) + if len(self.value) > 100: + raise InvalidCreditCardNameLengthError( + f"CreditCardName must be at most 100 characters, got {len(self.value)}" + ) + + @classmethod + def from_trusted_source(cls, value: str) -> "CreditCardName": + """Create CreditCardName from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/credit_card/domain/value_objects/credit_card_user_id.py b/app/context/credit_card/domain/value_objects/credit_card_user_id.py new file mode 100644 index 0000000..d5ffcc3 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_user_id.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedUserID + + +@dataclass(frozen=True) +class CreditCardUserID(SharedUserID): + pass diff --git a/app/context/credit_card/infrastructure/__init__.py b/app/context/credit_card/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/credit_card/infrastructure/dependencies.py b/app/context/credit_card/infrastructure/dependencies.py new file mode 100644 index 0000000..091400c --- /dev/null +++ b/app/context/credit_card/infrastructure/dependencies.py @@ -0,0 +1,141 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.credit_card.application.contracts.create_credit_card_handler_contract import ( + CreateCreditCardHandlerContract, +) +from app.context.credit_card.application.contracts.delete_credit_card_handler_contract import ( + DeleteCreditCardHandlerContract, +) +from app.context.credit_card.application.contracts.find_credit_card_by_id_handler_contract import ( + FindCreditCardByIdHandlerContract, +) +from app.context.credit_card.application.contracts.find_credit_cards_by_user_handler_contract import ( + FindCreditCardsByUserHandlerContract, +) +from app.context.credit_card.application.contracts.update_credit_card_handler_contract import ( + UpdateCreditCardHandlerContract, +) +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.contracts.services.create_credit_card_service_contract import ( + CreateCreditCardServiceContract, +) +from app.context.credit_card.domain.contracts.services.update_credit_card_service_contract import ( + UpdateCreditCardServiceContract, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.database import get_db +from app.shared.infrastructure.dependencies import get_logger + +# ───────────────────────────────────────────────────────────────── +# REPOSITORY +# ───────────────────────────────────────────────────────────────── + + +def get_credit_card_repository( + db: Annotated[AsyncSession, Depends(get_db)], +) -> CreditCardRepositoryContract: + """CreditCardRepository dependency injection""" + from app.context.credit_card.infrastructure.repositories.credit_card_repository import ( + CreditCardRepository, + ) + + return CreditCardRepository(db) + + +# ───────────────────────────────────────────────────────────────── +# COMMAND HANDLERS (Write operations) +# ───────────────────────────────────────────────────────────────── + + +def get_create_credit_card_service( + card_repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateCreditCardServiceContract: + """CreateCreditCardService dependency injection""" + from app.context.credit_card.domain.services.create_credit_card_service import ( + CreateCreditCardService, + ) + + return CreateCreditCardService(card_repository, logger) + + +def get_create_credit_card_handler( + service: Annotated[CreateCreditCardServiceContract, Depends(get_create_credit_card_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateCreditCardHandlerContract: + """CreateCreditCardHandler dependency injection""" + from app.context.credit_card.application.handlers.create_credit_card_handler import ( + CreateCreditCardHandler, + ) + + return CreateCreditCardHandler(service, logger) + + +def get_update_credit_card_service( + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateCreditCardServiceContract: + """UpdateCreditCardService dependency injection""" + from app.context.credit_card.domain.services.update_credit_card_service import ( + UpdateCreditCardService, + ) + + return UpdateCreditCardService(repository, logger) + + +def get_update_credit_card_handler( + service: Annotated[UpdateCreditCardServiceContract, Depends(get_update_credit_card_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateCreditCardHandlerContract: + """UpdateCreditCardHandler dependency injection""" + from app.context.credit_card.application.handlers.update_credit_card_handler import ( + UpdateCreditCardHandler, + ) + + return UpdateCreditCardHandler(service, logger) + + +def get_delete_credit_card_handler( + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> DeleteCreditCardHandlerContract: + """DeleteCreditCardHandler dependency injection""" + from app.context.credit_card.application.handlers.delete_credit_card_handler import ( + DeleteCreditCardHandler, + ) + + return DeleteCreditCardHandler(repository, logger) + + +# ───────────────────────────────────────────────────────────────── +# QUERY HANDLERS (Read operations) +# ───────────────────────────────────────────────────────────────── + + +def get_find_credit_card_by_id_handler( + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> FindCreditCardByIdHandlerContract: + """FindCreditCardByIdHandler dependency injection""" + from app.context.credit_card.application.handlers.find_credit_card_by_id_handler import ( + FindCreditCardByIdHandler, + ) + + return FindCreditCardByIdHandler(repository, logger) + + +def get_find_credit_cards_by_user_handler( + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> FindCreditCardsByUserHandlerContract: + """FindCreditCardsByUserHandler dependency injection""" + from app.context.credit_card.application.handlers.find_credit_cards_by_user_handler import ( + FindCreditCardsByUserHandler, + ) + + return FindCreditCardsByUserHandler(repository, logger) diff --git a/app/context/credit_card/infrastructure/mappers/__init__.py b/app/context/credit_card/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..aaa2088 --- /dev/null +++ b/app/context/credit_card/infrastructure/mappers/__init__.py @@ -0,0 +1,3 @@ +from .credit_card_mapper import CreditCardMapper + +__all__ = ["CreditCardMapper"] diff --git a/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py new file mode 100644 index 0000000..283086e --- /dev/null +++ b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py @@ -0,0 +1,57 @@ +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import CreditCardMapperError +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardDeletedAt, + CreditCardID, + CreditCardName, + CreditCardUserID, +) +from app.context.credit_card.infrastructure.models import CreditCardModel + + +class CreditCardMapper: + """Mapper for converting between CreditCardModel and CreditCardDTO""" + + @staticmethod + def to_dto(model: CreditCardModel | None) -> CreditCardDTO | None: + """Convert database model to domain DTO""" + return ( + CreditCardDTO( + credit_card_id=CreditCardID.from_trusted_source(model.id), + user_id=CreditCardUserID(model.user_id), + account_id=CreditCardAccountID.from_trusted_source(model.account_id), + name=CreditCardName.from_trusted_source(model.name), + currency=CreditCardCurrency.from_trusted_source(model.currency), + limit=CardLimit.from_trusted_source(model.limit), + used=CardUsed.from_trusted_source(model.used), + deleted_at=CreditCardDeletedAt.from_optional(model.deleted_at), + ) + if model + else None + ) + + @staticmethod + def to_dto_or_fail(model: CreditCardModel) -> CreditCardDTO: + """Convert database model to domain DTO, raising error if model is None""" + dto = CreditCardMapper.to_dto(model) + if dto is None: + raise CreditCardMapperError("Credit card dto cannot be null") + return dto + + @staticmethod + def to_model(dto: CreditCardDTO) -> CreditCardModel: + """Convert domain DTO to database model""" + return CreditCardModel( + id=dto.credit_card_id.value if dto.credit_card_id is not None else None, + user_id=dto.user_id.value, + account_id=dto.account_id.value, + name=dto.name.value, + currency=dto.currency.value, + limit=dto.limit.value, + used=dto.used.value if dto.used is not None else 0, + deleted_at=dto.deleted_at.value if dto.deleted_at is not None else None, + ) diff --git a/app/context/credit_card/infrastructure/models/__init__.py b/app/context/credit_card/infrastructure/models/__init__.py new file mode 100644 index 0000000..e230107 --- /dev/null +++ b/app/context/credit_card/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .credit_card_model import CreditCardModel + +__all__ = ["CreditCardModel"] diff --git a/app/context/credit_card/infrastructure/models/credit_card_model.py b/app/context/credit_card/infrastructure/models/credit_card_model.py new file mode 100644 index 0000000..003dc65 --- /dev/null +++ b/app/context/credit_card/infrastructure/models/credit_card_model.py @@ -0,0 +1,20 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DECIMAL, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class CreditCardModel(BaseDBModel): + __tablename__ = "credit_cards" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + account_id: Mapped[int] = mapped_column(Integer, ForeignKey("user_accounts.id", ondelete="CASCADE"), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False) + limit: Mapped[Decimal] = mapped_column(DECIMAL(15, 2), nullable=False) + used: Mapped[Decimal] = mapped_column(DECIMAL(15, 2), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) diff --git a/app/context/credit_card/infrastructure/repositories/__init__.py b/app/context/credit_card/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..0c4c9af --- /dev/null +++ b/app/context/credit_card/infrastructure/repositories/__init__.py @@ -0,0 +1,3 @@ +from .credit_card_repository import CreditCardRepository + +__all__ = ["CreditCardRepository"] diff --git a/app/context/credit_card/infrastructure/repositories/credit_card_repository.py b/app/context/credit_card/infrastructure/repositories/credit_card_repository.py new file mode 100644 index 0000000..fd5fc55 --- /dev/null +++ b/app/context/credit_card/infrastructure/repositories/credit_card_repository.py @@ -0,0 +1,190 @@ +from typing import Any, cast + +from sqlalchemy import select, update +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.credit_card.domain.contracts.infrastructure import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import ( + CreditCardDatabaseError, + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, +) +from app.context.credit_card.domain.value_objects import ( + CreditCardDeletedAt, + CreditCardID, + CreditCardName, + CreditCardUserID, +) +from app.context.credit_card.infrastructure.mappers import CreditCardMapper +from app.context.credit_card.infrastructure.models import CreditCardModel + + +class CreditCardRepository(CreditCardRepositoryContract): + """Repository implementation for credit card operations""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def save_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: + """Create a new credit card""" + try: + model = CreditCardMapper.to_model(card) + self._db.add(model) + await self._db.commit() + await self._db.refresh(model) + return CreditCardMapper.to_dto_or_fail(model) + except IntegrityError as e: + await self._db.rollback() + raise CreditCardNameAlreadyExistError( + f"Credit card with name '{card.name.value}' already exists for this user" + ) from e + except SQLAlchemyError as e: + await self._db.rollback() + raise CreditCardDatabaseError(f"Database error while saving credit card: {str(e)}") from e + + async def find_credit_card( + self, + card_id: CreditCardID | None = None, + user_id: CreditCardUserID | None = None, + name: CreditCardName | None = None, + only_active: bool | None = True, + ) -> CreditCardDTO | None: + """Find a credit card by ID or by user_id and name (admin/unrestricted usage)""" + try: + stmt = select(CreditCardModel) + if only_active: + stmt = stmt.where(CreditCardModel.deleted_at.is_(None)) + + if card_id is not None: + stmt = stmt.where(CreditCardModel.id == card_id.value) + elif user_id is not None and name is not None: + stmt = stmt.where( + CreditCardModel.user_id == user_id.value, + CreditCardModel.name == name.value, + ) + else: + raise ValueError("Must provide either card_id or both user_id and name") + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return CreditCardMapper.to_dto(model) if model else None + except SQLAlchemyError as e: + raise CreditCardDatabaseError(f"Database error while finding credit card: {str(e)}") from e + + async def find_user_credit_cards( + self, + user_id: CreditCardUserID, + card_id: CreditCardID | None = None, + name: CreditCardName | None = None, + only_active: bool | None = True, + ) -> list[CreditCardDTO] | None: + """Find user credit cards always filtering by user_id (for user-scoped queries)""" + try: + stmt = select(CreditCardModel).where(CreditCardModel.user_id == user_id.value) + if only_active: + stmt = stmt.where(CreditCardModel.deleted_at.is_(None)) + + if card_id is not None: + stmt = stmt.where(CreditCardModel.id == card_id.value) + else: + if name is not None: + stmt = stmt.where(CreditCardModel.name.like(f"%{name.value}%")) + + models = (await self._db.execute(stmt)).scalars() + return [CreditCardMapper.to_dto_or_fail(model) for model in models] if models else [] + except SQLAlchemyError as e: + raise CreditCardDatabaseError(f"Database error while finding user credit cards: {str(e)}") from e + + async def find_user_credit_card_by_id( + self, + user_id: CreditCardUserID, + card_id: CreditCardID, + only_active: bool | None = True, + ) -> CreditCardDTO | None: + """Find a specific credit card by ID for a user""" + try: + stmt = select(CreditCardModel).where( + CreditCardModel.id == card_id.value, + CreditCardModel.user_id == user_id.value, + ) + if only_active: + stmt = stmt.where(CreditCardModel.deleted_at.is_(None)) + + model = (await self._db.execute(stmt)).scalar_one_or_none() + return CreditCardMapper.to_dto(model) + except SQLAlchemyError as e: + raise CreditCardDatabaseError(f"Database error while finding credit card by ID: {str(e)}") from e + + async def update_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: + """Update an existing credit card""" + if card.credit_card_id is None: + raise ValueError("Credit Card ID not given") + + try: + stmt = ( + update(CreditCardModel) + .where( + CreditCardModel.id == card.credit_card_id.value, + CreditCardModel.deleted_at.is_(None), + ) + .values( + name=card.name.value, + limit=card.limit.value, + ) + ) + if card.used is not None: + stmt = stmt.values(used=card.used.value) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + if result.rowcount == 0: + raise CreditCardNotFoundError( + f"Credit card with ID {card.credit_card_id.value} not found or already deleted" + ) + + await self._db.commit() + + return card + except IntegrityError as e: + await self._db.rollback() + raise CreditCardNameAlreadyExistError( + f"Credit card with name '{card.name.value}' already exists for this user" + ) from e + except SQLAlchemyError as e: + await self._db.rollback() + raise CreditCardDatabaseError(f"Database error while updating credit card: {str(e)}") from e + + async def delete_credit_card(self, card_id: CreditCardID, user_id: CreditCardUserID) -> bool: + """Soft delete a credit card""" + try: + # Verify card exists and user owns it + card = await self.find_credit_card(card_id=card_id) + if not card or card.user_id.value != user_id.value: + return False + + # Soft delete: set deleted_at timestamp + stmt = ( + update(CreditCardModel) + .where( + CreditCardModel.id == card_id.value, + CreditCardModel.user_id == user_id.value, + CreditCardModel.deleted_at.is_(None), + ) + .values(deleted_at=CreditCardDeletedAt.now().value) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + await self._db.commit() + + return result.rowcount > 0 + except SQLAlchemyError as e: + await self._db.rollback() + raise CreditCardDatabaseError(f"Database error while deleting credit card: {str(e)}") from e + except Exception as e: + await self._db.rollback() + raise CreditCardDatabaseError(f"Unexpected error while deleting credit card: {str(e)}") from e diff --git a/app/context/credit_card/interface/__init__.py b/app/context/credit_card/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/credit_card/interface/rest/__init__.py b/app/context/credit_card/interface/rest/__init__.py new file mode 100644 index 0000000..20c4113 --- /dev/null +++ b/app/context/credit_card/interface/rest/__init__.py @@ -0,0 +1,3 @@ +from .routes import credit_card_routes + +__all__ = ["credit_card_routes"] diff --git a/app/context/credit_card/interface/rest/controllers/__init__.py b/app/context/credit_card/interface/rest/controllers/__init__.py new file mode 100644 index 0000000..0fb6f51 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/__init__.py @@ -0,0 +1,13 @@ +from .create_credit_card_controller import router as create_router +from .delete_credit_card_controller import router as delete_router +from .find_credit_card_by_id_controller import router as find_by_id_router +from .find_credit_cards_by_user_controller import router as find_by_user_router +from .update_credit_card_controller import router as update_router + +__all__ = [ + "create_router", + "delete_router", + "find_by_id_router", + "find_by_user_router", + "update_router", +] diff --git a/app/context/credit_card/interface/rest/controllers/create_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/create_credit_card_controller.py new file mode 100644 index 0000000..f272237 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/create_credit_card_controller.py @@ -0,0 +1,80 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.credit_card.application.commands import CreateCreditCardCommand +from app.context.credit_card.application.contracts import ( + CreateCreditCardHandlerContract, +) +from app.context.credit_card.application.dto import CreateCreditCardErrorCode +from app.context.credit_card.domain.value_objects import CardLimit +from app.context.credit_card.infrastructure.dependencies import ( + get_create_credit_card_handler, +) +from app.context.credit_card.interface.schemas.create_credit_card_response import ( + CreateCreditCardResponse, +) +from app.context.credit_card.interface.schemas.create_credit_card_schema import ( + CreateCreditCardRequest, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/cards") + + +@router.post("", response_model=CreateCreditCardResponse, status_code=201) +async def create_credit_card( + request: CreateCreditCardRequest, + handler: Annotated[CreateCreditCardHandlerContract, Depends(get_create_credit_card_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Create a new credit card""" + logger.info("Create credit card request", user_id=user_id, account_id=request.account_id, name=request.name) + + command = CreateCreditCardCommand( + user_id=user_id, + account_id=request.account_id, + name=request.name, + currency=request.currency, + limit=request.limit, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + CreateCreditCardErrorCode.NAME_ALREADY_EXISTS: 409, # Conflict + CreateCreditCardErrorCode.MAPPER_ERROR: 500, # Internal Server Error + CreateCreditCardErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 409: + logger.warning("Create credit card failed - name conflict", user_id=user_id, name=request.name) + elif status_code == 500: + logger.error( + "Create credit card failed - server error", user_id=user_id, error_code=result.error_code.value + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + if result.credit_card_id is None: + logger.error("Create credit card failed - missing ID", user_id=user_id) + raise HTTPException( + status_code=500, + detail="credit card id is not present", + ) + + logger.info( + "Credit card created successfully", user_id=user_id, credit_card_id=result.credit_card_id, name=request.name + ) + + return CreateCreditCardResponse( + credit_card_id=result.credit_card_id, + name=request.name, + limit=CardLimit.from_float(request.limit).value, + ) diff --git a/app/context/credit_card/interface/rest/controllers/delete_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/delete_credit_card_controller.py new file mode 100644 index 0000000..5120c21 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/delete_credit_card_controller.py @@ -0,0 +1,59 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.context.credit_card.application.commands import DeleteCreditCardCommand +from app.context.credit_card.application.contracts import ( + DeleteCreditCardHandlerContract, +) +from app.context.credit_card.application.dto import DeleteCreditCardErrorCode +from app.context.credit_card.infrastructure.dependencies import ( + get_delete_credit_card_handler, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/cards") + + +@router.delete("/{credit_card_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_credit_card( + credit_card_id: int, + handler: Annotated[DeleteCreditCardHandlerContract, Depends(get_delete_credit_card_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Delete a credit card (soft delete)""" + logger.info("Delete credit card request", user_id=user_id, credit_card_id=credit_card_id) + + command = DeleteCreditCardCommand( + credit_card_id=credit_card_id, + user_id=user_id, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + DeleteCreditCardErrorCode.NOT_FOUND: 404, # Not Found + DeleteCreditCardErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 404: + logger.warning("Delete credit card failed - not found", user_id=user_id, credit_card_id=credit_card_id) + elif status_code == 500: + logger.error( + "Delete credit card failed - server error", + user_id=user_id, + credit_card_id=credit_card_id, + error_code=result.error_code.value, + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + logger.info("Credit card deleted successfully", user_id=user_id, credit_card_id=credit_card_id) + + return None diff --git a/app/context/credit_card/interface/rest/controllers/find_credit_card_by_id_controller.py b/app/context/credit_card/interface/rest/controllers/find_credit_card_by_id_controller.py new file mode 100644 index 0000000..82facf2 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/find_credit_card_by_id_controller.py @@ -0,0 +1,56 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.credit_card.application.contracts import ( + FindCreditCardByIdHandlerContract, +) +from app.context.credit_card.application.queries import FindCreditCardByIdQuery +from app.context.credit_card.infrastructure.dependencies import ( + get_find_credit_card_by_id_handler, +) +from app.context.credit_card.interface.schemas.credit_card_response import ( + CreditCardResponse, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/cards") + + +@router.get("/{credit_card_id}", response_model=CreditCardResponse) +async def get_credit_card( + credit_card_id: int, + handler: Annotated[FindCreditCardByIdHandlerContract, Depends(get_find_credit_card_by_id_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Get a credit card by ID""" + logger.info("Get credit card by ID request", user_id=user_id, credit_card_id=credit_card_id) + + query = FindCreditCardByIdQuery( + credit_card_id=credit_card_id, + user_id=user_id, + ) + + result = await handler.handle(query) + + if not result: + logger.warning("Credit card not found", user_id=user_id, credit_card_id=credit_card_id) + raise HTTPException( + status_code=404, + detail=f"Credit card with ID {credit_card_id} not found", + ) + + logger.info("Credit card retrieved successfully", user_id=user_id, credit_card_id=credit_card_id) + + return CreditCardResponse( + credit_card_id=result.credit_card_id, + user_id=result.user_id, + account_id=result.account_id, + name=result.name, + currency=result.currency, + limit=result.limit, + used=result.used, + ) diff --git a/app/context/credit_card/interface/rest/controllers/find_credit_cards_by_user_controller.py b/app/context/credit_card/interface/rest/controllers/find_credit_cards_by_user_controller.py new file mode 100644 index 0000000..9567ae8 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/find_credit_cards_by_user_controller.py @@ -0,0 +1,48 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.context.credit_card.application.contracts import ( + FindCreditCardsByUserHandlerContract, +) +from app.context.credit_card.application.queries import FindCreditCardsByUserQuery +from app.context.credit_card.infrastructure.dependencies import ( + get_find_credit_cards_by_user_handler, +) +from app.context.credit_card.interface.schemas.credit_card_response import ( + CreditCardResponse, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/cards") + + +@router.get("", response_model=list[CreditCardResponse]) +async def get_credit_cards( + handler: Annotated[FindCreditCardsByUserHandlerContract, Depends(get_find_credit_cards_by_user_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Get all credit cards for the current user""" + logger.info("Get all credit cards for user request", user_id=user_id) + + query = FindCreditCardsByUserQuery(user_id=user_id) + + results = await handler.handle(query) + + logger.info("Credit cards retrieved successfully", user_id=user_id, count=len(results)) + + return [ + CreditCardResponse( + credit_card_id=result.credit_card_id, + user_id=result.user_id, + account_id=result.account_id, + name=result.name, + currency=result.currency, + limit=result.limit, + used=result.used, + ) + for result in results + ] diff --git a/app/context/credit_card/interface/rest/controllers/update_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/update_credit_card_controller.py new file mode 100644 index 0000000..c26af55 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/update_credit_card_controller.py @@ -0,0 +1,73 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.credit_card.application.commands import UpdateCreditCardCommand +from app.context.credit_card.application.contracts import ( + UpdateCreditCardHandlerContract, +) +from app.context.credit_card.application.dto import UpdateCreditCardErrorCode +from app.context.credit_card.infrastructure.dependencies import ( + get_update_credit_card_handler, +) +from app.context.credit_card.interface.schemas.update_credit_card_response import ( + UpdateCreditCardResponse, +) +from app.context.credit_card.interface.schemas.update_credit_card_schema import ( + UpdateCreditCardRequest, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/cards") + + +@router.put("/{credit_card_id}", response_model=UpdateCreditCardResponse) +async def update_credit_card( + credit_card_id: int, + request: UpdateCreditCardRequest, + handler: Annotated[UpdateCreditCardHandlerContract, Depends(get_update_credit_card_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Update an existing credit card""" + logger.info("Update credit card request", user_id=user_id, credit_card_id=credit_card_id) + + command = UpdateCreditCardCommand( + credit_card_id=credit_card_id, + user_id=user_id, + name=request.name, + limit=request.limit, + used=request.used, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + UpdateCreditCardErrorCode.NOT_FOUND: 404, # Not Found + UpdateCreditCardErrorCode.NAME_ALREADY_EXISTS: 409, # Conflict + UpdateCreditCardErrorCode.MAPPER_ERROR: 500, # Internal Server Error + UpdateCreditCardErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 404: + logger.warning("Update credit card failed - not found", user_id=user_id, credit_card_id=credit_card_id) + elif status_code == 409: + logger.warning("Update credit card failed - name conflict", user_id=user_id, credit_card_id=credit_card_id) + elif status_code == 500: + logger.error( + "Update credit card failed - server error", + user_id=user_id, + credit_card_id=credit_card_id, + error_code=result.error_code.value, + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + logger.info("Credit card updated successfully", user_id=user_id, credit_card_id=credit_card_id) + + return UpdateCreditCardResponse(success=True, message="Credit card updated successfully") diff --git a/app/context/credit_card/interface/rest/routes.py b/app/context/credit_card/interface/rest/routes.py new file mode 100644 index 0000000..dbdf678 --- /dev/null +++ b/app/context/credit_card/interface/rest/routes.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter + +from app.context.credit_card.interface.rest.controllers import ( + create_router, + delete_router, + find_by_id_router, + find_by_user_router, + update_router, +) + +credit_card_routes = APIRouter(prefix="/api/credit-cards", tags=["credit-cards"]) + +# Include all controller routers +credit_card_routes.include_router(create_router) +credit_card_routes.include_router(find_by_id_router) +credit_card_routes.include_router(find_by_user_router) +credit_card_routes.include_router(update_router) +credit_card_routes.include_router(delete_router) diff --git a/app/context/credit_card/interface/schemas/__init__.py b/app/context/credit_card/interface/schemas/__init__.py new file mode 100644 index 0000000..351d8bf --- /dev/null +++ b/app/context/credit_card/interface/schemas/__init__.py @@ -0,0 +1,13 @@ +from .create_credit_card_response import CreateCreditCardResponse +from .create_credit_card_schema import CreateCreditCardRequest +from .credit_card_response import CreditCardResponse +from .update_credit_card_response import UpdateCreditCardResponse +from .update_credit_card_schema import UpdateCreditCardRequest + +__all__ = [ + "CreateCreditCardRequest", + "CreateCreditCardResponse", + "CreditCardResponse", + "UpdateCreditCardRequest", + "UpdateCreditCardResponse", +] diff --git a/app/context/credit_card/interface/schemas/create_credit_card_response.py b/app/context/credit_card/interface/schemas/create_credit_card_response.py new file mode 100644 index 0000000..77e28a3 --- /dev/null +++ b/app/context/credit_card/interface/schemas/create_credit_card_response.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class CreateCreditCardResponse: + credit_card_id: int + name: str + limit: Decimal diff --git a/app/context/credit_card/interface/schemas/create_credit_card_schema.py b/app/context/credit_card/interface/schemas/create_credit_card_schema.py new file mode 100644 index 0000000..b4a9b27 --- /dev/null +++ b/app/context/credit_card/interface/schemas/create_credit_card_schema.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class CreateCreditCardRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str = Field(..., min_length=3, max_length=100, description="Credit card name") + account_id: int = Field(..., description="Account associated with the credit card") + currency: str = Field(..., min_length=3, max_length=3, description="Currency code (ISO 4217)") + limit: float = Field(..., gt=0, description="Credit card limit") + + @field_validator("currency") + @classmethod + def validate_currency(cls, v: str) -> str: + """Ensure currency is uppercase and exactly 3 characters""" + return v.upper() diff --git a/app/context/credit_card/interface/schemas/credit_card_response.py b/app/context/credit_card/interface/schemas/credit_card_response.py new file mode 100644 index 0000000..832ca6c --- /dev/null +++ b/app/context/credit_card/interface/schemas/credit_card_response.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class CreditCardResponse: + credit_card_id: int + user_id: int + account_id: int + name: str + currency: str + limit: Decimal + used: Decimal diff --git a/app/context/credit_card/interface/schemas/update_credit_card_response.py b/app/context/credit_card/interface/schemas/update_credit_card_response.py new file mode 100644 index 0000000..8267a2a --- /dev/null +++ b/app/context/credit_card/interface/schemas/update_credit_card_response.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UpdateCreditCardResponse: + success: bool + message: str diff --git a/app/context/credit_card/interface/schemas/update_credit_card_schema.py b/app/context/credit_card/interface/schemas/update_credit_card_schema.py new file mode 100644 index 0000000..0d3502b --- /dev/null +++ b/app/context/credit_card/interface/schemas/update_credit_card_schema.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class UpdateCreditCardRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str | None = Field(None, min_length=3, max_length=100, description="Credit card name") + limit: float | None = Field(None, gt=0, description="Credit card limit") + used: float | None = Field(None, ge=0, description="Credit card used amount") diff --git a/app/context/entry/application/commands/__init__.py b/app/context/entry/application/commands/__init__.py new file mode 100644 index 0000000..c52cab5 --- /dev/null +++ b/app/context/entry/application/commands/__init__.py @@ -0,0 +1,9 @@ +from .create_entry_command import CreateEntryCommand +from .delete_entry_command import DeleteEntryCommand +from .update_entry_command import UpdateEntryCommand + +__all__ = [ + "CreateEntryCommand", + "UpdateEntryCommand", + "DeleteEntryCommand", +] diff --git a/app/context/entry/application/commands/create_entry_command.py b/app/context/entry/application/commands/create_entry_command.py new file mode 100644 index 0000000..03b58f8 --- /dev/null +++ b/app/context/entry/application/commands/create_entry_command.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class CreateEntryCommand: + user_id: int + account_id: int + category_id: int + entry_type: str + entry_date: datetime + amount: float + description: str + household_id: int | None = None diff --git a/app/context/entry/application/commands/delete_entry_command.py b/app/context/entry/application/commands/delete_entry_command.py new file mode 100644 index 0000000..4547310 --- /dev/null +++ b/app/context/entry/application/commands/delete_entry_command.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeleteEntryCommand: + """Command for deleting an entry""" + + entry_id: int + user_id: int diff --git a/app/context/entry/application/commands/update_entry_command.py b/app/context/entry/application/commands/update_entry_command.py new file mode 100644 index 0000000..bc0f3e1 --- /dev/null +++ b/app/context/entry/application/commands/update_entry_command.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class UpdateEntryCommand: + """Command for updating an entry""" + + entry_id: int + user_id: int + account_id: int + category_id: int + entry_type: str + entry_date: datetime + amount: float + description: str + household_id: int | None = None diff --git a/app/context/entry/application/contracts/__init__.py b/app/context/entry/application/contracts/__init__.py new file mode 100644 index 0000000..509c757 --- /dev/null +++ b/app/context/entry/application/contracts/__init__.py @@ -0,0 +1,13 @@ +from .create_entry_handler_contract import CreateEntryHandlerContract +from .delete_entry_handler_contract import DeleteEntryHandlerContract +from .find_entries_by_account_month_handler_contract import FindEntriesByAccountMonthHandlerContract +from .find_entry_by_id_handler_contract import FindEntryByIdHandlerContract +from .update_entry_handler_contract import UpdateEntryHandlerContract + +__all__ = [ + "CreateEntryHandlerContract", + "FindEntryByIdHandlerContract", + "FindEntriesByAccountMonthHandlerContract", + "UpdateEntryHandlerContract", + "DeleteEntryHandlerContract", +] diff --git a/app/context/entry/application/contracts/create_entry_handler_contract.py b/app/context/entry/application/contracts/create_entry_handler_contract.py new file mode 100644 index 0000000..ff655af --- /dev/null +++ b/app/context/entry/application/contracts/create_entry_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.entry.application.commands import CreateEntryCommand +from app.context.entry.application.dto import CreateEntryResult + + +class CreateEntryHandlerContract(ABC): + """Contract for create entry handler""" + + @abstractmethod + async def handle(self, command: CreateEntryCommand) -> CreateEntryResult: + """Handle create entry command""" + pass diff --git a/app/context/entry/application/contracts/delete_entry_handler_contract.py b/app/context/entry/application/contracts/delete_entry_handler_contract.py new file mode 100644 index 0000000..74e396b --- /dev/null +++ b/app/context/entry/application/contracts/delete_entry_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.entry.application.commands import DeleteEntryCommand +from app.context.entry.application.dto import DeleteEntryResult + + +class DeleteEntryHandlerContract(ABC): + """Contract for delete entry handler""" + + @abstractmethod + async def handle(self, command: DeleteEntryCommand) -> DeleteEntryResult: + """Handle delete entry command""" + pass diff --git a/app/context/entry/application/contracts/find_entries_by_account_month_handler_contract.py b/app/context/entry/application/contracts/find_entries_by_account_month_handler_contract.py new file mode 100644 index 0000000..592b6e2 --- /dev/null +++ b/app/context/entry/application/contracts/find_entries_by_account_month_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.entry.application.dto import FindMultipleEntriesResult +from app.context.entry.application.queries import FindEntriesByAccountMonthQuery + + +class FindEntriesByAccountMonthHandlerContract(ABC): + """Contract for find entries by account and month handler""" + + @abstractmethod + async def handle(self, query: FindEntriesByAccountMonthQuery) -> FindMultipleEntriesResult: + """Handle find entries by account and month query""" + pass diff --git a/app/context/entry/application/contracts/find_entry_by_id_handler_contract.py b/app/context/entry/application/contracts/find_entry_by_id_handler_contract.py new file mode 100644 index 0000000..6a9a830 --- /dev/null +++ b/app/context/entry/application/contracts/find_entry_by_id_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.entry.application.dto import FindSingleEntryResult +from app.context.entry.application.queries import FindEntryByIdQuery + + +class FindEntryByIdHandlerContract(ABC): + """Contract for find entry by ID handler""" + + @abstractmethod + async def handle(self, query: FindEntryByIdQuery) -> FindSingleEntryResult: + """Handle find entry by ID query""" + pass diff --git a/app/context/entry/application/contracts/update_entry_handler_contract.py b/app/context/entry/application/contracts/update_entry_handler_contract.py new file mode 100644 index 0000000..ad96cb7 --- /dev/null +++ b/app/context/entry/application/contracts/update_entry_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.entry.application.commands import UpdateEntryCommand +from app.context.entry.application.dto import UpdateEntryResult + + +class UpdateEntryHandlerContract(ABC): + """Contract for update entry handler""" + + @abstractmethod + async def handle(self, command: UpdateEntryCommand) -> UpdateEntryResult: + """Handle update entry command""" + pass diff --git a/app/context/entry/application/dto/__init__.py b/app/context/entry/application/dto/__init__.py new file mode 100644 index 0000000..b05beab --- /dev/null +++ b/app/context/entry/application/dto/__init__.py @@ -0,0 +1,20 @@ +from .create_entry_result import CreateEntryErrorCode, CreateEntryResult +from .delete_entry_result import DeleteEntryErrorCode, DeleteEntryResult +from .entry_response_dto import EntryResponseDTO +from .find_multiple_entries_result import FindMultipleEntriesErrorCode, FindMultipleEntriesResult +from .find_single_entry_result import FindSingleEntryErrorCode, FindSingleEntryResult +from .update_entry_result import UpdateEntryErrorCode, UpdateEntryResult + +__all__ = [ + "EntryResponseDTO", + "CreateEntryResult", + "CreateEntryErrorCode", + "FindSingleEntryResult", + "FindSingleEntryErrorCode", + "FindMultipleEntriesResult", + "FindMultipleEntriesErrorCode", + "UpdateEntryResult", + "UpdateEntryErrorCode", + "DeleteEntryResult", + "DeleteEntryErrorCode", +] diff --git a/app/context/entry/application/dto/create_entry_result.py b/app/context/entry/application/dto/create_entry_result.py new file mode 100644 index 0000000..4e375ac --- /dev/null +++ b/app/context/entry/application/dto/create_entry_result.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from enum import Enum + + +class CreateEntryErrorCode(str, Enum): + """Error codes for entry creation""" + + ACCOUNT_NOT_BELONGS_TO_USER = "ACCOUNT_NOT_BELONGS_TO_USER" + CATEGORY_NOT_FOUND = "CATEGORY_NOT_FOUND" + CATEGORY_NOT_BELONGS_TO_USER = "CATEGORY_NOT_BELONGS_TO_USER" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class CreateEntryResult: + """Result of create entry operation""" + + # Success fields + entry_id: int | None = None + account_id: int | None = None + category_id: int | None = None + entry_type: str | None = None + entry_date: str | None = None # ISO format + amount: float | None = None + description: str | None = None + + # Error fields + error_code: CreateEntryErrorCode | None = None + error_message: str | None = None diff --git a/app/context/entry/application/dto/delete_entry_result.py b/app/context/entry/application/dto/delete_entry_result.py new file mode 100644 index 0000000..ef42047 --- /dev/null +++ b/app/context/entry/application/dto/delete_entry_result.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from enum import Enum + + +class DeleteEntryErrorCode(str, Enum): + """Error codes for entry deletion""" + + NOT_FOUND = "NOT_FOUND" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class DeleteEntryResult: + """Result of delete entry operation""" + + # Success field + success: bool | None = None + + # Error fields + error_code: DeleteEntryErrorCode | None = None + error_message: str | None = None diff --git a/app/context/entry/application/dto/entry_response_dto.py b/app/context/entry/application/dto/entry_response_dto.py new file mode 100644 index 0000000..83c9615 --- /dev/null +++ b/app/context/entry/application/dto/entry_response_dto.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass + +from app.context.entry.domain.dto import EntryDTO + + +@dataclass(frozen=True) +class EntryResponseDTO: + """Application DTO for entry responses (primitives only)""" + + entry_id: int + user_id: int + account_id: int + category_id: int + entry_type: str + entry_date: str # ISO format + amount: float + description: str + household_id: int | None = None + + @classmethod + def from_domain_dto(cls, dto: EntryDTO) -> "EntryResponseDTO": + """Convert domain DTO to application response DTO""" + if dto.entry_id is None: + raise ValueError("Entry ID cannot be None in response DTO") + + return cls( + entry_id=dto.entry_id.value, + user_id=dto.user_id.value, + account_id=dto.account_id.value, + category_id=dto.category_id.value, + entry_type=dto.entry_type.value, + entry_date=dto.entry_date.value.isoformat(), + amount=float(dto.amount.value), + description=dto.description.value, + household_id=dto.household_id.value if dto.household_id else None, + ) diff --git a/app/context/entry/application/dto/find_multiple_entries_result.py b/app/context/entry/application/dto/find_multiple_entries_result.py new file mode 100644 index 0000000..e7df9c7 --- /dev/null +++ b/app/context/entry/application/dto/find_multiple_entries_result.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from enum import Enum + +from app.context.entry.application.dto.entry_response_dto import EntryResponseDTO + + +class FindMultipleEntriesErrorCode(str, Enum): + """Error codes for finding multiple entries""" + + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class FindMultipleEntriesResult: + """Result of find multiple entries operation""" + + # Success field + entries: list[EntryResponseDTO] | None = None + + # Error fields + error_code: FindMultipleEntriesErrorCode | None = None + error_message: str | None = None diff --git a/app/context/entry/application/dto/find_single_entry_result.py b/app/context/entry/application/dto/find_single_entry_result.py new file mode 100644 index 0000000..14e231e --- /dev/null +++ b/app/context/entry/application/dto/find_single_entry_result.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from enum import Enum + +from app.context.entry.application.dto.entry_response_dto import EntryResponseDTO + + +class FindSingleEntryErrorCode(str, Enum): + """Error codes for finding single entry""" + + NOT_FOUND = "NOT_FOUND" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class FindSingleEntryResult: + """Result of find single entry operation""" + + # Success field + entry: EntryResponseDTO | None = None + + # Error fields + error_code: FindSingleEntryErrorCode | None = None + error_message: str | None = None diff --git a/app/context/entry/application/dto/update_entry_result.py b/app/context/entry/application/dto/update_entry_result.py new file mode 100644 index 0000000..76b0365 --- /dev/null +++ b/app/context/entry/application/dto/update_entry_result.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from enum import Enum + +from app.context.entry.application.dto.entry_response_dto import EntryResponseDTO + + +class UpdateEntryErrorCode(str, Enum): + """Error codes for entry update""" + + NOT_FOUND = "NOT_FOUND" + ACCOUNT_NOT_BELONGS_TO_USER = "ACCOUNT_NOT_BELONGS_TO_USER" + CATEGORY_NOT_FOUND = "CATEGORY_NOT_FOUND" + CATEGORY_NOT_BELONGS_TO_USER = "CATEGORY_NOT_BELONGS_TO_USER" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class UpdateEntryResult: + """Result of update entry operation""" + + # Success fields + entry: EntryResponseDTO | None = None + + # Error fields + error_code: UpdateEntryErrorCode | None = None + error_message: str | None = None diff --git a/app/context/entry/application/handlers/__init__.py b/app/context/entry/application/handlers/__init__.py new file mode 100644 index 0000000..8f8b9de --- /dev/null +++ b/app/context/entry/application/handlers/__init__.py @@ -0,0 +1,13 @@ +from .create_entry_handler import CreateEntryHandler +from .delete_entry_handler import DeleteEntryHandler +from .find_entries_by_account_month_handler import FindEntriesByAccountMonthHandler +from .find_entry_by_id_handler import FindEntryByIdHandler +from .update_entry_handler import UpdateEntryHandler + +__all__ = [ + "CreateEntryHandler", + "FindEntryByIdHandler", + "FindEntriesByAccountMonthHandler", + "UpdateEntryHandler", + "DeleteEntryHandler", +] diff --git a/app/context/entry/application/handlers/create_entry_handler.py b/app/context/entry/application/handlers/create_entry_handler.py new file mode 100644 index 0000000..293ad63 --- /dev/null +++ b/app/context/entry/application/handlers/create_entry_handler.py @@ -0,0 +1,97 @@ +from app.context.entry.application.commands import CreateEntryCommand +from app.context.entry.application.contracts import CreateEntryHandlerContract +from app.context.entry.application.dto import ( + CreateEntryErrorCode, + CreateEntryResult, +) +from app.context.entry.domain.contracts.services import CreateEntryServiceContract +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotBelongsToUserError, + EntryCategoryNotFoundError, + EntryMapperError, +) +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryType, + EntryUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class CreateEntryHandler(CreateEntryHandlerContract): + """Handler for create entry command""" + + def __init__( + self, + service: CreateEntryServiceContract, + logger: LoggerContract, + ): + self._service = service + self._logger = logger + + async def handle(self, command: CreateEntryCommand) -> CreateEntryResult: + """Execute the create entry command""" + try: + # Convert command primitives to value objects + entry_dto = await self._service.create_entry( + user_id=EntryUserID(command.user_id), + account_id=EntryAccountID(command.account_id), + category_id=EntryCategoryID(command.category_id), + entry_type=EntryType(command.entry_type), + entry_date=EntryDate(command.entry_date), + amount=EntryAmount.from_float(command.amount), + description=EntryDescription(command.description), + household_id=(EntryHouseholdID(command.household_id) if command.household_id is not None else None), + ) + + # Validate operation succeeded + if entry_dto.entry_id is None: + self._logger.error("Entry creation returned None entry_id") + return CreateEntryResult( + error_code=CreateEntryErrorCode.UNEXPECTED_ERROR, + error_message="Error creating entry", + ) + + # Return success result with primitives + return CreateEntryResult( + entry_id=entry_dto.entry_id.value, + account_id=entry_dto.account_id.value, + category_id=entry_dto.category_id.value, + entry_type=entry_dto.entry_type.value, + entry_date=entry_dto.entry_date.value.isoformat(), + amount=float(entry_dto.amount.value), + description=entry_dto.description.value, + ) + + except EntryAccountNotBelongsToUserError: + return CreateEntryResult( + error_code=CreateEntryErrorCode.ACCOUNT_NOT_BELONGS_TO_USER, + error_message="Account does not belong to user", + ) + except EntryCategoryNotFoundError: + return CreateEntryResult( + error_code=CreateEntryErrorCode.CATEGORY_NOT_FOUND, + error_message="Category not found", + ) + except EntryCategoryNotBelongsToUserError: + return CreateEntryResult( + error_code=CreateEntryErrorCode.CATEGORY_NOT_BELONGS_TO_USER, + error_message="Category does not belong to user", + ) + except EntryMapperError: + return CreateEntryResult( + error_code=CreateEntryErrorCode.MAPPER_ERROR, + error_message="Error mapping entry data", + ) + except Exception as e: + self._logger.error("Unexpected error during entry creation", error=str(e)) + return CreateEntryResult( + error_code=CreateEntryErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/entry/application/handlers/delete_entry_handler.py b/app/context/entry/application/handlers/delete_entry_handler.py new file mode 100644 index 0000000..740d45f --- /dev/null +++ b/app/context/entry/application/handlers/delete_entry_handler.py @@ -0,0 +1,44 @@ +from app.context.entry.application.commands import DeleteEntryCommand +from app.context.entry.application.contracts import DeleteEntryHandlerContract +from app.context.entry.application.dto import ( + DeleteEntryErrorCode, + DeleteEntryResult, +) +from app.context.entry.domain.contracts.infrastructure import EntryRepositoryContract +from app.context.entry.domain.value_objects import EntryID, EntryUserID +from app.shared.domain.contracts import LoggerContract + + +class DeleteEntryHandler(DeleteEntryHandlerContract): + """Handler for delete entry command""" + + def __init__( + self, + repository: EntryRepositoryContract, + logger: LoggerContract, + ): + self._repository = repository + self._logger = logger + + async def handle(self, command: DeleteEntryCommand) -> DeleteEntryResult: + """Execute the delete entry command (hard delete)""" + try: + success = await self._repository.delete_entry( + entry_id=EntryID(command.entry_id), + user_id=EntryUserID(command.user_id), + ) + + if not success: + return DeleteEntryResult( + error_code=DeleteEntryErrorCode.NOT_FOUND, + error_message="Entry not found", + ) + + return DeleteEntryResult(success=True) + + except Exception as e: + self._logger.error("Unexpected error during entry deletion", error=str(e)) + return DeleteEntryResult( + error_code=DeleteEntryErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/entry/application/handlers/find_entries_by_account_month_handler.py b/app/context/entry/application/handlers/find_entries_by_account_month_handler.py new file mode 100644 index 0000000..fc70a32 --- /dev/null +++ b/app/context/entry/application/handlers/find_entries_by_account_month_handler.py @@ -0,0 +1,57 @@ +from app.context.entry.application.contracts import FindEntriesByAccountMonthHandlerContract +from app.context.entry.application.dto import ( + EntryResponseDTO, + FindMultipleEntriesErrorCode, + FindMultipleEntriesResult, +) +from app.context.entry.application.queries import FindEntriesByAccountMonthQuery +from app.context.entry.domain.contracts.infrastructure import EntryRepositoryContract +from app.context.entry.domain.value_objects import EntryAccountID, EntryDate, EntryMonth, EntryUserID, EntryYear +from app.shared.domain.contracts import LoggerContract + + +class FindEntriesByAccountMonthHandler(FindEntriesByAccountMonthHandlerContract): + """Handler for find entries by account and month query""" + + def __init__( + self, + repository: EntryRepositoryContract, + logger: LoggerContract, + ): + self._repository = repository + self._logger = logger + + async def handle(self, query: FindEntriesByAccountMonthQuery) -> FindMultipleEntriesResult: + """Execute the find entries by account and month query""" + self._logger.debug( + "Finding entries by account and month", + user_id=query.user_id, + account_id=query.account_id, + month=query.month, + year=query.year, + last_row_date=query.last_entry_date.isoformat() if query.last_entry_date else None, + ) + + try: + entries = await self._repository.find_entries_by_account_and_month( + user_id=EntryUserID(query.user_id), + account_id=EntryAccountID(query.account_id), + month=EntryMonth(query.month), + year=EntryYear(query.year), + last_entry_date=EntryDate(query.last_entry_date) if query.last_entry_date else None, + ) + + # Return empty list if no entries found (not an error) + if not entries: + self._logger.debug("No entries found", user_id=query.user_id) + return FindMultipleEntriesResult(entries=[]) + + entry_dtos = [EntryResponseDTO.from_domain_dto(entry) for entry in entries] + return FindMultipleEntriesResult(entries=entry_dtos) + + except Exception as e: + self._logger.error("Unexpected error while finding entries", error=str(e)) + return FindMultipleEntriesResult( + error_code=FindMultipleEntriesErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error while finding entries", + ) diff --git a/app/context/entry/application/handlers/find_entry_by_id_handler.py b/app/context/entry/application/handlers/find_entry_by_id_handler.py new file mode 100644 index 0000000..e27d328 --- /dev/null +++ b/app/context/entry/application/handlers/find_entry_by_id_handler.py @@ -0,0 +1,45 @@ +from app.context.entry.application.contracts import FindEntryByIdHandlerContract +from app.context.entry.application.dto import ( + EntryResponseDTO, + FindSingleEntryErrorCode, + FindSingleEntryResult, +) +from app.context.entry.application.queries import FindEntryByIdQuery +from app.context.entry.domain.contracts.infrastructure import EntryRepositoryContract +from app.context.entry.domain.value_objects import EntryID, EntryUserID +from app.shared.domain.contracts import LoggerContract + + +class FindEntryByIdHandler(FindEntryByIdHandlerContract): + """Handler for find entry by ID query""" + + def __init__( + self, + repository: EntryRepositoryContract, + logger: LoggerContract, + ): + self._repository = repository + self._logger = logger + + async def handle(self, query: FindEntryByIdQuery) -> FindSingleEntryResult: + """Execute the find entry by ID query""" + try: + entry = await self._repository.find_entry_by_id( + entry_id=EntryID(query.entry_id), + user_id=EntryUserID(query.user_id), + ) + + if not entry: + return FindSingleEntryResult( + error_code=FindSingleEntryErrorCode.NOT_FOUND, + error_message="Entry not found", + ) + + return FindSingleEntryResult(entry=EntryResponseDTO.from_domain_dto(entry)) + + except Exception as e: + self._logger.error("Unexpected error while finding entry", error=str(e)) + return FindSingleEntryResult( + error_code=FindSingleEntryErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error while finding entry", + ) diff --git a/app/context/entry/application/handlers/update_entry_handler.py b/app/context/entry/application/handlers/update_entry_handler.py new file mode 100644 index 0000000..166bd46 --- /dev/null +++ b/app/context/entry/application/handlers/update_entry_handler.py @@ -0,0 +1,104 @@ +from app.context.entry.application.commands import UpdateEntryCommand +from app.context.entry.application.contracts import UpdateEntryHandlerContract +from app.context.entry.application.dto import ( + EntryResponseDTO, + UpdateEntryErrorCode, + UpdateEntryResult, +) +from app.context.entry.domain.contracts.services import UpdateEntryServiceContract +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotFoundError, + EntryMapperError, + EntryNotFoundError, +) +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class UpdateEntryHandler(UpdateEntryHandlerContract): + """Handler for update entry command""" + + def __init__( + self, + service: UpdateEntryServiceContract, + logger: LoggerContract, + ): + self._service = service + self._logger = logger + + async def handle(self, command: UpdateEntryCommand) -> UpdateEntryResult: + """Execute the update entry command""" + try: + # Convert command primitives to value objects + updated_dto = await self._service.update_entry( + entry_id=EntryID(command.entry_id), + user_id=EntryUserID(command.user_id), + account_id=EntryAccountID(command.account_id), + category_id=EntryCategoryID(command.category_id), + entry_type=EntryType(command.entry_type), + entry_date=EntryDate(command.entry_date), + amount=EntryAmount.from_float(command.amount), + description=EntryDescription(command.description), + household_id=EntryHouseholdID(command.household_id) if command.household_id else None, + ) + + # Validate operation succeeded + if updated_dto.entry_id is None: + self._logger.error("Entry update returned None entry_id") + return UpdateEntryResult( + error_code=UpdateEntryErrorCode.UNEXPECTED_ERROR, + error_message="Error updating entry", + ) + + # Return success result with primitives + return UpdateEntryResult( + entry=EntryResponseDTO( + entry_id=updated_dto.entry_id.value, + user_id=updated_dto.user_id.value, + account_id=updated_dto.account_id.value, + category_id=updated_dto.category_id.value, + entry_type=updated_dto.entry_type.value, + entry_date=updated_dto.entry_date.to_db_value(), + amount=float(updated_dto.amount.value), + description=updated_dto.description.value, + household_id=updated_dto.household_id.value if updated_dto.household_id else None, + ) + ) + + except EntryNotFoundError: + return UpdateEntryResult( + error_code=UpdateEntryErrorCode.NOT_FOUND, + error_message="Entry not found", + ) + except EntryAccountNotBelongsToUserError: + return UpdateEntryResult( + error_code=UpdateEntryErrorCode.ACCOUNT_NOT_BELONGS_TO_USER, + error_message="Account does not belong to user", + ) + except EntryCategoryNotFoundError: + return UpdateEntryResult( + error_code=UpdateEntryErrorCode.CATEGORY_NOT_FOUND, + error_message="Category not found", + ) + except EntryMapperError: + return UpdateEntryResult( + error_code=UpdateEntryErrorCode.MAPPER_ERROR, + error_message="Error mapping entry data", + ) + except Exception as e: + self._logger.error("Unexpected error during entry update", error=str(e)) + return UpdateEntryResult( + error_code=UpdateEntryErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/entry/application/queries/__init__.py b/app/context/entry/application/queries/__init__.py new file mode 100644 index 0000000..1c3b126 --- /dev/null +++ b/app/context/entry/application/queries/__init__.py @@ -0,0 +1,7 @@ +from .find_entries_by_account_month_query import FindEntriesByAccountMonthQuery +from .find_entry_by_id_query import FindEntryByIdQuery + +__all__ = [ + "FindEntryByIdQuery", + "FindEntriesByAccountMonthQuery", +] diff --git a/app/context/entry/application/queries/find_entries_by_account_month_query.py b/app/context/entry/application/queries/find_entries_by_account_month_query.py new file mode 100644 index 0000000..eef48d9 --- /dev/null +++ b/app/context/entry/application/queries/find_entries_by_account_month_query.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class FindEntriesByAccountMonthQuery: + """Query for finding entries by account and month""" + + user_id: int + account_id: int + month: int # 1-12 + year: int # e.g., 2025 + last_entry_date: datetime | None = None diff --git a/app/context/entry/application/queries/find_entry_by_id_query.py b/app/context/entry/application/queries/find_entry_by_id_query.py new file mode 100644 index 0000000..4f09ee7 --- /dev/null +++ b/app/context/entry/application/queries/find_entry_by_id_query.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FindEntryByIdQuery: + """Query for finding an entry by ID""" + + entry_id: int + user_id: int diff --git a/app/context/entry/domain/contracts/__init__.py b/app/context/entry/domain/contracts/__init__.py new file mode 100644 index 0000000..6c8c9bd --- /dev/null +++ b/app/context/entry/domain/contracts/__init__.py @@ -0,0 +1,8 @@ +from .infrastructure import EntryRepositoryContract +from .services import CreateEntryServiceContract, UpdateEntryServiceContract + +__all__ = [ + "EntryRepositoryContract", + "CreateEntryServiceContract", + "UpdateEntryServiceContract", +] diff --git a/app/context/entry/domain/contracts/infrastructure/__init__.py b/app/context/entry/domain/contracts/infrastructure/__init__.py new file mode 100644 index 0000000..a529640 --- /dev/null +++ b/app/context/entry/domain/contracts/infrastructure/__init__.py @@ -0,0 +1,3 @@ +from .entry_repository_contract import EntryRepositoryContract + +__all__ = ["EntryRepositoryContract"] diff --git a/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py b/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py new file mode 100644 index 0000000..39c5071 --- /dev/null +++ b/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py @@ -0,0 +1,144 @@ +from abc import ABC, abstractmethod + +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryCategoryID, + EntryDate, + EntryID, + EntryMonth, + EntryUserID, + EntryYear, +) + + +class EntryRepositoryContract(ABC): + """Contract for entry repository""" + + @abstractmethod + async def save_entry(self, entry: EntryDTO) -> EntryDTO: + """ + Create a new entry + + Args: + entry: Entry DTO to save + + Returns: + Saved entry DTO with generated ID + + Raises: + EntryMapperError: If mapping fails + """ + pass + + @abstractmethod + async def find_entry_by_id( + self, + entry_id: EntryID, + user_id: EntryUserID, + ) -> EntryDTO | None: + """ + Find entry by ID (with user_id check for security) + + Args: + entry_id: Entry identifier + user_id: User identifier (for security check) + + Returns: + Entry DTO if found and belongs to user, None otherwise + """ + pass + + @abstractmethod + async def find_entries_by_account_and_month( + self, + user_id: EntryUserID, + account_id: EntryAccountID, + month: EntryMonth, + year: EntryYear, + last_entry_date: EntryDate | None = None, + ) -> list[EntryDTO]: + """ + Find all entries for a specific account in a given month/year + + Args: + user_id: User identifier (for security check) + account_id: Account identifier + month: Month (1-12) + year: Year (e.g., 2025) + + Returns: + List of entry DTOs (empty list if none found) + """ + pass + + @abstractmethod + async def update_entry(self, entry: EntryDTO) -> EntryDTO: + """ + Update an existing entry + + Args: + entry: Entry DTO with updated values (must have entry_id) + + Returns: + Updated entry DTO + + Raises: + EntryNotFoundError: If entry doesn't exist + EntryMapperError: If mapping fails + """ + pass + + @abstractmethod + async def delete_entry( + self, + entry_id: EntryID, + user_id: EntryUserID, + ) -> bool: + """ + Hard delete an entry + + Args: + entry_id: Entry identifier + user_id: User identifier (for security check) + + Returns: + True if deleted, False if not found or doesn't belong to user + """ + pass + + @abstractmethod + async def verify_account_belongs_to_user( + self, + account_id: EntryAccountID, + user_id: EntryUserID, + ) -> bool: + """ + Verify that an account belongs to a specific user + + Args: + account_id: Account identifier + user_id: User identifier + + Returns: + True if account belongs to user, False otherwise + """ + pass + + @abstractmethod + async def verify_category_belongs_to_user( + self, + category_id: EntryCategoryID, + user_id: EntryUserID, + ) -> bool: + """ + Verify that a category belongs to a specific user + + Args: + category_id: Category identifier + user_id: User identifier + + Returns: + True if category belongs to user, False otherwise + """ + pass diff --git a/app/context/entry/domain/contracts/services/__init__.py b/app/context/entry/domain/contracts/services/__init__.py new file mode 100644 index 0000000..2186baa --- /dev/null +++ b/app/context/entry/domain/contracts/services/__init__.py @@ -0,0 +1,7 @@ +from .create_entry_service_contract import CreateEntryServiceContract +from .update_entry_service_contract import UpdateEntryServiceContract + +__all__ = [ + "CreateEntryServiceContract", + "UpdateEntryServiceContract", +] diff --git a/app/context/entry/domain/contracts/services/create_entry_service_contract.py b/app/context/entry/domain/contracts/services/create_entry_service_contract.py new file mode 100644 index 0000000..1071c7a --- /dev/null +++ b/app/context/entry/domain/contracts/services/create_entry_service_contract.py @@ -0,0 +1,53 @@ +from abc import ABC, abstractmethod + +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryType, + EntryUserID, +) + + +class CreateEntryServiceContract(ABC): + """Contract for create entry service""" + + @abstractmethod + async def create_entry( + self, + user_id: EntryUserID, + account_id: EntryAccountID, + category_id: EntryCategoryID, + entry_type: EntryType, + entry_date: EntryDate, + amount: EntryAmount, + description: EntryDescription, + household_id: EntryHouseholdID | None = None, + ) -> EntryDTO: + """ + Create a new entry with validation + + Args: + user_id: User identifier + account_id: Account identifier + category_id: Category identifier + entry_type: Type of entry (income/expense) + entry_date: Date of entry + amount: Amount (non-negative) + description: Entry description + household_id: Optional household identifier + + Returns: + Created entry DTO + + Raises: + EntryAccountNotBelongsToUserError: If account doesn't belong to user + EntryCategoryNotFoundError: If category doesn't exist + EntryCategoryNotBelongsToUserError: If category doesn't belong to user + EntryMapperError: If mapping fails + """ + pass diff --git a/app/context/entry/domain/contracts/services/update_entry_service_contract.py b/app/context/entry/domain/contracts/services/update_entry_service_contract.py new file mode 100644 index 0000000..d956634 --- /dev/null +++ b/app/context/entry/domain/contracts/services/update_entry_service_contract.py @@ -0,0 +1,57 @@ +from abc import ABC, abstractmethod + +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) + + +class UpdateEntryServiceContract(ABC): + """Contract for update entry service""" + + @abstractmethod + async def update_entry( + self, + entry_id: EntryID, + user_id: EntryUserID, + account_id: EntryAccountID, + category_id: EntryCategoryID, + entry_type: EntryType, + entry_date: EntryDate, + amount: EntryAmount, + description: EntryDescription, + household_id: EntryHouseholdID | None = None, + ) -> EntryDTO: + """ + Update an existing entry with validation + + Args: + entry_id: Entry identifier + user_id: User identifier + account_id: Account identifier + category_id: Category identifier + entry_type: Type of entry (income/expense) + entry_date: Date of entry + amount: Amount (non-negative) + description: Entry description + + Returns: + Updated entry DTO + + Raises: + EntryNotFoundError: If entry doesn't exist + EntryNotBelongsToUserError: If entry doesn't belong to user + EntryAccountNotBelongsToUserError: If account doesn't belong to user + EntryCategoryNotFoundError: If category doesn't exist + EntryCategoryNotBelongsToUserError: If category doesn't belong to user + EntryMapperError: If mapping fails + """ + pass diff --git a/app/context/entry/domain/dto/__init__.py b/app/context/entry/domain/dto/__init__.py new file mode 100644 index 0000000..5c6515a --- /dev/null +++ b/app/context/entry/domain/dto/__init__.py @@ -0,0 +1,3 @@ +from .entry_dto import EntryDTO + +__all__ = ["EntryDTO"] diff --git a/app/context/entry/domain/dto/entry_dto.py b/app/context/entry/domain/dto/entry_dto.py new file mode 100644 index 0000000..111b01e --- /dev/null +++ b/app/context/entry/domain/dto/entry_dto.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) + + +@dataclass(frozen=True) +class EntryDTO: + """Domain DTO for entry entity""" + + user_id: EntryUserID + account_id: EntryAccountID + category_id: EntryCategoryID + entry_type: EntryType + entry_date: EntryDate + amount: EntryAmount + description: EntryDescription + entry_id: EntryID | None = None + household_id: EntryHouseholdID | None = None diff --git a/app/context/entry/domain/exceptions/__init__.py b/app/context/entry/domain/exceptions/__init__.py new file mode 100644 index 0000000..dd82f85 --- /dev/null +++ b/app/context/entry/domain/exceptions/__init__.py @@ -0,0 +1,17 @@ +from .exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotBelongsToUserError, + EntryCategoryNotFoundError, + EntryMapperError, + EntryNotBelongsToUserError, + EntryNotFoundError, +) + +__all__ = [ + "EntryMapperError", + "EntryNotFoundError", + "EntryAccountNotBelongsToUserError", + "EntryCategoryNotFoundError", + "EntryCategoryNotBelongsToUserError", + "EntryNotBelongsToUserError", +] diff --git a/app/context/entry/domain/exceptions/exceptions.py b/app/context/entry/domain/exceptions/exceptions.py new file mode 100644 index 0000000..59477d0 --- /dev/null +++ b/app/context/entry/domain/exceptions/exceptions.py @@ -0,0 +1,34 @@ +class EntryMapperError(Exception): + """Raised when entry mapper fails to convert between model and DTO""" + + pass + + +class EntryNotFoundError(Exception): + """Raised when entry is not found""" + + pass + + +class EntryAccountNotBelongsToUserError(Exception): + """Raised when account does not belong to the user""" + + pass + + +class EntryCategoryNotFoundError(Exception): + """Raised when category is not found""" + + pass + + +class EntryCategoryNotBelongsToUserError(Exception): + """Raised when category does not belong to the user""" + + pass + + +class EntryNotBelongsToUserError(Exception): + """Raised when entry does not belong to the user""" + + pass diff --git a/app/context/entry/domain/services/__init__.py b/app/context/entry/domain/services/__init__.py new file mode 100644 index 0000000..42a6419 --- /dev/null +++ b/app/context/entry/domain/services/__init__.py @@ -0,0 +1,7 @@ +from .create_entry_service import CreateEntryService +from .update_entry_service import UpdateEntryService + +__all__ = [ + "CreateEntryService", + "UpdateEntryService", +] diff --git a/app/context/entry/domain/services/create_entry_service.py b/app/context/entry/domain/services/create_entry_service.py new file mode 100644 index 0000000..6d42656 --- /dev/null +++ b/app/context/entry/domain/services/create_entry_service.py @@ -0,0 +1,102 @@ +from app.context.entry.domain.contracts.infrastructure import EntryRepositoryContract +from app.context.entry.domain.contracts.services import CreateEntryServiceContract +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotFoundError, +) +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryType, + EntryUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class CreateEntryService(CreateEntryServiceContract): + """Service for creating entries with validation""" + + def __init__( + self, + repository: EntryRepositoryContract, + logger: LoggerContract, + ): + self._repository = repository + self._logger = logger + + async def create_entry( + self, + user_id: EntryUserID, + account_id: EntryAccountID, + category_id: EntryCategoryID, + entry_type: EntryType, + entry_date: EntryDate, + amount: EntryAmount, + description: EntryDescription, + household_id: EntryHouseholdID | None = None, + ) -> EntryDTO: + """Create a new entry with validation""" + self._logger.debug( + "Creating entry", + user_id=user_id.value, + account_id=account_id.value, + category_id=category_id.value, + entry_type=entry_type.value, + ) + + # Verify account belongs to user + account_valid = await self._repository.verify_account_belongs_to_user( + account_id=account_id, + user_id=user_id, + ) + if not account_valid: + self._logger.warning( + "Account does not belong to user", + user_id=user_id.value, + account_id=account_id.value, + ) + raise EntryAccountNotBelongsToUserError( + f"Account {account_id.value} does not belong to user {user_id.value}" + ) + + # Verify category belongs to user + category_valid = await self._repository.verify_category_belongs_to_user( + category_id=category_id, + user_id=user_id, + ) + if not category_valid: + self._logger.warning( + "Category not found or does not belong to user", + user_id=user_id.value, + category_id=category_id.value, + ) + raise EntryCategoryNotFoundError(f"Category {category_id.value} not found or does not belong to user") + + # Create entry DTO + entry_dto = EntryDTO( + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=household_id, + ) + + # Save entry + created_entry = await self._repository.save_entry(entry_dto) + + if created_entry.entry_id: + self._logger.debug( + "Entry created successfully", + user_id=user_id.value, + entry_id=created_entry.entry_id.value, + ) + + return created_entry diff --git a/app/context/entry/domain/services/update_entry_service.py b/app/context/entry/domain/services/update_entry_service.py new file mode 100644 index 0000000..ca2f15e --- /dev/null +++ b/app/context/entry/domain/services/update_entry_service.py @@ -0,0 +1,115 @@ +from app.context.entry.domain.contracts.infrastructure import EntryRepositoryContract +from app.context.entry.domain.contracts.services import UpdateEntryServiceContract +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotFoundError, + EntryNotFoundError, +) +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class UpdateEntryService(UpdateEntryServiceContract): + """Service for updating entries with validation""" + + def __init__( + self, + repository: EntryRepositoryContract, + logger: LoggerContract, + ): + self._repository = repository + self._logger = logger + + async def update_entry( + self, + entry_id: EntryID, + user_id: EntryUserID, + account_id: EntryAccountID, + category_id: EntryCategoryID, + entry_type: EntryType, + entry_date: EntryDate, + amount: EntryAmount, + description: EntryDescription, + household_id: EntryHouseholdID | None = None, + ) -> EntryDTO: + """Update an existing entry with validation""" + self._logger.debug( + "Updating entry", + entry_id=entry_id.value, + user_id=user_id.value, + ) + + existing_entry = await self._repository.find_entry_by_id( + entry_id=entry_id, + user_id=user_id, + ) + if not existing_entry: + self._logger.warning( + "Entry not found or does not belong to user", + entry_id=entry_id.value, + user_id=user_id.value, + ) + raise EntryNotFoundError(f"Entry {entry_id.value} not found or does not belong to user") + + # Verify account belongs to user + account_valid = await self._repository.verify_account_belongs_to_user( + account_id=account_id, + user_id=user_id, + ) + if not account_valid: + self._logger.warning( + "Account does not belong to user", + user_id=user_id.value, + account_id=account_id.value, + ) + raise EntryAccountNotBelongsToUserError( + f"Account {account_id.value} does not belong to user {user_id.value}" + ) + + # Verify category belongs to user + category_valid = await self._repository.verify_category_belongs_to_user( + category_id=category_id, + user_id=user_id, + ) + if not category_valid: + self._logger.warning( + "Category not found or does not belong to user", + user_id=user_id.value, + category_id=category_id.value, + ) + raise EntryCategoryNotFoundError(f"Category {category_id.value} not found or does not belong to user") + + # Create updated entry DTO (preserve household_id from existing) + updated_dto = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=household_id, + ) + + # Update entry + updated_entry = await self._repository.update_entry(updated_dto) + + self._logger.debug( + "Entry updated successfully", + entry_id=entry_id.value, + user_id=user_id.value, + ) + + return updated_entry diff --git a/app/context/entry/domain/value_objects/__init__.py b/app/context/entry/domain/value_objects/__init__.py new file mode 100644 index 0000000..6727286 --- /dev/null +++ b/app/context/entry/domain/value_objects/__init__.py @@ -0,0 +1,25 @@ +from .entry_account_id import EntryAccountID +from .entry_amount import EntryAmount +from .entry_category_id import EntryCategoryID +from .entry_date import EntryDate +from .entry_description import EntryDescription +from .entry_household_id import EntryHouseholdID +from .entry_id import EntryID +from .entry_month import EntryMonth +from .entry_type import EntryType +from .entry_user_id import EntryUserID +from .entry_year import EntryYear + +__all__ = [ + "EntryID", + "EntryUserID", + "EntryAccountID", + "EntryCategoryID", + "EntryHouseholdID", + "EntryAmount", + "EntryDescription", + "EntryDate", + "EntryType", + "EntryMonth", + "EntryYear", +] diff --git a/app/context/entry/domain/value_objects/entry_account_id.py b/app/context/entry/domain/value_objects/entry_account_id.py new file mode 100644 index 0000000..5c35755 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_account_id.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_account_id import SharedAccountID + + +@dataclass(frozen=True) +class EntryAccountID(SharedAccountID): + """Entry context-specific wrapper for account identifier""" + + pass diff --git a/app/context/entry/domain/value_objects/entry_amount.py b/app/context/entry/domain/value_objects/entry_amount.py new file mode 100644 index 0000000..9ba656e --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_amount.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_amount import SharedAmount + + +@dataclass(frozen=True) +class EntryAmount(SharedAmount): + """Entry context-specific wrapper for monetary amounts""" + + pass diff --git a/app/context/entry/domain/value_objects/entry_category_id.py b/app/context/entry/domain/value_objects/entry_category_id.py new file mode 100644 index 0000000..6668ff7 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_category_id.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_category_id import SharedCategoryID + + +@dataclass(frozen=True) +class EntryCategoryID(SharedCategoryID): + """Entry context-specific wrapper for category identifier""" + + pass diff --git a/app/context/entry/domain/value_objects/entry_date.py b/app/context/entry/domain/value_objects/entry_date.py new file mode 100644 index 0000000..255a4b3 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_date.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass, field +from datetime import datetime + +from app.shared.domain.value_objects import SharedDateTime + + +@dataclass(frozen=True) +class EntryDate(SharedDateTime): + """Value object for entry date (when the financial transaction occurred)""" + + value: datetime + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + """Validate that entry_date is timezone-aware""" + if not self._validated: + if not isinstance(self.value, datetime): + raise ValueError("EntryDate must be a datetime object") + super().__post_init__() diff --git a/app/context/entry/domain/value_objects/entry_description.py b/app/context/entry/domain/value_objects/entry_description.py new file mode 100644 index 0000000..a6a39a5 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_description.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class EntryDescription: + """Value object for entry description""" + + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and not isinstance(self.value, str): + raise ValueError(f"EntryDescription must be a string, got {type(self.value)}") + if not self._validated and len(self.value) > 500: + raise ValueError(f"EntryDescription cannot exceed 500 characters, got {len(self.value)}") + + @classmethod + def from_trusted_source(cls, value: str) -> "EntryDescription": + """Create EntryDescription from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/entry/domain/value_objects/entry_household_id.py b/app/context/entry/domain/value_objects/entry_household_id.py new file mode 100644 index 0000000..0e316c9 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_household_id.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_household_id import SharedHouseholdID + + +@dataclass(frozen=True) +class EntryHouseholdID(SharedHouseholdID): + """Entry context-specific wrapper for household identifier""" + + pass diff --git a/app/context/entry/domain/value_objects/entry_id.py b/app/context/entry/domain/value_objects/entry_id.py new file mode 100644 index 0000000..04036d7 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_id.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_entry_id import SharedEntryID + + +@dataclass(frozen=True) +class EntryID(SharedEntryID): + """Entry context-specific wrapper for entry identifier""" + + pass diff --git a/app/context/entry/domain/value_objects/entry_month.py b/app/context/entry/domain/value_objects/entry_month.py new file mode 100644 index 0000000..fcefa43 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_month.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedMonth + + +@dataclass(frozen=True) +class EntryMonth(SharedMonth): + pass diff --git a/app/context/entry/domain/value_objects/entry_type.py b/app/context/entry/domain/value_objects/entry_type.py new file mode 100644 index 0000000..89699e1 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_type.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_entry_type import SharedEntryType + + +@dataclass(frozen=True) +class EntryType(SharedEntryType): + pass diff --git a/app/context/entry/domain/value_objects/entry_user_id.py b/app/context/entry/domain/value_objects/entry_user_id.py new file mode 100644 index 0000000..9e41b3d --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_user_id.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_user_id import SharedUserID + + +@dataclass(frozen=True) +class EntryUserID(SharedUserID): + """Entry context-specific wrapper for user identifier""" + + pass diff --git a/app/context/entry/domain/value_objects/entry_year.py b/app/context/entry/domain/value_objects/entry_year.py new file mode 100644 index 0000000..61a65a5 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_year.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedYear + + +@dataclass(frozen=True) +class EntryYear(SharedYear): + pass diff --git a/app/context/entry/infrastructure/dependencies.py b/app/context/entry/infrastructure/dependencies.py new file mode 100644 index 0000000..119dfde --- /dev/null +++ b/app/context/entry/infrastructure/dependencies.py @@ -0,0 +1,102 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.entry.application.contracts import ( + CreateEntryHandlerContract, + DeleteEntryHandlerContract, + FindEntriesByAccountMonthHandlerContract, + FindEntryByIdHandlerContract, + UpdateEntryHandlerContract, +) +from app.context.entry.domain.contracts import ( + CreateEntryServiceContract, + EntryRepositoryContract, + UpdateEntryServiceContract, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.database import get_db +from app.shared.infrastructure.dependencies import get_logger + + +# Repository +def get_entry_repository( + db: Annotated[AsyncSession, Depends(get_db)], +) -> EntryRepositoryContract: + """Get entry repository""" + from app.context.entry.infrastructure.repositories import EntryRepository + + return EntryRepository(db) + + +# Services +def get_create_entry_service( + repository: Annotated[EntryRepositoryContract, Depends(get_entry_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateEntryServiceContract: + """Get create entry service""" + from app.context.entry.domain.services import CreateEntryService + + return CreateEntryService(repository, logger) + + +def get_update_entry_service( + repository: Annotated[EntryRepositoryContract, Depends(get_entry_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateEntryServiceContract: + """Get update entry service""" + from app.context.entry.domain.services import UpdateEntryService + + return UpdateEntryService(repository, logger) + + +# Handlers +def get_create_entry_handler( + service: Annotated[CreateEntryServiceContract, Depends(get_create_entry_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateEntryHandlerContract: + """Get create entry handler""" + from app.context.entry.application.handlers import CreateEntryHandler + + return CreateEntryHandler(service, logger) + + +def get_find_entry_by_id_handler( + repository: Annotated[EntryRepositoryContract, Depends(get_entry_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> FindEntryByIdHandlerContract: + """Get find entry by ID handler""" + from app.context.entry.application.handlers import FindEntryByIdHandler + + return FindEntryByIdHandler(repository, logger) + + +def get_find_entries_by_account_month_handler( + repository: Annotated[EntryRepositoryContract, Depends(get_entry_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> FindEntriesByAccountMonthHandlerContract: + """Get find entries by account and month handler""" + from app.context.entry.application.handlers import FindEntriesByAccountMonthHandler + + return FindEntriesByAccountMonthHandler(repository, logger) + + +def get_update_entry_handler( + service: Annotated[UpdateEntryServiceContract, Depends(get_update_entry_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateEntryHandlerContract: + """Get update entry handler""" + from app.context.entry.application.handlers import UpdateEntryHandler + + return UpdateEntryHandler(service, logger) + + +def get_delete_entry_handler( + repository: Annotated[EntryRepositoryContract, Depends(get_entry_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> DeleteEntryHandlerContract: + """Get delete entry handler""" + from app.context.entry.application.handlers import DeleteEntryHandler + + return DeleteEntryHandler(repository, logger) diff --git a/app/context/entry/infrastructure/mappers/__init__.py b/app/context/entry/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..5e05f0c --- /dev/null +++ b/app/context/entry/infrastructure/mappers/__init__.py @@ -0,0 +1,3 @@ +from .entry_mapper import EntryMapper + +__all__ = ["EntryMapper"] diff --git a/app/context/entry/infrastructure/mappers/entry_mapper.py b/app/context/entry/infrastructure/mappers/entry_mapper.py new file mode 100644 index 0000000..153be41 --- /dev/null +++ b/app/context/entry/infrastructure/mappers/entry_mapper.py @@ -0,0 +1,62 @@ +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import EntryMapperError +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.context.entry.infrastructure.models import EntryModel + + +class EntryMapper: + """Mapper for converting between EntryModel and EntryDTO""" + + @staticmethod + def to_dto(model: EntryModel | None) -> EntryDTO | None: + """Convert database model to domain DTO""" + return ( + EntryDTO( + entry_id=EntryID.from_trusted_source(model.id), + user_id=EntryUserID.from_trusted_source(model.user_id), + account_id=EntryAccountID.from_trusted_source(model.account_id), + category_id=EntryCategoryID.from_trusted_source(model.category_id), + entry_type=EntryType.from_trusted_source(model.entry_type), + entry_date=EntryDate.from_trusted_source(model.entry_date), + amount=EntryAmount.from_trusted_source(model.amount), + description=EntryDescription.from_trusted_source(model.description), + household_id=( + EntryHouseholdID.from_trusted_source(model.household_id) if model.household_id is not None else None + ), + ) + if model + else None + ) + + @staticmethod + def to_dto_or_fail(model: EntryModel) -> EntryDTO: + """Convert model to DTO or raise exception""" + dto = EntryMapper.to_dto(model) + if dto is None: + raise EntryMapperError("Entry DTO cannot be null") + return dto + + @staticmethod + def to_model(dto: EntryDTO) -> EntryModel: + """Convert domain DTO to database model""" + return EntryModel( + id=dto.entry_id.value if dto.entry_id is not None else None, + user_id=dto.user_id.value, + account_id=dto.account_id.value, + category_id=dto.category_id.value, + entry_type=dto.entry_type.value, + entry_date=dto.entry_date.value, + amount=dto.amount.value, + description=dto.description.value, + household_id=dto.household_id.value if dto.household_id is not None else None, + ) diff --git a/app/context/entry/infrastructure/models/__init__.py b/app/context/entry/infrastructure/models/__init__.py new file mode 100644 index 0000000..d78ef8a --- /dev/null +++ b/app/context/entry/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .entry_model import EntryModel + +__all__ = ["EntryModel"] diff --git a/app/context/entry/infrastructure/models/entry_model.py b/app/context/entry/infrastructure/models/entry_model.py new file mode 100644 index 0000000..a1e5ae2 --- /dev/null +++ b/app/context/entry/infrastructure/models/entry_model.py @@ -0,0 +1,43 @@ +from datetime import UTC, datetime +from decimal import Decimal + +from sqlalchemy import DECIMAL, BigInteger, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class EntryModel(BaseDBModel): + __tablename__ = "entries" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + account_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("user_accounts.id", ondelete="CASCADE"), + nullable=False, + ) + category_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("categories.id", ondelete="RESTRICT"), + nullable=False, + ) + entry_type: Mapped[str] = mapped_column(String(20), nullable=False) + entry_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + amount: Mapped[Decimal] = mapped_column(DECIMAL(15, 2), nullable=False) + description: Mapped[str] = mapped_column(String(500), nullable=False) + household_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("households.id", ondelete="SET NULL"), + nullable=True, + default=None, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) diff --git a/app/context/entry/infrastructure/repositories/__init__.py b/app/context/entry/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..a0e122b --- /dev/null +++ b/app/context/entry/infrastructure/repositories/__init__.py @@ -0,0 +1,3 @@ +from .entry_repository import EntryRepository + +__all__ = ["EntryRepository"] diff --git a/app/context/entry/infrastructure/repositories/entry_repository.py b/app/context/entry/infrastructure/repositories/entry_repository.py new file mode 100644 index 0000000..6b62887 --- /dev/null +++ b/app/context/entry/infrastructure/repositories/entry_repository.py @@ -0,0 +1,146 @@ +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.entry.domain.contracts.infrastructure import EntryRepositoryContract +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import EntryNotFoundError +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryCategoryID, + EntryDate, + EntryID, + EntryMonth, + EntryUserID, + EntryYear, +) +from app.context.entry.infrastructure.mappers import EntryMapper +from app.context.entry.infrastructure.models import EntryModel +from app.context.user_account.infrastructure.models.user_account_model import ( + UserAccountModel, +) + + +class EntryRepository(EntryRepositoryContract): + """Repository for entry entity""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def save_entry(self, entry: EntryDTO) -> EntryDTO: + """Create a new entry""" + model = EntryMapper.to_model(entry) + self._db.add(model) + await self._db.commit() + await self._db.refresh(model) + return EntryMapper.to_dto_or_fail(model) + + async def find_entry_by_id( + self, + entry_id: EntryID, + user_id: EntryUserID, + ) -> EntryDTO | None: + """Find entry by ID with user security check""" + stmt = select(EntryModel).where( + EntryModel.id == entry_id.value, + EntryModel.user_id == user_id.value, + ) + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + return EntryMapper.to_dto(model) + + async def find_entries_by_account_and_month( + self, + user_id: EntryUserID, + account_id: EntryAccountID, + month: EntryMonth, + year: EntryYear, + last_entry_date: EntryDate | None = None, + ) -> list[EntryDTO]: + """Find all entries for account in specific month/year""" + + stmt = ( + select(EntryModel) + .where( + EntryModel.user_id == user_id.value, + EntryModel.account_id == account_id.value, + EntryModel.entry_date >= EntryDate.start_of_month(month, year).value, + EntryModel.entry_date < EntryDate.start_of_next_month(month, year).value, + ) + .order_by(EntryModel.entry_date.desc()) + .limit(20) + ) + if last_entry_date: + stmt = stmt.where(EntryModel.entry_date < last_entry_date.value) + + result = await self._db.execute(stmt) + models = result.scalars().all() + return [EntryMapper.to_dto_or_fail(model) for model in models] + + async def update_entry(self, entry: EntryDTO) -> EntryDTO: + """Update an existing entry""" + if entry.entry_id is None: + raise ValueError("Entry ID is required for update") + + # Get existing entry + existing = await self.find_entry_by_id(entry.entry_id, entry.user_id) + if not existing: + raise EntryNotFoundError(f"Entry with ID {entry.entry_id.value} not found") + + # Convert DTO to model and update + model = EntryMapper.to_model(entry) + await self._db.merge(model) + await self._db.commit() + + # Re-fetch to get updated values + updated = await self.find_entry_by_id(entry.entry_id, entry.user_id) + if not updated: + raise EntryNotFoundError(f"Entry with ID {entry.entry_id.value} not found after update") + + return updated + + async def delete_entry( + self, + entry_id: EntryID, + user_id: EntryUserID, + ) -> bool: + """Hard delete an entry""" + stmt = delete(EntryModel).where( + EntryModel.id == entry_id.value, + EntryModel.user_id == user_id.value, + ) + result = await self._db.execute(stmt) + await self._db.commit() + return result.rowcount > 0 + + async def verify_account_belongs_to_user( + self, + account_id: EntryAccountID, + user_id: EntryUserID, + ) -> bool: + """Verify account belongs to user""" + stmt = select(UserAccountModel).where( + UserAccountModel.id == account_id.value, + UserAccountModel.user_id == user_id.value, + UserAccountModel.deleted_at.is_(None), + ) + result = await self._db.execute(stmt) + return result.scalar_one_or_none() is not None + + async def verify_category_belongs_to_user( + self, + category_id: EntryCategoryID, + user_id: EntryUserID, + ) -> bool: + """Verify category belongs to user""" + # Import here to avoid circular dependency + from app.context.category.infrastructure.models.category_model import ( + CategoryModel, + ) + + stmt = select(CategoryModel).where( + CategoryModel.id == category_id.value, + CategoryModel.user_id == user_id.value, + CategoryModel.deleted_at.is_(None), + ) + result = await self._db.execute(stmt) + return result.scalar_one_or_none() is not None diff --git a/app/context/entry/interface/__init__.py b/app/context/entry/interface/__init__.py new file mode 100644 index 0000000..9118026 --- /dev/null +++ b/app/context/entry/interface/__init__.py @@ -0,0 +1 @@ +# Interface layer exports diff --git a/app/context/entry/interface/rest/__init__.py b/app/context/entry/interface/rest/__init__.py new file mode 100644 index 0000000..d71dd8e --- /dev/null +++ b/app/context/entry/interface/rest/__init__.py @@ -0,0 +1,3 @@ +from .routes import entry_routes + +__all__ = ["entry_routes"] diff --git a/app/context/entry/interface/rest/controllers/__init__.py b/app/context/entry/interface/rest/controllers/__init__.py new file mode 100644 index 0000000..017063b --- /dev/null +++ b/app/context/entry/interface/rest/controllers/__init__.py @@ -0,0 +1,7 @@ +# Controllers export their routers directly in routes.py +from .create_entry_controller import router as create_router +from .delete_entry_controller import router as delete_router +from .find_entry_controller import router as find_router +from .update_entry_controller import router as update_router + +__all__ = ["create_router", "find_router", "update_router", "delete_router"] diff --git a/app/context/entry/interface/rest/controllers/create_entry_controller.py b/app/context/entry/interface/rest/controllers/create_entry_controller.py new file mode 100644 index 0000000..68584d6 --- /dev/null +++ b/app/context/entry/interface/rest/controllers/create_entry_controller.py @@ -0,0 +1,79 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.entry.application.commands import CreateEntryCommand +from app.context.entry.application.contracts import CreateEntryHandlerContract +from app.context.entry.application.dto import CreateEntryErrorCode +from app.context.entry.infrastructure.dependencies import get_create_entry_handler +from app.context.entry.interface.schemas import CreateEntryRequest, CreateEntryResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/entries") + + +@router.post("", response_model=CreateEntryResponse, status_code=201) +async def create_entry( + request: CreateEntryRequest, + handler: Annotated[CreateEntryHandlerContract, Depends(get_create_entry_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Create a new entry""" + logger.info("Create entry request", user_id=user_id, account_id=request.account_id) + + command = CreateEntryCommand( + user_id=user_id, + account_id=request.account_id, + category_id=request.category_id, + entry_type=request.entry_type, + entry_date=request.entry_date, + amount=request.amount, + description=request.description, + household_id=request.household_id, + ) + + result = await handler.handle(command) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + CreateEntryErrorCode.ACCOUNT_NOT_BELONGS_TO_USER: 403, + CreateEntryErrorCode.CATEGORY_NOT_FOUND: 404, + CreateEntryErrorCode.CATEGORY_NOT_BELONGS_TO_USER: 403, + CreateEntryErrorCode.MAPPER_ERROR: 500, + CreateEntryErrorCode.UNEXPECTED_ERROR: 500, + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 403: + logger.warning("Entry creation failed - forbidden", user_id=user_id, error=result.error_message) + elif status_code == 404: + logger.warning("Entry creation failed - not found", user_id=user_id, error=result.error_message) + else: + logger.error("Entry creation failed", user_id=user_id, error=result.error_message) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + assert result.entry_id + assert result.account_id + assert result.category_id + assert result.entry_type + assert result.entry_date + assert result.amount + assert result.description + + logger.info("Entry created successfully", user_id=user_id, entry_id=result.entry_id) + + return CreateEntryResponse( + entry_id=result.entry_id, + account_id=result.account_id, + category_id=result.category_id, + entry_type=result.entry_type, + entry_date=result.entry_date, + amount=result.amount, + description=result.description, + ) diff --git a/app/context/entry/interface/rest/controllers/delete_entry_controller.py b/app/context/entry/interface/rest/controllers/delete_entry_controller.py new file mode 100644 index 0000000..3ced140 --- /dev/null +++ b/app/context/entry/interface/rest/controllers/delete_entry_controller.py @@ -0,0 +1,46 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.entry.application.commands import DeleteEntryCommand +from app.context.entry.application.contracts import DeleteEntryHandlerContract +from app.context.entry.application.dto import DeleteEntryErrorCode +from app.context.entry.infrastructure.dependencies import get_delete_entry_handler +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/entries") + + +@router.delete("/{entry_id}", status_code=204) +async def delete_entry( + entry_id: int, + handler: Annotated[DeleteEntryHandlerContract, Depends(get_delete_entry_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Delete an entry (hard delete)""" + logger.info("Delete entry request", user_id=user_id, entry_id=entry_id) + + command = DeleteEntryCommand(entry_id=entry_id, user_id=user_id) + result = await handler.handle(command) + + if result.error_code: + status_code_map = { + DeleteEntryErrorCode.NOT_FOUND: 404, + DeleteEntryErrorCode.UNEXPECTED_ERROR: 500, + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 404: + logger.warning("Entry deletion failed - not found", user_id=user_id, entry_id=entry_id) + else: + logger.error("Entry deletion failed", user_id=user_id, entry_id=entry_id, error=result.error_message) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return 204 No Content on success + logger.info("Entry deleted successfully", user_id=user_id, entry_id=entry_id) + return diff --git a/app/context/entry/interface/rest/controllers/find_entry_controller.py b/app/context/entry/interface/rest/controllers/find_entry_controller.py new file mode 100644 index 0000000..b8868a5 --- /dev/null +++ b/app/context/entry/interface/rest/controllers/find_entry_controller.py @@ -0,0 +1,113 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.context.entry.application.contracts import ( + FindEntriesByAccountMonthHandlerContract, + FindEntryByIdHandlerContract, +) +from app.context.entry.application.dto import ( + FindMultipleEntriesErrorCode, + FindSingleEntryErrorCode, +) +from app.context.entry.application.queries import ( + FindEntriesByAccountMonthQuery, + FindEntryByIdQuery, +) +from app.context.entry.infrastructure.dependencies import ( + get_find_entries_by_account_month_handler, + get_find_entry_by_id_handler, +) +from app.context.entry.interface.schemas import EntryResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/entries") + + +@router.get("/{entry_id}", response_model=EntryResponse) +async def get_entry( + entry_id: int, + handler: Annotated[FindEntryByIdHandlerContract, Depends(get_find_entry_by_id_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Get a specific entry by ID""" + query = FindEntryByIdQuery(entry_id=entry_id, user_id=user_id) + result = await handler.handle(query) + + if result.error_code: + status_code_map = { + FindSingleEntryErrorCode.NOT_FOUND: 404, + FindSingleEntryErrorCode.UNEXPECTED_ERROR: 500, + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code != 404: + logger.error("Get entry failed", user_id=user_id, entry_id=entry_id, error=result.error_message) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.entry: + logger.error("Get entry response missing entry data", user_id=user_id, entry_id=entry_id) + raise HTTPException(status_code=500, detail="Error in response data") + + return EntryResponse( + entry_id=result.entry.entry_id, + user_id=result.entry.user_id, + account_id=result.entry.account_id, + category_id=result.entry.category_id, + entry_type=result.entry.entry_type, + entry_date=result.entry.entry_date, + amount=result.entry.amount, + description=result.entry.description, + household_id=result.entry.household_id, + ) + + +@router.get("", response_model=list[EntryResponse]) +async def list_entries( + account_id: Annotated[int, Query(gt=0, description="Account ID (required)")], + month: Annotated[int, Query(ge=1, le=12, description="Month (1-12, required)")], + year: Annotated[int, Query(ge=1900, le=2100, description="Year (e.g., 2025, required)")], + handler: Annotated[FindEntriesByAccountMonthHandlerContract, Depends(get_find_entries_by_account_month_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Get all entries for a specific account in a given month/year""" + query = FindEntriesByAccountMonthQuery( + user_id=user_id, + account_id=account_id, + month=month, + year=year, + ) + result = await handler.handle(query) + + if result.error_code: + status_code_map = { + FindMultipleEntriesErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + logger.error("List entries failed", user_id=user_id, error=result.error_message) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return empty list if no entries (not an error) + if not result.entries: + return [] + + return [ + EntryResponse( + entry_id=e.entry_id, + user_id=e.user_id, + account_id=e.account_id, + category_id=e.category_id, + entry_type=e.entry_type, + entry_date=e.entry_date, + amount=e.amount, + description=e.description, + household_id=e.household_id, + ) + for e in result.entries + ] diff --git a/app/context/entry/interface/rest/controllers/update_entry_controller.py b/app/context/entry/interface/rest/controllers/update_entry_controller.py new file mode 100644 index 0000000..626bba3 --- /dev/null +++ b/app/context/entry/interface/rest/controllers/update_entry_controller.py @@ -0,0 +1,76 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.entry.application.commands import UpdateEntryCommand +from app.context.entry.application.contracts import UpdateEntryHandlerContract +from app.context.entry.application.dto import UpdateEntryErrorCode +from app.context.entry.infrastructure.dependencies import get_update_entry_handler +from app.context.entry.interface.schemas import UpdateEntryRequest, UpdateEntryResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/entries") + + +@router.put("/{entry_id}", response_model=UpdateEntryResponse) +async def update_entry( + entry_id: int, + request: UpdateEntryRequest, + handler: Annotated[UpdateEntryHandlerContract, Depends(get_update_entry_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Update an entry (full update - all fields required)""" + logger.info("Update entry request", user_id=user_id, entry_id=entry_id) + + command = UpdateEntryCommand( + entry_id=entry_id, + user_id=user_id, + account_id=request.account_id, + category_id=request.category_id, + entry_type=request.entry_type, + entry_date=request.entry_date, + amount=request.amount, + description=request.description, + ) + + result = await handler.handle(command) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + UpdateEntryErrorCode.NOT_FOUND: 404, + UpdateEntryErrorCode.ACCOUNT_NOT_BELONGS_TO_USER: 403, + UpdateEntryErrorCode.CATEGORY_NOT_FOUND: 404, + UpdateEntryErrorCode.CATEGORY_NOT_BELONGS_TO_USER: 403, + UpdateEntryErrorCode.MAPPER_ERROR: 500, + UpdateEntryErrorCode.UNEXPECTED_ERROR: 500, + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 404: + logger.warning("Entry update failed - not found", user_id=user_id, entry_id=entry_id) + elif status_code == 403: + logger.warning("Entry update failed - forbidden", user_id=user_id, entry_id=entry_id) + else: + logger.error("Entry update failed", user_id=user_id, entry_id=entry_id, error=result.error_message) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.entry: + raise HTTPException(status_code=500, detail="Unexpected error on response values") + + logger.info("Entry updated successfully", user_id=user_id, entry_id=entry_id) + + return UpdateEntryResponse( + entry_id=result.entry.entry_id, + account_id=result.entry.account_id, + category_id=result.entry.category_id, + entry_type=result.entry.entry_type, + entry_date=result.entry.entry_date, + amount=result.entry.amount, + description=result.entry.description, + ) diff --git a/app/context/entry/interface/rest/routes.py b/app/context/entry/interface/rest/routes.py new file mode 100644 index 0000000..cd8bf86 --- /dev/null +++ b/app/context/entry/interface/rest/routes.py @@ -0,0 +1,15 @@ +from fastapi import APIRouter + +from app.context.entry.interface.rest.controllers import ( + create_router, + delete_router, + find_router, + update_router, +) + +entry_routes = APIRouter(prefix="/api/entries", tags=["entries"]) + +entry_routes.include_router(create_router) +entry_routes.include_router(find_router) +entry_routes.include_router(update_router) +entry_routes.include_router(delete_router) diff --git a/app/context/entry/interface/schemas/__init__.py b/app/context/entry/interface/schemas/__init__.py new file mode 100644 index 0000000..37ca249 --- /dev/null +++ b/app/context/entry/interface/schemas/__init__.py @@ -0,0 +1,11 @@ +from .create_entry_schema import CreateEntryRequest, CreateEntryResponse +from .entry_response import EntryResponse +from .update_entry_schema import UpdateEntryRequest, UpdateEntryResponse + +__all__ = [ + "CreateEntryRequest", + "CreateEntryResponse", + "EntryResponse", + "UpdateEntryRequest", + "UpdateEntryResponse", +] diff --git a/app/context/entry/interface/schemas/create_entry_schema.py b/app/context/entry/interface/schemas/create_entry_schema.py new file mode 100644 index 0000000..a462b24 --- /dev/null +++ b/app/context/entry/interface/schemas/create_entry_schema.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class CreateEntryRequest(BaseModel): + """Request schema for creating an entry""" + + model_config = ConfigDict(frozen=True) + + account_id: int = Field(..., gt=0, description="Account ID") + category_id: int = Field(..., gt=0, description="Category ID") + entry_type: str = Field(..., pattern="^(income|expense)$", description="Entry type (income or expense)") + entry_date: datetime = Field(..., description="Entry date (timezone-aware)") + amount: float = Field(..., ge=0, description="Amount (non-negative)") + description: str = Field(..., max_length=500, description="Entry description") + household_id: int | None = Field(None, gt=0, description="Optional household ID") + + +@dataclass(frozen=True) +class CreateEntryResponse: + """Response schema for creating an entry""" + + entry_id: int + account_id: int + category_id: int + entry_type: str + entry_date: str # ISO format + amount: float + description: str diff --git a/app/context/entry/interface/schemas/entry_response.py b/app/context/entry/interface/schemas/entry_response.py new file mode 100644 index 0000000..2ee9acf --- /dev/null +++ b/app/context/entry/interface/schemas/entry_response.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class EntryResponse: + """Response schema for entry data""" + + entry_id: int + user_id: int + account_id: int + category_id: int + entry_type: str + entry_date: str # ISO format + amount: float + description: str + household_id: int | None = None diff --git a/app/context/entry/interface/schemas/update_entry_schema.py b/app/context/entry/interface/schemas/update_entry_schema.py new file mode 100644 index 0000000..6ec5446 --- /dev/null +++ b/app/context/entry/interface/schemas/update_entry_schema.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class UpdateEntryRequest(BaseModel): + """Request schema for updating an entry (full update - all fields required)""" + + model_config = ConfigDict(frozen=True) + + account_id: int = Field(..., gt=0, description="Account ID") + category_id: int = Field(..., gt=0, description="Category ID") + entry_type: str = Field(..., pattern="^(income|expense)$", description="Entry type (income or expense)") + entry_date: datetime = Field(..., description="Entry date (timezone-aware)") + amount: float = Field(..., ge=0, description="Amount (non-negative)") + description: str = Field(..., max_length=500, description="Entry description") + + +@dataclass(frozen=True) +class UpdateEntryResponse: + """Response schema for updating an entry""" + + entry_id: int + account_id: int + category_id: int + entry_type: str + entry_date: str # ISO format + amount: float + description: str diff --git a/app/context/household/__init__.py b/app/context/household/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/household/application/__init__.py b/app/context/household/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/household/application/commands/__init__.py b/app/context/household/application/commands/__init__.py new file mode 100644 index 0000000..91c9398 --- /dev/null +++ b/app/context/household/application/commands/__init__.py @@ -0,0 +1,17 @@ +from .accept_invite_command import AcceptInviteCommand +from .create_household_command import CreateHouseholdCommand +from .decline_invite_command import DeclineInviteCommand +from .delete_household_command import DeleteHouseholdCommand +from .invite_user_command import InviteUserCommand +from .remove_member_command import RemoveMemberCommand +from .update_household_command import UpdateHouseholdCommand + +__all__ = [ + "CreateHouseholdCommand", + "InviteUserCommand", + "AcceptInviteCommand", + "DeclineInviteCommand", + "RemoveMemberCommand", + "UpdateHouseholdCommand", + "DeleteHouseholdCommand", +] diff --git a/app/context/household/application/commands/accept_invite_command.py b/app/context/household/application/commands/accept_invite_command.py new file mode 100644 index 0000000..92cd0aa --- /dev/null +++ b/app/context/household/application/commands/accept_invite_command.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AcceptInviteCommand: + """Command to accept a household invitation""" + + user_id: int + household_id: int diff --git a/app/context/household/application/commands/create_household_command.py b/app/context/household/application/commands/create_household_command.py new file mode 100644 index 0000000..86a6aa5 --- /dev/null +++ b/app/context/household/application/commands/create_household_command.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CreateHouseholdCommand: + user_id: int + name: str diff --git a/app/context/household/application/commands/decline_invite_command.py b/app/context/household/application/commands/decline_invite_command.py new file mode 100644 index 0000000..9b917ee --- /dev/null +++ b/app/context/household/application/commands/decline_invite_command.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeclineInviteCommand: + """Command to decline a household invitation""" + + user_id: int + household_id: int diff --git a/app/context/household/application/commands/delete_household_command.py b/app/context/household/application/commands/delete_household_command.py new file mode 100644 index 0000000..0fdf353 --- /dev/null +++ b/app/context/household/application/commands/delete_household_command.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeleteHouseholdCommand: + """Command to delete household (soft delete)""" + + household_id: int + user_id: int diff --git a/app/context/household/application/commands/invite_user_command.py b/app/context/household/application/commands/invite_user_command.py new file mode 100644 index 0000000..f71df06 --- /dev/null +++ b/app/context/household/application/commands/invite_user_command.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class InviteUserCommand: + """Command to invite a user to a household""" + + inviter_user_id: int + household_id: int + invitee_user_id: int + role: str diff --git a/app/context/household/application/commands/remove_member_command.py b/app/context/household/application/commands/remove_member_command.py new file mode 100644 index 0000000..d429224 --- /dev/null +++ b/app/context/household/application/commands/remove_member_command.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RemoveMemberCommand: + """Command to remove a member from a household""" + + remover_user_id: int + household_id: int + member_user_id: int diff --git a/app/context/household/application/commands/update_household_command.py b/app/context/household/application/commands/update_household_command.py new file mode 100644 index 0000000..cdf7794 --- /dev/null +++ b/app/context/household/application/commands/update_household_command.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UpdateHouseholdCommand: + """Command to update household""" + + household_id: int + user_id: int + name: str diff --git a/app/context/household/application/contracts/__init__.py b/app/context/household/application/contracts/__init__.py new file mode 100644 index 0000000..937d367 --- /dev/null +++ b/app/context/household/application/contracts/__init__.py @@ -0,0 +1,29 @@ +from .accept_invite_handler_contract import AcceptInviteHandlerContract +from .create_household_handler_contract import CreateHouseholdHandlerContract +from .decline_invite_handler_contract import DeclineInviteHandlerContract +from .delete_household_handler_contract import DeleteHouseholdHandlerContract +from .get_household_handler_contract import GetHouseholdHandlerContract +from .invite_user_handler_contract import InviteUserHandlerContract +from .list_household_invites_handler_contract import ( + ListHouseholdInvitesHandlerContract, +) +from .list_user_households_handler_contract import ListUserHouseholdsHandlerContract +from .list_user_pending_invites_handler_contract import ( + ListUserPendingInvitesHandlerContract, +) +from .remove_member_handler_contract import RemoveMemberHandlerContract +from .update_household_handler_contract import UpdateHouseholdHandlerContract + +__all__ = [ + "CreateHouseholdHandlerContract", + "InviteUserHandlerContract", + "AcceptInviteHandlerContract", + "DeclineInviteHandlerContract", + "RemoveMemberHandlerContract", + "GetHouseholdHandlerContract", + "ListHouseholdInvitesHandlerContract", + "ListUserHouseholdsHandlerContract", + "ListUserPendingInvitesHandlerContract", + "UpdateHouseholdHandlerContract", + "DeleteHouseholdHandlerContract", +] diff --git a/app/context/household/application/contracts/accept_invite_handler_contract.py b/app/context/household/application/contracts/accept_invite_handler_contract.py new file mode 100644 index 0000000..b012de7 --- /dev/null +++ b/app/context/household/application/contracts/accept_invite_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.commands import AcceptInviteCommand +from app.context.household.application.dto import AcceptInviteResult + + +class AcceptInviteHandlerContract(ABC): + """Contract for accept invite command handler""" + + @abstractmethod + async def handle(self, command: AcceptInviteCommand) -> AcceptInviteResult: + """Execute the accept invite command""" + pass diff --git a/app/context/household/application/contracts/create_household_handler_contract.py b/app/context/household/application/contracts/create_household_handler_contract.py new file mode 100644 index 0000000..5b1b623 --- /dev/null +++ b/app/context/household/application/contracts/create_household_handler_contract.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.commands import CreateHouseholdCommand +from app.context.household.application.dto import CreateHouseholdResult + + +class CreateHouseholdHandlerContract(ABC): + @abstractmethod + async def handle(self, command: CreateHouseholdCommand) -> CreateHouseholdResult: + """Execute the create household command""" + pass diff --git a/app/context/household/application/contracts/decline_invite_handler_contract.py b/app/context/household/application/contracts/decline_invite_handler_contract.py new file mode 100644 index 0000000..687441c --- /dev/null +++ b/app/context/household/application/contracts/decline_invite_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.commands import DeclineInviteCommand +from app.context.household.application.dto import DeclineInviteResult + + +class DeclineInviteHandlerContract(ABC): + """Contract for decline invite command handler""" + + @abstractmethod + async def handle(self, command: DeclineInviteCommand) -> DeclineInviteResult: + """Execute the decline invite command""" + pass diff --git a/app/context/household/application/contracts/delete_household_handler_contract.py b/app/context/household/application/contracts/delete_household_handler_contract.py new file mode 100644 index 0000000..d94f8e9 --- /dev/null +++ b/app/context/household/application/contracts/delete_household_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.commands import DeleteHouseholdCommand +from app.context.household.application.dto import DeleteHouseholdResult + + +class DeleteHouseholdHandlerContract(ABC): + """Contract for delete household command handler""" + + @abstractmethod + async def handle(self, command: DeleteHouseholdCommand) -> DeleteHouseholdResult: + """Execute the delete household command""" + pass diff --git a/app/context/household/application/contracts/get_household_handler_contract.py b/app/context/household/application/contracts/get_household_handler_contract.py new file mode 100644 index 0000000..88c4190 --- /dev/null +++ b/app/context/household/application/contracts/get_household_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.dto import GetHouseholdResult +from app.context.household.application.queries import GetHouseholdQuery + + +class GetHouseholdHandlerContract(ABC): + """Contract for get household query handler""" + + @abstractmethod + async def handle(self, query: GetHouseholdQuery) -> GetHouseholdResult: + """Execute the get household query""" + pass diff --git a/app/context/household/application/contracts/invite_user_handler_contract.py b/app/context/household/application/contracts/invite_user_handler_contract.py new file mode 100644 index 0000000..c63cd0a --- /dev/null +++ b/app/context/household/application/contracts/invite_user_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.commands import InviteUserCommand +from app.context.household.application.dto import InviteUserResult + + +class InviteUserHandlerContract(ABC): + """Contract for invite user command handler""" + + @abstractmethod + async def handle(self, command: InviteUserCommand) -> InviteUserResult: + """Execute the invite user command""" + pass diff --git a/app/context/household/application/contracts/list_household_invites_handler_contract.py b/app/context/household/application/contracts/list_household_invites_handler_contract.py new file mode 100644 index 0000000..2947e64 --- /dev/null +++ b/app/context/household/application/contracts/list_household_invites_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.dto import HouseholdMemberResponseDTO +from app.context.household.application.queries import ListHouseholdInvitesQuery + + +class ListHouseholdInvitesHandlerContract(ABC): + """Contract for list household invites query handler""" + + @abstractmethod + async def handle(self, query: ListHouseholdInvitesQuery) -> list[HouseholdMemberResponseDTO]: + """Execute the list household invites query""" + pass diff --git a/app/context/household/application/contracts/list_user_households_handler_contract.py b/app/context/household/application/contracts/list_user_households_handler_contract.py new file mode 100644 index 0000000..320d3f4 --- /dev/null +++ b/app/context/household/application/contracts/list_user_households_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.dto import ListUserHouseholdsResult +from app.context.household.application.queries import ListUserHouseholdsQuery + + +class ListUserHouseholdsHandlerContract(ABC): + """Contract for list user households query handler""" + + @abstractmethod + async def handle(self, query: ListUserHouseholdsQuery) -> ListUserHouseholdsResult: + """Execute the list user households query""" + pass diff --git a/app/context/household/application/contracts/list_user_pending_invites_handler_contract.py b/app/context/household/application/contracts/list_user_pending_invites_handler_contract.py new file mode 100644 index 0000000..7866181 --- /dev/null +++ b/app/context/household/application/contracts/list_user_pending_invites_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.dto import HouseholdMemberResponseDTO +from app.context.household.application.queries import ListUserPendingInvitesQuery + + +class ListUserPendingInvitesHandlerContract(ABC): + """Contract for list user pending invites query handler""" + + @abstractmethod + async def handle(self, query: ListUserPendingInvitesQuery) -> list[HouseholdMemberResponseDTO]: + """Execute the list user pending invites query""" + pass diff --git a/app/context/household/application/contracts/remove_member_handler_contract.py b/app/context/household/application/contracts/remove_member_handler_contract.py new file mode 100644 index 0000000..4693388 --- /dev/null +++ b/app/context/household/application/contracts/remove_member_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.commands import RemoveMemberCommand +from app.context.household.application.dto import RemoveMemberResult + + +class RemoveMemberHandlerContract(ABC): + """Contract for remove member command handler""" + + @abstractmethod + async def handle(self, command: RemoveMemberCommand) -> RemoveMemberResult: + """Execute the remove member command""" + pass diff --git a/app/context/household/application/contracts/update_household_handler_contract.py b/app/context/household/application/contracts/update_household_handler_contract.py new file mode 100644 index 0000000..f8fe482 --- /dev/null +++ b/app/context/household/application/contracts/update_household_handler_contract.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + +from app.context.household.application.commands import UpdateHouseholdCommand +from app.context.household.application.dto import UpdateHouseholdResult + + +class UpdateHouseholdHandlerContract(ABC): + """Contract for update household command handler""" + + @abstractmethod + async def handle(self, command: UpdateHouseholdCommand) -> UpdateHouseholdResult: + """Execute the update household command""" + pass diff --git a/app/context/household/application/dto/__init__.py b/app/context/household/application/dto/__init__.py new file mode 100644 index 0000000..73ffef6 --- /dev/null +++ b/app/context/household/application/dto/__init__.py @@ -0,0 +1,37 @@ +from .accept_invite_result import AcceptInviteErrorCode, AcceptInviteResult +from .create_household_result import CreateHouseholdErrorCode, CreateHouseholdResult +from .decline_invite_result import DeclineInviteErrorCode, DeclineInviteResult +from .delete_household_result import DeleteHouseholdErrorCode, DeleteHouseholdResult +from .get_household_result import GetHouseholdErrorCode, GetHouseholdResult +from .household_member_response_dto import HouseholdMemberResponseDTO +from .invite_user_result import InviteUserErrorCode, InviteUserResult +from .list_user_households_result import ( + HouseholdSummary, + ListUserHouseholdsErrorCode, + ListUserHouseholdsResult, +) +from .remove_member_result import RemoveMemberErrorCode, RemoveMemberResult +from .update_household_result import UpdateHouseholdErrorCode, UpdateHouseholdResult + +__all__ = [ + "CreateHouseholdErrorCode", + "CreateHouseholdResult", + "InviteUserErrorCode", + "InviteUserResult", + "AcceptInviteErrorCode", + "AcceptInviteResult", + "DeclineInviteErrorCode", + "DeclineInviteResult", + "RemoveMemberErrorCode", + "RemoveMemberResult", + "GetHouseholdErrorCode", + "GetHouseholdResult", + "ListUserHouseholdsErrorCode", + "ListUserHouseholdsResult", + "HouseholdSummary", + "UpdateHouseholdErrorCode", + "UpdateHouseholdResult", + "DeleteHouseholdErrorCode", + "DeleteHouseholdResult", + "HouseholdMemberResponseDTO", +] diff --git a/app/context/household/application/dto/accept_invite_result.py b/app/context/household/application/dto/accept_invite_result.py new file mode 100644 index 0000000..dfb6b9e --- /dev/null +++ b/app/context/household/application/dto/accept_invite_result.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from enum import Enum + + +class AcceptInviteErrorCode(str, Enum): + """Error codes for accepting invitation""" + + NOT_INVITED = "NOT_INVITED" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class AcceptInviteResult: + """Result of accept invitation operation""" + + # Success fields - populated when operation succeeds + member_id: int | None = None + household_id: int | None = None + user_id: int | None = None + role: str | None = None + + # Error fields - populated when operation fails + error_code: AcceptInviteErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/create_household_result.py b/app/context/household/application/dto/create_household_result.py new file mode 100644 index 0000000..525369f --- /dev/null +++ b/app/context/household/application/dto/create_household_result.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from enum import Enum + + +class CreateHouseholdErrorCode(str, Enum): + """Error codes for household creation""" + + NAME_ALREADY_EXISTS = "NAME_ALREADY_EXISTS" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class CreateHouseholdResult: + """Result of household creation operation""" + + # Success fields - populated when operation succeeds + household_id: int | None = None + household_name: str | None = None + + # Error fields - populated when operation fails + error_code: CreateHouseholdErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/decline_invite_result.py b/app/context/household/application/dto/decline_invite_result.py new file mode 100644 index 0000000..2354241 --- /dev/null +++ b/app/context/household/application/dto/decline_invite_result.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from enum import Enum + + +class DeclineInviteErrorCode(str, Enum): + """Error codes for declining invitation""" + + NOT_INVITED = "NOT_INVITED" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class DeclineInviteResult: + """Result of decline invitation operation""" + + # Success field - simple boolean for success case + success: bool = False + + # Error fields - populated when operation fails + error_code: DeclineInviteErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/delete_household_result.py b/app/context/household/application/dto/delete_household_result.py new file mode 100644 index 0000000..b8c2350 --- /dev/null +++ b/app/context/household/application/dto/delete_household_result.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from enum import Enum + + +class DeleteHouseholdErrorCode(str, Enum): + """Error codes for delete household operation""" + + NOT_FOUND = "NOT_FOUND" + NOT_OWNER = "NOT_OWNER" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class DeleteHouseholdResult: + """Result of delete household command""" + + success: bool = False + error_code: DeleteHouseholdErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/get_household_result.py b/app/context/household/application/dto/get_household_result.py new file mode 100644 index 0000000..ec1f53d --- /dev/null +++ b/app/context/household/application/dto/get_household_result.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from enum import Enum + + +class GetHouseholdErrorCode(str, Enum): + """Error codes for get household operation""" + + NOT_FOUND = "NOT_FOUND" + UNAUTHORIZED_ACCESS = "UNAUTHORIZED_ACCESS" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class GetHouseholdResult: + """Result of get household query""" + + # Success fields - populated when operation succeeds + household_id: int | None = None + household_name: str | None = None + owner_user_id: int | None = None + created_at: str | None = None + + # Error fields - populated when operation fails + error_code: GetHouseholdErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/household_member_response_dto.py b/app/context/household/application/dto/household_member_response_dto.py new file mode 100644 index 0000000..3bffa3f --- /dev/null +++ b/app/context/household/application/dto/household_member_response_dto.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from datetime import datetime + +from app.context.household.domain.dto import HouseholdMemberDTO + + +@dataclass(frozen=True) +class HouseholdMemberResponseDTO: + """Response DTO for household member information""" + + member_id: int + household_id: int + user_id: int + role: str + joined_at: datetime | None + invited_by_user_id: int | None + invited_at: datetime | None + household_name: str | None = None + inviter: str | None = None + + @staticmethod + def from_domain_dto(member_dto: HouseholdMemberDTO) -> "HouseholdMemberResponseDTO": + """Convert domain DTO to response DTO""" + return HouseholdMemberResponseDTO( + member_id=member_dto.member_id.value if member_dto.member_id else 0, + household_id=member_dto.household_id.value, + user_id=member_dto.user_id.value, + role=member_dto.role.value, + joined_at=member_dto.joined_at, + invited_by_user_id=(member_dto.invited_by_user_id.value if member_dto.invited_by_user_id else None), + invited_at=member_dto.invited_at, + household_name=member_dto.household_name.value if member_dto.household_name else None, + inviter=member_dto.inviter_username.value if member_dto.inviter_username else None, + ) diff --git a/app/context/household/application/dto/invite_user_result.py b/app/context/household/application/dto/invite_user_result.py new file mode 100644 index 0000000..1fe3374 --- /dev/null +++ b/app/context/household/application/dto/invite_user_result.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from enum import Enum + + +class InviteUserErrorCode(str, Enum): + """Error codes for user invitation""" + + ONLY_OWNER_CAN_INVITE = "ONLY_OWNER_CAN_INVITE" + ALREADY_ACTIVE_MEMBER = "ALREADY_ACTIVE_MEMBER" + ALREADY_INVITED = "ALREADY_INVITED" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class InviteUserResult: + """Result of user invitation operation""" + + # Success fields - populated when operation succeeds + member_id: int | None = None + household_id: int | None = None + user_id: int | None = None + role: str | None = None + + # Error fields - populated when operation fails + error_code: InviteUserErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/list_user_households_result.py b/app/context/household/application/dto/list_user_households_result.py new file mode 100644 index 0000000..cd595b7 --- /dev/null +++ b/app/context/household/application/dto/list_user_households_result.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from enum import Enum + + +@dataclass(frozen=True) +class HouseholdSummary: + """Summary of household for list response""" + + household_id: int + household_name: str + owner_user_id: int + created_at: str + + +class ListUserHouseholdsErrorCode(str, Enum): + """Error codes for list user households operation""" + + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class ListUserHouseholdsResult: + """Result of list user households query""" + + # Success field - populated when operation succeeds + households: list[HouseholdSummary] | None = None + + # Error fields - populated when operation fails + error_code: ListUserHouseholdsErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/remove_member_result.py b/app/context/household/application/dto/remove_member_result.py new file mode 100644 index 0000000..bbdf075 --- /dev/null +++ b/app/context/household/application/dto/remove_member_result.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from enum import Enum + + +class RemoveMemberErrorCode(str, Enum): + """Error codes for removing member""" + + ONLY_OWNER_CAN_REMOVE = "ONLY_OWNER_CAN_REMOVE" + CANNOT_REMOVE_SELF = "CANNOT_REMOVE_SELF" + MEMBER_NOT_FOUND = "MEMBER_NOT_FOUND" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class RemoveMemberResult: + """Result of remove member operation""" + + # Success field - simple boolean for success case + success: bool = False + + # Error fields - populated when operation fails + error_code: RemoveMemberErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/dto/update_household_result.py b/app/context/household/application/dto/update_household_result.py new file mode 100644 index 0000000..d90b646 --- /dev/null +++ b/app/context/household/application/dto/update_household_result.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from enum import Enum + + +class UpdateHouseholdErrorCode(str, Enum): + """Error codes for update household operation""" + + NOT_FOUND = "NOT_FOUND" + NOT_OWNER = "NOT_OWNER" + NAME_ALREADY_EXISTS = "NAME_ALREADY_EXISTS" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class UpdateHouseholdResult: + """Result of update household command""" + + # Success fields - populated when operation succeeds + household_id: int | None = None + household_name: str | None = None + owner_user_id: int | None = None + + # Error fields - populated when operation fails + error_code: UpdateHouseholdErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/application/handlers/__init__.py b/app/context/household/application/handlers/__init__.py new file mode 100644 index 0000000..9c261a2 --- /dev/null +++ b/app/context/household/application/handlers/__init__.py @@ -0,0 +1,25 @@ +from .accept_invite_handler import AcceptInviteHandler +from .create_household_handler import CreateHouseholdHandler +from .decline_invite_handler import DeclineInviteHandler +from .delete_household_handler import DeleteHouseholdHandler +from .get_household_handler import GetHouseholdHandler +from .invite_user_handler import InviteUserHandler +from .list_household_invites_handler import ListHouseholdInvitesHandler +from .list_user_households_handler import ListUserHouseholdsHandler +from .list_user_pending_invites_handler import ListUserPendingInvitesHandler +from .remove_member_handler import RemoveMemberHandler +from .update_household_handler import UpdateHouseholdHandler + +__all__ = [ + "CreateHouseholdHandler", + "InviteUserHandler", + "AcceptInviteHandler", + "DeclineInviteHandler", + "RemoveMemberHandler", + "GetHouseholdHandler", + "ListHouseholdInvitesHandler", + "ListUserHouseholdsHandler", + "ListUserPendingInvitesHandler", + "UpdateHouseholdHandler", + "DeleteHouseholdHandler", +] diff --git a/app/context/household/application/handlers/accept_invite_handler.py b/app/context/household/application/handlers/accept_invite_handler.py new file mode 100644 index 0000000..fe84dff --- /dev/null +++ b/app/context/household/application/handlers/accept_invite_handler.py @@ -0,0 +1,69 @@ +from app.context.household.application.commands import AcceptInviteCommand +from app.context.household.application.contracts import AcceptInviteHandlerContract +from app.context.household.application.dto import ( + AcceptInviteErrorCode, + AcceptInviteResult, +) +from app.context.household.domain.contracts import AcceptInviteServiceContract +from app.context.household.domain.exceptions import ( + HouseholdMapperError, + NotInvitedError, +) +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class AcceptInviteHandler(AcceptInviteHandlerContract): + """Handler for accept invite command""" + + def __init__(self, service: AcceptInviteServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: AcceptInviteCommand) -> AcceptInviteResult: + """Execute the accept invite command""" + + self._logger.debug("Handling accept invite command", user_id=command.user_id, household_id=command.household_id) + + try: + member_dto = await self._service.accept_invite( + user_id=HouseholdUserID(command.user_id), + household_id=HouseholdID(command.household_id), + ) + + if member_dto.member_id is None: + self._logger.error( + "Member ID is None after accepting invite", + user_id=command.user_id, + household_id=command.household_id, + ) + return AcceptInviteResult( + error_code=AcceptInviteErrorCode.UNEXPECTED_ERROR, + error_message="Error accepting invitation", + ) + + return AcceptInviteResult( + member_id=member_dto.member_id.value, + household_id=member_dto.household_id.value, + user_id=member_dto.user_id.value, + role=member_dto.role.value, + ) + + except NotInvitedError: + self._logger.debug("No pending invite found", user_id=command.user_id, household_id=command.household_id) + return AcceptInviteResult( + error_code=AcceptInviteErrorCode.NOT_INVITED, + error_message="No pending invite found for this household", + ) + except HouseholdMapperError: + self._logger.error("Mapper error accepting invite", household_id=command.household_id) + return AcceptInviteResult( + error_code=AcceptInviteErrorCode.MAPPER_ERROR, + error_message="Error mapping model to DTO", + ) + except Exception as e: + self._logger.error("Unexpected error accepting invite", household_id=command.household_id, error=str(e)) + return AcceptInviteResult( + error_code=AcceptInviteErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/create_household_handler.py b/app/context/household/application/handlers/create_household_handler.py new file mode 100644 index 0000000..65909ad --- /dev/null +++ b/app/context/household/application/handlers/create_household_handler.py @@ -0,0 +1,63 @@ +from app.context.household.application.commands import CreateHouseholdCommand +from app.context.household.application.contracts import CreateHouseholdHandlerContract +from app.context.household.application.dto import ( + CreateHouseholdErrorCode, + CreateHouseholdResult, +) +from app.context.household.domain.contracts import CreateHouseholdServiceContract +from app.context.household.domain.exceptions import ( + HouseholdMapperError, + HouseholdNameAlreadyExistError, +) +from app.context.household.domain.value_objects import HouseholdName, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class CreateHouseholdHandler(CreateHouseholdHandlerContract): + """Handler for create household command""" + + def __init__(self, service: CreateHouseholdServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: CreateHouseholdCommand) -> CreateHouseholdResult: + """Execute the create household command""" + + self._logger.debug("Handling create household command", user_id=command.user_id, name=command.name) + + try: + household_dto = await self._service.create_household( + name=HouseholdName(command.name), + creator_user_id=HouseholdUserID(command.user_id), + ) + + if household_dto.household_id is None: + self._logger.error("Household ID is None after creation", user_id=command.user_id) + return CreateHouseholdResult( + error_code=CreateHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Error creating household", + ) + + return CreateHouseholdResult( + household_id=household_dto.household_id.value, + household_name=household_dto.name.value, + ) + + except HouseholdNameAlreadyExistError: + self._logger.debug("Household name already exists", user_id=command.user_id, name=command.name) + return CreateHouseholdResult( + error_code=CreateHouseholdErrorCode.NAME_ALREADY_EXISTS, + error_message="Household name already exists", + ) + except HouseholdMapperError: + self._logger.error("Mapper error creating household", user_id=command.user_id) + return CreateHouseholdResult( + error_code=CreateHouseholdErrorCode.MAPPER_ERROR, + error_message="Error mapping model to DTO", + ) + except Exception as e: + self._logger.error("Unexpected error creating household", user_id=command.user_id, error=str(e)) + return CreateHouseholdResult( + error_code=CreateHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/decline_invite_handler.py b/app/context/household/application/handlers/decline_invite_handler.py new file mode 100644 index 0000000..d9a7f5f --- /dev/null +++ b/app/context/household/application/handlers/decline_invite_handler.py @@ -0,0 +1,46 @@ +from app.context.household.application.commands import DeclineInviteCommand +from app.context.household.application.contracts import DeclineInviteHandlerContract +from app.context.household.application.dto import ( + DeclineInviteErrorCode, + DeclineInviteResult, +) +from app.context.household.domain.contracts import DeclineInviteServiceContract +from app.context.household.domain.exceptions import NotInvitedError +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class DeclineInviteHandler(DeclineInviteHandlerContract): + """Handler for decline invite command""" + + def __init__(self, service: DeclineInviteServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: DeclineInviteCommand) -> DeclineInviteResult: + """Execute the decline invite command""" + + self._logger.debug( + "Handling decline invite command", user_id=command.user_id, household_id=command.household_id + ) + + try: + await self._service.decline_invite( + user_id=HouseholdUserID(command.user_id), + household_id=HouseholdID(command.household_id), + ) + + return DeclineInviteResult(success=True) + + except NotInvitedError: + self._logger.debug("No pending invite found", user_id=command.user_id, household_id=command.household_id) + return DeclineInviteResult( + error_code=DeclineInviteErrorCode.NOT_INVITED, + error_message="No pending invite found for this household", + ) + except Exception as e: + self._logger.error("Unexpected error declining invite", household_id=command.household_id, error=str(e)) + return DeclineInviteResult( + error_code=DeclineInviteErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/delete_household_handler.py b/app/context/household/application/handlers/delete_household_handler.py new file mode 100644 index 0000000..6a60a7b --- /dev/null +++ b/app/context/household/application/handlers/delete_household_handler.py @@ -0,0 +1,50 @@ +from app.context.household.application.commands import DeleteHouseholdCommand +from app.context.household.application.contracts import DeleteHouseholdHandlerContract +from app.context.household.application.dto import DeleteHouseholdErrorCode, DeleteHouseholdResult +from app.context.household.domain.contracts import HouseholdRepositoryContract +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class DeleteHouseholdHandler(DeleteHouseholdHandlerContract): + """Handler for delete household command""" + + def __init__(self, repository: HouseholdRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, command: DeleteHouseholdCommand) -> DeleteHouseholdResult: + """Execute the delete household command""" + + self._logger.debug( + "Handling delete household command", + household_id=command.household_id, + user_id=command.user_id, + ) + + try: + # Convert primitives to value objects + success = await self._repository.delete_household( + household_id=HouseholdID(command.household_id), + user_id=HouseholdUserID(command.user_id), + ) + + if not success: + self._logger.debug( + "Household not found or user not owner", + household_id=command.household_id, + user_id=command.user_id, + ) + return DeleteHouseholdResult( + error_code=DeleteHouseholdErrorCode.NOT_FOUND, + error_message="Household not found or you are not the owner", + ) + + return DeleteHouseholdResult(success=True) + + except Exception as e: + self._logger.error("Unexpected error deleting household", household_id=command.household_id, error=str(e)) + return DeleteHouseholdResult( + error_code=DeleteHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/get_household_handler.py b/app/context/household/application/handlers/get_household_handler.py new file mode 100644 index 0000000..5045cb9 --- /dev/null +++ b/app/context/household/application/handlers/get_household_handler.py @@ -0,0 +1,61 @@ +from app.context.household.application.contracts import GetHouseholdHandlerContract +from app.context.household.application.dto import GetHouseholdErrorCode, GetHouseholdResult +from app.context.household.application.queries import GetHouseholdQuery +from app.context.household.domain.contracts import HouseholdRepositoryContract +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class GetHouseholdHandler(GetHouseholdHandlerContract): + """Handler for get household query""" + + def __init__(self, repository: HouseholdRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: GetHouseholdQuery) -> GetHouseholdResult: + """Execute the get household query""" + + self._logger.debug("Handling get household query", household_id=query.household_id, user_id=query.user_id) + + try: + # Convert primitives to value objects + household_id = HouseholdID(query.household_id) + user_id = HouseholdUserID(query.user_id) + + # Check if user has access (owner or active member) + has_access = await self._repository.user_has_access(user_id, household_id) + if not has_access: + self._logger.debug( + "User does not have access to household", + household_id=query.household_id, + user_id=query.user_id, + ) + return GetHouseholdResult( + error_code=GetHouseholdErrorCode.UNAUTHORIZED_ACCESS, + error_message="You do not have access to this household", + ) + + # Find the household + household = await self._repository.find_household_by_id(household_id) + if not household: + self._logger.debug("Household not found", household_id=query.household_id) + return GetHouseholdResult( + error_code=GetHouseholdErrorCode.NOT_FOUND, + error_message="Household not found", + ) + + # Return success with primitives + return GetHouseholdResult( + household_id=household.household_id.value if household.household_id else None, + household_name=household.name.value, + owner_user_id=household.owner_user_id.value, + created_at=household.created_at.isoformat() if household.created_at else None, + ) + + except Exception as e: + self._logger.error("Unexpected error getting household", household_id=query.household_id, error=str(e)) + return GetHouseholdResult( + error_code=GetHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/invite_user_handler.py b/app/context/household/application/handlers/invite_user_handler.py new file mode 100644 index 0000000..c026dd3 --- /dev/null +++ b/app/context/household/application/handlers/invite_user_handler.py @@ -0,0 +1,103 @@ +from app.context.household.application.commands import InviteUserCommand +from app.context.household.application.contracts import InviteUserHandlerContract +from app.context.household.application.dto import InviteUserErrorCode, InviteUserResult +from app.context.household.domain.contracts import InviteUserServiceContract +from app.context.household.domain.exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + HouseholdMapperError, + OnlyOwnerCanInviteError, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdRole, + HouseholdUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class InviteUserHandler(InviteUserHandlerContract): + """Handler for invite user command""" + + def __init__(self, service: InviteUserServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: InviteUserCommand) -> InviteUserResult: + """Execute the invite user command""" + + self._logger.debug( + "Handling invite user command", + inviter_user_id=command.inviter_user_id, + household_id=command.household_id, + invitee_user_id=command.invitee_user_id, + ) + + try: + member_dto = await self._service.invite_user( + inviter_user_id=HouseholdUserID(command.inviter_user_id), + household_id=HouseholdID(command.household_id), + invitee_user_id=HouseholdUserID(command.invitee_user_id), + role=HouseholdRole(command.role), + ) + + if member_dto.member_id is None: + self._logger.error( + "Member ID is None after invitation", + household_id=command.household_id, + invitee_user_id=command.invitee_user_id, + ) + return InviteUserResult( + error_code=InviteUserErrorCode.UNEXPECTED_ERROR, + error_message="Error creating invitation", + ) + + return InviteUserResult( + member_id=member_dto.member_id.value, + household_id=member_dto.household_id.value, + user_id=member_dto.user_id.value, + role=member_dto.role.value, + ) + + except OnlyOwnerCanInviteError: + self._logger.debug( + "Non-owner attempted invite", + inviter_user_id=command.inviter_user_id, + household_id=command.household_id, + ) + return InviteUserResult( + error_code=InviteUserErrorCode.ONLY_OWNER_CAN_INVITE, + error_message="Only the household owner can invite users", + ) + except AlreadyActiveMemberError: + self._logger.debug( + "User already active member", + household_id=command.household_id, + invitee_user_id=command.invitee_user_id, + ) + return InviteUserResult( + error_code=InviteUserErrorCode.ALREADY_ACTIVE_MEMBER, + error_message="User is already an active member of this household", + ) + except AlreadyInvitedError: + self._logger.debug( + "User already invited", + household_id=command.household_id, + invitee_user_id=command.invitee_user_id, + ) + return InviteUserResult( + error_code=InviteUserErrorCode.ALREADY_INVITED, + error_message="User already has a pending invite to this household", + ) + except HouseholdMapperError: + self._logger.error("Mapper error inviting user", household_id=command.household_id) + return InviteUserResult( + error_code=InviteUserErrorCode.MAPPER_ERROR, + error_message="Error mapping model to DTO", + ) + except Exception as e: + self._logger.error("Unexpected error inviting user", household_id=command.household_id, error=str(e)) + return InviteUserResult( + error_code=InviteUserErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/list_household_invites_handler.py b/app/context/household/application/handlers/list_household_invites_handler.py new file mode 100644 index 0000000..9ffd146 --- /dev/null +++ b/app/context/household/application/handlers/list_household_invites_handler.py @@ -0,0 +1,32 @@ +from app.context.household.application.contracts import ( + ListHouseholdInvitesHandlerContract, +) +from app.context.household.application.dto import HouseholdMemberResponseDTO +from app.context.household.application.queries import ListHouseholdInvitesQuery +from app.context.household.domain.contracts import HouseholdRepositoryContract +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class ListHouseholdInvitesHandler(ListHouseholdInvitesHandlerContract): + """Handler for list household invites query""" + + def __init__(self, repository: HouseholdRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: ListHouseholdInvitesQuery) -> list[HouseholdMemberResponseDTO]: + """Execute the list household invites query""" + + self._logger.debug( + "Handling list household invites query", + household_id=query.household_id, + user_id=query.user_id, + ) + + members = await self._repository.list_household_pending_invites( + household_id=HouseholdID(query.household_id), + owner_id=HouseholdUserID(query.user_id), + ) + + return [HouseholdMemberResponseDTO.from_domain_dto(member) for member in members] if members else [] diff --git a/app/context/household/application/handlers/list_user_households_handler.py b/app/context/household/application/handlers/list_user_households_handler.py new file mode 100644 index 0000000..0a3e361 --- /dev/null +++ b/app/context/household/application/handlers/list_user_households_handler.py @@ -0,0 +1,50 @@ +from app.context.household.application.contracts import ListUserHouseholdsHandlerContract +from app.context.household.application.dto import ( + HouseholdSummary, + ListUserHouseholdsErrorCode, + ListUserHouseholdsResult, +) +from app.context.household.application.queries import ListUserHouseholdsQuery +from app.context.household.domain.contracts import HouseholdRepositoryContract +from app.context.household.domain.value_objects import HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class ListUserHouseholdsHandler(ListUserHouseholdsHandlerContract): + """Handler for list user households query""" + + def __init__(self, repository: HouseholdRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: ListUserHouseholdsQuery) -> ListUserHouseholdsResult: + """Execute the list user households query""" + + self._logger.debug("Handling list user households query", user_id=query.user_id) + + try: + # Convert primitive to value object + user_id = HouseholdUserID(query.user_id) + + # Get all households for user + households = await self._repository.list_user_households(user_id) + + # Convert DTOs to summary primitives + summaries = [ + HouseholdSummary( + household_id=h.household_id.value if h.household_id else 0, + household_name=h.name.value, + owner_user_id=h.owner_user_id.value, + created_at=h.created_at.isoformat() if h.created_at else "", + ) + for h in households + ] + + return ListUserHouseholdsResult(households=summaries) + + except Exception as e: + self._logger.error("Unexpected error listing user households", user_id=query.user_id, error=str(e)) + return ListUserHouseholdsResult( + error_code=ListUserHouseholdsErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/list_user_pending_invites_handler.py b/app/context/household/application/handlers/list_user_pending_invites_handler.py new file mode 100644 index 0000000..7634f70 --- /dev/null +++ b/app/context/household/application/handlers/list_user_pending_invites_handler.py @@ -0,0 +1,27 @@ +from app.context.household.application.contracts import ( + ListUserPendingInvitesHandlerContract, +) +from app.context.household.application.dto import HouseholdMemberResponseDTO +from app.context.household.application.queries import ListUserPendingInvitesQuery +from app.context.household.domain.contracts import HouseholdRepositoryContract +from app.context.household.domain.value_objects import HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class ListUserPendingInvitesHandler(ListUserPendingInvitesHandlerContract): + """Handler for list user pending invites query""" + + def __init__(self, repository: HouseholdRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: ListUserPendingInvitesQuery) -> list[HouseholdMemberResponseDTO]: + """Execute the list user pending invites query""" + + self._logger.debug("Handling list user pending invites query", user_id=query.user_id) + + members = await self._repository.list_user_pending_household_invites( + user_id=HouseholdUserID(query.user_id), + ) + + return [HouseholdMemberResponseDTO.from_domain_dto(member) for member in members] if members else [] diff --git a/app/context/household/application/handlers/remove_member_handler.py b/app/context/household/application/handlers/remove_member_handler.py new file mode 100644 index 0000000..0b2174c --- /dev/null +++ b/app/context/household/application/handlers/remove_member_handler.py @@ -0,0 +1,74 @@ +from app.context.household.application.commands import RemoveMemberCommand +from app.context.household.application.contracts import RemoveMemberHandlerContract +from app.context.household.application.dto import ( + RemoveMemberErrorCode, + RemoveMemberResult, +) +from app.context.household.domain.contracts import RemoveMemberServiceContract +from app.context.household.domain.exceptions import ( + CannotRemoveSelfError, + InviteNotFoundError, + OnlyOwnerCanRemoveMemberError, +) +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class RemoveMemberHandler(RemoveMemberHandlerContract): + """Handler for remove member command""" + + def __init__(self, service: RemoveMemberServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: RemoveMemberCommand) -> RemoveMemberResult: + """Execute the remove member command""" + + self._logger.debug( + "Handling remove member command", + remover_user_id=command.remover_user_id, + household_id=command.household_id, + member_user_id=command.member_user_id, + ) + + try: + await self._service.remove_member( + remover_user_id=HouseholdUserID(command.remover_user_id), + household_id=HouseholdID(command.household_id), + member_user_id=HouseholdUserID(command.member_user_id), + ) + + return RemoveMemberResult(success=True) + + except OnlyOwnerCanRemoveMemberError: + self._logger.debug( + "Non-owner attempted to remove member", + remover_user_id=command.remover_user_id, + household_id=command.household_id, + ) + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.ONLY_OWNER_CAN_REMOVE, + error_message="Only the household owner can remove members", + ) + except CannotRemoveSelfError: + self._logger.debug("Owner attempted to remove themselves", user_id=command.remover_user_id) + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.CANNOT_REMOVE_SELF, + error_message="Owner cannot remove themselves from the household", + ) + except InviteNotFoundError: + self._logger.debug( + "Member not found", + household_id=command.household_id, + member_user_id=command.member_user_id, + ) + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.MEMBER_NOT_FOUND, + error_message="No active member found with this user ID", + ) + except Exception as e: + self._logger.error("Unexpected error removing member", household_id=command.household_id, error=str(e)) + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/handlers/update_household_handler.py b/app/context/household/application/handlers/update_household_handler.py new file mode 100644 index 0000000..cc7070c --- /dev/null +++ b/app/context/household/application/handlers/update_household_handler.py @@ -0,0 +1,91 @@ +from app.context.household.application.commands import UpdateHouseholdCommand +from app.context.household.application.contracts import UpdateHouseholdHandlerContract +from app.context.household.application.dto import UpdateHouseholdErrorCode, UpdateHouseholdResult +from app.context.household.domain.contracts import UpdateHouseholdServiceContract +from app.context.household.domain.exceptions import ( + HouseholdMapperError, + HouseholdNameAlreadyExistError, + HouseholdNotFoundError, + OnlyOwnerCanUpdateError, +) +from app.context.household.domain.value_objects import HouseholdID, HouseholdName, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class UpdateHouseholdHandler(UpdateHouseholdHandlerContract): + """Handler for update household command""" + + def __init__(self, service: UpdateHouseholdServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: UpdateHouseholdCommand) -> UpdateHouseholdResult: + """Execute the update household command""" + + self._logger.debug( + "Handling update household command", + household_id=command.household_id, + user_id=command.user_id, + name=command.name, + ) + + try: + # Convert primitives to value objects + updated = await self._service.update_household( + household_id=HouseholdID(command.household_id), + user_id=HouseholdUserID(command.user_id), + name=HouseholdName(command.name), + ) + + if updated.household_id is None: + self._logger.error( + "Household ID is None after update", + household_id=command.household_id, + user_id=command.user_id, + ) + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Error updating household", + ) + + # Return success with primitives + return UpdateHouseholdResult( + household_id=updated.household_id.value, + household_name=updated.name.value, + owner_user_id=updated.owner_user_id.value, + ) + + except HouseholdNotFoundError: + self._logger.debug("Household not found", household_id=command.household_id) + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.NOT_FOUND, + error_message="Household not found", + ) + except OnlyOwnerCanUpdateError: + self._logger.debug( + "Non-owner attempted to update household", + household_id=command.household_id, + user_id=command.user_id, + ) + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.NOT_OWNER, + error_message="Only the household owner can update the household", + ) + except HouseholdNameAlreadyExistError: + self._logger.debug("Duplicate household name", household_id=command.household_id, name=command.name) + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.NAME_ALREADY_EXISTS, + error_message="Household name already exists", + ) + except HouseholdMapperError: + self._logger.error("Mapper error updating household", household_id=command.household_id) + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) + except Exception as e: + self._logger.error("Unexpected error updating household", household_id=command.household_id, error=str(e)) + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/household/application/queries/__init__.py b/app/context/household/application/queries/__init__.py new file mode 100644 index 0000000..4c683b6 --- /dev/null +++ b/app/context/household/application/queries/__init__.py @@ -0,0 +1,11 @@ +from .get_household_query import GetHouseholdQuery +from .list_household_invites_query import ListHouseholdInvitesQuery +from .list_user_households_query import ListUserHouseholdsQuery +from .list_user_pending_invites_query import ListUserPendingInvitesQuery + +__all__ = [ + "GetHouseholdQuery", + "ListHouseholdInvitesQuery", + "ListUserHouseholdsQuery", + "ListUserPendingInvitesQuery", +] diff --git a/app/context/household/application/queries/get_household_query.py b/app/context/household/application/queries/get_household_query.py new file mode 100644 index 0000000..b3c0d44 --- /dev/null +++ b/app/context/household/application/queries/get_household_query.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class GetHouseholdQuery: + """Query to get a household by ID""" + + household_id: int + user_id: int # For access check (must be owner or active member) diff --git a/app/context/household/application/queries/list_household_invites_query.py b/app/context/household/application/queries/list_household_invites_query.py new file mode 100644 index 0000000..f22f83f --- /dev/null +++ b/app/context/household/application/queries/list_household_invites_query.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ListHouseholdInvitesQuery: + """Query to list pending invites for a household""" + + household_id: int + user_id: int diff --git a/app/context/household/application/queries/list_user_households_query.py b/app/context/household/application/queries/list_user_households_query.py new file mode 100644 index 0000000..9d1f339 --- /dev/null +++ b/app/context/household/application/queries/list_user_households_query.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ListUserHouseholdsQuery: + """Query to list all households for a user""" + + user_id: int diff --git a/app/context/household/application/queries/list_user_pending_invites_query.py b/app/context/household/application/queries/list_user_pending_invites_query.py new file mode 100644 index 0000000..0e6d5b0 --- /dev/null +++ b/app/context/household/application/queries/list_user_pending_invites_query.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ListUserPendingInvitesQuery: + """Query to list all pending invites for a user""" + + user_id: int diff --git a/app/context/household/domain/__init__.py b/app/context/household/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/household/domain/contracts/__init__.py b/app/context/household/domain/contracts/__init__.py new file mode 100644 index 0000000..f2d85f0 --- /dev/null +++ b/app/context/household/domain/contracts/__init__.py @@ -0,0 +1,19 @@ +from .accept_invite_service_contract import AcceptInviteServiceContract +from .create_household_service_contract import CreateHouseholdServiceContract +from .decline_invite_service_contract import DeclineInviteServiceContract +from .household_repository_contract import HouseholdRepositoryContract +from .invite_user_service_contract import InviteUserServiceContract +from .remove_member_service_contract import RemoveMemberServiceContract +from .revoke_invite_service_contract import RevokeInviteServiceContract +from .update_household_service_contract import UpdateHouseholdServiceContract + +__all__ = [ + "AcceptInviteServiceContract", + "CreateHouseholdServiceContract", + "DeclineInviteServiceContract", + "HouseholdRepositoryContract", + "InviteUserServiceContract", + "RemoveMemberServiceContract", + "RevokeInviteServiceContract", + "UpdateHouseholdServiceContract", +] diff --git a/app/context/household/domain/contracts/accept_invite_service_contract.py b/app/context/household/domain/contracts/accept_invite_service_contract.py new file mode 100644 index 0000000..a8f994f --- /dev/null +++ b/app/context/household/domain/contracts/accept_invite_service_contract.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID + + +class AcceptInviteServiceContract(ABC): + @abstractmethod + async def accept_invite( + self, + user_id: HouseholdUserID, + household_id: HouseholdID, + ) -> HouseholdMemberDTO: + """ + Accept a pending household invite. + + Raises: + NotInvitedError: If user has no pending invite for this household + """ + pass diff --git a/app/context/household/domain/contracts/create_household_service_contract.py b/app/context/household/domain/contracts/create_household_service_contract.py new file mode 100644 index 0000000..588da30 --- /dev/null +++ b/app/context/household/domain/contracts/create_household_service_contract.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.value_objects import HouseholdName, HouseholdUserID + + +class CreateHouseholdServiceContract(ABC): + @abstractmethod + async def create_household(self, name: HouseholdName, creator_user_id: HouseholdUserID) -> HouseholdDTO: + """Create a new household with the creator as the first member""" + pass diff --git a/app/context/household/domain/contracts/decline_invite_service_contract.py b/app/context/household/domain/contracts/decline_invite_service_contract.py new file mode 100644 index 0000000..e5fe9f9 --- /dev/null +++ b/app/context/household/domain/contracts/decline_invite_service_contract.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID + + +class DeclineInviteServiceContract(ABC): + @abstractmethod + async def decline_invite( + self, + user_id: HouseholdUserID, + household_id: HouseholdID, + ) -> None: + """ + Decline a pending household invite. + + Raises: + NotInvitedError: If user has no pending invite for this household + """ + pass diff --git a/app/context/household/domain/contracts/household_repository_contract.py b/app/context/household/domain/contracts/household_repository_contract.py new file mode 100644 index 0000000..77dc104 --- /dev/null +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -0,0 +1,83 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) + + +class HouseholdRepositoryContract(ABC): + @abstractmethod + async def create_household(self, household_dto: HouseholdDTO) -> HouseholdDTO: + """Create a new household and add the creator as first member""" + pass + + @abstractmethod + async def find_household_by_name(self, name: HouseholdName, user_id: HouseholdUserID) -> HouseholdDTO | None: + """Find a household by name for a specific user""" + pass + + @abstractmethod + async def find_household_by_id(self, household_id: HouseholdID) -> HouseholdDTO | None: + """Find a household by ID""" + pass + + # Member management methods + @abstractmethod + async def create_member(self, member: HouseholdMemberDTO) -> HouseholdMemberDTO: + """Create a new household member (for invites or direct adds)""" + pass + + @abstractmethod + async def find_member(self, household_id: HouseholdID, user_id: HouseholdUserID) -> HouseholdMemberDTO | None: + """Find the most recent member record for user in household""" + pass + + @abstractmethod + async def accept_invite(self, household_id: HouseholdID, user_id: HouseholdUserID) -> HouseholdMemberDTO: + """Accept invite by setting joined_at to current timestamp""" + pass + + @abstractmethod + async def revoke_or_remove(self, household_id: HouseholdID, user_id: HouseholdUserID) -> None: + """Revoke invite or remove member by setting left_at to current timestamp""" + pass + + @abstractmethod + async def list_user_households(self, user_id: HouseholdUserID) -> list[HouseholdDTO]: + """List all households user owns or is an active participant in""" + pass + + @abstractmethod + async def list_user_pending_invites(self, user_id: HouseholdUserID) -> list[HouseholdDTO]: + """List all households user has been invited to but not yet accepted""" + pass + + @abstractmethod + async def list_household_pending_invites( + self, household_id: HouseholdID, owner_id: HouseholdUserID + ) -> list[HouseholdMemberDTO]: + """List all pending invites for a household""" + pass + + @abstractmethod + async def list_user_pending_household_invites(self, user_id: HouseholdUserID) -> list[HouseholdMemberDTO]: + """List user pending invitation to households""" + pass + + @abstractmethod + async def update_household(self, household: HouseholdDTO) -> HouseholdDTO: + """Update household name""" + pass + + @abstractmethod + async def delete_household(self, household_id: HouseholdID, user_id: HouseholdUserID) -> bool: + """Soft delete a household (owner only)""" + pass + + @abstractmethod + async def user_has_access(self, user_id: HouseholdUserID, household_id: HouseholdID) -> bool: + """Check if user owns or is an active member of household""" + pass diff --git a/app/context/household/domain/contracts/invite_user_service_contract.py b/app/context/household/domain/contracts/invite_user_service_contract.py new file mode 100644 index 0000000..6da854b --- /dev/null +++ b/app/context/household/domain/contracts/invite_user_service_contract.py @@ -0,0 +1,35 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdRole, + HouseholdUserID, +) + + +class InviteUserServiceContract(ABC): + @abstractmethod + async def invite_user( + self, + inviter_user_id: HouseholdUserID, + household_id: HouseholdID, + invitee_user_id: HouseholdUserID, + role: HouseholdRole, + ) -> HouseholdMemberDTO: + """ + Invite a user to a household. + Only the owner can invite users. + + Args: + inviter_user_id: The user ID of the person sending the invite + household_id: The household ID to invite the user to + invitee_user_id: The user ID of the person being invited + role: The role to assign to the invited user + + Raises: + OnlyOwnerCanInviteError: If inviter is not the household owner + AlreadyActiveMemberError: If user is already an active member + AlreadyInvitedError: If user already has a pending invite + """ + pass diff --git a/app/context/household/domain/contracts/remove_member_service_contract.py b/app/context/household/domain/contracts/remove_member_service_contract.py new file mode 100644 index 0000000..3330cb0 --- /dev/null +++ b/app/context/household/domain/contracts/remove_member_service_contract.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID + + +class RemoveMemberServiceContract(ABC): + @abstractmethod + async def remove_member( + self, + remover_user_id: HouseholdUserID, + household_id: HouseholdID, + member_user_id: HouseholdUserID, + ) -> None: + """ + Remove an active member from a household. + Only the owner can remove members. + Owner cannot remove themselves. + + Raises: + OnlyOwnerCanRemoveMemberError: If remover is not the household owner + CannotRemoveSelfError: If owner tries to remove themselves + InviteNotFoundError: If member is not found or not active + """ + pass diff --git a/app/context/household/domain/contracts/revoke_invite_service_contract.py b/app/context/household/domain/contracts/revoke_invite_service_contract.py new file mode 100644 index 0000000..85f90b7 --- /dev/null +++ b/app/context/household/domain/contracts/revoke_invite_service_contract.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID + + +class RevokeInviteServiceContract(ABC): + @abstractmethod + async def revoke_invite( + self, + revoker_user_id: HouseholdUserID, + household_id: HouseholdID, + invitee_user_id: HouseholdUserID, + ) -> None: + """ + Revoke a pending household invite. + Only the owner can revoke invites. + + Raises: + OnlyOwnerCanRevokeError: If revoker is not the household owner + InviteNotFoundError: If no pending invite exists + """ + pass diff --git a/app/context/household/domain/contracts/update_household_service_contract.py b/app/context/household/domain/contracts/update_household_service_contract.py new file mode 100644 index 0000000..5a3d063 --- /dev/null +++ b/app/context/household/domain/contracts/update_household_service_contract.py @@ -0,0 +1,18 @@ +from abc import ABC, abstractmethod + +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.value_objects import HouseholdID, HouseholdName, HouseholdUserID + + +class UpdateHouseholdServiceContract(ABC): + """Contract for update household service""" + + @abstractmethod + async def update_household( + self, + household_id: HouseholdID, + user_id: HouseholdUserID, + name: HouseholdName, + ) -> HouseholdDTO: + """Update household name (owner only)""" + pass diff --git a/app/context/household/domain/dto/__init__.py b/app/context/household/domain/dto/__init__.py new file mode 100644 index 0000000..f46cc01 --- /dev/null +++ b/app/context/household/domain/dto/__init__.py @@ -0,0 +1,4 @@ +from .household_dto import HouseholdDTO +from .household_member_dto import HouseholdMemberDTO + +__all__ = ["HouseholdDTO", "HouseholdMemberDTO"] diff --git a/app/context/household/domain/dto/household_dto.py b/app/context/household/domain/dto/household_dto.py new file mode 100644 index 0000000..e8d3c60 --- /dev/null +++ b/app/context/household/domain/dto/household_dto.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from datetime import datetime + +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) + + +@dataclass(frozen=True) +class HouseholdDTO: + household_id: HouseholdID | None + owner_user_id: HouseholdUserID + name: HouseholdName + created_at: datetime | None = None diff --git a/app/context/household/domain/dto/household_member_dto.py b/app/context/household/domain/dto/household_member_dto.py new file mode 100644 index 0000000..bba90df --- /dev/null +++ b/app/context/household/domain/dto/household_member_dto.py @@ -0,0 +1,36 @@ +from dataclasses import dataclass +from datetime import datetime + +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) +from app.shared.domain.value_objects import SharedUsername + + +@dataclass(frozen=True) +class HouseholdMemberDTO: + """Domain DTO for household member""" + + member_id: HouseholdMemberID | None + household_id: HouseholdID + user_id: HouseholdUserID + role: HouseholdRole + joined_at: datetime | None = None + invited_by_user_id: HouseholdUserID | None = None + invited_at: datetime | None = None + household_name: HouseholdName | None = None + inviter_username: SharedUsername | None = None + + @property + def is_invited(self) -> bool: + """Check if this is a pending invite (not yet accepted)""" + return self.joined_at is None + + @property + def is_active(self) -> bool: + """Check if this is an active member (accepted and not left)""" + return self.joined_at is not None diff --git a/app/context/household/domain/exceptions/__init__.py b/app/context/household/domain/exceptions/__init__.py new file mode 100644 index 0000000..7797ead --- /dev/null +++ b/app/context/household/domain/exceptions/__init__.py @@ -0,0 +1,33 @@ +from .exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + CannotRemoveSelfError, + HouseholdMapperError, + HouseholdNameAlreadyExistError, + HouseholdNotFoundError, + InviteNotFoundError, + NotInvitedError, + OnlyOwnerCanDeleteError, + OnlyOwnerCanInviteError, + OnlyOwnerCanRemoveMemberError, + OnlyOwnerCanRevokeError, + OnlyOwnerCanUpdateError, + UserNotFoundError, +) + +__all__ = [ + "AlreadyActiveMemberError", + "AlreadyInvitedError", + "CannotRemoveSelfError", + "HouseholdMapperError", + "HouseholdNameAlreadyExistError", + "HouseholdNotFoundError", + "InviteNotFoundError", + "NotInvitedError", + "OnlyOwnerCanDeleteError", + "OnlyOwnerCanInviteError", + "OnlyOwnerCanRemoveMemberError", + "OnlyOwnerCanRevokeError", + "OnlyOwnerCanUpdateError", + "UserNotFoundError", +] diff --git a/app/context/household/domain/exceptions/exceptions.py b/app/context/household/domain/exceptions/exceptions.py new file mode 100644 index 0000000..c485ca1 --- /dev/null +++ b/app/context/household/domain/exceptions/exceptions.py @@ -0,0 +1,55 @@ +class HouseholdNotFoundError(Exception): + pass + + +class HouseholdNameAlreadyExistError(Exception): + pass + + +class HouseholdMapperError(Exception): + pass + + +# Member-related exceptions +class OnlyOwnerCanInviteError(Exception): + pass + + +class UserNotFoundError(Exception): + pass + + +class AlreadyActiveMemberError(Exception): + pass + + +class AlreadyInvitedError(Exception): + pass + + +class InviteNotFoundError(Exception): + pass + + +class NotInvitedError(Exception): + pass + + +class OnlyOwnerCanRevokeError(Exception): + pass + + +class OnlyOwnerCanRemoveMemberError(Exception): + pass + + +class CannotRemoveSelfError(Exception): + pass + + +class OnlyOwnerCanUpdateError(Exception): + pass + + +class OnlyOwnerCanDeleteError(Exception): + pass diff --git a/app/context/household/domain/services/__init__.py b/app/context/household/domain/services/__init__.py new file mode 100644 index 0000000..24b3858 --- /dev/null +++ b/app/context/household/domain/services/__init__.py @@ -0,0 +1,17 @@ +from .accept_invite_service import AcceptInviteService +from .create_household_service import CreateHouseholdService +from .decline_invite_service import DeclineInviteService +from .invite_user_service import InviteUserService +from .remove_member_service import RemoveMemberService +from .revoke_invite_service import RevokeInviteService +from .update_household_service import UpdateHouseholdService + +__all__ = [ + "AcceptInviteService", + "CreateHouseholdService", + "DeclineInviteService", + "InviteUserService", + "RemoveMemberService", + "RevokeInviteService", + "UpdateHouseholdService", +] diff --git a/app/context/household/domain/services/accept_invite_service.py b/app/context/household/domain/services/accept_invite_service.py new file mode 100644 index 0000000..2fcec30 --- /dev/null +++ b/app/context/household/domain/services/accept_invite_service.py @@ -0,0 +1,33 @@ +from app.context.household.domain.contracts import ( + AcceptInviteServiceContract, + HouseholdRepositoryContract, +) +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.exceptions import NotInvitedError +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class AcceptInviteService(AcceptInviteServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): + self._household_repo = household_repo + self._logger = logger + + async def accept_invite( + self, + user_id: HouseholdUserID, + household_id: HouseholdID, + ) -> HouseholdMemberDTO: + """Accept a pending household invite""" + + self._logger.debug("Accepting household invite", user_id=user_id.value, household_id=household_id.value) + + # Check if user has a pending invite + member = await self._household_repo.find_member(household_id, user_id) + + if not member or not member.is_invited: + self._logger.debug("No pending invite found", user_id=user_id.value, household_id=household_id.value) + raise NotInvitedError("No pending invite found for this household") + + # Accept the invite (sets joined_at) + return await self._household_repo.accept_invite(household_id, user_id) diff --git a/app/context/household/domain/services/create_household_service.py b/app/context/household/domain/services/create_household_service.py new file mode 100644 index 0000000..1f21903 --- /dev/null +++ b/app/context/household/domain/services/create_household_service.py @@ -0,0 +1,30 @@ +from app.context.household.domain.contracts import ( + CreateHouseholdServiceContract, + HouseholdRepositoryContract, +) +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.value_objects import HouseholdName, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class CreateHouseholdService(CreateHouseholdServiceContract): + def __init__(self, household_repository: HouseholdRepositoryContract, logger: LoggerContract): + self._household_repository = household_repository + self._logger = logger + + async def create_household(self, name: HouseholdName, creator_user_id: HouseholdUserID) -> HouseholdDTO: + """Create a new household with the creator as owner""" + + self._logger.debug("Creating household", user_id=creator_user_id.value, household_name=name.value) + + # Create new household DTO with owner + household_dto = HouseholdDTO( + household_id=None, + owner_user_id=creator_user_id, + name=name, + ) + + # Save to repository - will raise HouseholdNameAlreadyExistError if duplicate + created_household = await self._household_repository.create_household(household_dto=household_dto) + + return created_household diff --git a/app/context/household/domain/services/decline_invite_service.py b/app/context/household/domain/services/decline_invite_service.py new file mode 100644 index 0000000..cc0def6 --- /dev/null +++ b/app/context/household/domain/services/decline_invite_service.py @@ -0,0 +1,31 @@ +from app.context.household.domain.contracts import ( + DeclineInviteServiceContract, + HouseholdRepositoryContract, +) +from app.context.household.domain.exceptions import NotInvitedError +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class DeclineInviteService(DeclineInviteServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): + self._household_repo = household_repo + self._logger = logger + + async def decline_invite( + self, + user_id: HouseholdUserID, + household_id: HouseholdID, + ) -> None: + """Decline a pending household invite""" + + self._logger.debug("Declining household invite", user_id=user_id.value, household_id=household_id.value) + + # Check if user has a pending invite + member = await self._household_repo.find_member(household_id, user_id) + + if not member or not member.is_invited: + self._logger.debug("No pending invite found", user_id=user_id.value, household_id=household_id.value) + raise NotInvitedError("No pending invite found for this household") + + await self._household_repo.revoke_or_remove(household_id, user_id) diff --git a/app/context/household/domain/services/invite_user_service.py b/app/context/household/domain/services/invite_user_service.py new file mode 100644 index 0000000..cd04922 --- /dev/null +++ b/app/context/household/domain/services/invite_user_service.py @@ -0,0 +1,87 @@ +from datetime import UTC, datetime + +from app.context.household.domain.contracts import ( + HouseholdRepositoryContract, + InviteUserServiceContract, +) +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + OnlyOwnerCanInviteError, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdRole, + HouseholdUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class InviteUserService(InviteUserServiceContract): + def __init__( + self, + household_repo: HouseholdRepositoryContract, + logger: LoggerContract, + ): + self._household_repo = household_repo + self._logger = logger + + async def invite_user( + self, + inviter_user_id: HouseholdUserID, + household_id: HouseholdID, + invitee_user_id: HouseholdUserID, + role: HouseholdRole, + ) -> HouseholdMemberDTO: + """ + Invite a user to a household. + """ + + self._logger.debug( + "Inviting user to household", + inviter_user_id=inviter_user_id.value, + household_id=household_id.value, + invitee_user_id=invitee_user_id.value, + role=role.value, + ) + + household = await self._household_repo.find_household_by_id(household_id) + if not household or household.owner_user_id.value != inviter_user_id.value: + self._logger.warning( + "Non-owner attempted to invite user", + inviter_user_id=inviter_user_id.value, + household_id=household_id.value, + ) + raise OnlyOwnerCanInviteError("Only the household owner can invite users") + + existing_member = await self._household_repo.find_member(household_id, invitee_user_id) + + if existing_member: + if existing_member.is_active: + self._logger.debug( + "User already active member", + household_id=household_id.value, + user_id=invitee_user_id.value, + ) + raise AlreadyActiveMemberError("User is already an active member of this household") + if existing_member.is_invited: + self._logger.debug( + "User already has pending invite", + household_id=household_id.value, + user_id=invitee_user_id.value, + ) + raise AlreadyInvitedError("User already has a pending invite to this household") + + # 3. Create invite (household_member with joined_at=None) + member_dto = HouseholdMemberDTO( + member_id=None, + household_id=household_id, + user_id=invitee_user_id, + role=role, + joined_at=None, # NULL = invited + invited_by_user_id=inviter_user_id, + invited_at=datetime.now(UTC), + ) + + return await self._household_repo.create_member(member_dto) diff --git a/app/context/household/domain/services/remove_member_service.py b/app/context/household/domain/services/remove_member_service.py new file mode 100644 index 0000000..aec78af --- /dev/null +++ b/app/context/household/domain/services/remove_member_service.py @@ -0,0 +1,60 @@ +from app.context.household.domain.contracts import ( + HouseholdRepositoryContract, + RemoveMemberServiceContract, +) +from app.context.household.domain.exceptions import ( + CannotRemoveSelfError, + InviteNotFoundError, + OnlyOwnerCanRemoveMemberError, +) +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class RemoveMemberService(RemoveMemberServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): + self._household_repo = household_repo + self._logger = logger + + async def remove_member( + self, + remover_user_id: HouseholdUserID, + household_id: HouseholdID, + member_user_id: HouseholdUserID, + ) -> None: + """Remove an active member from a household""" + + self._logger.debug( + "Removing member from household", + remover_user_id=remover_user_id.value, + household_id=household_id.value, + member_user_id=member_user_id.value, + ) + + # Check if remover is the owner + household = await self._household_repo.find_household_by_id(household_id) + if not household or household.owner_user_id.value != remover_user_id.value: + self._logger.warning( + "Non-owner attempted to remove member", + remover_user_id=remover_user_id.value, + household_id=household_id.value, + ) + raise OnlyOwnerCanRemoveMemberError("Only the household owner can remove members") + + # Owner cannot remove themselves + if remover_user_id.value == member_user_id.value: + self._logger.debug("Owner attempted to remove themselves", user_id=remover_user_id.value) + raise CannotRemoveSelfError("Owner cannot remove themselves from the household") + + # Check if member exists and is active + member = await self._household_repo.find_member(household_id, member_user_id) + + if not member or not member.is_active: + self._logger.debug( + "Member not found or not active", + household_id=household_id.value, + member_user_id=member_user_id.value, + ) + raise InviteNotFoundError("No active member found with this user ID") + + await self._household_repo.revoke_or_remove(household_id, member_user_id) diff --git a/app/context/household/domain/services/revoke_invite_service.py b/app/context/household/domain/services/revoke_invite_service.py new file mode 100644 index 0000000..fad642c --- /dev/null +++ b/app/context/household/domain/services/revoke_invite_service.py @@ -0,0 +1,59 @@ +from app.context.household.domain.contracts import ( + HouseholdRepositoryContract, + RevokeInviteServiceContract, +) +from app.context.household.domain.exceptions import ( + InviteNotFoundError, + OnlyOwnerCanRevokeError, +) +from app.context.household.domain.value_objects import HouseholdID, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class RevokeInviteService(RevokeInviteServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): + self._household_repo = household_repo + self._logger = logger + + async def revoke_invite( + self, + revoker_user_id: HouseholdUserID, + household_id: HouseholdID, + invitee_user_id: HouseholdUserID, + ) -> None: + """Revoke a pending household invite""" + + self._logger.debug( + "Revoking invite", + revoker_user_id=revoker_user_id.value, + household_id=household_id.value, + invitee_user_id=invitee_user_id.value, + ) + + # Check if revoker is the owner + household = await self._household_repo.find_household_by_id(household_id) + if not household or household.owner_user_id.value != revoker_user_id.value: + self._logger.warning( + "Non-owner attempted to revoke invite", + revoker_user_id=revoker_user_id.value, + household_id=household_id.value, + ) + raise OnlyOwnerCanRevokeError("Only the household owner can revoke invites") + + # Check if there's a pending invite + member = await self._household_repo.find_member(household_id, invitee_user_id) + + if not member or not member.is_invited: + self._logger.debug( + "No pending invite found to revoke", + household_id=household_id.value, + invitee_user_id=invitee_user_id.value, + ) + raise InviteNotFoundError("No pending invite found for this user") + + await self._household_repo.revoke_or_remove(household_id, invitee_user_id) + self._logger.debug( + "Invite revoked successfully", + household_id=household_id.value, + invitee_user_id=invitee_user_id.value, + ) diff --git a/app/context/household/domain/services/update_household_service.py b/app/context/household/domain/services/update_household_service.py new file mode 100644 index 0000000..ec438ca --- /dev/null +++ b/app/context/household/domain/services/update_household_service.py @@ -0,0 +1,66 @@ +from app.context.household.domain.contracts import ( + HouseholdRepositoryContract, + UpdateHouseholdServiceContract, +) +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.exceptions import ( + HouseholdNameAlreadyExistError, + HouseholdNotFoundError, + OnlyOwnerCanUpdateError, +) +from app.context.household.domain.value_objects import HouseholdID, HouseholdName, HouseholdUserID +from app.shared.domain.contracts import LoggerContract + + +class UpdateHouseholdService(UpdateHouseholdServiceContract): + """Service for updating households""" + + def __init__(self, repository: HouseholdRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def update_household( + self, + household_id: HouseholdID, + user_id: HouseholdUserID, + name: HouseholdName, + ) -> HouseholdDTO: + """Update household name (owner only)""" + + self._logger.debug( + "Updating household", household_id=household_id.value, user_id=user_id.value, name=name.value + ) + + # 1. Find existing household + existing = await self._repository.find_household_by_id(household_id) + + if not existing: + self._logger.debug("Household not found", household_id=household_id.value) + raise HouseholdNotFoundError(f"Household with ID {household_id.value} not found") + + # 2. Verify user is owner + if existing.owner_user_id.value != user_id.value: + self._logger.warning( + "Non-owner attempted to update household", + household_id=household_id.value, + user_id=user_id.value, + ) + raise OnlyOwnerCanUpdateError("Only the household owner can update the household") + + # 3. Check duplicate name (if name changed) + if existing.name.value != name.value: + duplicate = await self._repository.find_household_by_name(name, user_id) + if duplicate and duplicate.household_id and duplicate.household_id.value != household_id.value: + self._logger.debug("Duplicate household name found", user_id=user_id.value, name=name.value) + raise HouseholdNameAlreadyExistError(f"Household with name '{name.value}' already exists") + + # 4. Create updated DTO + updated_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=user_id, + name=name, + created_at=existing.created_at, + ) + + # 5. Persist and return + return await self._repository.update_household(updated_dto) diff --git a/app/context/household/domain/value_objects/__init__.py b/app/context/household/domain/value_objects/__init__.py new file mode 100644 index 0000000..66a4d27 --- /dev/null +++ b/app/context/household/domain/value_objects/__init__.py @@ -0,0 +1,17 @@ +from .deleted_at import HouseholdDeletedAt +from .household_id import HouseholdID +from .household_member_id import HouseholdMemberID +from .household_name import HouseholdName +from .household_role import HouseholdRole +from .household_user_id import HouseholdUserID +from .household_user_name import HouseholdUserName + +__all__ = [ + "HouseholdDeletedAt", + "HouseholdID", + "HouseholdMemberID", + "HouseholdName", + "HouseholdRole", + "HouseholdUserID", + "HouseholdUserName", +] diff --git a/app/context/household/domain/value_objects/deleted_at.py b/app/context/household/domain/value_objects/deleted_at.py new file mode 100644 index 0000000..c849db8 --- /dev/null +++ b/app/context/household/domain/value_objects/deleted_at.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_deleted_at import SharedDeletedAt + + +@dataclass(frozen=True) +class HouseholdDeletedAt(SharedDeletedAt): + """Household context-specific deleted_at value object""" + + pass diff --git a/app/context/household/domain/value_objects/household_id.py b/app/context/household/domain/value_objects/household_id.py new file mode 100644 index 0000000..ead195f --- /dev/null +++ b/app/context/household/domain/value_objects/household_id.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class HouseholdID: + value: int + + def __post_init__(self): + if not isinstance(self.value, int): + raise ValueError(f"HouseholdID must be an integer, got {type(self.value)}") + if self.value <= 0: + raise ValueError(f"HouseholdID must be positive, got {self.value}") diff --git a/app/context/household/domain/value_objects/household_member_id.py b/app/context/household/domain/value_objects/household_member_id.py new file mode 100644 index 0000000..33e7d45 --- /dev/null +++ b/app/context/household/domain/value_objects/household_member_id.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class HouseholdMemberID: + value: int + + def __post_init__(self): + if not isinstance(self.value, int): + raise ValueError("Household member ID must be an integer") + if self.value <= 0: + raise ValueError("Household member ID must be a positive integer") diff --git a/app/context/household/domain/value_objects/household_name.py b/app/context/household/domain/value_objects/household_name.py new file mode 100644 index 0000000..ed7573e --- /dev/null +++ b/app/context/household/domain/value_objects/household_name.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class HouseholdName: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not self.value or not self.value.strip(): + raise ValueError("Household name cannot be empty") + if len(self.value) > 100: + raise ValueError(f"Household name cannot exceed 100 characters, got {len(self.value)}") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """Create HouseholdName from trusted source - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/household/domain/value_objects/household_role.py b/app/context/household/domain/value_objects/household_role.py new file mode 100644 index 0000000..abf6a22 --- /dev/null +++ b/app/context/household/domain/value_objects/household_role.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class HouseholdRole: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + # Only role available for now is participant + VALID_ROLES = {"participant"} + + def __post_init__(self): + if not self._validated: + if not self.value: + raise ValueError("Role cannot be empty") + if self.value not in self.VALID_ROLES: + raise ValueError(f"Invalid role: '{self.value}'. Must be one of {self.VALID_ROLES}") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """Create role from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/household/domain/value_objects/household_user_id.py b/app/context/household/domain/value_objects/household_user_id.py new file mode 100644 index 0000000..4af13d0 --- /dev/null +++ b/app/context/household/domain/value_objects/household_user_id.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Self + + +@dataclass(frozen=True) +class HouseholdUserID: + value: int + + def __post_init__(self): + if not isinstance(self.value, int): + raise ValueError(f"HouseholdUserID must be an integer, got {type(self.value)}") + if self.value <= 0: + raise ValueError(f"HouseholdUserID must be positive, got {self.value}") + + def is_equal(self, otherID: Self) -> bool: + return self.value == otherID.value diff --git a/app/context/household/domain/value_objects/household_user_name.py b/app/context/household/domain/value_objects/household_user_name.py new file mode 100644 index 0000000..9c6c6c1 --- /dev/null +++ b/app/context/household/domain/value_objects/household_user_name.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedUsername + + +@dataclass(frozen=True) +class HouseholdUserName(SharedUsername): + pass diff --git a/app/context/household/infrastructure/__init__.py b/app/context/household/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/household/infrastructure/dependencies.py b/app/context/household/infrastructure/dependencies.py new file mode 100644 index 0000000..a9fe201 --- /dev/null +++ b/app/context/household/infrastructure/dependencies.py @@ -0,0 +1,190 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.household.application.contracts import ( + AcceptInviteHandlerContract, + CreateHouseholdHandlerContract, + DeclineInviteHandlerContract, + DeleteHouseholdHandlerContract, + GetHouseholdHandlerContract, + InviteUserHandlerContract, + ListHouseholdInvitesHandlerContract, + ListUserHouseholdsHandlerContract, + ListUserPendingInvitesHandlerContract, + RemoveMemberHandlerContract, + UpdateHouseholdHandlerContract, +) +from app.context.household.application.handlers import ( + AcceptInviteHandler, + CreateHouseholdHandler, + DeclineInviteHandler, + DeleteHouseholdHandler, + GetHouseholdHandler, + InviteUserHandler, + ListHouseholdInvitesHandler, + ListUserHouseholdsHandler, + ListUserPendingInvitesHandler, + RemoveMemberHandler, + UpdateHouseholdHandler, +) +from app.context.household.domain.contracts import ( + AcceptInviteServiceContract, + CreateHouseholdServiceContract, + DeclineInviteServiceContract, + HouseholdRepositoryContract, + InviteUserServiceContract, + RemoveMemberServiceContract, + RevokeInviteServiceContract, + UpdateHouseholdServiceContract, +) +from app.context.household.domain.services import ( + AcceptInviteService, + CreateHouseholdService, + DeclineInviteService, + InviteUserService, + RemoveMemberService, + RevokeInviteService, + UpdateHouseholdService, +) +from app.context.household.infrastructure.repositories import HouseholdRepository +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.database import get_db +from app.shared.infrastructure.dependencies import get_logger + + +# Repository dependencies +def get_household_repository( + db: Annotated[AsyncSession, Depends(get_db)], +) -> HouseholdRepositoryContract: + return HouseholdRepository(db) + + +# Service dependencies +def get_create_household_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateHouseholdServiceContract: + return CreateHouseholdService(household_repository, logger) + + +def get_invite_user_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> InviteUserServiceContract: + return InviteUserService(household_repository, logger) + + +def get_accept_invite_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> AcceptInviteServiceContract: + return AcceptInviteService(household_repository, logger) + + +def get_decline_invite_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> DeclineInviteServiceContract: + return DeclineInviteService(household_repository, logger) + + +def get_remove_member_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> RemoveMemberServiceContract: + return RemoveMemberService(household_repository, logger) + + +def get_update_household_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateHouseholdServiceContract: + return UpdateHouseholdService(household_repository, logger) + + +def get_revoke_invite_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> RevokeInviteServiceContract: + return RevokeInviteService(household_repository, logger) + + +# Handler dependencies (Commands) +def get_create_household_handler( + service: Annotated[CreateHouseholdServiceContract, Depends(get_create_household_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateHouseholdHandlerContract: + return CreateHouseholdHandler(service, logger) + + +def get_invite_user_handler( + service: Annotated[InviteUserServiceContract, Depends(get_invite_user_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> InviteUserHandlerContract: + return InviteUserHandler(service, logger) + + +def get_accept_invite_handler( + service: Annotated[AcceptInviteServiceContract, Depends(get_accept_invite_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> AcceptInviteHandlerContract: + return AcceptInviteHandler(service, logger) + + +def get_decline_invite_handler( + service: Annotated[DeclineInviteServiceContract, Depends(get_decline_invite_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> DeclineInviteHandlerContract: + return DeclineInviteHandler(service, logger) + + +def get_remove_member_handler( + service: Annotated[RemoveMemberServiceContract, Depends(get_remove_member_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> RemoveMemberHandlerContract: + return RemoveMemberHandler(service, logger) + + +def get_update_household_handler( + service: Annotated[UpdateHouseholdServiceContract, Depends(get_update_household_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateHouseholdHandlerContract: + return UpdateHouseholdHandler(service, logger) + + +def get_delete_household_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> DeleteHouseholdHandlerContract: + return DeleteHouseholdHandler(repository, logger) + + +# Handler dependencies (Queries) +def get_list_household_invites_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> ListHouseholdInvitesHandlerContract: + return ListHouseholdInvitesHandler(repository, logger) + + +def get_list_user_pending_invites_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> ListUserPendingInvitesHandlerContract: + return ListUserPendingInvitesHandler(repository, logger) + + +def get_get_household_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> GetHouseholdHandlerContract: + return GetHouseholdHandler(repository, logger) + + +def get_list_user_households_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> ListUserHouseholdsHandlerContract: + return ListUserHouseholdsHandler(repository, logger) diff --git a/app/context/household/infrastructure/mappers/__init__.py b/app/context/household/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..edea2b7 --- /dev/null +++ b/app/context/household/infrastructure/mappers/__init__.py @@ -0,0 +1,4 @@ +from .household_mapper import HouseholdMapper +from .household_member_mapper import HouseholdMemberMapper + +__all__ = ["HouseholdMapper", "HouseholdMemberMapper"] diff --git a/app/context/household/infrastructure/mappers/household_mapper.py b/app/context/household/infrastructure/mappers/household_mapper.py new file mode 100644 index 0000000..58aa1a0 --- /dev/null +++ b/app/context/household/infrastructure/mappers/household_mapper.py @@ -0,0 +1,52 @@ +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.exceptions import HouseholdMapperError +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) +from app.context.household.infrastructure.models import HouseholdModel + + +class HouseholdMapper: + @staticmethod + def to_dto(model: HouseholdModel | None) -> HouseholdDTO | None: + """Convert database model to domain DTO""" + if model is None: + return None + + try: + return HouseholdDTO( + household_id=HouseholdID(model.id), + owner_user_id=HouseholdUserID(model.owner_user_id), + name=HouseholdName.from_trusted_source(model.name), + created_at=model.created_at, + ) + except Exception as e: + raise HouseholdMapperError(f"Error mapping HouseholdModel to DTO: {e}") from e + + @staticmethod + def to_dto_or_fail(model: HouseholdModel | None) -> HouseholdDTO: + dto = HouseholdMapper.to_dto(model) + if not dto: + raise HouseholdMapperError("Error mapping HouseholdModel to DTO") + return dto + + @staticmethod + def to_model(dto: HouseholdDTO) -> HouseholdModel: + """Convert domain DTO to database model""" + try: + model = HouseholdModel( + owner_user_id=dto.owner_user_id.value, + name=dto.name.value, + ) + + if dto.household_id is not None: + model.id = dto.household_id.value + + if dto.created_at is not None: + model.created_at = dto.created_at + + return model + except Exception as e: + raise HouseholdMapperError(f"Error mapping HouseholdDTO to model: {e}") from e diff --git a/app/context/household/infrastructure/mappers/household_member_mapper.py b/app/context/household/infrastructure/mappers/household_member_mapper.py new file mode 100644 index 0000000..afbac86 --- /dev/null +++ b/app/context/household/infrastructure/mappers/household_member_mapper.py @@ -0,0 +1,63 @@ +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.exceptions import HouseholdMapperError +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.domain.value_objects.household_user_name import ( + HouseholdUserName, +) +from app.context.household.infrastructure.models import ( + HouseholdMemberModel, + HouseholdModel, +) +from app.context.user.infrastructure.models import UserModel + + +class HouseholdMemberMapper: + """Mapper for converting between HouseholdMemberModel and HouseholdMemberDTO""" + + @staticmethod + def to_dto( + model: HouseholdMemberModel, + household_model: HouseholdModel | None = None, + user_model: UserModel | None = None, + ) -> HouseholdMemberDTO: + """Convert database model to domain DTO""" + return HouseholdMemberDTO( + member_id=HouseholdMemberID(model.id), + household_id=HouseholdID(model.household_id), + user_id=HouseholdUserID(model.user_id), + role=HouseholdRole.from_trusted_source(model.role), + joined_at=model.joined_at, + invited_by_user_id=( + HouseholdUserID(model.invited_by_user_id) if model.invited_by_user_id is not None else None + ), + invited_at=model.invited_at, + household_name=(HouseholdName(household_model.name) if household_model else None), + inviter_username=(HouseholdUserName(user_model.username or user_model.email) if user_model else None), + ) + + @staticmethod + def to_model(dto: HouseholdMemberDTO) -> HouseholdMemberModel: + """Convert domain DTO to database model""" + return HouseholdMemberModel( + id=dto.member_id.value if dto.member_id else None, + household_id=dto.household_id.value, + user_id=dto.user_id.value, + role=dto.role.value, + joined_at=dto.joined_at, + invited_by_user_id=(dto.invited_by_user_id.value if dto.invited_by_user_id else None), + invited_at=dto.invited_at, + ) + + @staticmethod + def to_dto_or_fail(model: HouseholdMemberModel) -> HouseholdMemberDTO: + """Convert model to DTO, raise HouseholdMapperError if fails""" + try: + return HouseholdMemberMapper.to_dto(model) + except Exception as e: + raise HouseholdMapperError(f"Failed to map model to DTO: {str(e)}") from Exception diff --git a/app/context/household/infrastructure/models/__init__.py b/app/context/household/infrastructure/models/__init__.py new file mode 100644 index 0000000..2d4fad6 --- /dev/null +++ b/app/context/household/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .household_model import HouseholdMemberModel, HouseholdModel + +__all__ = ["HouseholdModel", "HouseholdMemberModel"] diff --git a/app/context/household/infrastructure/models/household_model.py b/app/context/household/infrastructure/models/household_model.py new file mode 100644 index 0000000..55d2091 --- /dev/null +++ b/app/context/household/infrastructure/models/household_model.py @@ -0,0 +1,47 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class HouseholdModel(BaseDBModel): + __tablename__ = "households" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + owner_user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + ) + + +class HouseholdMemberModel(BaseDBModel): + __tablename__ = "household_members" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + household_id: Mapped[int] = mapped_column(Integer, ForeignKey("households.id", ondelete="CASCADE"), nullable=False) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False) + role: Mapped[str] = mapped_column(String(20), nullable=False, default="participant") + # NULL = invited (pending), NOT NULL = active member + joined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) + invited_by_user_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=True, + default=None, + ) + invited_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True, default=lambda: datetime.now(UTC) + ) + + # Composite unique constraint - user can only have one record per household + __table_args__ = (UniqueConstraint("household_id", "user_id", name="uq_household_user"),) diff --git a/app/context/household/infrastructure/repositories/__init__.py b/app/context/household/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..60901a9 --- /dev/null +++ b/app/context/household/infrastructure/repositories/__init__.py @@ -0,0 +1,3 @@ +from .household_repository import HouseholdRepository + +__all__ = ["HouseholdRepository"] diff --git a/app/context/household/infrastructure/repositories/household_repository.py b/app/context/household/infrastructure/repositories/household_repository.py new file mode 100644 index 0000000..1c599c9 --- /dev/null +++ b/app/context/household/infrastructure/repositories/household_repository.py @@ -0,0 +1,350 @@ +from datetime import UTC, datetime +from typing import Any, cast + +from sqlalchemy import and_, select, union, update +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased + +from app.context.household.domain.contracts import HouseholdRepositoryContract +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.exceptions import ( + HouseholdNameAlreadyExistError, + HouseholdNotFoundError, + InviteNotFoundError, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) +from app.context.household.infrastructure.mappers import ( + HouseholdMapper, + HouseholdMemberMapper, +) +from app.context.household.infrastructure.models import ( + HouseholdMemberModel, + HouseholdModel, +) +from app.context.user.infrastructure.models.user_model import UserModel + + +class HouseholdRepository(HouseholdRepositoryContract): + def __init__(self, db: AsyncSession): + self._db = db + + async def create_household(self, household_dto: HouseholdDTO) -> HouseholdDTO: + """Create a new household with the owner stored in the household table""" + + household_model = HouseholdMapper.to_model(household_dto) + + self._db.add(household_model) + + try: + await self._db.commit() + await self._db.refresh(household_model) + except IntegrityError as e: + await self._db.rollback() + if "uq_households_owner_name" in str(e.orig): + raise HouseholdNameAlreadyExistError( + f"Household with name '{household_dto.name.value}' already exists for this user" + ) from None + raise Exception(e) from None + + return HouseholdMapper.to_dto_or_fail(household_model) + + async def find_household_by_name(self, name: HouseholdName, user_id: HouseholdUserID) -> HouseholdDTO | None: + """Find a household by name for a specific user (owner)""" + + stmt = select(HouseholdModel).where( + and_( + HouseholdModel.owner_user_id == user_id.value, + HouseholdModel.name == name.value, + HouseholdModel.deleted_at.is_(None), + ) + ) + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return HouseholdMapper.to_dto(model) if model else None + + async def find_household_by_id(self, household_id: HouseholdID) -> HouseholdDTO | None: + """Find a household by ID""" + + stmt = select(HouseholdModel).where( + and_( + HouseholdModel.id == household_id.value, + HouseholdModel.deleted_at.is_(None), + ) + ) + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return HouseholdMapper.to_dto(model) if model else None + + # Member management methods + async def create_member(self, member: HouseholdMemberDTO) -> HouseholdMemberDTO: + """Create a new household member (for invites or direct adds)""" + member_model = HouseholdMemberMapper.to_model(member) + self._db.add(member_model) + + await self._db.commit() + await self._db.refresh(member_model) + + return HouseholdMemberMapper.to_dto_or_fail(member_model) + + async def find_member(self, household_id: HouseholdID, user_id: HouseholdUserID) -> HouseholdMemberDTO | None: + """Find the most recent member record for user in household""" + stmt = ( + select(HouseholdMemberModel) + .where( + and_( + HouseholdMemberModel.household_id == household_id.value, + HouseholdMemberModel.user_id == user_id.value, + ) + ) + .order_by(HouseholdMemberModel.invited_at.desc()) + .limit(1) + ) + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return HouseholdMemberMapper.to_dto(model) if model else None + + async def accept_invite(self, household_id: HouseholdID, user_id: HouseholdUserID) -> HouseholdMemberDTO: + """Accept invite by setting joined_at to current timestamp""" + # Find the pending invite + stmt = ( + select(HouseholdMemberModel) + .where( + and_( + HouseholdMemberModel.household_id == household_id.value, + HouseholdMemberModel.user_id == user_id.value, + HouseholdMemberModel.joined_at.is_(None), + ) + ) + .order_by(HouseholdMemberModel.invited_at.desc()) + .limit(1) + ) + + result = await self._db.execute(stmt) + member_model = result.scalar_one_or_none() + + if not member_model: + raise InviteNotFoundError("No pending invite found") + + # Update joined_at + member_model.joined_at = datetime.now(UTC) + + await self._db.commit() + await self._db.refresh(member_model) + + return HouseholdMemberMapper.to_dto_or_fail(member_model) + + async def revoke_or_remove(self, household_id: HouseholdID, user_id: HouseholdUserID) -> None: + """Revoke invite or remove member by setting left_at to current timestamp""" + # Find active invite or membership + stmt = ( + select(HouseholdMemberModel) + .where( + and_( + HouseholdMemberModel.household_id == household_id.value, + HouseholdMemberModel.user_id == user_id.value, + ) + ) + .order_by(HouseholdMemberModel.invited_at.desc()) + .limit(1) + ) + + result = await self._db.execute(stmt) + member_model = result.scalar_one_or_none() + + if not member_model: + raise InviteNotFoundError("No active invite or membership found") + + await self._db.commit() + + async def list_user_households(self, user_id: HouseholdUserID) -> list[HouseholdDTO]: + """List all households user owns or is an active participant in""" + # Get households where user is owner + owner_stmt = select(HouseholdModel).where( + and_( + HouseholdModel.owner_user_id == user_id.value, + HouseholdModel.deleted_at.is_(None), + ) + ) + + # Get households where user is active member + member_stmt = ( + select(HouseholdModel) + .join( + HouseholdMemberModel, + HouseholdModel.id == HouseholdMemberModel.household_id, + ) + .where( + and_( + HouseholdMemberModel.user_id == user_id.value, + HouseholdMemberModel.joined_at.isnot(None), + HouseholdModel.deleted_at.is_(None), + ) + ) + ) + + # Combine with UNION (automatically deduplicates) + combined_stmt = union(owner_stmt, member_stmt) + + result = await self._db.execute(combined_stmt) + households = result.scalars().all() + + return [HouseholdMapper.to_dto_or_fail(h) for h in households] + + async def list_user_pending_invites(self, user_id: HouseholdUserID) -> list[HouseholdDTO]: + """List all households user has been invited to but not yet accepted""" + stmt = ( + select(HouseholdModel) + .join( + HouseholdMemberModel, + HouseholdModel.id == HouseholdMemberModel.household_id, + ) + .where( + and_( + HouseholdMemberModel.user_id == user_id.value, + HouseholdMemberModel.joined_at.is_(None), + HouseholdModel.deleted_at.is_(None), + ) + ) + ) + + result = await self._db.execute(stmt) + households = result.scalars().all() + + return [HouseholdMapper.to_dto_or_fail(h) for h in households] + + async def list_user_pending_household_invites(self, user_id: HouseholdUserID) -> list[HouseholdMemberDTO]: + """List user pending invitation to households""" + InviterUser = aliased(UserModel) + stmt = ( + select(HouseholdMemberModel, HouseholdModel, InviterUser) + .join(HouseholdModel, HouseholdModel.id == HouseholdMemberModel.household_id) + .join(InviterUser, InviterUser.id == HouseholdMemberModel.invited_by_user_id) + .where( + and_( + HouseholdMemberModel.user_id == user_id.value, + HouseholdMemberModel.joined_at.is_(None), + HouseholdModel.deleted_at.is_(None), + ) + ) + ) + + result = await self._db.execute(stmt) + rows = result.all() + + # Each row is a tuple: (HouseholdMemberModel, HouseholdModel, InviterUser) + member_list = [] + for member_model, household_model, inviter_model in rows: + member_dto = HouseholdMemberMapper.to_dto(member_model, household_model, inviter_model) + member_list.append(member_dto) + + return member_list + + async def list_household_pending_invites( + self, household_id: HouseholdID, owner_id: HouseholdUserID + ) -> list[HouseholdMemberDTO]: + """List all pending invites for a household with household name and inviter username""" + # Create alias for the inviter user + InviterUser = aliased(UserModel) + + # Join with HouseholdModel and UserModel to get household name and inviter username + stmt = ( + select(HouseholdMemberModel, HouseholdModel, InviterUser) + .join(HouseholdModel, HouseholdModel.id == HouseholdMemberModel.household_id) + .join(InviterUser, InviterUser.id == HouseholdMemberModel.invited_by_user_id) + .where( + and_( + HouseholdMemberModel.household_id == household_id.value, + HouseholdModel.owner_user_id == owner_id.value, + HouseholdMemberModel.joined_at.is_(None), + HouseholdModel.deleted_at.is_(None), + ) + ) + ) + + result = await self._db.execute(stmt) + rows = result.all() + + # Each row is a tuple: (HouseholdMemberModel, HouseholdModel, InviterUser) + member_list = [] + for member_model, household_model, inviter_model in rows: + member_dto = HouseholdMemberMapper.to_dto(member_model, household_model, inviter_model) + member_list.append(member_dto) + + return member_list + + async def update_household(self, household: HouseholdDTO) -> HouseholdDTO: + """Update household name""" + if household.household_id is None: + raise ValueError("Household ID not given") + + stmt = ( + update(HouseholdModel) + .where( + and_( + HouseholdModel.id == household.household_id.value, + HouseholdModel.deleted_at.is_(None), + ) + ) + .values(name=household.name.value) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + if result.rowcount == 0: + raise HouseholdNotFoundError( + f"Household with ID {household.household_id.value} not found or already deleted" + ) + + await self._db.commit() + return household + + async def delete_household(self, household_id: HouseholdID, user_id: HouseholdUserID) -> bool: + """Soft delete a household (owner only)""" + # Verify exists and user owns it + household = await self.find_household_by_id(household_id) + # if not household or household.owner_user_id.value != user_id.value: + if not household or not household.owner_user_id.is_equal(user_id): + return False + + # Soft delete + stmt = ( + update(HouseholdModel) + .where( + and_( + HouseholdModel.id == household_id.value, + HouseholdModel.owner_user_id == user_id.value, + HouseholdModel.deleted_at.is_(None), + ) + ) + .values(deleted_at=datetime.now(UTC)) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + await self._db.commit() + + return result.rowcount > 0 + + async def user_has_access(self, user_id: HouseholdUserID, household_id: HouseholdID) -> bool: + """Check if user owns or is an active member of household""" + # Check if owner + household = await self.find_household_by_id(household_id) + if household and household.owner_user_id.value == user_id.value: + return True + + # Check if active member + member = await self.find_member(household_id, user_id) + if member and member.is_active: + return True + + return False diff --git a/app/context/household/interface/__init__.py b/app/context/household/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/household/interface/rest/__init__.py b/app/context/household/interface/rest/__init__.py new file mode 100644 index 0000000..bc3b8e9 --- /dev/null +++ b/app/context/household/interface/rest/__init__.py @@ -0,0 +1,3 @@ +from .routes import household_routes + +__all__ = ["household_routes"] diff --git a/app/context/household/interface/rest/controllers/__init__.py b/app/context/household/interface/rest/controllers/__init__.py new file mode 100644 index 0000000..b7b4d47 --- /dev/null +++ b/app/context/household/interface/rest/controllers/__init__.py @@ -0,0 +1,27 @@ +from .accept_invite_controller import router as accept_invite_router +from .create_household_controller import router as create_household_router +from .decline_invite_controller import router as decline_invite_router +from .delete_household_controller import router as delete_household_router +from .get_household_controller import router as get_household_router +from .invite_user_controller import router as invite_user_router +from .list_household_invites_controller import router as list_household_invites_router +from .list_user_households_controller import router as list_user_households_router +from .list_user_pending_invites_controller import ( + router as list_user_pending_invites_router, +) +from .remove_member_controller import router as remove_member_router +from .update_household_controller import router as update_household_router + +__all__ = [ + "create_household_router", + "invite_user_router", + "accept_invite_router", + "decline_invite_router", + "remove_member_router", + "get_household_router", + "list_household_invites_router", + "list_user_households_router", + "list_user_pending_invites_router", + "update_household_router", + "delete_household_router", +] diff --git a/app/context/household/interface/rest/controllers/accept_invite_controller.py b/app/context/household/interface/rest/controllers/accept_invite_controller.py new file mode 100644 index 0000000..3758ace --- /dev/null +++ b/app/context/household/interface/rest/controllers/accept_invite_controller.py @@ -0,0 +1,57 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.commands import AcceptInviteCommand +from app.context.household.application.contracts import AcceptInviteHandlerContract +from app.context.household.application.dto import AcceptInviteErrorCode +from app.context.household.infrastructure.dependencies import get_accept_invite_handler +from app.context.household.interface.schemas import AcceptInviteResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.post("/{household_id}/invites/accept", status_code=200) +async def accept_invite( + household_id: int, + handler: Annotated[AcceptInviteHandlerContract, Depends(get_accept_invite_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> AcceptInviteResponse: + """Accept a household invitation""" + + logger.info("Accept invite request", user_id=user_id, household_id=household_id) + + command = AcceptInviteCommand( + user_id=user_id, + household_id=household_id, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + AcceptInviteErrorCode.NOT_INVITED: 404, # Not Found + AcceptInviteErrorCode.MAPPER_ERROR: 500, # Internal Server Error + AcceptInviteErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Accept invite failed", household_id=household_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.member_id: + logger.error("Accept invite failed - missing member ID", household_id=household_id) + raise HTTPException(status_code=500, detail="Unexpected server error") + + logger.info("Invite accepted successfully", user_id=user_id, household_id=household_id) + return AcceptInviteResponse( + member_id=result.member_id, + household_id=result.household_id, + user_id=result.user_id, + role=result.role, + ) diff --git a/app/context/household/interface/rest/controllers/create_household_controller.py b/app/context/household/interface/rest/controllers/create_household_controller.py new file mode 100644 index 0000000..1bf1bf1 --- /dev/null +++ b/app/context/household/interface/rest/controllers/create_household_controller.py @@ -0,0 +1,61 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.commands import CreateHouseholdCommand +from app.context.household.application.contracts import CreateHouseholdHandlerContract +from app.context.household.application.dto import CreateHouseholdErrorCode +from app.context.household.infrastructure.dependencies import ( + get_create_household_handler, +) +from app.context.household.interface.schemas import ( + CreateHouseholdRequest, + CreateHouseholdResponse, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.post("/", status_code=201) +async def create_household( + request: CreateHouseholdRequest, + handler: Annotated[CreateHouseholdHandlerContract, Depends(get_create_household_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateHouseholdResponse: + """Create a new household""" + + logger.info("Create household request", user_id=user_id, name=request.name) + + command = CreateHouseholdCommand( + user_id=user_id, + name=request.name, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + # Map error codes to status codes + status_code_map = { + CreateHouseholdErrorCode.NAME_ALREADY_EXISTS: 409, # Conflict + CreateHouseholdErrorCode.MAPPER_ERROR: 500, # Internal Server Error + CreateHouseholdErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Create household failed", user_id=user_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + elif not result.household_id or not result.household_name: + logger.error("Create household failed - missing data", user_id=user_id) + raise HTTPException(status_code=500, detail="unexpected server error") + + # Return success response + logger.info("Household created successfully", user_id=user_id, household_id=result.household_id) + return CreateHouseholdResponse( + id=result.household_id, + name=result.household_name, + ) diff --git a/app/context/household/interface/rest/controllers/decline_invite_controller.py b/app/context/household/interface/rest/controllers/decline_invite_controller.py new file mode 100644 index 0000000..0d22b6e --- /dev/null +++ b/app/context/household/interface/rest/controllers/decline_invite_controller.py @@ -0,0 +1,50 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.commands import DeclineInviteCommand +from app.context.household.application.contracts import DeclineInviteHandlerContract +from app.context.household.application.dto import DeclineInviteErrorCode +from app.context.household.infrastructure.dependencies import get_decline_invite_handler +from app.context.household.interface.schemas import DeclineInviteResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.post("/{household_id}/invites/decline", status_code=200) +async def decline_invite( + household_id: int, + handler: Annotated[DeclineInviteHandlerContract, Depends(get_decline_invite_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> DeclineInviteResponse: + """Decline a household invitation""" + + logger.info("Decline invite request", user_id=user_id, household_id=household_id) + + command = DeclineInviteCommand( + user_id=user_id, + household_id=household_id, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + DeclineInviteErrorCode.NOT_INVITED: 404, # Not Found + DeclineInviteErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Decline invite failed", household_id=household_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + + logger.info("Invite declined successfully", user_id=user_id, household_id=household_id) + return DeclineInviteResponse( + success=True, + message="Invitation declined successfully", + ) diff --git a/app/context/household/interface/rest/controllers/delete_household_controller.py b/app/context/household/interface/rest/controllers/delete_household_controller.py new file mode 100644 index 0000000..22d470d --- /dev/null +++ b/app/context/household/interface/rest/controllers/delete_household_controller.py @@ -0,0 +1,47 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.commands import DeleteHouseholdCommand +from app.context.household.application.contracts import DeleteHouseholdHandlerContract +from app.context.household.application.dto import DeleteHouseholdErrorCode +from app.context.household.infrastructure.dependencies import get_delete_household_handler +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.delete("/{household_id}", status_code=204) +async def delete_household( + household_id: int, + handler: Annotated[DeleteHouseholdHandlerContract, Depends(get_delete_household_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Soft delete household (owner only)""" + + logger.info("Delete household request", household_id=household_id, user_id=user_id) + + command = DeleteHouseholdCommand( + household_id=household_id, + user_id=user_id, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + DeleteHouseholdErrorCode.NOT_FOUND: 404, + DeleteHouseholdErrorCode.NOT_OWNER: 403, + DeleteHouseholdErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Delete household failed", household_id=household_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return 204 No Content on success + logger.info("Household deleted successfully", household_id=household_id, user_id=user_id) + return diff --git a/app/context/household/interface/rest/controllers/get_household_controller.py b/app/context/household/interface/rest/controllers/get_household_controller.py new file mode 100644 index 0000000..58d198f --- /dev/null +++ b/app/context/household/interface/rest/controllers/get_household_controller.py @@ -0,0 +1,50 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.contracts import GetHouseholdHandlerContract +from app.context.household.application.dto import GetHouseholdErrorCode +from app.context.household.application.queries import GetHouseholdQuery +from app.context.household.infrastructure.dependencies import get_get_household_handler +from app.context.household.interface.schemas import HouseholdResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.get("/{household_id}", response_model=HouseholdResponse) +async def get_household( + household_id: int, + handler: Annotated[GetHouseholdHandlerContract, Depends(get_get_household_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Get household by ID (owner or active member only)""" + + logger.info("Get household request", household_id=household_id, user_id=user_id) + + query = GetHouseholdQuery(household_id=household_id, user_id=user_id) + + result = await handler.handle(query) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + GetHouseholdErrorCode.NOT_FOUND: 404, + GetHouseholdErrorCode.UNAUTHORIZED_ACCESS: 403, + GetHouseholdErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Get household failed", household_id=household_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + logger.info("Household retrieved successfully", household_id=household_id, user_id=user_id) + return HouseholdResponse( + id=result.household_id, + name=result.household_name, + owner_user_id=result.owner_user_id, + created_at=result.created_at, + ) diff --git a/app/context/household/interface/rest/controllers/invite_user_controller.py b/app/context/household/interface/rest/controllers/invite_user_controller.py new file mode 100644 index 0000000..693a750 --- /dev/null +++ b/app/context/household/interface/rest/controllers/invite_user_controller.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.commands import InviteUserCommand +from app.context.household.application.contracts import InviteUserHandlerContract +from app.context.household.application.dto import InviteUserErrorCode +from app.context.household.infrastructure.dependencies import get_invite_user_handler +from app.context.household.interface.schemas import ( + InviteUserRequest, + InviteUserResponse, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.post("/{household_id}/invites", status_code=201) +async def invite_user( + household_id: int, + request: InviteUserRequest, + handler: Annotated[InviteUserHandlerContract, Depends(get_invite_user_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> InviteUserResponse: + """Invite a user to a household""" + + logger.info( + "Invite user request", + inviter_user_id=user_id, + household_id=household_id, + invitee_user_id=request.invitee_user_id, + ) + + command = InviteUserCommand( + inviter_user_id=user_id, + household_id=household_id, + invitee_user_id=request.invitee_user_id, + role=request.role, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + InviteUserErrorCode.ONLY_OWNER_CAN_INVITE: 403, # Forbidden + InviteUserErrorCode.ALREADY_ACTIVE_MEMBER: 409, # Conflict + InviteUserErrorCode.ALREADY_INVITED: 409, # Conflict + InviteUserErrorCode.MAPPER_ERROR: 500, # Internal Server Error + InviteUserErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Invite user failed", household_id=household_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.member_id: + logger.error("Invite user failed - missing member ID", household_id=household_id) + raise HTTPException(status_code=500, detail="Unexpected server error") + + logger.info("User invited successfully", household_id=household_id, invitee_user_id=request.invitee_user_id) + return InviteUserResponse( + member_id=result.member_id, + household_id=result.household_id, + user_id=result.user_id, + role=result.role, + ) diff --git a/app/context/household/interface/rest/controllers/list_household_invites_controller.py b/app/context/household/interface/rest/controllers/list_household_invites_controller.py new file mode 100644 index 0000000..6c93e71 --- /dev/null +++ b/app/context/household/interface/rest/controllers/list_household_invites_controller.py @@ -0,0 +1,49 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.context.household.application.contracts import ( + ListHouseholdInvitesHandlerContract, +) +from app.context.household.application.queries import ListHouseholdInvitesQuery +from app.context.household.infrastructure.dependencies import ( + get_list_household_invites_handler, +) +from app.context.household.interface.schemas import HouseholdMemberResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.get("/{household_id}/invites", status_code=200) +async def list_household_invites( + household_id: int, + handler: Annotated[ListHouseholdInvitesHandlerContract, Depends(get_list_household_invites_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> list[HouseholdMemberResponse]: + """List pending invitations for a household""" + + logger.info("List household invites request", household_id=household_id, user_id=user_id) + + query = ListHouseholdInvitesQuery(household_id=household_id, user_id=user_id) + + members = await handler.handle(query) + + logger.info("Household invites retrieved successfully", household_id=household_id, count=len(members)) + return [ + HouseholdMemberResponse( + member_id=member.member_id, + household_id=member.household_id, + user_id=member.user_id, + role=member.role, + joined_at=member.joined_at, + invited_by_user_id=member.invited_by_user_id, + invited_at=member.invited_at, + household_name=member.household_name, + inviter=member.inviter, + ) + for member in members + ] diff --git a/app/context/household/interface/rest/controllers/list_user_households_controller.py b/app/context/household/interface/rest/controllers/list_user_households_controller.py new file mode 100644 index 0000000..89c401c --- /dev/null +++ b/app/context/household/interface/rest/controllers/list_user_households_controller.py @@ -0,0 +1,49 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.contracts import ListUserHouseholdsHandlerContract +from app.context.household.application.queries import ListUserHouseholdsQuery +from app.context.household.infrastructure.dependencies import ( + get_list_user_households_handler, +) +from app.context.household.interface.schemas import HouseholdResponse, ListHouseholdsResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.get("/", response_model=ListHouseholdsResponse) +async def list_user_households( + handler: Annotated[ListUserHouseholdsHandlerContract, Depends(get_list_user_households_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """List all households for the authenticated user""" + + logger.info("List user households request", user_id=user_id) + + query = ListUserHouseholdsQuery(user_id=user_id) + + result = await handler.handle(query) + + # Check for errors + if result.error_code: + logger.error("List user households failed", user_id=user_id) + raise HTTPException(status_code=500, detail=result.error_message) + + # Convert summaries to response objects + households = [ + HouseholdResponse( + id=h.household_id, + name=h.household_name, + owner_user_id=h.owner_user_id, + created_at=h.created_at, + ) + for h in (result.households if result.households else []) + ] + + logger.info("User households retrieved successfully", user_id=user_id, count=len(households)) + return ListHouseholdsResponse(households=households) diff --git a/app/context/household/interface/rest/controllers/list_user_pending_invites_controller.py b/app/context/household/interface/rest/controllers/list_user_pending_invites_controller.py new file mode 100644 index 0000000..afb6565 --- /dev/null +++ b/app/context/household/interface/rest/controllers/list_user_pending_invites_controller.py @@ -0,0 +1,48 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.context.household.application.contracts import ( + ListUserPendingInvitesHandlerContract, +) +from app.context.household.application.queries import ListUserPendingInvitesQuery +from app.context.household.infrastructure.dependencies import ( + get_list_user_pending_invites_handler, +) +from app.context.household.interface.schemas import HouseholdMemberResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.get("/invites/pending", status_code=200) +async def list_user_pending_invites( + handler: Annotated[ListUserPendingInvitesHandlerContract, Depends(get_list_user_pending_invites_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> list[HouseholdMemberResponse]: + """List all pending invitations for the authenticated user""" + + logger.info("List user pending invites request", user_id=user_id) + + query = ListUserPendingInvitesQuery(user_id=user_id) + + members = await handler.handle(query) + + logger.info("User pending invites retrieved successfully", user_id=user_id, count=len(members)) + return [ + HouseholdMemberResponse( + member_id=member.member_id, + household_id=member.household_id, + user_id=member.user_id, + role=member.role, + joined_at=member.joined_at, + invited_by_user_id=member.invited_by_user_id, + invited_at=member.invited_at, + household_name=member.household_name, + inviter=member.inviter, + ) + for member in members + ] diff --git a/app/context/household/interface/rest/controllers/remove_member_controller.py b/app/context/household/interface/rest/controllers/remove_member_controller.py new file mode 100644 index 0000000..2d3cb9b --- /dev/null +++ b/app/context/household/interface/rest/controllers/remove_member_controller.py @@ -0,0 +1,59 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.commands import RemoveMemberCommand +from app.context.household.application.contracts import RemoveMemberHandlerContract +from app.context.household.application.dto import RemoveMemberErrorCode +from app.context.household.infrastructure.dependencies import get_remove_member_handler +from app.context.household.interface.schemas import RemoveMemberResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.delete("/{household_id}/members/{member_user_id}", status_code=200) +async def remove_member( + household_id: int, + member_user_id: int, + handler: Annotated[RemoveMemberHandlerContract, Depends(get_remove_member_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> RemoveMemberResponse: + """Remove a member from a household""" + + logger.info( + "Remove member request", + remover_user_id=user_id, + household_id=household_id, + member_user_id=member_user_id, + ) + + command = RemoveMemberCommand( + remover_user_id=user_id, + household_id=household_id, + member_user_id=member_user_id, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + RemoveMemberErrorCode.ONLY_OWNER_CAN_REMOVE: 403, # Forbidden + RemoveMemberErrorCode.CANNOT_REMOVE_SELF: 400, # Bad Request + RemoveMemberErrorCode.MEMBER_NOT_FOUND: 404, # Not Found + RemoveMemberErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Remove member failed", household_id=household_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + + logger.info("Member removed successfully", household_id=household_id, member_user_id=member_user_id) + return RemoveMemberResponse( + success=True, + message="Member removed successfully", + ) diff --git a/app/context/household/interface/rest/controllers/update_household_controller.py b/app/context/household/interface/rest/controllers/update_household_controller.py new file mode 100644 index 0000000..2e8c7eb --- /dev/null +++ b/app/context/household/interface/rest/controllers/update_household_controller.py @@ -0,0 +1,57 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.household.application.commands import UpdateHouseholdCommand +from app.context.household.application.contracts import UpdateHouseholdHandlerContract +from app.context.household.application.dto import UpdateHouseholdErrorCode +from app.context.household.infrastructure.dependencies import get_update_household_handler +from app.context.household.interface.schemas import HouseholdResponse, UpdateHouseholdRequest +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.put("/{household_id}", response_model=HouseholdResponse) +async def update_household( + household_id: int, + request: UpdateHouseholdRequest, + handler: Annotated[UpdateHouseholdHandlerContract, Depends(get_update_household_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Update household name (owner only)""" + + logger.info("Update household request", household_id=household_id, user_id=user_id, name=request.name) + + command = UpdateHouseholdCommand( + household_id=household_id, + user_id=user_id, + name=request.name, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + UpdateHouseholdErrorCode.NOT_FOUND: 404, + UpdateHouseholdErrorCode.NOT_OWNER: 403, + UpdateHouseholdErrorCode.NAME_ALREADY_EXISTS: 409, + UpdateHouseholdErrorCode.MAPPER_ERROR: 500, + UpdateHouseholdErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + logger.warning("Update household failed", household_id=household_id, error_code=result.error_code.value) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + logger.info("Household updated successfully", household_id=household_id, user_id=user_id) + return HouseholdResponse( + id=result.household_id, + name=result.household_name, + owner_user_id=result.owner_user_id, + created_at="", # Not returned from update + ) diff --git a/app/context/household/interface/rest/routes.py b/app/context/household/interface/rest/routes.py new file mode 100644 index 0000000..1cb1aeb --- /dev/null +++ b/app/context/household/interface/rest/routes.py @@ -0,0 +1,30 @@ +from fastapi import APIRouter + +from app.context.household.interface.rest.controllers import ( + accept_invite_router, + create_household_router, + decline_invite_router, + delete_household_router, + get_household_router, + invite_user_router, + list_household_invites_router, + list_user_households_router, + list_user_pending_invites_router, + remove_member_router, + update_household_router, +) + +household_routes = APIRouter(prefix="/api/households", tags=["households"]) + +# Include all household-related routes +household_routes.include_router(create_household_router) +household_routes.include_router(get_household_router) +household_routes.include_router(list_user_households_router) +household_routes.include_router(update_household_router) +household_routes.include_router(delete_household_router) +household_routes.include_router(invite_user_router) +household_routes.include_router(accept_invite_router) +household_routes.include_router(decline_invite_router) +household_routes.include_router(remove_member_router) +household_routes.include_router(list_household_invites_router) +household_routes.include_router(list_user_pending_invites_router) diff --git a/app/context/household/interface/schemas/__init__.py b/app/context/household/interface/schemas/__init__.py new file mode 100644 index 0000000..206f078 --- /dev/null +++ b/app/context/household/interface/schemas/__init__.py @@ -0,0 +1,25 @@ +from .accept_invite_response import AcceptInviteResponse +from .create_household_request import CreateHouseholdRequest +from .create_household_response import CreateHouseholdResponse +from .decline_invite_response import DeclineInviteResponse +from .household_member_response import HouseholdMemberResponse +from .household_response import HouseholdResponse +from .invite_user_request import InviteUserRequest +from .invite_user_response import InviteUserResponse +from .list_households_response import ListHouseholdsResponse +from .remove_member_response import RemoveMemberResponse +from .update_household_request import UpdateHouseholdRequest + +__all__ = [ + "CreateHouseholdRequest", + "CreateHouseholdResponse", + "InviteUserRequest", + "InviteUserResponse", + "AcceptInviteResponse", + "DeclineInviteResponse", + "RemoveMemberResponse", + "HouseholdMemberResponse", + "HouseholdResponse", + "ListHouseholdsResponse", + "UpdateHouseholdRequest", +] diff --git a/app/context/household/interface/schemas/accept_invite_response.py b/app/context/household/interface/schemas/accept_invite_response.py new file mode 100644 index 0000000..b0e496d --- /dev/null +++ b/app/context/household/interface/schemas/accept_invite_response.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AcceptInviteResponse: + member_id: int + household_id: int + user_id: int + role: str diff --git a/app/context/household/interface/schemas/create_household_request.py b/app/context/household/interface/schemas/create_household_request.py new file mode 100644 index 0000000..8a878d0 --- /dev/null +++ b/app/context/household/interface/schemas/create_household_request.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class CreateHouseholdRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str = Field(..., min_length=1, max_length=100, description="Household name") diff --git a/app/context/household/interface/schemas/create_household_response.py b/app/context/household/interface/schemas/create_household_response.py new file mode 100644 index 0000000..5bdf6c0 --- /dev/null +++ b/app/context/household/interface/schemas/create_household_response.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CreateHouseholdResponse: + id: int + name: str diff --git a/app/context/household/interface/schemas/decline_invite_response.py b/app/context/household/interface/schemas/decline_invite_response.py new file mode 100644 index 0000000..eca547f --- /dev/null +++ b/app/context/household/interface/schemas/decline_invite_response.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeclineInviteResponse: + success: bool + message: str diff --git a/app/context/household/interface/schemas/household_member_response.py b/app/context/household/interface/schemas/household_member_response.py new file mode 100644 index 0000000..ea1d9ae --- /dev/null +++ b/app/context/household/interface/schemas/household_member_response.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class HouseholdMemberResponse: + """Response schema for household member/invite information""" + + member_id: int + household_id: int + user_id: int + role: str + joined_at: datetime | None + invited_by_user_id: int | None + invited_at: datetime | None + household_name: str | None + inviter: str | None diff --git a/app/context/household/interface/schemas/household_response.py b/app/context/household/interface/schemas/household_response.py new file mode 100644 index 0000000..5bd5cbc --- /dev/null +++ b/app/context/household/interface/schemas/household_response.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class HouseholdResponse: + """Shared response schema for household data""" + + id: int + name: str + owner_user_id: int + created_at: str # ISO format datetime string diff --git a/app/context/household/interface/schemas/invite_user_request.py b/app/context/household/interface/schemas/invite_user_request.py new file mode 100644 index 0000000..ea14835 --- /dev/null +++ b/app/context/household/interface/schemas/invite_user_request.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, ConfigDict, Field + + +class InviteUserRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + invitee_user_id: int = Field(..., gt=0, description="User ID of the person being invited") + role: str = Field( + ..., description="Role to assign (owner, admin, participant)", pattern="^(owner|admin|participant)$" + ) diff --git a/app/context/household/interface/schemas/invite_user_response.py b/app/context/household/interface/schemas/invite_user_response.py new file mode 100644 index 0000000..3f090ae --- /dev/null +++ b/app/context/household/interface/schemas/invite_user_response.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class InviteUserResponse: + member_id: int + household_id: int + user_id: int + role: str diff --git a/app/context/household/interface/schemas/list_households_response.py b/app/context/household/interface/schemas/list_households_response.py new file mode 100644 index 0000000..5508ec0 --- /dev/null +++ b/app/context/household/interface/schemas/list_households_response.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.context.household.interface.schemas.household_response import HouseholdResponse + + +@dataclass(frozen=True) +class ListHouseholdsResponse: + """Response for list households endpoint""" + + households: list[HouseholdResponse] diff --git a/app/context/household/interface/schemas/remove_member_response.py b/app/context/household/interface/schemas/remove_member_response.py new file mode 100644 index 0000000..1255a08 --- /dev/null +++ b/app/context/household/interface/schemas/remove_member_response.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class RemoveMemberResponse: + success: bool + message: str diff --git a/app/context/household/interface/schemas/update_household_request.py b/app/context/household/interface/schemas/update_household_request.py new file mode 100644 index 0000000..477dc75 --- /dev/null +++ b/app/context/household/interface/schemas/update_household_request.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, ConfigDict + + +class UpdateHouseholdRequest(BaseModel): + """Request schema for updating household""" + + model_config = ConfigDict(frozen=True) + name: str diff --git a/app/context/reminder/application/commands/__init__.py b/app/context/reminder/application/commands/__init__.py new file mode 100644 index 0000000..c8ff1dd --- /dev/null +++ b/app/context/reminder/application/commands/__init__.py @@ -0,0 +1,9 @@ +from .create_reminder_command import CreateReminderCommand +from .delete_reminder_command import DeleteReminderCommand +from .update_reminder_command import UpdateReminderCommand + +__all__ = [ + "CreateReminderCommand", + "UpdateReminderCommand", + "DeleteReminderCommand", +] diff --git a/app/context/reminder/application/commands/create_reminder_command.py b/app/context/reminder/application/commands/create_reminder_command.py new file mode 100644 index 0000000..47e1f4f --- /dev/null +++ b/app/context/reminder/application/commands/create_reminder_command.py @@ -0,0 +1,19 @@ +"""Command for creating a reminder""" + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class CreateReminderCommand: + """Command to create a new reminder""" + + user_id: int + description: str + entry_type: str + currency: str + amount: float + frequency: str + start_date: datetime + category_id: int + end_date: datetime | None = None diff --git a/app/context/reminder/application/commands/delete_reminder_command.py b/app/context/reminder/application/commands/delete_reminder_command.py new file mode 100644 index 0000000..87a79bb --- /dev/null +++ b/app/context/reminder/application/commands/delete_reminder_command.py @@ -0,0 +1,11 @@ +"""Command for deleting a reminder""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeleteReminderCommand: + """Command to delete a reminder""" + + reminder_id: int + user_id: int diff --git a/app/context/reminder/application/commands/update_reminder_command.py b/app/context/reminder/application/commands/update_reminder_command.py new file mode 100644 index 0000000..ec2a44d --- /dev/null +++ b/app/context/reminder/application/commands/update_reminder_command.py @@ -0,0 +1,19 @@ +"""Command for updating a reminder""" + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class UpdateReminderCommand: + """Command to update an existing reminder""" + + reminder_id: int + user_id: int + description: str | None = None + entry_type: str | None = None + currency: str | None = None + frequency: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + category_id: int | None = None diff --git a/app/context/reminder/application/contracts/__init__.py b/app/context/reminder/application/contracts/__init__.py new file mode 100644 index 0000000..18651df --- /dev/null +++ b/app/context/reminder/application/contracts/__init__.py @@ -0,0 +1,15 @@ +from .create_reminder_handler_contract import CreateReminderHandlerContract +from .delete_reminder_handler_contract import DeleteReminderHandlerContract +from .find_reminder_handler_contract import FindReminderHandlerContract +from .list_occurrences_handler_contract import ListOccurrencesHandlerContract +from .list_reminders_handler_contract import ListRemindersHandlerContract +from .update_reminder_handler_contract import UpdateReminderHandlerContract + +__all__ = [ + "CreateReminderHandlerContract", + "UpdateReminderHandlerContract", + "DeleteReminderHandlerContract", + "FindReminderHandlerContract", + "ListRemindersHandlerContract", + "ListOccurrencesHandlerContract", +] diff --git a/app/context/reminder/application/contracts/create_reminder_handler_contract.py b/app/context/reminder/application/contracts/create_reminder_handler_contract.py new file mode 100644 index 0000000..de79f01 --- /dev/null +++ b/app/context/reminder/application/contracts/create_reminder_handler_contract.py @@ -0,0 +1,15 @@ +"""Contract for create reminder handler""" + +from abc import ABC, abstractmethod + +from app.context.reminder.application.commands import CreateReminderCommand +from app.context.reminder.application.dto import CreateReminderResult + + +class CreateReminderHandlerContract(ABC): + """Handler contract for creating reminders""" + + @abstractmethod + async def handle(self, command: CreateReminderCommand) -> CreateReminderResult: + """Handle create reminder command""" + pass diff --git a/app/context/reminder/application/contracts/delete_reminder_handler_contract.py b/app/context/reminder/application/contracts/delete_reminder_handler_contract.py new file mode 100644 index 0000000..862bba2 --- /dev/null +++ b/app/context/reminder/application/contracts/delete_reminder_handler_contract.py @@ -0,0 +1,15 @@ +"""Contract for delete reminder handler""" + +from abc import ABC, abstractmethod + +from app.context.reminder.application.commands import DeleteReminderCommand +from app.context.reminder.application.dto import DeleteReminderResult + + +class DeleteReminderHandlerContract(ABC): + """Handler contract for deleting reminders""" + + @abstractmethod + async def handle(self, command: DeleteReminderCommand) -> DeleteReminderResult: + """Handle delete reminder command""" + pass diff --git a/app/context/reminder/application/contracts/find_reminder_handler_contract.py b/app/context/reminder/application/contracts/find_reminder_handler_contract.py new file mode 100644 index 0000000..b689936 --- /dev/null +++ b/app/context/reminder/application/contracts/find_reminder_handler_contract.py @@ -0,0 +1,15 @@ +"""Contract for find reminder handler""" + +from abc import ABC, abstractmethod + +from app.context.reminder.application.dto import FindReminderResult +from app.context.reminder.application.queries import FindReminderQuery + + +class FindReminderHandlerContract(ABC): + """Handler contract for finding a reminder""" + + @abstractmethod + async def handle(self, query: FindReminderQuery) -> FindReminderResult: + """Handle find reminder query""" + pass diff --git a/app/context/reminder/application/contracts/list_occurrences_handler_contract.py b/app/context/reminder/application/contracts/list_occurrences_handler_contract.py new file mode 100644 index 0000000..49834b3 --- /dev/null +++ b/app/context/reminder/application/contracts/list_occurrences_handler_contract.py @@ -0,0 +1,15 @@ +"""Contract for list occurrences handler""" + +from abc import ABC, abstractmethod + +from app.context.reminder.application.dto import ListOccurrencesResult +from app.context.reminder.application.queries import ListOccurrencesQuery + + +class ListOccurrencesHandlerContract(ABC): + """Handler contract for listing occurrences""" + + @abstractmethod + async def handle(self, query: ListOccurrencesQuery) -> ListOccurrencesResult: + """Handle list occurrences query""" + pass diff --git a/app/context/reminder/application/contracts/list_reminders_handler_contract.py b/app/context/reminder/application/contracts/list_reminders_handler_contract.py new file mode 100644 index 0000000..fec0df3 --- /dev/null +++ b/app/context/reminder/application/contracts/list_reminders_handler_contract.py @@ -0,0 +1,15 @@ +"""Contract for list reminders handler""" + +from abc import ABC, abstractmethod + +from app.context.reminder.application.dto import ListRemindersResult +from app.context.reminder.application.queries import ListRemindersQuery + + +class ListRemindersHandlerContract(ABC): + """Handler contract for listing reminders""" + + @abstractmethod + async def handle(self, query: ListRemindersQuery) -> ListRemindersResult: + """Handle list reminders query""" + pass diff --git a/app/context/reminder/application/contracts/update_reminder_handler_contract.py b/app/context/reminder/application/contracts/update_reminder_handler_contract.py new file mode 100644 index 0000000..6adc218 --- /dev/null +++ b/app/context/reminder/application/contracts/update_reminder_handler_contract.py @@ -0,0 +1,15 @@ +"""Contract for update reminder handler""" + +from abc import ABC, abstractmethod + +from app.context.reminder.application.commands import UpdateReminderCommand +from app.context.reminder.application.dto import UpdateReminderResult + + +class UpdateReminderHandlerContract(ABC): + """Handler contract for updating reminders""" + + @abstractmethod + async def handle(self, command: UpdateReminderCommand) -> UpdateReminderResult: + """Handle update reminder command""" + pass diff --git a/app/context/reminder/application/dto/__init__.py b/app/context/reminder/application/dto/__init__.py new file mode 100644 index 0000000..5e9504f --- /dev/null +++ b/app/context/reminder/application/dto/__init__.py @@ -0,0 +1,23 @@ +from .create_reminder_result import CreateReminderErrorCode, CreateReminderResult +from .delete_reminder_result import DeleteReminderErrorCode, DeleteReminderResult +from .find_reminder_result import FindReminderErrorCode, FindReminderResult +from .list_occurrences_result import ListOccurrencesErrorCode, ListOccurrencesResult, OccurrenceListItem +from .list_reminders_result import ListRemindersErrorCode, ListRemindersResult, ReminderListItem +from .update_reminder_result import UpdateReminderErrorCode, UpdateReminderResult + +__all__ = [ + "CreateReminderResult", + "CreateReminderErrorCode", + "UpdateReminderResult", + "UpdateReminderErrorCode", + "DeleteReminderResult", + "DeleteReminderErrorCode", + "FindReminderResult", + "FindReminderErrorCode", + "ListRemindersResult", + "ListRemindersErrorCode", + "ReminderListItem", + "ListOccurrencesResult", + "ListOccurrencesErrorCode", + "OccurrenceListItem", +] diff --git a/app/context/reminder/application/dto/create_reminder_result.py b/app/context/reminder/application/dto/create_reminder_result.py new file mode 100644 index 0000000..07f571e --- /dev/null +++ b/app/context/reminder/application/dto/create_reminder_result.py @@ -0,0 +1,33 @@ +"""Result DTO for create reminder command""" + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class CreateReminderErrorCode(str, Enum): + """Error codes for create reminder operation""" + + INVALID_DATE_RANGE = "INVALID_DATE_RANGE" + INVALID_FREQUENCY = "INVALID_FREQUENCY" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class CreateReminderResult: + """Result of create reminder operation""" + + # Success fields + reminder_id: int | None = None + description: str | None = None + entry_type: str | None = None + currency: str | None = None + frequency: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + category_id: int | None = None + + # Error fields + error_code: CreateReminderErrorCode | None = None + error_message: str | None = None diff --git a/app/context/reminder/application/dto/delete_reminder_result.py b/app/context/reminder/application/dto/delete_reminder_result.py new file mode 100644 index 0000000..e0e9578 --- /dev/null +++ b/app/context/reminder/application/dto/delete_reminder_result.py @@ -0,0 +1,24 @@ +"""Result DTO for delete reminder command""" + +from dataclasses import dataclass +from enum import Enum + + +class DeleteReminderErrorCode(str, Enum): + """Error codes for delete reminder operation""" + + REMINDER_NOT_FOUND = "REMINDER_NOT_FOUND" + REMINDER_NOT_BELONGS_TO_USER = "REMINDER_NOT_BELONGS_TO_USER" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class DeleteReminderResult: + """Result of delete reminder operation""" + + # Success field + deleted: bool = False + + # Error fields + error_code: DeleteReminderErrorCode | None = None + error_message: str | None = None diff --git a/app/context/reminder/application/dto/find_reminder_result.py b/app/context/reminder/application/dto/find_reminder_result.py new file mode 100644 index 0000000..4493b6b --- /dev/null +++ b/app/context/reminder/application/dto/find_reminder_result.py @@ -0,0 +1,32 @@ +"""Result DTO for find reminder query""" + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class FindReminderErrorCode(str, Enum): + """Error codes for find reminder operation""" + + REMINDER_NOT_FOUND = "REMINDER_NOT_FOUND" + REMINDER_NOT_BELONGS_TO_USER = "REMINDER_NOT_BELONGS_TO_USER" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class FindReminderResult: + """Result of find reminder operation""" + + # Success fields + reminder_id: int | None = None + description: str | None = None + entry_type: str | None = None + currency: str | None = None + frequency: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + category_id: int | None = None + + # Error fields + error_code: FindReminderErrorCode | None = None + error_message: str | None = None diff --git a/app/context/reminder/application/dto/list_occurrences_result.py b/app/context/reminder/application/dto/list_occurrences_result.py new file mode 100644 index 0000000..9514ed9 --- /dev/null +++ b/app/context/reminder/application/dto/list_occurrences_result.py @@ -0,0 +1,40 @@ +"""Result DTO for list occurrences query""" + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from enum import Enum + + +class ListOccurrencesErrorCode(str, Enum): + """Error codes for list occurrences operation""" + + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class OccurrenceListItem: + """Single occurrence item in list""" + + occurrence_id: int + reminder_id: int + scheduled_date: datetime + amount: Decimal + status: str + entry_id: int | None = None + description: str | None = None + entry_type: str | None = None + currency: str | None = None + category_id: int | None = None + + +@dataclass(frozen=True) +class ListOccurrencesResult: + """Result of list occurrences operation""" + + # Success fields + occurrences: list[OccurrenceListItem] | None = None + + # Error fields + error_code: ListOccurrencesErrorCode | None = None + error_message: str | None = None diff --git a/app/context/reminder/application/dto/list_reminders_result.py b/app/context/reminder/application/dto/list_reminders_result.py new file mode 100644 index 0000000..0cff1ba --- /dev/null +++ b/app/context/reminder/application/dto/list_reminders_result.py @@ -0,0 +1,37 @@ +"""Result DTO for list reminders query""" + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class ListRemindersErrorCode(str, Enum): + """Error codes for list reminders operation""" + + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class ReminderListItem: + """Single reminder item in list""" + + reminder_id: int + description: str + entry_type: str + currency: str + frequency: str + start_date: datetime + end_date: datetime | None = None + category_id: int | None = None + + +@dataclass(frozen=True) +class ListRemindersResult: + """Result of list reminders operation""" + + # Success fields + reminders: list[ReminderListItem] | None = None + + # Error fields + error_code: ListRemindersErrorCode | None = None + error_message: str | None = None diff --git a/app/context/reminder/application/dto/update_reminder_result.py b/app/context/reminder/application/dto/update_reminder_result.py new file mode 100644 index 0000000..369b776 --- /dev/null +++ b/app/context/reminder/application/dto/update_reminder_result.py @@ -0,0 +1,35 @@ +"""Result DTO for update reminder command""" + +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class UpdateReminderErrorCode(str, Enum): + """Error codes for update reminder operation""" + + REMINDER_NOT_FOUND = "REMINDER_NOT_FOUND" + REMINDER_NOT_BELONGS_TO_USER = "REMINDER_NOT_BELONGS_TO_USER" + INVALID_DATE_RANGE = "INVALID_DATE_RANGE" + INVALID_FREQUENCY = "INVALID_FREQUENCY" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class UpdateReminderResult: + """Result of update reminder operation""" + + # Success fields + reminder_id: int | None = None + description: str | None = None + entry_type: str | None = None + currency: str | None = None + frequency: str | None = None + start_date: datetime | None = None + end_date: datetime | None = None + category_id: int | None = None + + # Error fields + error_code: UpdateReminderErrorCode | None = None + error_message: str | None = None diff --git a/app/context/reminder/application/handlers/__init__.py b/app/context/reminder/application/handlers/__init__.py new file mode 100644 index 0000000..99b4b4a --- /dev/null +++ b/app/context/reminder/application/handlers/__init__.py @@ -0,0 +1,15 @@ +from .create_reminder_handler import CreateReminderHandler +from .delete_reminder_handler import DeleteReminderHandler +from .find_reminder_handler import FindReminderHandler +from .list_occurrences_handler import ListOccurrencesHandler +from .list_reminders_handler import ListRemindersHandler +from .update_reminder_handler import UpdateReminderHandler + +__all__ = [ + "CreateReminderHandler", + "UpdateReminderHandler", + "DeleteReminderHandler", + "FindReminderHandler", + "ListRemindersHandler", + "ListOccurrencesHandler", +] diff --git a/app/context/reminder/application/handlers/create_reminder_handler.py b/app/context/reminder/application/handlers/create_reminder_handler.py new file mode 100644 index 0000000..640f329 --- /dev/null +++ b/app/context/reminder/application/handlers/create_reminder_handler.py @@ -0,0 +1,100 @@ +"""Handler for creating reminders""" + +from app.context.reminder.application.commands import CreateReminderCommand +from app.context.reminder.application.contracts import CreateReminderHandlerContract +from app.context.reminder.application.dto import CreateReminderErrorCode, CreateReminderResult +from app.context.reminder.domain.contracts.services import CreateReminderServiceContract +from app.context.reminder.domain.exceptions import ( + InvalidReminderDateRangeError, + InvalidReminderFrequencyError, + ReminderMapperError, +) +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderOccurrenceAmount, + ReminderStartDate, + ReminderUserID, +) + + +class CreateReminderHandler(CreateReminderHandlerContract): + """Handler for create reminder command""" + + def __init__(self, service: CreateReminderServiceContract): + self._service = service + + async def handle(self, command: CreateReminderCommand) -> CreateReminderResult: + """Execute create reminder command""" + + try: + # Convert primitives to value objects + user_id = ReminderUserID(command.user_id) + description = ReminderDescription(command.description) + entry_type = ReminderEntryType(command.entry_type) + currency = ReminderCurrency(command.currency) + amount = ReminderOccurrenceAmount.from_float(command.amount) + frequency = ReminderFrequency(command.frequency) + start_date = ReminderStartDate(command.start_date) + end_date = ReminderEndDate(command.end_date) if command.end_date else None + category_id = ReminderCategoryID(command.category_id) + + # Call domain service + reminder_dto = await self._service.create( + user_id=user_id, + description=description, + entry_type=entry_type, + currency=currency, + amount=amount, + frequency=frequency, + start_date=start_date, + end_date=end_date, + category_id=category_id, + ) + + # Validate result + if reminder_dto.reminder_id is None: + return CreateReminderResult( + error_code=CreateReminderErrorCode.UNEXPECTED_ERROR, + error_message="Failed to create reminder", + ) + + # Convert to result DTO + return CreateReminderResult( + reminder_id=reminder_dto.reminder_id.value, + description=reminder_dto.description.value, + entry_type=reminder_dto.entry_type.value, + currency=reminder_dto.currency.value, + frequency=reminder_dto.frequency.value, + start_date=reminder_dto.start_date.value, + end_date=reminder_dto.end_date.value if reminder_dto.end_date else None, + category_id=reminder_dto.category_id.value if reminder_dto.category_id else None, + ) + + except InvalidReminderDateRangeError: + return CreateReminderResult( + error_code=CreateReminderErrorCode.INVALID_DATE_RANGE, + error_message="End date cannot be before start date", + ) + + except InvalidReminderFrequencyError: + return CreateReminderResult( + error_code=CreateReminderErrorCode.INVALID_FREQUENCY, + error_message="Invalid reminder frequency", + ) + + except ReminderMapperError: + return CreateReminderResult( + error_code=CreateReminderErrorCode.MAPPER_ERROR, + error_message="Error mapping reminder data", + ) + + except Exception: + return CreateReminderResult( + error_code=CreateReminderErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error creating reminder", + ) diff --git a/app/context/reminder/application/handlers/delete_reminder_handler.py b/app/context/reminder/application/handlers/delete_reminder_handler.py new file mode 100644 index 0000000..a7d4510 --- /dev/null +++ b/app/context/reminder/application/handlers/delete_reminder_handler.py @@ -0,0 +1,46 @@ +"""Handler for deleting reminders""" + +from app.context.reminder.application.commands import DeleteReminderCommand +from app.context.reminder.application.contracts import DeleteReminderHandlerContract +from app.context.reminder.application.dto import DeleteReminderErrorCode, DeleteReminderResult +from app.context.reminder.domain.contracts.services import DeleteReminderServiceContract +from app.context.reminder.domain.exceptions import ReminderNotBelongsToUserError, ReminderNotFoundError +from app.context.reminder.domain.value_objects import ReminderID, ReminderUserID + + +class DeleteReminderHandler(DeleteReminderHandlerContract): + """Handler for delete reminder command""" + + def __init__(self, service: DeleteReminderServiceContract): + self._service = service + + async def handle(self, command: DeleteReminderCommand) -> DeleteReminderResult: + """Execute delete reminder command""" + + try: + # Convert primitives to value objects + reminder_id = ReminderID(command.reminder_id) + user_id = ReminderUserID(command.user_id) + + # Call domain service + await self._service.delete(reminder_id=reminder_id, user_id=user_id) + + return DeleteReminderResult(deleted=True) + + except ReminderNotFoundError: + return DeleteReminderResult( + error_code=DeleteReminderErrorCode.REMINDER_NOT_FOUND, + error_message="Reminder not found", + ) + + except ReminderNotBelongsToUserError: + return DeleteReminderResult( + error_code=DeleteReminderErrorCode.REMINDER_NOT_BELONGS_TO_USER, + error_message="Reminder does not belong to user", + ) + + except Exception: + return DeleteReminderResult( + error_code=DeleteReminderErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error deleting reminder", + ) diff --git a/app/context/reminder/application/handlers/find_reminder_handler.py b/app/context/reminder/application/handlers/find_reminder_handler.py new file mode 100644 index 0000000..be753b6 --- /dev/null +++ b/app/context/reminder/application/handlers/find_reminder_handler.py @@ -0,0 +1,56 @@ +"""Handler for finding a reminder""" + +from app.context.reminder.application.contracts import FindReminderHandlerContract +from app.context.reminder.application.dto import FindReminderErrorCode, FindReminderResult +from app.context.reminder.application.queries import FindReminderQuery +from app.context.reminder.domain.contracts.infrastructure import ReminderRepositoryContract +from app.context.reminder.domain.value_objects import ReminderID, ReminderUserID + + +class FindReminderHandler(FindReminderHandlerContract): + """Handler for find reminder query""" + + def __init__(self, repository: ReminderRepositoryContract): + self._repository = repository + + async def handle(self, query: FindReminderQuery) -> FindReminderResult: + """Execute find reminder query""" + + try: + # Convert primitives to value objects + reminder_id = ReminderID(query.reminder_id) + user_id = ReminderUserID(query.user_id) + + # Query repository + reminder_dto = await self._repository.find_user_reminder_by_id(reminder_id=reminder_id, user_id=user_id) + + if not reminder_dto: + return FindReminderResult( + error_code=FindReminderErrorCode.REMINDER_NOT_FOUND, + error_message="Reminder not found", + ) + + # Verify ownership + if reminder_dto.user_id.value != user_id.value: + return FindReminderResult( + error_code=FindReminderErrorCode.REMINDER_NOT_BELONGS_TO_USER, + error_message="Reminder does not belong to user", + ) + + # Convert to result DTO + return FindReminderResult( + reminder_id=reminder_dto.reminder_id.value if reminder_dto.reminder_id else None, + description=reminder_dto.description.value, + entry_type=reminder_dto.entry_type.value, + currency=reminder_dto.currency.value, + frequency=reminder_dto.frequency.value, + start_date=reminder_dto.start_date.value, + end_date=reminder_dto.end_date.value if reminder_dto.end_date else None, + category_id=reminder_dto.category_id.value if reminder_dto.category_id else None, + ) + + except Exception: + return FindReminderResult( + error_code=FindReminderErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error finding reminder", + ) diff --git a/app/context/reminder/application/handlers/list_occurrences_handler.py b/app/context/reminder/application/handlers/list_occurrences_handler.py new file mode 100644 index 0000000..7de213b --- /dev/null +++ b/app/context/reminder/application/handlers/list_occurrences_handler.py @@ -0,0 +1,55 @@ +"""Handler for listing occurrences""" + +from app.context.reminder.application.contracts import ListOccurrencesHandlerContract +from app.context.reminder.application.dto import ListOccurrencesErrorCode, ListOccurrencesResult, OccurrenceListItem +from app.context.reminder.application.queries import ListOccurrencesQuery +from app.context.reminder.domain.contracts.infrastructure import ReminderOccurrenceRepositoryContract +from app.context.reminder.domain.value_objects import ReminderID, ReminderUserID + + +class ListOccurrencesHandler(ListOccurrencesHandlerContract): + """Handler for list occurrences query""" + + def __init__(self, repository: ReminderOccurrenceRepositoryContract): + self._repository = repository + + async def handle(self, query: ListOccurrencesQuery) -> ListOccurrencesResult: + """Execute list occurrences query""" + + try: + # Convert primitives to value objects + user_id = ReminderUserID(query.user_id) + reminder_id = ReminderID(query.reminder_id) if query.reminder_id else None + + # Query repository based on parameters + if reminder_id: + # Get occurrences for specific reminder + occurrence_dtos = await self._repository.find_occurrences_by_reminder(reminder_id=reminder_id) + else: + # Get all pending occurrences for user + occurrence_dtos = await self._repository.find_pending_occurrences_by_user(user_id=user_id) + + # Convert to list items + items = [ + OccurrenceListItem( + occurrence_id=dto.occurrence_id.value if dto.occurrence_id else 0, + reminder_id=dto.reminder_id.value, + scheduled_date=dto.scheduled_date.value, + amount=dto.amount.value, + status=dto.status.value, + entry_id=dto.entry_id, + description=dto.description.value if dto.description else None, + entry_type=dto.entry_type.value if dto.entry_type else None, + currency=dto.currency.value if dto.currency else None, + category_id=dto.category_id.value if dto.category_id else None, + ) + for dto in occurrence_dtos + ] + + return ListOccurrencesResult(occurrences=items) + + except Exception: + return ListOccurrencesResult( + error_code=ListOccurrencesErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error listing occurrences", + ) diff --git a/app/context/reminder/application/handlers/list_reminders_handler.py b/app/context/reminder/application/handlers/list_reminders_handler.py new file mode 100644 index 0000000..794d42f --- /dev/null +++ b/app/context/reminder/application/handlers/list_reminders_handler.py @@ -0,0 +1,47 @@ +"""Handler for listing reminders""" + +from app.context.reminder.application.contracts import ListRemindersHandlerContract +from app.context.reminder.application.dto import ListRemindersErrorCode, ListRemindersResult, ReminderListItem +from app.context.reminder.application.queries import ListRemindersQuery +from app.context.reminder.domain.contracts.infrastructure import ReminderRepositoryContract +from app.context.reminder.domain.value_objects import ReminderUserID + + +class ListRemindersHandler(ListRemindersHandlerContract): + """Handler for list reminders query""" + + def __init__(self, repository: ReminderRepositoryContract): + self._repository = repository + + async def handle(self, query: ListRemindersQuery) -> ListRemindersResult: + """Execute list reminders query""" + + try: + # Convert primitives to value objects + user_id = ReminderUserID(query.user_id) + + # Query repository + reminder_dtos = await self._repository.find_user_reminders(user_id=user_id, active_only=query.active_only) + + # Convert to list items + items = [ + ReminderListItem( + reminder_id=dto.reminder_id.value if dto.reminder_id else 0, + description=dto.description.value, + entry_type=dto.entry_type.value, + currency=dto.currency.value, + frequency=dto.frequency.value, + start_date=dto.start_date.value, + end_date=dto.end_date.value if dto.end_date else None, + category_id=dto.category_id.value if dto.category_id else None, + ) + for dto in reminder_dtos + ] + + return ListRemindersResult(reminders=items) + + except Exception: + return ListRemindersResult( + error_code=ListRemindersErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error listing reminders", + ) diff --git a/app/context/reminder/application/handlers/update_reminder_handler.py b/app/context/reminder/application/handlers/update_reminder_handler.py new file mode 100644 index 0000000..7de7a47 --- /dev/null +++ b/app/context/reminder/application/handlers/update_reminder_handler.py @@ -0,0 +1,107 @@ +"""Handler for updating reminders""" + +from app.context.reminder.application.commands import UpdateReminderCommand +from app.context.reminder.application.contracts import UpdateReminderHandlerContract +from app.context.reminder.application.dto import UpdateReminderErrorCode, UpdateReminderResult +from app.context.reminder.domain.contracts.services import UpdateReminderServiceContract +from app.context.reminder.domain.exceptions import ( + InvalidReminderDateRangeError, + InvalidReminderFrequencyError, + ReminderMapperError, + ReminderNotBelongsToUserError, + ReminderNotFoundError, +) +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderStartDate, + ReminderUserID, +) + + +class UpdateReminderHandler(UpdateReminderHandlerContract): + """Handler for update reminder command""" + + def __init__(self, service: UpdateReminderServiceContract): + self._service = service + + async def handle(self, command: UpdateReminderCommand) -> UpdateReminderResult: + """Execute update reminder command""" + + try: + # Convert primitives to value objects + reminder_id = ReminderID(command.reminder_id) + user_id = ReminderUserID(command.user_id) + description = ReminderDescription(command.description) if command.description else None + entry_type = ReminderEntryType(command.entry_type) if command.entry_type else None + currency = ReminderCurrency(command.currency) if command.currency else None + frequency = ReminderFrequency(command.frequency) if command.frequency else None + start_date = ReminderStartDate(command.start_date) if command.start_date else None + end_date = ReminderEndDate(command.end_date) if command.end_date else None + category_id = ReminderCategoryID(command.category_id) if command.category_id else None + + # Call domain service + reminder_dto = await self._service.update( + reminder_id=reminder_id, + user_id=user_id, + description=description, + entry_type=entry_type, + currency=currency, + frequency=frequency, + start_date=start_date, + end_date=end_date, + category_id=category_id, + ) + + # Convert to result DTO + return UpdateReminderResult( + reminder_id=reminder_dto.reminder_id.value if reminder_dto.reminder_id else None, + description=reminder_dto.description.value, + entry_type=reminder_dto.entry_type.value, + currency=reminder_dto.currency.value, + frequency=reminder_dto.frequency.value, + start_date=reminder_dto.start_date.value, + end_date=reminder_dto.end_date.value if reminder_dto.end_date else None, + category_id=reminder_dto.category_id.value if reminder_dto.category_id else None, + ) + + except ReminderNotFoundError: + return UpdateReminderResult( + error_code=UpdateReminderErrorCode.REMINDER_NOT_FOUND, + error_message="Reminder not found", + ) + + except ReminderNotBelongsToUserError: + return UpdateReminderResult( + error_code=UpdateReminderErrorCode.REMINDER_NOT_BELONGS_TO_USER, + error_message="Reminder does not belong to user", + ) + + except InvalidReminderDateRangeError: + return UpdateReminderResult( + error_code=UpdateReminderErrorCode.INVALID_DATE_RANGE, + error_message="End date cannot be before start date", + ) + + except InvalidReminderFrequencyError: + return UpdateReminderResult( + error_code=UpdateReminderErrorCode.INVALID_FREQUENCY, + error_message="Invalid reminder frequency", + ) + + except ReminderMapperError: + return UpdateReminderResult( + error_code=UpdateReminderErrorCode.MAPPER_ERROR, + error_message="Error mapping reminder data", + ) + + except Exception: + return UpdateReminderResult( + error_code=UpdateReminderErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error updating reminder", + ) diff --git a/app/context/reminder/application/queries/__init__.py b/app/context/reminder/application/queries/__init__.py new file mode 100644 index 0000000..ba355f2 --- /dev/null +++ b/app/context/reminder/application/queries/__init__.py @@ -0,0 +1,9 @@ +from .find_reminder_query import FindReminderQuery +from .list_occurrences_query import ListOccurrencesQuery +from .list_reminders_query import ListRemindersQuery + +__all__ = [ + "FindReminderQuery", + "ListRemindersQuery", + "ListOccurrencesQuery", +] diff --git a/app/context/reminder/application/queries/find_reminder_query.py b/app/context/reminder/application/queries/find_reminder_query.py new file mode 100644 index 0000000..ecd1e7d --- /dev/null +++ b/app/context/reminder/application/queries/find_reminder_query.py @@ -0,0 +1,11 @@ +"""Query for finding a single reminder""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FindReminderQuery: + """Query to find a reminder by ID""" + + reminder_id: int + user_id: int diff --git a/app/context/reminder/application/queries/list_occurrences_query.py b/app/context/reminder/application/queries/list_occurrences_query.py new file mode 100644 index 0000000..8dd8041 --- /dev/null +++ b/app/context/reminder/application/queries/list_occurrences_query.py @@ -0,0 +1,15 @@ +"""Query for listing reminder occurrences""" + +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class ListOccurrencesQuery: + """Query to list occurrences for a reminder or user""" + + user_id: int + reminder_id: int | None = None + start_date: datetime | None = None + end_date: datetime | None = None + status: str | None = None diff --git a/app/context/reminder/application/queries/list_reminders_query.py b/app/context/reminder/application/queries/list_reminders_query.py new file mode 100644 index 0000000..98f8653 --- /dev/null +++ b/app/context/reminder/application/queries/list_reminders_query.py @@ -0,0 +1,11 @@ +"""Query for listing user reminders""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ListRemindersQuery: + """Query to list reminders for a user""" + + user_id: int + active_only: bool | None = True diff --git a/app/context/reminder/domain/contracts/__init__.py b/app/context/reminder/domain/contracts/__init__.py new file mode 100644 index 0000000..60588a3 --- /dev/null +++ b/app/context/reminder/domain/contracts/__init__.py @@ -0,0 +1,6 @@ +from .infrastructure.reminder_occurrence_repository_contract import ( + ReminderOccurrenceRepositoryContract, +) +from .infrastructure.reminder_repository_contract import ReminderRepositoryContract + +__all__ = ["ReminderRepositoryContract", "ReminderOccurrenceRepositoryContract"] diff --git a/app/context/reminder/domain/contracts/infrastructure/__init__.py b/app/context/reminder/domain/contracts/infrastructure/__init__.py new file mode 100644 index 0000000..9c84223 --- /dev/null +++ b/app/context/reminder/domain/contracts/infrastructure/__init__.py @@ -0,0 +1,4 @@ +from .reminder_occurrence_repository_contract import ReminderOccurrenceRepositoryContract +from .reminder_repository_contract import ReminderRepositoryContract + +__all__ = ["ReminderRepositoryContract", "ReminderOccurrenceRepositoryContract"] diff --git a/app/context/reminder/domain/contracts/infrastructure/reminder_occurrence_repository_contract.py b/app/context/reminder/domain/contracts/infrastructure/reminder_occurrence_repository_contract.py new file mode 100644 index 0000000..a119dd0 --- /dev/null +++ b/app/context/reminder/domain/contracts/infrastructure/reminder_occurrence_repository_contract.py @@ -0,0 +1,75 @@ +from abc import ABC, abstractmethod +from datetime import datetime + +from app.context.reminder.domain.dto import ReminderOccurrenceDTO +from app.context.reminder.domain.value_objects import ( + ReminderID, + ReminderOccurrenceID, + ReminderOccurrenceStatus, +) + + +class ReminderOccurrenceRepositoryContract(ABC): + """Contract for reminder occurrence repository operations""" + + @abstractmethod + async def save_occurrence(self, occurrence: ReminderOccurrenceDTO) -> ReminderOccurrenceDTO: + """Create a new occurrence""" + pass + + @abstractmethod + async def save_occurrences(self, occurrences: list[ReminderOccurrenceDTO]) -> list[ReminderOccurrenceDTO]: + """Create multiple occurrences in a single transaction""" + pass + + @abstractmethod + async def find_occurrence( + self, + occurrence_id: ReminderOccurrenceID | None = None, + reminder_id: ReminderID | None = None, + ) -> ReminderOccurrenceDTO | None: + """Find an occurrence by ID or reminder_id""" + pass + + @abstractmethod + async def find_occurrences_by_reminder( + self, + reminder_id: ReminderID, + ) -> list[ReminderOccurrenceDTO]: + """Find all occurrences for a reminder""" + pass + + @abstractmethod + async def find_pending_occurrences_by_date( + self, + before: datetime, + ) -> list[ReminderOccurrenceDTO]: + """Find all pending occurrences scheduled before the given datetime""" + pass + + @abstractmethod + async def find_pending_occurrences_by_user( + self, + user_id: int, + before: datetime | None = None, + ) -> list[ReminderOccurrenceDTO]: + """Find all pending occurrences for a user's reminders""" + pass + + @abstractmethod + async def update_occurrence_status( + self, + occurrence_id: ReminderOccurrenceID, + status: ReminderOccurrenceStatus, + entry_id: int | None = None, + ) -> bool: + """Update occurrence status (e.g., mark as completed with entry_id)""" + pass + + @abstractmethod + async def delete_occurrences_by_reminder( + self, + reminder_id: ReminderID, + ) -> int: + """Delete all occurrences for a reminder (when reminder is deleted)""" + pass diff --git a/app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py b/app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py new file mode 100644 index 0000000..496f093 --- /dev/null +++ b/app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py @@ -0,0 +1,68 @@ +from abc import ABC, abstractmethod +from datetime import datetime + +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.value_objects import ( + ReminderID, + ReminderUserID, +) + + +class ReminderRepositoryContract(ABC): + """Contract for reminder repository operations""" + + @abstractmethod + async def save_reminder(self, reminder: ReminderDTO) -> ReminderDTO: + """Create a new reminder""" + pass + + @abstractmethod + async def find_reminder( + self, + reminder_id: ReminderID | None = None, + user_id: ReminderUserID | None = None, + ) -> ReminderDTO | None: + """Find a reminder by ID or user_id""" + pass + + @abstractmethod + async def find_user_reminders( + self, + user_id: ReminderUserID, + reminder_id: ReminderID | None = None, + only_active: bool | None = True, + ) -> list[ReminderDTO]: + """Find all reminders for a user""" + pass + + @abstractmethod + async def find_user_reminder_by_id( + self, + user_id: ReminderUserID, + reminder_id: ReminderID, + only_active: bool | None = True, + ) -> ReminderDTO | None: + """Find a specific reminder by ID for a user""" + pass + + @abstractmethod + async def update_reminder(self, reminder: ReminderDTO) -> ReminderDTO: + """Update an existing reminder""" + pass + + @abstractmethod + async def delete_reminder( + self, + reminder_id: ReminderID, + user_id: ReminderUserID, + ) -> bool: + """Delete a reminder""" + pass + + @abstractmethod + async def find_active_reminders_due_before( + self, + before: datetime, + ) -> list[ReminderDTO]: + """Find all active reminders with start_date before the given datetime""" + pass diff --git a/app/context/reminder/domain/contracts/services/__init__.py b/app/context/reminder/domain/contracts/services/__init__.py new file mode 100644 index 0000000..03e6133 --- /dev/null +++ b/app/context/reminder/domain/contracts/services/__init__.py @@ -0,0 +1,11 @@ +from .create_reminder_service_contract import CreateReminderServiceContract +from .delete_reminder_service_contract import DeleteReminderServiceContract +from .generate_occurrences_service_contract import GenerateOccurrencesServiceContract +from .update_reminder_service_contract import UpdateReminderServiceContract + +__all__ = [ + "CreateReminderServiceContract", + "UpdateReminderServiceContract", + "DeleteReminderServiceContract", + "GenerateOccurrencesServiceContract", +] diff --git a/app/context/reminder/domain/contracts/services/create_reminder_service_contract.py b/app/context/reminder/domain/contracts/services/create_reminder_service_contract.py new file mode 100644 index 0000000..03dc412 --- /dev/null +++ b/app/context/reminder/domain/contracts/services/create_reminder_service_contract.py @@ -0,0 +1,55 @@ +"""Contract for create reminder service""" + +from abc import ABC, abstractmethod + +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderOccurrenceAmount, + ReminderStartDate, + ReminderUserID, +) + + +class CreateReminderServiceContract(ABC): + """Service contract for creating reminders""" + + @abstractmethod + async def create( + self, + user_id: ReminderUserID, + amount: ReminderOccurrenceAmount, + description: ReminderDescription, + entry_type: ReminderEntryType, + currency: ReminderCurrency, + frequency: ReminderFrequency, + start_date: ReminderStartDate, + category_id: ReminderCategoryID, + end_date: ReminderEndDate | None = None, + ) -> ReminderDTO: + """ + Create a new reminder and generate initial occurrences + + Args: + user_id: User who owns the reminder + description: Reminder description + entry_type: Type of entry (income/expense) + currency: Currency for amounts + frequency: Recurrence frequency + start_date: When the reminder starts + end_date: Optional end date for the reminder + category_id: Optional category ID + + Returns: + Created reminder DTO + + Raises: + InvalidReminderDateRangeError: If end_date is before start_date + InvalidReminderFrequencyError: If frequency is invalid + """ + pass diff --git a/app/context/reminder/domain/contracts/services/delete_reminder_service_contract.py b/app/context/reminder/domain/contracts/services/delete_reminder_service_contract.py new file mode 100644 index 0000000..cf2c543 --- /dev/null +++ b/app/context/reminder/domain/contracts/services/delete_reminder_service_contract.py @@ -0,0 +1,24 @@ +"""Contract for delete reminder service""" + +from abc import ABC, abstractmethod + +from app.context.reminder.domain.value_objects import ReminderID, ReminderUserID + + +class DeleteReminderServiceContract(ABC): + """Service contract for deleting reminders""" + + @abstractmethod + async def delete(self, reminder_id: ReminderID, user_id: ReminderUserID) -> None: + """ + Delete a reminder and all its occurrences + + Args: + reminder_id: ID of reminder to delete + user_id: User who owns the reminder (for ownership validation) + + Raises: + ReminderNotFoundError: If reminder doesn't exist + ReminderNotBelongsToUserError: If reminder doesn't belong to user + """ + pass diff --git a/app/context/reminder/domain/contracts/services/generate_occurrences_service_contract.py b/app/context/reminder/domain/contracts/services/generate_occurrences_service_contract.py new file mode 100644 index 0000000..1404d86 --- /dev/null +++ b/app/context/reminder/domain/contracts/services/generate_occurrences_service_contract.py @@ -0,0 +1,63 @@ +"""Contract for generate occurrences service""" + +from abc import ABC, abstractmethod + +from app.context.reminder.domain.dto import ReminderOccurrenceDTO +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderOccurrenceAmount, + ReminderStartDate, +) + + +class GenerateOccurrencesServiceContract(ABC): + """Service contract for generating reminder occurrences""" + + @abstractmethod + async def generate( + self, + reminder_id: ReminderID, + frequency: ReminderFrequency, + start_date: ReminderStartDate, + end_date: ReminderEndDate | None, + amount: ReminderOccurrenceAmount, + description: ReminderDescription, + entry_type: ReminderEntryType, + currency: ReminderCurrency, + category_id: ReminderCategoryID | None, + ) -> list[ReminderOccurrenceDTO]: + """ + Generate occurrences for a reminder based on frequency and date range + + The generation logic handles: + - daily: Every day + - weekly: Same day of week + - biweekly: Every 2 weeks, same day of week + - monthly: Same day of month + - quarterly: Every 3 months, same day + - yearly: Same month and day + + Args: + reminder_id: ID of the reminder + frequency: How often the reminder recurs + start_date: When occurrences start + end_date: Optional end date (if None, generates up to 1 year ahead) + amount: Amount for each occurrence + description: Reminder description (copied to occurrences) + entry_type: Entry type (copied to occurrences) + currency: Currency (copied to occurrences) + category_id: Optional category ID (copied to occurrences) + + Returns: + List of generated occurrence DTOs + + Raises: + InvalidReminderFrequencyError: If frequency is not supported + """ + pass diff --git a/app/context/reminder/domain/contracts/services/update_reminder_service_contract.py b/app/context/reminder/domain/contracts/services/update_reminder_service_contract.py new file mode 100644 index 0000000..0c4d13d --- /dev/null +++ b/app/context/reminder/domain/contracts/services/update_reminder_service_contract.py @@ -0,0 +1,57 @@ +"""Contract for update reminder service""" + +from abc import ABC, abstractmethod + +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderStartDate, + ReminderUserID, +) + + +class UpdateReminderServiceContract(ABC): + """Service contract for updating reminders""" + + @abstractmethod + async def update( + self, + reminder_id: ReminderID, + user_id: ReminderUserID, + description: ReminderDescription | None = None, + entry_type: ReminderEntryType | None = None, + currency: ReminderCurrency | None = None, + frequency: ReminderFrequency | None = None, + start_date: ReminderStartDate | None = None, + end_date: ReminderEndDate | None = None, + category_id: ReminderCategoryID | None = None, + ) -> ReminderDTO: + """ + Update an existing reminder and regenerate occurrences if needed + + Args: + reminder_id: ID of reminder to update + user_id: User who owns the reminder (for ownership validation) + description: New description + entry_type: New entry type + currency: New currency + frequency: New frequency + start_date: New start date + end_date: New end date + category_id: New category ID + + Returns: + Updated reminder DTO + + Raises: + ReminderNotFoundError: If reminder doesn't exist + ReminderNotBelongsToUserError: If reminder doesn't belong to user + InvalidReminderDateRangeError: If end_date is before start_date + """ + pass diff --git a/app/context/reminder/domain/dto/__init__.py b/app/context/reminder/domain/dto/__init__.py new file mode 100644 index 0000000..2f1f813 --- /dev/null +++ b/app/context/reminder/domain/dto/__init__.py @@ -0,0 +1,4 @@ +from .reminder_dto import ReminderDTO +from .reminder_occurrence_dto import ReminderOccurrenceDTO + +__all__ = ["ReminderDTO", "ReminderOccurrenceDTO"] diff --git a/app/context/reminder/domain/dto/reminder_dto.py b/app/context/reminder/domain/dto/reminder_dto.py new file mode 100644 index 0000000..b3bd983 --- /dev/null +++ b/app/context/reminder/domain/dto/reminder_dto.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass + +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderOccurrenceAmount, + ReminderStartDate, + ReminderUserID, +) + + +@dataclass(frozen=True) +class ReminderDTO: + user_id: ReminderUserID + category_id: ReminderCategoryID + entry_type: ReminderEntryType + currency: ReminderCurrency + amount: ReminderOccurrenceAmount + frequency: ReminderFrequency + start_date: ReminderStartDate + description: ReminderDescription + end_date: ReminderEndDate | None = None + reminder_id: ReminderID | None = None diff --git a/app/context/reminder/domain/dto/reminder_occurrence_dto.py b/app/context/reminder/domain/dto/reminder_occurrence_dto.py new file mode 100644 index 0000000..e2b762b --- /dev/null +++ b/app/context/reminder/domain/dto/reminder_occurrence_dto.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + +from app.context.reminder.domain.value_objects import ( + ReminderID, + ReminderOccurrenceAmount, + ReminderOccurrenceID, + ReminderOccurrenceScheduledDate, + ReminderOccurrenceStatus, +) + + +@dataclass(frozen=True) +class ReminderOccurrenceDTO: + reminder_id: ReminderID + scheduled_date: ReminderOccurrenceScheduledDate + amount: ReminderOccurrenceAmount + status: ReminderOccurrenceStatus + occurrence_id: ReminderOccurrenceID | None = None + entry_id: int | None = None diff --git a/app/context/reminder/domain/exceptions/__init__.py b/app/context/reminder/domain/exceptions/__init__.py new file mode 100644 index 0000000..6d99183 --- /dev/null +++ b/app/context/reminder/domain/exceptions/__init__.py @@ -0,0 +1,19 @@ +from .exceptions import ( + InvalidReminderDateRangeError, + InvalidReminderFrequencyError, + ReminderMapperError, + ReminderNotBelongsToUserError, + ReminderNotFoundError, + ReminderOccurrenceMapperError, + ReminderOccurrenceNotFoundError, +) + +__all__ = [ + "ReminderNotFoundError", + "ReminderNotBelongsToUserError", + "ReminderMapperError", + "ReminderOccurrenceNotFoundError", + "ReminderOccurrenceMapperError", + "InvalidReminderFrequencyError", + "InvalidReminderDateRangeError", +] diff --git a/app/context/reminder/domain/exceptions/exceptions.py b/app/context/reminder/domain/exceptions/exceptions.py new file mode 100644 index 0000000..cda2722 --- /dev/null +++ b/app/context/reminder/domain/exceptions/exceptions.py @@ -0,0 +1,43 @@ +"""Domain exceptions for reminder context""" + + +class ReminderNotFoundError(Exception): + """Raised when a reminder is not found""" + + pass + + +class ReminderNotBelongsToUserError(Exception): + """Raised when a reminder does not belong to the user""" + + pass + + +class ReminderMapperError(Exception): + """Raised when mapping between model and DTO fails""" + + pass + + +class ReminderOccurrenceNotFoundError(Exception): + """Raised when a reminder occurrence is not found""" + + pass + + +class ReminderOccurrenceMapperError(Exception): + """Raised when mapping between occurrence model and DTO fails""" + + pass + + +class InvalidReminderFrequencyError(Exception): + """Raised when an invalid reminder frequency is provided""" + + pass + + +class InvalidReminderDateRangeError(Exception): + """Raised when end_date is before start_date""" + + pass diff --git a/app/context/reminder/domain/services/__init__.py b/app/context/reminder/domain/services/__init__.py new file mode 100644 index 0000000..012c1b7 --- /dev/null +++ b/app/context/reminder/domain/services/__init__.py @@ -0,0 +1,11 @@ +from .create_reminder_service import CreateReminderService +from .delete_reminder_service import DeleteReminderService +from .generate_occurrences_service import GenerateOccurrencesService +from .update_reminder_service import UpdateReminderService + +__all__ = [ + "CreateReminderService", + "UpdateReminderService", + "DeleteReminderService", + "GenerateOccurrencesService", +] diff --git a/app/context/reminder/domain/services/create_reminder_service.py b/app/context/reminder/domain/services/create_reminder_service.py new file mode 100644 index 0000000..715067d --- /dev/null +++ b/app/context/reminder/domain/services/create_reminder_service.py @@ -0,0 +1,83 @@ +"""Service for creating reminders""" + +from app.context.reminder.domain.contracts.infrastructure import ReminderRepositoryContract +from app.context.reminder.domain.contracts.services import ( + CreateReminderServiceContract, + GenerateOccurrencesServiceContract, +) +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.exceptions import InvalidReminderDateRangeError +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderOccurrenceAmount, + ReminderStartDate, + ReminderUserID, +) + + +class CreateReminderService(CreateReminderServiceContract): + """Service for creating reminders and generating occurrences""" + + def __init__( + self, + reminder_repo: ReminderRepositoryContract, + generate_occurrences_service: GenerateOccurrencesServiceContract, + ): + self._reminder_repo = reminder_repo + self._generate_occurrences_service = generate_occurrences_service + + async def create( + self, + user_id: ReminderUserID, + amount: ReminderOccurrenceAmount, + description: ReminderDescription, + entry_type: ReminderEntryType, + currency: ReminderCurrency, + frequency: ReminderFrequency, + start_date: ReminderStartDate, + category_id: ReminderCategoryID, + end_date: ReminderEndDate | None = None, + ) -> ReminderDTO: + """Create a new reminder and generate initial occurrences""" + + # Validate date range + if end_date and end_date.value < start_date.value: + raise InvalidReminderDateRangeError("End date cannot be before start date") + + # Create reminder DTO + reminder_dto = ReminderDTO( + reminder_id=None, # Will be assigned by repository + user_id=user_id, + description=description, + entry_type=entry_type, + currency=currency, + amount=amount, + frequency=frequency, + start_date=start_date, + end_date=end_date, + category_id=category_id, + ) + + # Save reminder + saved_reminder = await self._reminder_repo.save_reminder(reminder_dto) + + # Generate occurrences + if saved_reminder.reminder_id: + await self._generate_occurrences_service.generate( + reminder_id=saved_reminder.reminder_id, + frequency=frequency, + start_date=start_date, + end_date=end_date, + amount=amount, + description=description, + entry_type=entry_type, + currency=currency, + category_id=category_id, + ) + + return saved_reminder diff --git a/app/context/reminder/domain/services/delete_reminder_service.py b/app/context/reminder/domain/services/delete_reminder_service.py new file mode 100644 index 0000000..ccbdf98 --- /dev/null +++ b/app/context/reminder/domain/services/delete_reminder_service.py @@ -0,0 +1,39 @@ +"""Service for deleting reminders""" + +from app.context.reminder.domain.contracts.infrastructure import ( + ReminderOccurrenceRepositoryContract, + ReminderRepositoryContract, +) +from app.context.reminder.domain.contracts.services import DeleteReminderServiceContract +from app.context.reminder.domain.exceptions import ReminderNotBelongsToUserError, ReminderNotFoundError +from app.context.reminder.domain.value_objects import ReminderID, ReminderUserID + + +class DeleteReminderService(DeleteReminderServiceContract): + """Service for deleting reminders and their occurrences""" + + def __init__( + self, + reminder_repo: ReminderRepositoryContract, + occurrence_repo: ReminderOccurrenceRepositoryContract, + ): + self._reminder_repo = reminder_repo + self._occurrence_repo = occurrence_repo + + async def delete(self, reminder_id: ReminderID, user_id: ReminderUserID) -> None: + """Delete a reminder and all its occurrences""" + + # Verify reminder exists and belongs to user + existing = await self._reminder_repo.find_user_reminder_by_id(reminder_id=reminder_id, user_id=user_id) + + if not existing: + raise ReminderNotFoundError(f"Reminder {reminder_id.value} not found") + + if existing.user_id.value != user_id.value: + raise ReminderNotBelongsToUserError(f"Reminder {reminder_id.value} does not belong to user") + + # Delete occurrences first (cascade delete) + await self._occurrence_repo.delete_occurrences_by_reminder(reminder_id) + + # Delete reminder + await self._reminder_repo.delete_reminder(reminder_id) diff --git a/app/context/reminder/domain/services/generate_occurrences_service.py b/app/context/reminder/domain/services/generate_occurrences_service.py new file mode 100644 index 0000000..23d6dfd --- /dev/null +++ b/app/context/reminder/domain/services/generate_occurrences_service.py @@ -0,0 +1,156 @@ +"""Service for generating reminder occurrences""" + +from datetime import datetime, timedelta + +from app.context.reminder.domain.contracts.infrastructure import ReminderOccurrenceRepositoryContract +from app.context.reminder.domain.contracts.services import GenerateOccurrencesServiceContract +from app.context.reminder.domain.dto import ReminderOccurrenceDTO +from app.context.reminder.domain.exceptions import InvalidReminderFrequencyError +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderOccurrenceAmount, + ReminderOccurrenceScheduledDate, + ReminderOccurrenceStatus, + ReminderStartDate, +) + + +class GenerateOccurrencesService(GenerateOccurrencesServiceContract): + """Service for generating reminder occurrences based on frequency""" + + def __init__(self, occurrence_repo: ReminderOccurrenceRepositoryContract): + self._occurrence_repo = occurrence_repo + + async def generate( + self, + reminder_id: ReminderID, + frequency: ReminderFrequency, + start_date: ReminderStartDate, + end_date: ReminderEndDate | None, + amount: ReminderOccurrenceAmount, + description: ReminderDescription, + entry_type: ReminderEntryType, + currency: ReminderCurrency, + category_id: ReminderCategoryID | None, + ) -> list[ReminderOccurrenceDTO]: + """Generate occurrences based on frequency and date range""" + + # Determine the end date for generation (1 year from start if no end_date) + generation_end = end_date.value if end_date else self._add_years(start_date.value, 1) + + # Generate occurrence dates based on frequency + occurrence_dates = self._generate_dates(frequency, start_date.value, generation_end) + + # Create occurrence DTOs + occurrences: list[ReminderOccurrenceDTO] = [] + for scheduled_date in occurrence_dates: + occurrence_dto = ReminderOccurrenceDTO( + occurrence_id=None, # Will be assigned by repository + reminder_id=reminder_id, + scheduled_date=ReminderOccurrenceScheduledDate.from_trusted_source(scheduled_date), + amount=amount, + status=ReminderOccurrenceStatus("pending"), + entry_id=None, + ) + occurrences.append(occurrence_dto) + + # Save all occurrences in batch + if occurrences: + await self._occurrence_repo.save_occurrences(occurrences) + + return occurrences + + def _generate_dates(self, frequency: ReminderFrequency, start: datetime, end: datetime) -> list[datetime]: + """Generate list of occurrence dates based on frequency""" + + dates: list[datetime] = [] + current = start + + # Generate dates until we exceed the end date + while current <= end: + dates.append(current) + current = self._next_occurrence(current, frequency) + + return dates + + def _next_occurrence(self, current: datetime, frequency: ReminderFrequency) -> datetime: + """Calculate the next occurrence date based on frequency""" + + freq_value = frequency.value.lower() + + if freq_value == "daily": + return current + timedelta(days=1) + + elif freq_value == "weekly": + return current + timedelta(weeks=1) + + elif freq_value == "biweekly": + return current + timedelta(weeks=2) + + elif freq_value == "monthly": + return self._add_months(current, 1) + + elif freq_value == "quarterly": + return self._add_months(current, 3) + + elif freq_value == "yearly": + return self._add_years(current, 1) + + else: + raise InvalidReminderFrequencyError(f"Unsupported frequency: {frequency.value}") + + def _add_months(self, dt: datetime, months: int) -> datetime: + """ + Add months to a datetime, handling variable month lengths + + Examples: + - Jan 31 + 1 month = Feb 28 (or 29 in leap year) + - Jan 15 + 1 month = Feb 15 + - Dec 15 + 1 month = Jan 15 (next year) + """ + # Calculate target month and year + month = dt.month - 1 + months # 0-indexed + year = dt.year + month // 12 + month = month % 12 + 1 # Back to 1-indexed + + # Handle day overflow (e.g., Jan 31 -> Feb 28) + day = min(dt.day, self._days_in_month(year, month)) + + return dt.replace(year=year, month=month, day=day) + + def _add_years(self, dt: datetime, years: int) -> datetime: + """ + Add years to a datetime, handling leap year Feb 29 + + Examples: + - Feb 29, 2024 + 1 year = Feb 28, 2025 + - Jan 15, 2024 + 1 year = Jan 15, 2025 + """ + target_year = dt.year + years + + # Handle Feb 29 in leap year -> non-leap year + if dt.month == 2 and dt.day == 29 and not self._is_leap_year(target_year): + return dt.replace(year=target_year, day=28) + + return dt.replace(year=target_year) + + def _days_in_month(self, year: int, month: int) -> int: + """Return the number of days in a given month""" + if month in [1, 3, 5, 7, 8, 10, 12]: + return 31 + elif month in [4, 6, 9, 11]: + return 30 + elif month == 2: + return 29 if self._is_leap_year(year) else 28 + else: + raise ValueError(f"Invalid month: {month}") + + def _is_leap_year(self, year: int) -> bool: + """Check if a year is a leap year""" + return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) diff --git a/app/context/reminder/domain/services/update_reminder_service.py b/app/context/reminder/domain/services/update_reminder_service.py new file mode 100644 index 0000000..56a6b0a --- /dev/null +++ b/app/context/reminder/domain/services/update_reminder_service.py @@ -0,0 +1,106 @@ +"""Service for updating reminders""" + +from app.context.reminder.domain.contracts.infrastructure import ( + ReminderOccurrenceRepositoryContract, + ReminderRepositoryContract, +) +from app.context.reminder.domain.contracts.services import ( + GenerateOccurrencesServiceContract, + UpdateReminderServiceContract, +) +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.exceptions import ( + InvalidReminderDateRangeError, + ReminderNotBelongsToUserError, + ReminderNotFoundError, +) +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderStartDate, + ReminderUserID, +) + + +class UpdateReminderService(UpdateReminderServiceContract): + """Service for updating reminders""" + + def __init__( + self, + reminder_repo: ReminderRepositoryContract, + occurrence_repo: ReminderOccurrenceRepositoryContract, + generate_occurrences_service: GenerateOccurrencesServiceContract, + ): + self._reminder_repo = reminder_repo + self._occurrence_repo = occurrence_repo + self._generate_occurrences_service = generate_occurrences_service + + async def update( + self, + reminder_id: ReminderID, + user_id: ReminderUserID, + description: ReminderDescription | None = None, + entry_type: ReminderEntryType | None = None, + currency: ReminderCurrency | None = None, + frequency: ReminderFrequency | None = None, + start_date: ReminderStartDate | None = None, + end_date: ReminderEndDate | None = None, + category_id: ReminderCategoryID | None = None, + ) -> ReminderDTO: + """Update an existing reminder and regenerate occurrences if needed""" + + # Fetch existing reminder with ownership check + existing = await self._reminder_repo.find_user_reminder_by_id(reminder_id=reminder_id, user_id=user_id) + + if not existing: + raise ReminderNotFoundError(f"Reminder {reminder_id.value} not found") + + if existing.user_id.value != user_id.value: + raise ReminderNotBelongsToUserError(f"Reminder {reminder_id.value} does not belong to user") + + # Build updated reminder DTO (keep existing values if not provided) + updated_reminder = ReminderDTO( + reminder_id=reminder_id, + user_id=user_id, + description=description if description else existing.description, + entry_type=entry_type if entry_type else existing.entry_type, + currency=currency if currency else existing.currency, + frequency=frequency if frequency else existing.frequency, + start_date=start_date if start_date else existing.start_date, + end_date=end_date if end_date is not None else existing.end_date, + category_id=category_id if category_id is not None else existing.category_id, + ) + + # Validate date range + if updated_reminder.end_date and updated_reminder.end_date.value < updated_reminder.start_date.value: + raise InvalidReminderDateRangeError("End date cannot be before start date") + + # Check if frequency or dates changed (need to regenerate occurrences) + needs_regeneration = frequency is not None or start_date is not None or end_date is not None + + # Update reminder + saved_reminder = await self._reminder_repo.update_reminder(updated_reminder) + + # Regenerate occurrences if needed + if needs_regeneration: + # Delete old pending occurrences + await self._occurrence_repo.delete_occurrences_by_reminder(reminder_id) + + # Generate new occurrences + await self._generate_occurrences_service.generate( + reminder_id=reminder_id, + frequency=saved_reminder.frequency, + start_date=saved_reminder.start_date, + end_date=saved_reminder.end_date, + description=saved_reminder.description, + entry_type=saved_reminder.entry_type, + currency=saved_reminder.currency, + category_id=saved_reminder.category_id, + ) + + return saved_reminder diff --git a/app/context/reminder/domain/value_objects/__init__.py b/app/context/reminder/domain/value_objects/__init__.py new file mode 100644 index 0000000..00f517f --- /dev/null +++ b/app/context/reminder/domain/value_objects/__init__.py @@ -0,0 +1,29 @@ +from .reminder_category_id import ReminderCategoryID +from .reminder_currency import ReminderCurrency +from .reminder_description import ReminderDescription +from .reminder_end_date import ReminderEndDate +from .reminder_entry_type import ReminderEntryType +from .reminder_frequency import ReminderFrequency +from .reminder_id import ReminderID +from .reminder_occurrence_amount import ReminderOccurrenceAmount +from .reminder_occurrence_id import ReminderOccurrenceID +from .reminder_occurrence_scheduled_date import ReminderOccurrenceScheduledDate +from .reminder_occurrence_status import ReminderOccurrenceStatus +from .reminder_start_date import ReminderStartDate +from .reminder_user_id import ReminderUserID + +__all__ = [ + "ReminderID", + "ReminderUserID", + "ReminderEntryType", + "ReminderCurrency", + "ReminderFrequency", + "ReminderStartDate", + "ReminderEndDate", + "ReminderDescription", + "ReminderOccurrenceID", + "ReminderOccurrenceAmount", + "ReminderOccurrenceScheduledDate", + "ReminderOccurrenceStatus", + "ReminderCategoryID", +] diff --git a/app/context/reminder/domain/value_objects/reminder_category_id.py b/app/context/reminder/domain/value_objects/reminder_category_id.py new file mode 100644 index 0000000..e3fa713 --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_category_id.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedCategoryID + + +@dataclass(frozen=True) +class ReminderCategoryID(SharedCategoryID): + pass diff --git a/app/context/reminder/domain/value_objects/reminder_currency.py b/app/context/reminder/domain/value_objects/reminder_currency.py new file mode 100644 index 0000000..aa5a8bd --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_currency.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedCurrency + + +@dataclass(frozen=True) +class ReminderCurrency(SharedCurrency): + pass diff --git a/app/context/reminder/domain/value_objects/reminder_description.py b/app/context/reminder/domain/value_objects/reminder_description.py new file mode 100644 index 0000000..01a4675 --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_description.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class ReminderDescription: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, str): + raise ValueError(f"ReminderDescription must be a string, got {type(self.value)}") + if len(self.value) > 500: + raise ValueError(f"ReminderDescription must be <= 500 characters, got {len(self.value)}") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """Create ReminderDescription from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/reminder/domain/value_objects/reminder_end_date.py b/app/context/reminder/domain/value_objects/reminder_end_date.py new file mode 100644 index 0000000..ce2b9cf --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_end_date.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedDateTime + + +@dataclass(frozen=True) +class ReminderEndDate(SharedDateTime): + pass diff --git a/app/context/reminder/domain/value_objects/reminder_entry_type.py b/app/context/reminder/domain/value_objects/reminder_entry_type.py new file mode 100644 index 0000000..4eea74d --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_entry_type.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedEntryType + + +@dataclass(frozen=True) +class ReminderEntryType(SharedEntryType): + pass diff --git a/app/context/reminder/domain/value_objects/reminder_frequency.py b/app/context/reminder/domain/value_objects/reminder_frequency.py new file mode 100644 index 0000000..7dd669e --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_frequency.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field +from typing import Self + +VALID_FREQUENCIES = {"daily", "weekly", "biweekly", "monthly", "quarterly", "yearly"} + + +@dataclass(frozen=True) +class ReminderFrequency: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, str): + raise ValueError(f"ReminderFrequency must be a string, got {type(self.value)}") + if self.value.lower() not in VALID_FREQUENCIES: + raise ValueError(f"ReminderFrequency must be one of {VALID_FREQUENCIES}, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """Create ReminderFrequency from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/reminder/domain/value_objects/reminder_id.py b/app/context/reminder/domain/value_objects/reminder_id.py new file mode 100644 index 0000000..18333c0 --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_id.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class ReminderID: + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, int): + raise ValueError(f"ReminderID must be an integer, got {type(self.value)}") + if self.value < 1: + raise ValueError(f"ReminderID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create ReminderID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/reminder/domain/value_objects/reminder_occurrence_amount.py b/app/context/reminder/domain/value_objects/reminder_occurrence_amount.py new file mode 100644 index 0000000..7e11de7 --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_occurrence_amount.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_amount import SharedAmount + + +@dataclass(frozen=True) +class ReminderOccurrenceAmount(SharedAmount): + pass diff --git a/app/context/reminder/domain/value_objects/reminder_occurrence_id.py b/app/context/reminder/domain/value_objects/reminder_occurrence_id.py new file mode 100644 index 0000000..f19f35f --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_occurrence_id.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class ReminderOccurrenceID: + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, int): + raise ValueError(f"ReminderOccurrenceID must be an integer, got {type(self.value)}") + if self.value < 1: + raise ValueError(f"ReminderOccurrenceID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create ReminderOccurrenceID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/reminder/domain/value_objects/reminder_occurrence_scheduled_date.py b/app/context/reminder/domain/value_objects/reminder_occurrence_scheduled_date.py new file mode 100644 index 0000000..fa4873f --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_occurrence_scheduled_date.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedDateTime + + +@dataclass(frozen=True) +class ReminderOccurrenceScheduledDate(SharedDateTime): + pass diff --git a/app/context/reminder/domain/value_objects/reminder_occurrence_status.py b/app/context/reminder/domain/value_objects/reminder_occurrence_status.py new file mode 100644 index 0000000..f350b31 --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_occurrence_status.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass, field +from typing import Self + +VALID_STATUSES = {"pending", "completed", "cancelled"} + + +@dataclass(frozen=True) +class ReminderOccurrenceStatus: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, str): + raise ValueError(f"ReminderOccurrenceStatus must be a string, got {type(self.value)}") + if self.value.lower() not in VALID_STATUSES: + raise ValueError(f"ReminderOccurrenceStatus must be one of {VALID_STATUSES}, got {self.value}") + + @property + def is_pending(self) -> bool: + return self.value.lower() == "pending" + + @property + def is_completed(self) -> bool: + return self.value.lower() == "completed" + + @property + def is_cancelled(self) -> bool: + return self.value.lower() == "cancelled" + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """Create ReminderOccurrenceStatus from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/reminder/domain/value_objects/reminder_start_date.py b/app/context/reminder/domain/value_objects/reminder_start_date.py new file mode 100644 index 0000000..506fbba --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_start_date.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedDateTime + + +@dataclass(frozen=True) +class ReminderStartDate(SharedDateTime): + pass diff --git a/app/context/reminder/domain/value_objects/reminder_user_id.py b/app/context/reminder/domain/value_objects/reminder_user_id.py new file mode 100644 index 0000000..e139b77 --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_user_id.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class ReminderUserID: + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, int): + raise ValueError(f"ReminderUserID must be an integer, got {type(self.value)}") + if self.value < 1: + raise ValueError(f"ReminderUserID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create ReminderUserID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/reminder/infrastructure/__init__.py b/app/context/reminder/infrastructure/__init__.py new file mode 100644 index 0000000..b0c7f13 --- /dev/null +++ b/app/context/reminder/infrastructure/__init__.py @@ -0,0 +1,9 @@ +from app.context.reminder.infrastructure.dependencies import ( + get_reminder_occurrence_repository, + get_reminder_repository, +) + +__all__ = [ + "get_reminder_repository", + "get_reminder_occurrence_repository", +] diff --git a/app/context/reminder/infrastructure/dependencies.py b/app/context/reminder/infrastructure/dependencies.py new file mode 100644 index 0000000..15055bc --- /dev/null +++ b/app/context/reminder/infrastructure/dependencies.py @@ -0,0 +1,156 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.reminder.application.contracts import ( + CreateReminderHandlerContract, + DeleteReminderHandlerContract, + FindReminderHandlerContract, + ListOccurrencesHandlerContract, + ListRemindersHandlerContract, + UpdateReminderHandlerContract, +) +from app.context.reminder.domain.contracts.infrastructure import ( + ReminderOccurrenceRepositoryContract, + ReminderRepositoryContract, +) +from app.context.reminder.domain.contracts.services import ( + CreateReminderServiceContract, + DeleteReminderServiceContract, + GenerateOccurrencesServiceContract, + UpdateReminderServiceContract, +) +from app.shared.infrastructure.database import get_db + +# ============================================================================ +# Repository Dependencies +# ============================================================================ + + +def get_reminder_repository( + db: Annotated[AsyncSession, Depends(get_db)], +) -> ReminderRepositoryContract: + """ReminderRepository dependency injection""" + from app.context.reminder.infrastructure.repositories.reminder_repository import ReminderRepository + + return ReminderRepository(db) + + +def get_reminder_occurrence_repository( + db: Annotated[AsyncSession, Depends(get_db)], +) -> ReminderOccurrenceRepositoryContract: + """ReminderOccurrenceRepository dependency injection""" + from app.context.reminder.infrastructure.repositories.reminder_occurrence_repository import ( + ReminderOccurrenceRepository, + ) + + return ReminderOccurrenceRepository(db) + + +# ============================================================================ +# Service Dependencies +# ============================================================================ + + +def get_generate_occurrences_service( + occurrence_repo: Annotated[ReminderOccurrenceRepositoryContract, Depends(get_reminder_occurrence_repository)], +) -> GenerateOccurrencesServiceContract: + """GenerateOccurrencesService dependency injection""" + from app.context.reminder.domain.services import GenerateOccurrencesService + + return GenerateOccurrencesService(occurrence_repo) + + +def get_create_reminder_service( + reminder_repo: Annotated[ReminderRepositoryContract, Depends(get_reminder_repository)], + generate_occurrences_service: Annotated[ + GenerateOccurrencesServiceContract, Depends(get_generate_occurrences_service) + ], +) -> CreateReminderServiceContract: + """CreateReminderService dependency injection""" + from app.context.reminder.domain.services import CreateReminderService + + return CreateReminderService(reminder_repo, generate_occurrences_service) + + +def get_update_reminder_service( + reminder_repo: Annotated[ReminderRepositoryContract, Depends(get_reminder_repository)], + occurrence_repo: Annotated[ReminderOccurrenceRepositoryContract, Depends(get_reminder_occurrence_repository)], + generate_occurrences_service: Annotated[ + GenerateOccurrencesServiceContract, Depends(get_generate_occurrences_service) + ], +) -> UpdateReminderServiceContract: + """UpdateReminderService dependency injection""" + from app.context.reminder.domain.services import UpdateReminderService + + return UpdateReminderService(reminder_repo, occurrence_repo, generate_occurrences_service) + + +def get_delete_reminder_service( + reminder_repo: Annotated[ReminderRepositoryContract, Depends(get_reminder_repository)], + occurrence_repo: Annotated[ReminderOccurrenceRepositoryContract, Depends(get_reminder_occurrence_repository)], +) -> DeleteReminderServiceContract: + """DeleteReminderService dependency injection""" + from app.context.reminder.domain.services import DeleteReminderService + + return DeleteReminderService(reminder_repo, occurrence_repo) + + +# ============================================================================ +# Handler Dependencies +# ============================================================================ + + +def get_create_reminder_handler( + service: Annotated[CreateReminderServiceContract, Depends(get_create_reminder_service)], +) -> CreateReminderHandlerContract: + """CreateReminderHandler dependency injection""" + from app.context.reminder.application.handlers import CreateReminderHandler + + return CreateReminderHandler(service) + + +def get_update_reminder_handler( + service: Annotated[UpdateReminderServiceContract, Depends(get_update_reminder_service)], +) -> UpdateReminderHandlerContract: + """UpdateReminderHandler dependency injection""" + from app.context.reminder.application.handlers import UpdateReminderHandler + + return UpdateReminderHandler(service) + + +def get_delete_reminder_handler( + service: Annotated[DeleteReminderServiceContract, Depends(get_delete_reminder_service)], +) -> DeleteReminderHandlerContract: + """DeleteReminderHandler dependency injection""" + from app.context.reminder.application.handlers import DeleteReminderHandler + + return DeleteReminderHandler(service) + + +def get_find_reminder_handler( + repository: Annotated[ReminderRepositoryContract, Depends(get_reminder_repository)], +) -> FindReminderHandlerContract: + """FindReminderHandler dependency injection""" + from app.context.reminder.application.handlers import FindReminderHandler + + return FindReminderHandler(repository) + + +def get_list_reminders_handler( + repository: Annotated[ReminderRepositoryContract, Depends(get_reminder_repository)], +) -> ListRemindersHandlerContract: + """ListRemindersHandler dependency injection""" + from app.context.reminder.application.handlers import ListRemindersHandler + + return ListRemindersHandler(repository) + + +def get_list_occurrences_handler( + repository: Annotated[ReminderOccurrenceRepositoryContract, Depends(get_reminder_occurrence_repository)], +) -> ListOccurrencesHandlerContract: + """ListOccurrencesHandler dependency injection""" + from app.context.reminder.application.handlers import ListOccurrencesHandler + + return ListOccurrencesHandler(repository) diff --git a/app/context/reminder/infrastructure/mappers/__init__.py b/app/context/reminder/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..380b5b7 --- /dev/null +++ b/app/context/reminder/infrastructure/mappers/__init__.py @@ -0,0 +1,4 @@ +from .reminder_mapper import ReminderMapper +from .reminder_occurrence_mapper import ReminderOccurrenceMapper + +__all__ = ["ReminderMapper", "ReminderOccurrenceMapper"] diff --git a/app/context/reminder/infrastructure/mappers/reminder_mapper.py b/app/context/reminder/infrastructure/mappers/reminder_mapper.py new file mode 100644 index 0000000..c007e98 --- /dev/null +++ b/app/context/reminder/infrastructure/mappers/reminder_mapper.py @@ -0,0 +1,62 @@ +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderOccurrenceAmount, + ReminderStartDate, + ReminderUserID, +) +from app.context.reminder.infrastructure.models import ReminderModel + + +class ReminderMapper: + """Mapper for converting between ReminderModel and ReminderDTO""" + + @staticmethod + def to_dto(model: ReminderModel | None) -> ReminderDTO | None: + """Convert database model to domain DTO""" + return ( + ReminderDTO( + reminder_id=ReminderID.from_trusted_source(model.id), + user_id=ReminderUserID.from_trusted_source(model.user_id), + category_id=ReminderCategoryID.from_trusted_source(model.category_id), + entry_type=ReminderEntryType.from_trusted_source(model.entry_type), + currency=ReminderCurrency.from_trusted_source(model.currency), + amount=ReminderOccurrenceAmount.from_trusted_source(model.amount), + frequency=ReminderFrequency.from_trusted_source(model.frequency), + start_date=ReminderStartDate.from_trusted_source(model.start_date), + end_date=ReminderEndDate.from_trusted_source(model.end_date) if model.end_date else None, + description=ReminderDescription.from_trusted_source(model.description), + ) + if model + else None + ) + + @staticmethod + def to_dto_or_fail(model: ReminderModel) -> ReminderDTO: + """Convert database model to domain DTO, raising error if model is None""" + dto = ReminderMapper.to_dto(model) + if dto is None: + raise ValueError("Reminder DTO cannot be null") + return dto + + @staticmethod + def to_model(dto: ReminderDTO) -> ReminderModel: + """Convert domain DTO to database model""" + return ReminderModel( + id=dto.reminder_id.value if dto.reminder_id is not None else None, + user_id=dto.user_id.value, + entry_type=dto.entry_type.value, + currency=dto.currency.value, + amount=dto.amount.value, + frequency=dto.frequency.value, + start_date=dto.start_date.value, + category_id=dto.category_id.value, + description=dto.description.value, + end_date=dto.end_date.value if dto.end_date else None, + ) diff --git a/app/context/reminder/infrastructure/mappers/reminder_occurrence_mapper.py b/app/context/reminder/infrastructure/mappers/reminder_occurrence_mapper.py new file mode 100644 index 0000000..09b8657 --- /dev/null +++ b/app/context/reminder/infrastructure/mappers/reminder_occurrence_mapper.py @@ -0,0 +1,49 @@ +from app.context.reminder.domain.dto import ReminderOccurrenceDTO +from app.context.reminder.domain.value_objects import ( + ReminderID, + ReminderOccurrenceAmount, + ReminderOccurrenceID, + ReminderOccurrenceScheduledDate, + ReminderOccurrenceStatus, +) +from app.context.reminder.infrastructure.models import ReminderOccurrenceModel + + +class ReminderOccurrenceMapper: + """Mapper for converting between ReminderOccurrenceModel and ReminderOccurrenceDTO""" + + @staticmethod + def to_dto(model: ReminderOccurrenceModel | None) -> ReminderOccurrenceDTO | None: + """Convert database model to domain DTO""" + return ( + ReminderOccurrenceDTO( + occurrence_id=ReminderOccurrenceID.from_trusted_source(model.id), + reminder_id=ReminderID.from_trusted_source(model.reminder_id), + scheduled_date=ReminderOccurrenceScheduledDate.from_trusted_source(model.scheduled_date), + amount=ReminderOccurrenceAmount.from_trusted_source(model.amount), + status=ReminderOccurrenceStatus(model.status), + entry_id=model.entry_id, + ) + if model + else None + ) + + @staticmethod + def to_dto_or_fail(model: ReminderOccurrenceModel) -> ReminderOccurrenceDTO: + """Convert database model to domain DTO, raising error if model is None""" + dto = ReminderOccurrenceMapper.to_dto(model) + if dto is None: + raise ValueError("Reminder occurrence DTO cannot be null") + return dto + + @staticmethod + def to_model(dto: ReminderOccurrenceDTO) -> ReminderOccurrenceModel: + """Convert domain DTO to database model""" + return ReminderOccurrenceModel( + id=dto.occurrence_id.value if dto.occurrence_id is not None else None, + reminder_id=dto.reminder_id.value, + scheduled_date=dto.scheduled_date.value, + amount=dto.amount.value, + status=dto.status.value, + entry_id=dto.entry_id, + ) diff --git a/app/context/reminder/infrastructure/models/__init__.py b/app/context/reminder/infrastructure/models/__init__.py new file mode 100644 index 0000000..70ed51f --- /dev/null +++ b/app/context/reminder/infrastructure/models/__init__.py @@ -0,0 +1,4 @@ +from .reminder_model import ReminderModel +from .reminder_occurrence_model import ReminderOccurrenceModel + +__all__ = ["ReminderModel", "ReminderOccurrenceModel"] diff --git a/app/context/reminder/infrastructure/models/reminder_model.py b/app/context/reminder/infrastructure/models/reminder_model.py new file mode 100644 index 0000000..5f68ff8 --- /dev/null +++ b/app/context/reminder/infrastructure/models/reminder_model.py @@ -0,0 +1,40 @@ +from datetime import UTC, datetime + +from sqlalchemy import DECIMAL, BigInteger, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class ReminderModel(BaseDBModel): + __tablename__ = "reminders" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + entry_type: Mapped[str] = mapped_column(String(20), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False) + amount: Mapped[int] = mapped_column(DECIMAL(15, 2), nullable=False) + frequency: Mapped[str] = mapped_column(String(50), nullable=False) + start_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_date: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) + category_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("categories.id", ondelete="RESTRICT"), + nullable=False, + ) + household_id: Mapped[int | None] = mapped_column( + Integer, + ForeignKey("households.id", ondelete="SET NULL"), + nullable=True, + default=None, + ) + description: Mapped[str] = mapped_column(String(500), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) diff --git a/app/context/reminder/infrastructure/models/reminder_occurrence_model.py b/app/context/reminder/infrastructure/models/reminder_occurrence_model.py new file mode 100644 index 0000000..422afa3 --- /dev/null +++ b/app/context/reminder/infrastructure/models/reminder_occurrence_model.py @@ -0,0 +1,30 @@ +from datetime import UTC, datetime + +from sqlalchemy import DECIMAL, BigInteger, DateTime, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class ReminderOccurrenceModel(BaseDBModel): + __tablename__ = "reminder_occurrences" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + reminder_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("reminders.id", ondelete="CASCADE"), + nullable=False, + ) + scheduled_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + amount: Mapped[int] = mapped_column(DECIMAL(15, 2), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending") + entry_id: Mapped[int | None] = mapped_column( + BigInteger, + nullable=True, + default=None, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) diff --git a/app/context/reminder/infrastructure/repositories/__init__.py b/app/context/reminder/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..653da6d --- /dev/null +++ b/app/context/reminder/infrastructure/repositories/__init__.py @@ -0,0 +1,4 @@ +from .reminder_occurrence_repository import ReminderOccurrenceRepository +from .reminder_repository import ReminderRepository + +__all__ = ["ReminderRepository", "ReminderOccurrenceRepository"] diff --git a/app/context/reminder/infrastructure/repositories/reminder_occurrence_repository.py b/app/context/reminder/infrastructure/repositories/reminder_occurrence_repository.py new file mode 100644 index 0000000..92949f0 --- /dev/null +++ b/app/context/reminder/infrastructure/repositories/reminder_occurrence_repository.py @@ -0,0 +1,195 @@ +from datetime import datetime +from typing import Any, cast + +from sqlalchemy import select, update +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.reminder.domain.contracts.infrastructure import ( + ReminderOccurrenceRepositoryContract, +) +from app.context.reminder.domain.dto import ReminderOccurrenceDTO +from app.context.reminder.domain.value_objects import ( + ReminderID, + ReminderOccurrenceID, + ReminderOccurrenceStatus, +) +from app.context.reminder.infrastructure.mappers import ReminderOccurrenceMapper +from app.context.reminder.infrastructure.models import ReminderModel, ReminderOccurrenceModel + + +class ReminderOccurrenceRepository(ReminderOccurrenceRepositoryContract): + """Repository implementation for reminder occurrence operations""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def save_occurrence(self, occurrence: ReminderOccurrenceDTO) -> ReminderOccurrenceDTO: + """Create a new occurrence""" + try: + model = ReminderOccurrenceMapper.to_model(occurrence) + self._db.add(model) + await self._db.commit() + await self._db.refresh(model) + return ReminderOccurrenceMapper.to_dto_or_fail(model) + except SQLAlchemyError as e: + await self._db.rollback() + raise ValueError(f"Database error while saving occurrence: {str(e)}") from e + + async def save_occurrences(self, occurrences: list[ReminderOccurrenceDTO]) -> list[ReminderOccurrenceDTO]: + """Create multiple occurrences in a single transaction""" + try: + models = [ReminderOccurrenceMapper.to_model(o) for o in occurrences] + for model in models: + self._db.add(model) + await self._db.commit() + + saved_occurrences = [] + for model in models: + await self._db.refresh(model) + saved_occurrences.append(ReminderOccurrenceMapper.to_dto_or_fail(model)) + + return saved_occurrences + except SQLAlchemyError as e: + await self._db.rollback() + raise ValueError(f"Database error while saving occurrences: {str(e)}") from e + + async def find_occurrence( + self, + occurrence_id: ReminderOccurrenceID | None = None, + reminder_id: ReminderID | None = None, + ) -> ReminderOccurrenceDTO | None: + """Find an occurrence by ID or reminder_id""" + try: + stmt = select(ReminderOccurrenceModel) + + if occurrence_id is not None: + stmt = stmt.where(ReminderOccurrenceModel.id == occurrence_id.value) + elif reminder_id is not None: + stmt = stmt.where(ReminderOccurrenceModel.reminder_id == reminder_id.value) + else: + raise ValueError("Must provide either occurrence_id or reminder_id") + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return ReminderOccurrenceMapper.to_dto(model) if model else None + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding occurrence: {str(e)}") from e + + async def find_occurrences_by_reminder( + self, + reminder_id: ReminderID, + ) -> list[ReminderOccurrenceDTO]: + """Find all occurrences for a reminder""" + try: + stmt = ( + select(ReminderOccurrenceModel) + .where(ReminderOccurrenceModel.reminder_id == reminder_id.value) + .order_by(ReminderOccurrenceModel.scheduled_date) + ) + + models = (await self._db.execute(stmt)).scalars() + return [ReminderOccurrenceMapper.to_dto_or_fail(model) for model in models] if models else [] + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding occurrences: {str(e)}") from e + + async def find_pending_occurrences_by_date( + self, + before: datetime, + ) -> list[ReminderOccurrenceDTO]: + """Find all pending occurrences scheduled before the given datetime""" + try: + stmt = ( + select(ReminderOccurrenceModel) + .where( + ReminderOccurrenceModel.scheduled_date <= before, + ReminderOccurrenceModel.status == "pending", + ) + .order_by(ReminderOccurrenceModel.scheduled_date) + ) + + models = (await self._db.execute(stmt)).scalars() + return [ReminderOccurrenceMapper.to_dto_or_fail(model) for model in models] if models else [] + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding pending occurrences: {str(e)}") from e + + async def find_pending_occurrences_by_user( + self, + user_id: int, + before: datetime | None = None, + ) -> list[ReminderOccurrenceDTO]: + """Find all pending occurrences for a user's reminders""" + try: + from sqlalchemy import join + + stmt = ( + select(ReminderOccurrenceModel) + .select_from( + join( + ReminderOccurrenceModel, + ReminderModel, + ReminderModel.id == ReminderOccurrenceModel.reminder_id, + ) + ) + .where( + ReminderModel.user_id == user_id, + ReminderOccurrenceModel.status == "pending", + ) + ) + + if before is not None: + stmt = stmt.where(ReminderOccurrenceModel.scheduled_date <= before) + + stmt = stmt.order_by(ReminderOccurrenceModel.scheduled_date) + + models = (await self._db.execute(stmt)).scalars() + return [ReminderOccurrenceMapper.to_dto_or_fail(model) for model in models] if models else [] + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding user pending occurrences: {str(e)}") from e + + async def update_occurrence_status( + self, + occurrence_id: ReminderOccurrenceID, + status: ReminderOccurrenceStatus, + entry_id: int | None = None, + ) -> bool: + """Update occurrence status (e.g., mark as completed with entry_id)""" + try: + stmt = ( + update(ReminderOccurrenceModel) + .where(ReminderOccurrenceModel.id == occurrence_id.value) + .values( + status=status.value, + entry_id=entry_id, + ) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + await self._db.commit() + + return result.rowcount > 0 + except SQLAlchemyError as e: + await self._db.rollback() + raise ValueError(f"Database error while updating occurrence status: {str(e)}") from e + + async def delete_occurrences_by_reminder( + self, + reminder_id: ReminderID, + ) -> int: + """Delete all occurrences for a reminder (when reminder is deleted)""" + try: + stmt = ( + update(ReminderOccurrenceModel) + .where(ReminderOccurrenceModel.reminder_id == reminder_id.value) + .values(deleted_at=datetime.utcnow()) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + await self._db.commit() + + return result.rowcount + except SQLAlchemyError as e: + await self._db.rollback() + raise ValueError(f"Database error while deleting occurrences: {str(e)}") from e diff --git a/app/context/reminder/infrastructure/repositories/reminder_repository.py b/app/context/reminder/infrastructure/repositories/reminder_repository.py new file mode 100644 index 0000000..0622dad --- /dev/null +++ b/app/context/reminder/infrastructure/repositories/reminder_repository.py @@ -0,0 +1,183 @@ +from datetime import datetime +from typing import Any, cast + +from sqlalchemy import select, update +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.reminder.domain.contracts.infrastructure import ( + ReminderRepositoryContract, +) +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.value_objects import ( + ReminderID, + ReminderStartDate, + ReminderUserID, +) +from app.context.reminder.infrastructure.mappers import ReminderMapper +from app.context.reminder.infrastructure.models import ReminderModel + + +class ReminderRepository(ReminderRepositoryContract): + """Repository implementation for reminder operations""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def save_reminder(self, reminder: ReminderDTO) -> ReminderDTO: + """Create a new reminder""" + try: + model = ReminderMapper.to_model(reminder) + self._db.add(model) + await self._db.commit() + await self._db.refresh(model) + return ReminderMapper.to_dto_or_fail(model) + except SQLAlchemyError as e: + await self._db.rollback() + raise ValueError(f"Database error while saving reminder: {str(e)}") from e + + async def find_reminder( + self, + reminder_id: ReminderID | None = None, + user_id: ReminderUserID | None = None, + ) -> ReminderDTO | None: + """Find a reminder by ID or user_id""" + try: + stmt = select(ReminderModel) + + if reminder_id is not None: + stmt = stmt.where(ReminderModel.id == reminder_id.value) + elif user_id is not None: + stmt = stmt.where(ReminderModel.user_id == user_id.value) + else: + raise ValueError("Must provide either reminder_id or user_id") + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return ReminderMapper.to_dto(model) if model else None + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding reminder: {str(e)}") from e + + async def find_user_reminders( + self, + user_id: ReminderUserID, + reminder_id: ReminderID | None = None, + only_active: bool | None = True, + ) -> list[ReminderDTO]: + """Find all reminders for a user""" + try: + stmt = select(ReminderModel).where(ReminderModel.user_id == user_id.value) + + if only_active: + now = ReminderStartDate.now().value + stmt = stmt.where( + (ReminderModel.start_date <= now) + & ((ReminderModel.end_date.is_(None)) | (ReminderModel.end_date > now)) + ) + + if reminder_id is not None: + stmt = stmt.where(ReminderModel.id == reminder_id.value) + + models = (await self._db.execute(stmt)).scalars() + return [ReminderMapper.to_dto_or_fail(model) for model in models] if models else [] + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding user reminders: {str(e)}") from e + + async def find_user_reminder_by_id( + self, + user_id: ReminderUserID, + reminder_id: ReminderID, + only_active: bool | None = True, + ) -> ReminderDTO | None: + """Find a specific reminder by ID for a user""" + try: + stmt = select(ReminderModel).where( + ReminderModel.id == reminder_id.value, + ReminderModel.user_id == user_id.value, + ) + + if only_active: + now = datetime.utcnow() + stmt = stmt.where( + (ReminderModel.start_date <= now) + & ((ReminderModel.end_date.is_(None)) | (ReminderModel.end_date > now)) + ) + + model = (await self._db.execute(stmt)).scalar_one_or_none() + return ReminderMapper.to_dto(model) + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding reminder by ID: {str(e)}") from e + + async def update_reminder(self, reminder: ReminderDTO) -> ReminderDTO: + """Update an existing reminder""" + if reminder.reminder_id is None: + raise ValueError("Reminder ID not given") + + try: + stmt = ( + update(ReminderModel) + .where(ReminderModel.id == reminder.reminder_id.value) + .values( + entry_type=reminder.entry_type.value, + currency=reminder.currency.value, + frequency=reminder.frequency.value, + start_date=reminder.start_date.value, + end_date=reminder.end_date.value if reminder.end_date else None, + description=reminder.description.value, + ) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + if result.rowcount == 0: + raise ValueError(f"Reminder with ID {reminder.reminder_id.value} not found") + + await self._db.commit() + + return reminder + except SQLAlchemyError as e: + await self._db.rollback() + raise ValueError(f"Database error while updating reminder: {str(e)}") from e + + async def delete_reminder( + self, + reminder_id: ReminderID, + user_id: ReminderUserID, + ) -> bool: + """Delete a reminder""" + try: + stmt = ( + update(ReminderModel) + .where( + ReminderModel.id == reminder_id.value, + ReminderModel.user_id == user_id.value, + ) + .values(deleted_at=datetime.utcnow()) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + await self._db.commit() + + return result.rowcount > 0 + except SQLAlchemyError as e: + await self._db.rollback() + raise ValueError(f"Database error while deleting reminder: {str(e)}") from e + + async def find_active_reminders_due_before( + self, + before: datetime, + ) -> list[ReminderDTO]: + """Find all active reminders with start_date before the given datetime""" + try: + now = datetime.utcnow() + stmt = select(ReminderModel).where( + (ReminderModel.start_date <= before) + & (ReminderModel.start_date >= now) + & ((ReminderModel.end_date.is_(None)) | (ReminderModel.end_date > now)) + ) + + models = (await self._db.execute(stmt)).scalars() + return [ReminderMapper.to_dto_or_fail(model) for model in models] if models else [] + except SQLAlchemyError as e: + raise ValueError(f"Database error while finding active reminders: {str(e)}") from e diff --git a/app/context/reminder/interface/rest/controllers/__init__.py b/app/context/reminder/interface/rest/controllers/__init__.py new file mode 100644 index 0000000..7df671b --- /dev/null +++ b/app/context/reminder/interface/rest/controllers/__init__.py @@ -0,0 +1,7 @@ +from .occurrence_controller import router as occurrence_router +from .reminder_controller import router as reminder_router + +__all__ = [ + "reminder_router", + "occurrence_router", +] diff --git a/app/context/reminder/interface/rest/controllers/occurrence_controller.py b/app/context/reminder/interface/rest/controllers/occurrence_controller.py new file mode 100644 index 0000000..fd33e8b --- /dev/null +++ b/app/context/reminder/interface/rest/controllers/occurrence_controller.py @@ -0,0 +1,59 @@ +"""REST controllers for occurrence endpoints""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.reminder.application.contracts import ListOccurrencesHandlerContract +from app.context.reminder.application.dto import ListOccurrencesErrorCode +from app.context.reminder.application.queries import ListOccurrencesQuery +from app.context.reminder.infrastructure.dependencies import get_list_occurrences_handler +from app.context.reminder.interface.rest.schemas import OccurrenceListResponse, OccurrenceResponse +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/occurrences", tags=["occurrences"]) + + +@router.get("", response_model=OccurrenceListResponse) +async def list_occurrences( + handler: Annotated[ListOccurrencesHandlerContract, Depends(get_list_occurrences_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + reminder_id: int | None = None, +): + """ + List occurrences for the authenticated user + + If reminder_id is provided, returns occurrences for that specific reminder. + Otherwise, returns all pending occurrences for the user. + """ + + query = ListOccurrencesQuery(user_id=user_id, reminder_id=reminder_id) + + result = await handler.handle(query) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + ListOccurrencesErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Convert to response + occurrences = [ + OccurrenceResponse( + id=item.occurrence_id, + reminder_id=item.reminder_id, + scheduled_date=item.scheduled_date, + amount=item.amount, + status=item.status, + entry_id=item.entry_id, + description=item.description, + entry_type=item.entry_type, + currency=item.currency, + category_id=item.category_id, + ) + for item in result.occurrences + ] + + return OccurrenceListResponse(occurrences=occurrences) diff --git a/app/context/reminder/interface/rest/controllers/reminder_controller.py b/app/context/reminder/interface/rest/controllers/reminder_controller.py new file mode 100644 index 0000000..5767640 --- /dev/null +++ b/app/context/reminder/interface/rest/controllers/reminder_controller.py @@ -0,0 +1,235 @@ +"""REST controllers for reminder endpoints""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.reminder.application.commands import ( + CreateReminderCommand, + DeleteReminderCommand, + UpdateReminderCommand, +) +from app.context.reminder.application.contracts import ( + CreateReminderHandlerContract, + DeleteReminderHandlerContract, + FindReminderHandlerContract, + ListRemindersHandlerContract, + UpdateReminderHandlerContract, +) +from app.context.reminder.application.dto import ( + CreateReminderErrorCode, + DeleteReminderErrorCode, + FindReminderErrorCode, + ListRemindersErrorCode, + UpdateReminderErrorCode, +) +from app.context.reminder.application.queries import FindReminderQuery, ListRemindersQuery +from app.context.reminder.infrastructure.dependencies import ( + get_create_reminder_handler, + get_delete_reminder_handler, + get_find_reminder_handler, + get_list_reminders_handler, + get_update_reminder_handler, +) +from app.context.reminder.interface.rest.schemas import ( + CreateReminderRequest, + DeleteReminderResponse, + ReminderListResponse, + ReminderResponse, + UpdateReminderRequest, +) +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/reminders", tags=["reminders"]) + + +@router.post("", status_code=201, response_model=ReminderResponse) +async def create_reminder( + request: CreateReminderRequest, + handler: Annotated[CreateReminderHandlerContract, Depends(get_create_reminder_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], +): + """Create a new reminder""" + + command = CreateReminderCommand( + user_id=user_id, + description=request.description, + entry_type=request.entry_type, + currency=request.currency, + frequency=request.frequency, + start_date=request.start_date, + end_date=request.end_date, + category_id=request.category_id, + ) + + result = await handler.handle(command) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + CreateReminderErrorCode.INVALID_DATE_RANGE: 400, + CreateReminderErrorCode.INVALID_FREQUENCY: 400, + CreateReminderErrorCode.MAPPER_ERROR: 500, + CreateReminderErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + return ReminderResponse( + id=result.reminder_id, + description=result.description, + entry_type=result.entry_type, + currency=result.currency, + frequency=result.frequency, + start_date=result.start_date, + end_date=result.end_date, + category_id=result.category_id, + ) + + +@router.get("", response_model=ReminderListResponse) +async def list_reminders( + handler: Annotated[ListRemindersHandlerContract, Depends(get_list_reminders_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + active_only: bool = True, +): + """List all reminders for the authenticated user""" + + query = ListRemindersQuery(user_id=user_id, active_only=active_only) + + result = await handler.handle(query) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + ListRemindersErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Convert to response + reminders = [ + ReminderResponse( + id=item.reminder_id, + description=item.description, + entry_type=item.entry_type, + currency=item.currency, + frequency=item.frequency, + start_date=item.start_date, + end_date=item.end_date, + category_id=item.category_id, + ) + for item in result.reminders + ] + + return ReminderListResponse(reminders=reminders) + + +@router.get("/{reminder_id}", response_model=ReminderResponse) +async def get_reminder( + reminder_id: int, + handler: Annotated[FindReminderHandlerContract, Depends(get_find_reminder_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], +): + """Get a specific reminder by ID""" + + query = FindReminderQuery(reminder_id=reminder_id, user_id=user_id) + + result = await handler.handle(query) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + FindReminderErrorCode.REMINDER_NOT_FOUND: 404, + FindReminderErrorCode.REMINDER_NOT_BELONGS_TO_USER: 403, + FindReminderErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + return ReminderResponse( + id=result.reminder_id, + description=result.description, + entry_type=result.entry_type, + currency=result.currency, + frequency=result.frequency, + start_date=result.start_date, + end_date=result.end_date, + category_id=result.category_id, + ) + + +@router.patch("/{reminder_id}", response_model=ReminderResponse) +async def update_reminder( + reminder_id: int, + request: UpdateReminderRequest, + handler: Annotated[UpdateReminderHandlerContract, Depends(get_update_reminder_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], +): + """Update an existing reminder""" + + command = UpdateReminderCommand( + reminder_id=reminder_id, + user_id=user_id, + description=request.description, + entry_type=request.entry_type, + currency=request.currency, + frequency=request.frequency, + start_date=request.start_date, + end_date=request.end_date, + category_id=request.category_id, + ) + + result = await handler.handle(command) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + UpdateReminderErrorCode.REMINDER_NOT_FOUND: 404, + UpdateReminderErrorCode.REMINDER_NOT_BELONGS_TO_USER: 403, + UpdateReminderErrorCode.INVALID_DATE_RANGE: 400, + UpdateReminderErrorCode.INVALID_FREQUENCY: 400, + UpdateReminderErrorCode.MAPPER_ERROR: 500, + UpdateReminderErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + return ReminderResponse( + id=result.reminder_id, + description=result.description, + entry_type=result.entry_type, + currency=result.currency, + frequency=result.frequency, + start_date=result.start_date, + end_date=result.end_date, + category_id=result.category_id, + ) + + +@router.delete("/{reminder_id}", response_model=DeleteReminderResponse) +async def delete_reminder( + reminder_id: int, + handler: Annotated[DeleteReminderHandlerContract, Depends(get_delete_reminder_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], +): + """Delete a reminder""" + + command = DeleteReminderCommand(reminder_id=reminder_id, user_id=user_id) + + result = await handler.handle(command) + + # Map error codes to HTTP status codes + if result.error_code: + status_code_map = { + DeleteReminderErrorCode.REMINDER_NOT_FOUND: 404, + DeleteReminderErrorCode.REMINDER_NOT_BELONGS_TO_USER: 403, + DeleteReminderErrorCode.UNEXPECTED_ERROR: 500, + } + status_code = status_code_map.get(result.error_code, 500) + raise HTTPException(status_code=status_code, detail=result.error_message) + + return DeleteReminderResponse(message="Reminder deleted successfully") diff --git a/app/context/reminder/interface/rest/routes.py b/app/context/reminder/interface/rest/routes.py new file mode 100644 index 0000000..27b9c03 --- /dev/null +++ b/app/context/reminder/interface/rest/routes.py @@ -0,0 +1,12 @@ +"""Router configuration for reminder context""" + +from fastapi import APIRouter + +from .controllers import occurrence_router, reminder_router + +# Create main router for reminder context +reminder_context_router = APIRouter(prefix="/api/v1") + +# Include all sub-routers +reminder_context_router.include_router(reminder_router) +reminder_context_router.include_router(occurrence_router) diff --git a/app/context/reminder/interface/rest/schemas/__init__.py b/app/context/reminder/interface/rest/schemas/__init__.py new file mode 100644 index 0000000..6d0c406 --- /dev/null +++ b/app/context/reminder/interface/rest/schemas/__init__.py @@ -0,0 +1,19 @@ +from .reminder_schemas import ( + CreateReminderRequest, + DeleteReminderResponse, + OccurrenceListResponse, + OccurrenceResponse, + ReminderListResponse, + ReminderResponse, + UpdateReminderRequest, +) + +__all__ = [ + "CreateReminderRequest", + "UpdateReminderRequest", + "ReminderResponse", + "ReminderListResponse", + "OccurrenceResponse", + "OccurrenceListResponse", + "DeleteReminderResponse", +] diff --git a/app/context/reminder/interface/rest/schemas/reminder_schemas.py b/app/context/reminder/interface/rest/schemas/reminder_schemas.py new file mode 100644 index 0000000..427be52 --- /dev/null +++ b/app/context/reminder/interface/rest/schemas/reminder_schemas.py @@ -0,0 +1,95 @@ +"""Request and response schemas for reminder endpoints""" + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, Field + +# ============================================================================ +# Request Schemas (Pydantic for validation) +# ============================================================================ + + +class CreateReminderRequest(BaseModel): + """Request to create a new reminder""" + + model_config = ConfigDict(frozen=True) + + description: str = Field(..., min_length=1, max_length=500) + entry_type: str = Field(..., pattern="^(income|expense)$") + currency: str = Field(..., min_length=3, max_length=3) + frequency: str = Field(..., pattern="^(daily|weekly|biweekly|monthly|quarterly|yearly)$") + start_date: datetime + end_date: datetime | None = None + category_id: int | None = None + + +class UpdateReminderRequest(BaseModel): + """Request to update a reminder""" + + model_config = ConfigDict(frozen=True) + + description: str | None = Field(None, min_length=1, max_length=500) + entry_type: str | None = Field(None, pattern="^(income|expense)$") + currency: str | None = Field(None, min_length=3, max_length=3) + frequency: str | None = Field(None, pattern="^(daily|weekly|biweekly|monthly|quarterly|yearly)$") + start_date: datetime | None = None + end_date: datetime | None = None + category_id: int | None = None + + +# ============================================================================ +# Response Schemas (Dataclasses for performance) +# ============================================================================ + + +@dataclass(frozen=True) +class ReminderResponse: + """Response with reminder data""" + + id: int + description: str + entry_type: str + currency: str + frequency: str + start_date: datetime + end_date: datetime | None + category_id: int | None + + +@dataclass(frozen=True) +class ReminderListResponse: + """Response with list of reminders""" + + reminders: list[ReminderResponse] + + +@dataclass(frozen=True) +class OccurrenceResponse: + """Response with occurrence data""" + + id: int + reminder_id: int + scheduled_date: datetime + amount: Decimal + status: str + entry_id: int | None + description: str | None + entry_type: str | None + currency: str | None + category_id: int | None + + +@dataclass(frozen=True) +class OccurrenceListResponse: + """Response with list of occurrences""" + + occurrences: list[OccurrenceResponse] + + +@dataclass(frozen=True) +class DeleteReminderResponse: + """Response for delete reminder operation""" + + message: str diff --git a/app/context/user/application/contracts/__init__.py b/app/context/user/application/contracts/__init__.py new file mode 100644 index 0000000..e4d34ed --- /dev/null +++ b/app/context/user/application/contracts/__init__.py @@ -0,0 +1,3 @@ +from .find_user_handler_contract import FindUserHandlerContract + +__all__ = ["FindUserHandlerContract"] diff --git a/app/context/user/application/contracts/find_user_handler_contract.py b/app/context/user/application/contracts/find_user_handler_contract.py new file mode 100644 index 0000000..c2c2cfe --- /dev/null +++ b/app/context/user/application/contracts/find_user_handler_contract.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod + +from app.context.user.application.dto import FindUserResult +from app.context.user.application.queries import FindUserQuery + + +class FindUserHandlerContract(ABC): + """Contract for FindUser query handler""" + + @abstractmethod + async def handle(self, query: FindUserQuery) -> FindUserResult: + """ + Handle find user query. + + Args: + query: FindUserQuery with user_id and/or email + + Returns: + FindUserResult with user data or error information + """ + pass diff --git a/app/context/user/application/dto/__init__.py b/app/context/user/application/dto/__init__.py new file mode 100644 index 0000000..6040527 --- /dev/null +++ b/app/context/user/application/dto/__init__.py @@ -0,0 +1,4 @@ +from .find_user_result import FindUserErrorCode, FindUserResult +from .user_context_dto import UserContextDTO + +__all__ = ["UserContextDTO", "FindUserResult", "FindUserErrorCode"] diff --git a/app/context/user/application/dto/find_user_result.py b/app/context/user/application/dto/find_user_result.py new file mode 100644 index 0000000..d23e5b3 --- /dev/null +++ b/app/context/user/application/dto/find_user_result.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from enum import Enum + + +class FindUserErrorCode(str, Enum): + """Error codes for find user operation""" + + USER_NOT_FOUND = "USER_NOT_FOUND" + INVALID_EMAIL = "INVALID_EMAIL" + INVALID_USER_ID = "INVALID_USER_ID" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class FindUserResult: + """Result of find user operation""" + + # Success fields - populated when operation succeeds + user_id: int | None = None + email: str | None = None + username: str | None = None + password: str | None = None + + # Error fields - populated when operation fails + error_code: FindUserErrorCode | None = None + error_message: str | None = None diff --git a/app/context/user/application/dto/user_context_dto.py b/app/context/user/application/dto/user_context_dto.py new file mode 100644 index 0000000..e8fd982 --- /dev/null +++ b/app/context/user/application/dto/user_context_dto.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserContextDTO: + user_id: int + email: str + password: str diff --git a/app/context/user/application/handlers/__init__.py b/app/context/user/application/handlers/__init__.py new file mode 100644 index 0000000..06e2251 --- /dev/null +++ b/app/context/user/application/handlers/__init__.py @@ -0,0 +1,3 @@ +from .find_user_handler import FindUserHandler + +__all__ = ["FindUserHandler"] diff --git a/app/context/user/application/handlers/find_user_handler.py b/app/context/user/application/handlers/find_user_handler.py new file mode 100644 index 0000000..981edec --- /dev/null +++ b/app/context/user/application/handlers/find_user_handler.py @@ -0,0 +1,67 @@ +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.dto import FindUserErrorCode, FindUserResult +from app.context.user.application.queries import FindUserQuery +from app.context.user.domain.contracts.infrastructure import UserRepositoryContract +from app.context.user.domain.exceptions import InvalidEmailFormatError +from app.context.user.domain.value_objects import UserEmail, UserID + + +class FindUserHandler(FindUserHandlerContract): + """Handler for find user query""" + + def __init__(self, user_repo: UserRepositoryContract): + self._user_repo = user_repo + + async def handle(self, query: FindUserQuery) -> FindUserResult: + """ + Execute the find user query. + Catches all exceptions and returns Result with error codes. + """ + try: + # Convert query primitives to value objects + email = UserEmail(query.email) if query.email is not None else None + user_id = UserID(query.user_id) if query.user_id is not None else None + + # Call repository + user_dto = await self._user_repo.find_user(user_id=user_id, email=email) + + # Check if user was found + if user_dto is None: + return FindUserResult( + error_code=FindUserErrorCode.USER_NOT_FOUND, + error_message="User not found", + ) + + # Return success with primitives + return FindUserResult( + user_id=user_dto.user_id.value, + email=user_dto.email.value, + password=user_dto.password.value, + username=user_dto.username.value if user_dto.username else None, + ) + + # Catch specific validation exceptions + except InvalidEmailFormatError: + return FindUserResult( + error_code=FindUserErrorCode.INVALID_EMAIL, + error_message="Invalid email format", + ) + except ValueError as e: + # Catch value object validation errors (UserID, etc.) + error_message = str(e) + if "user ID" in error_message.lower(): + return FindUserResult( + error_code=FindUserErrorCode.INVALID_USER_ID, + error_message="Invalid user ID", + ) + return FindUserResult( + error_code=FindUserErrorCode.UNEXPECTED_ERROR, + error_message="Validation error", + ) + + # Catch-all for unexpected errors + except Exception: + return FindUserResult( + error_code=FindUserErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error occurred", + ) diff --git a/app/context/user/application/queries/__init__.py b/app/context/user/application/queries/__init__.py new file mode 100644 index 0000000..7b72db0 --- /dev/null +++ b/app/context/user/application/queries/__init__.py @@ -0,0 +1,3 @@ +from .find_user_query import FindUserQuery + +__all__ = ["FindUserQuery"] diff --git a/app/context/user/application/queries/find_user_query.py b/app/context/user/application/queries/find_user_query.py new file mode 100644 index 0000000..469fe4f --- /dev/null +++ b/app/context/user/application/queries/find_user_query.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FindUserQuery: + user_id: int | None = None + email: str | None = None diff --git a/app/context/user/domain/contracts/infrastructure/__init__.py b/app/context/user/domain/contracts/infrastructure/__init__.py new file mode 100644 index 0000000..99cadbe --- /dev/null +++ b/app/context/user/domain/contracts/infrastructure/__init__.py @@ -0,0 +1,3 @@ +from .user_repository_contract import UserRepositoryContract + +__all__ = ["UserRepositoryContract"] diff --git a/app/context/user/domain/contracts/infrastructure/user_repository_contract.py b/app/context/user/domain/contracts/infrastructure/user_repository_contract.py new file mode 100644 index 0000000..62a8b51 --- /dev/null +++ b/app/context/user/domain/contracts/infrastructure/user_repository_contract.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod + +from app.context.user.domain.dto import UserDTO +from app.context.user.domain.value_objects import UserEmail, UserID + + +class UserRepositoryContract(ABC): + """Contract for User repository operations""" + + @abstractmethod + async def find_user(self, user_id: UserID | None = None, email: UserEmail | None = None) -> UserDTO | None: + """ + Find a user by ID or email. + + Args: + user_id: Optional user ID to search by + email: Optional email to search by + + Returns: + UserDTO if found, None otherwise + """ + pass diff --git a/app/context/user/domain/dto/__init__.py b/app/context/user/domain/dto/__init__.py new file mode 100644 index 0000000..fb383d5 --- /dev/null +++ b/app/context/user/domain/dto/__init__.py @@ -0,0 +1,3 @@ +from .user_dto import UserDTO + +__all__ = ["UserDTO"] diff --git a/app/context/user/domain/dto/user_dto.py b/app/context/user/domain/dto/user_dto.py new file mode 100644 index 0000000..800dc17 --- /dev/null +++ b/app/context/user/domain/dto/user_dto.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects import ( + UserDeletedAt, + UserEmail, + UserID, + UserName, + UserPassword, +) + + +@dataclass(frozen=True) +class UserDTO: + """Domain data transfer object for User aggregate""" + + user_id: UserID + email: UserEmail + password: UserPassword + username: UserName | None = None + deleted_at: UserDeletedAt | None = None + + @property + def is_deleted(self) -> bool: + """Check if the user is soft deleted""" + return self.deleted_at is not None diff --git a/app/context/user/domain/exceptions/__init__.py b/app/context/user/domain/exceptions/__init__.py new file mode 100644 index 0000000..d9a82cb --- /dev/null +++ b/app/context/user/domain/exceptions/__init__.py @@ -0,0 +1,17 @@ +from .exceptions import ( + InvalidEmailFormatError, + InvalidPasswordLengthError, + InvalidUserPasswordError, + UserEmailAlreadyExistError, + UserMapperError, + UserNotFoundError, +) + +__all__ = [ + "UserMapperError", + "UserEmailAlreadyExistError", + "UserNotFoundError", + "InvalidUserPasswordError", + "InvalidEmailFormatError", + "InvalidPasswordLengthError", +] diff --git a/app/context/user/domain/exceptions/exceptions.py b/app/context/user/domain/exceptions/exceptions.py new file mode 100644 index 0000000..3746757 --- /dev/null +++ b/app/context/user/domain/exceptions/exceptions.py @@ -0,0 +1,37 @@ +"""Domain exceptions for User context""" + + +class UserMapperError(Exception): + """Raised when there's an error mapping between model and DTO""" + + pass + + +class UserEmailAlreadyExistError(Exception): + """Raised when attempting to create a user with an email that already exists""" + + pass + + +class UserNotFoundError(Exception): + """Raised when a requested user cannot be found""" + + pass + + +class InvalidUserPasswordError(Exception): + """Raised when password validation fails""" + + pass + + +class InvalidEmailFormatError(Exception): + """Raised when email format validation fails""" + + pass + + +class InvalidPasswordLengthError(Exception): + """Raised when password length validation fails""" + + pass diff --git a/app/context/user/domain/value_objects/__init__.py b/app/context/user/domain/value_objects/__init__.py new file mode 100644 index 0000000..027a0fe --- /dev/null +++ b/app/context/user/domain/value_objects/__init__.py @@ -0,0 +1,7 @@ +from .deleted_at import UserDeletedAt +from .email import UserEmail +from .password import UserPassword +from .user_id import UserID +from .username import UserName + +__all__ = ["UserEmail", "UserPassword", "UserID", "UserDeletedAt", "UserName"] diff --git a/app/context/user/domain/value_objects/deleted_at.py b/app/context/user/domain/value_objects/deleted_at.py new file mode 100644 index 0000000..890f07a --- /dev/null +++ b/app/context/user/domain/value_objects/deleted_at.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedDeletedAt + + +@dataclass(frozen=True) +class UserDeletedAt(SharedDeletedAt): + pass diff --git a/app/context/user/domain/value_objects/email.py b/app/context/user/domain/value_objects/email.py new file mode 100644 index 0000000..4ebe76f --- /dev/null +++ b/app/context/user/domain/value_objects/email.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_email import SharedEmail + + +@dataclass(frozen=True) +class UserEmail(SharedEmail): + """ + Context-specific Email value object for User context. + Extends SharedEmail to maintain bounded context isolation. + """ + + pass diff --git a/app/context/user/domain/value_objects/password.py b/app/context/user/domain/value_objects/password.py new file mode 100644 index 0000000..e14406e --- /dev/null +++ b/app/context/user/domain/value_objects/password.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_password import SharedPassword + + +@dataclass(frozen=True) +class UserPassword(SharedPassword): + """ + Context-specific Password value object for User context. + Extends SharedPassword to maintain bounded context isolation. + Password always represents a hashed value in the domain. + """ + + pass diff --git a/app/context/user/domain/value_objects/user_id.py b/app/context/user/domain/value_objects/user_id.py new file mode 100644 index 0000000..e6523ce --- /dev/null +++ b/app/context/user/domain/value_objects/user_id.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class UserID: + """User identifier value object""" + + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and (not isinstance(self.value, int) or self.value <= 0): + raise ValueError(f"Invalid user ID: {self.value}. Must be a positive integer.") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """ + Create UserID from trusted source (e.g., database) - skips validation. + Use this to avoid performance overhead when data is already validated. + """ + return cls(value, _validated=True) diff --git a/app/context/user/domain/value_objects/username.py b/app/context/user/domain/value_objects/username.py new file mode 100644 index 0000000..5751e14 --- /dev/null +++ b/app/context/user/domain/value_objects/username.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedUsername + + +@dataclass(frozen=True) +class UserName(SharedUsername): + pass diff --git a/app/context/user/infrastructure/dependencies.py b/app/context/user/infrastructure/dependencies.py new file mode 100644 index 0000000..7ffd936 --- /dev/null +++ b/app/context/user/infrastructure/dependencies.py @@ -0,0 +1,26 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.handlers import FindUserHandler +from app.context.user.domain.contracts.infrastructure import UserRepositoryContract +from app.context.user.infrastructure.repositories import UserRepository +from app.shared.infrastructure.database import get_db + + +def get_user_repository(db: Annotated[AsyncSession, Depends(get_db)]) -> UserRepositoryContract: + """ + Initialize user repository + """ + return UserRepository(db) + + +def get_find_user_query_handler( + user_repo: Annotated[UserRepositoryContract, Depends(get_user_repository)], +) -> FindUserHandlerContract: + """ + Initialize FindUserHandler + """ + return FindUserHandler(user_repo) diff --git a/app/context/user/infrastructure/mappers/__init__.py b/app/context/user/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..ef180de --- /dev/null +++ b/app/context/user/infrastructure/mappers/__init__.py @@ -0,0 +1,3 @@ +from .user_mapper import UserMapper + +__all__ = ["UserMapper"] diff --git a/app/context/user/infrastructure/mappers/user_mapper.py b/app/context/user/infrastructure/mappers/user_mapper.py new file mode 100644 index 0000000..c18d7cf --- /dev/null +++ b/app/context/user/infrastructure/mappers/user_mapper.py @@ -0,0 +1,54 @@ +from app.context.user.domain.dto.user_dto import UserDTO +from app.context.user.domain.exceptions import UserMapperError +from app.context.user.domain.value_objects import ( + UserDeletedAt, + UserEmail, + UserID, + UserName, + UserPassword, +) +from app.context.user.infrastructure.models.user_model import UserModel + + +class UserMapper: + """Mapper between UserModel (database) and UserDTO (domain)""" + + @staticmethod + def to_dto(model: UserModel | None) -> UserDTO | None: + """ + Convert database model to domain DTO. + Uses from_trusted_source for performance optimization. + """ + return ( + UserDTO( + user_id=UserID.from_trusted_source(model.id), + email=UserEmail.from_trusted_source(model.email), + password=UserPassword.from_hash(model.password), + username=UserName.from_trusted_source(model.username) if model.username else None, + deleted_at=UserDeletedAt.from_optional(model.deleted_at), + ) + if model + else None + ) + + @staticmethod + def to_dto_or_fail(model: UserModel) -> UserDTO: + """ + Convert database model to domain DTO. + Raises UserMapperError if model is None. + """ + dto = UserMapper.to_dto(model) + if dto is None: + raise UserMapperError("User DTO cannot be null") + return dto + + @staticmethod + def to_model(dto: UserDTO) -> UserModel: + """Convert domain DTO to database model""" + return UserModel( + id=dto.user_id.value if dto.user_id else None, + email=dto.email.value, + password=dto.password.value, + username=dto.username.value if dto.username else None, + deleted_at=dto.deleted_at.value if dto.deleted_at else None, + ) diff --git a/app/context/user/infrastructure/models/__init__.py b/app/context/user/infrastructure/models/__init__.py new file mode 100644 index 0000000..4fc3466 --- /dev/null +++ b/app/context/user/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .user_model import UserModel + +__all__ = ["UserModel"] diff --git a/app/context/user/infrastructure/models/user_model.py b/app/context/user/infrastructure/models/user_model.py new file mode 100644 index 0000000..316b47d --- /dev/null +++ b/app/context/user/infrastructure/models/user_model.py @@ -0,0 +1,27 @@ +from datetime import UTC, datetime + +from sqlalchemy import DateTime, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class UserModel(BaseDBModel): + """Database model for User aggregate""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + password: Mapped[str] = mapped_column(String(150), nullable=False) + username: Mapped[str | None] = mapped_column(String(100), nullable=True) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + nullable=False, + ) diff --git a/app/context/user/infrastructure/repositories/__init__.py b/app/context/user/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..15c7389 --- /dev/null +++ b/app/context/user/infrastructure/repositories/__init__.py @@ -0,0 +1,3 @@ +from .user_repository import UserRepository + +__all__ = ["UserRepository"] diff --git a/app/context/user/infrastructure/repositories/user_repository.py b/app/context/user/infrastructure/repositories/user_repository.py new file mode 100644 index 0000000..e0ffa88 --- /dev/null +++ b/app/context/user/infrastructure/repositories/user_repository.py @@ -0,0 +1,30 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user.domain.contracts.infrastructure import UserRepositoryContract +from app.context.user.domain.dto import UserDTO +from app.context.user.domain.value_objects import UserEmail, UserID +from app.context.user.infrastructure.mappers import UserMapper +from app.context.user.infrastructure.models import UserModel + + +class UserRepository(UserRepositoryContract): + """Repository implementation for User aggregate""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def find_user(self, user_id: UserID | None = None, email: UserEmail | None = None) -> UserDTO | None: + """ + Find a user by ID or email. + Both filters can be applied simultaneously. + """ + stmt = select(UserModel) + + if user_id is not None: + stmt = stmt.where(UserModel.id == user_id.value) + if email is not None: + stmt = stmt.where(UserModel.email == email.value) + + result = await self._db.execute(stmt) + return UserMapper.to_dto(result.scalar_one_or_none()) diff --git a/app/context/user/interface/console/find_user_console.py b/app/context/user/interface/console/find_user_console.py new file mode 100644 index 0000000..682a42b --- /dev/null +++ b/app/context/user/interface/console/find_user_console.py @@ -0,0 +1,22 @@ +import asyncio + +from app.context.user.application.handlers import FindUserHandler +from app.context.user.application.queries import FindUserQuery +from app.context.user.infrastructure.repositories import UserRepository +from app.shared.infrastructure.database import AsyncSessionLocal + + +async def main(): + async with AsyncSessionLocal() as session: + # Manually construct the dependencies + user_repo = UserRepository(session) + handler = FindUserHandler(user_repo) + + # Example 1: Find by email + query = FindUserQuery(user_id=None, email="test@example.com") + result = await handler.handle(query) + print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/context/user_account/__init__.py b/app/context/user_account/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/context/user_account/__init__.py @@ -0,0 +1 @@ + diff --git a/app/context/user_account/application/__init__.py b/app/context/user_account/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/user_account/application/commands/__init__.py b/app/context/user_account/application/commands/__init__.py new file mode 100644 index 0000000..d55f843 --- /dev/null +++ b/app/context/user_account/application/commands/__init__.py @@ -0,0 +1,5 @@ +from .create_account_command import CreateAccountCommand +from .delete_account_command import DeleteAccountCommand +from .update_account_command import UpdateAccountCommand + +__all__ = ["CreateAccountCommand", "UpdateAccountCommand", "DeleteAccountCommand"] diff --git a/app/context/user_account/application/commands/create_account_command.py b/app/context/user_account/application/commands/create_account_command.py new file mode 100644 index 0000000..16b062c --- /dev/null +++ b/app/context/user_account/application/commands/create_account_command.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CreateAccountCommand: + """Command to create a new user account""" + + user_id: int + name: str + currency: str + balance: float diff --git a/app/context/user_account/application/commands/delete_account_command.py b/app/context/user_account/application/commands/delete_account_command.py new file mode 100644 index 0000000..b8c897e --- /dev/null +++ b/app/context/user_account/application/commands/delete_account_command.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeleteAccountCommand: + account_id: int + user_id: int diff --git a/app/context/user_account/application/commands/update_account_command.py b/app/context/user_account/application/commands/update_account_command.py new file mode 100644 index 0000000..f002894 --- /dev/null +++ b/app/context/user_account/application/commands/update_account_command.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UpdateAccountCommand: + account_id: int + user_id: int + name: str + currency: str + balance: float diff --git a/app/context/user_account/application/contracts/__init__.py b/app/context/user_account/application/contracts/__init__.py new file mode 100644 index 0000000..d32eb37 --- /dev/null +++ b/app/context/user_account/application/contracts/__init__.py @@ -0,0 +1,13 @@ +from .create_account_handler_contract import CreateAccountHandlerContract +from .delete_account_handler_contract import DeleteAccountHandlerContract +from .find_account_by_id_handler_contract import FindAccountByIdHandlerContract +from .find_accounts_by_user_handler_contract import FindAccountsByUserHandlerContract +from .update_account_handler_contract import UpdateAccountHandlerContract + +__all__ = [ + "CreateAccountHandlerContract", + "FindAccountByIdHandlerContract", + "FindAccountsByUserHandlerContract", + "UpdateAccountHandlerContract", + "DeleteAccountHandlerContract", +] diff --git a/app/context/user_account/application/contracts/create_account_handler_contract.py b/app/context/user_account/application/contracts/create_account_handler_contract.py new file mode 100644 index 0000000..80b3572 --- /dev/null +++ b/app/context/user_account/application/contracts/create_account_handler_contract.py @@ -0,0 +1,24 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.application.commands.create_account_command import CreateAccountCommand +from app.context.user_account.application.dto.create_account_result import CreateAccountResult + + +class CreateAccountHandlerContract(ABC): + """Contract for create account command handler""" + + @abstractmethod + async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: + """ + Handle the create account command + + Args: + command: The create account command + + Returns: + CreateAccountResult with the new account ID + + Raises: + ValueError if account creation fails + """ + pass diff --git a/app/context/user_account/application/contracts/delete_account_handler_contract.py b/app/context/user_account/application/contracts/delete_account_handler_contract.py new file mode 100644 index 0000000..f15fdde --- /dev/null +++ b/app/context/user_account/application/contracts/delete_account_handler_contract.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.application.commands import ( + DeleteAccountCommand, +) +from app.context.user_account.application.dto import ( + DeleteAccountResult, +) + + +class DeleteAccountHandlerContract(ABC): + @abstractmethod + async def handle(self, command: DeleteAccountCommand) -> DeleteAccountResult: + pass diff --git a/app/context/user_account/application/contracts/find_account_by_id_handler_contract.py b/app/context/user_account/application/contracts/find_account_by_id_handler_contract.py new file mode 100644 index 0000000..caa51cb --- /dev/null +++ b/app/context/user_account/application/contracts/find_account_by_id_handler_contract.py @@ -0,0 +1,12 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.application.dto.find_single_account_result import FindSingleAccountResult +from app.context.user_account.application.queries.find_account_by_id_query import ( + FindAccountByIdQuery, +) + + +class FindAccountByIdHandlerContract(ABC): + @abstractmethod + async def handle(self, query: FindAccountByIdQuery) -> FindSingleAccountResult: + pass diff --git a/app/context/user_account/application/contracts/find_accounts_by_user_handler_contract.py b/app/context/user_account/application/contracts/find_accounts_by_user_handler_contract.py new file mode 100644 index 0000000..99deb1e --- /dev/null +++ b/app/context/user_account/application/contracts/find_accounts_by_user_handler_contract.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.application.dto.find_multiple_accounts_result import ( + FindMultipleAccountsResult, +) +from app.context.user_account.application.queries.find_accounts_by_user_query import ( + FindAccountsByUserQuery, +) + + +class FindAccountsByUserHandlerContract(ABC): + @abstractmethod + async def handle(self, query: FindAccountsByUserQuery) -> FindMultipleAccountsResult: + pass diff --git a/app/context/user_account/application/contracts/update_account_handler_contract.py b/app/context/user_account/application/contracts/update_account_handler_contract.py new file mode 100644 index 0000000..46f2363 --- /dev/null +++ b/app/context/user_account/application/contracts/update_account_handler_contract.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.application.commands.update_account_command import ( + UpdateAccountCommand, +) +from app.context.user_account.application.dto.update_account_result import ( + UpdateAccountResult, +) + + +class UpdateAccountHandlerContract(ABC): + @abstractmethod + async def handle(self, command: UpdateAccountCommand) -> UpdateAccountResult: + pass diff --git a/app/context/user_account/application/dto/__init__.py b/app/context/user_account/application/dto/__init__.py new file mode 100644 index 0000000..ba1fff8 --- /dev/null +++ b/app/context/user_account/application/dto/__init__.py @@ -0,0 +1,23 @@ +from .account_response_dto import AccountResponseDTO +from .create_account_result import CreateAccountErrorCode, CreateAccountResult +from .delete_account_result import DeleteAccountErrorCode, DeleteAccountResult +from .find_multiple_accounts_result import ( + FindMultipleAccountsErrorCode, + FindMultipleAccountsResult, +) +from .find_single_account_result import FindSingleAccountErrorCode, FindSingleAccountResult +from .update_account_result import UpdateAccountErrorCode, UpdateAccountResult + +__all__ = [ + "AccountResponseDTO", + "CreateAccountResult", + "CreateAccountErrorCode", + "DeleteAccountResult", + "DeleteAccountErrorCode", + "FindMultipleAccountsErrorCode", + "FindMultipleAccountsResult", + "FindSingleAccountErrorCode", + "FindSingleAccountResult", + "UpdateAccountResult", + "UpdateAccountErrorCode", +] diff --git a/app/context/user_account/application/dto/account_response_dto.py b/app/context/user_account/application/dto/account_response_dto.py new file mode 100644 index 0000000..238c2c7 --- /dev/null +++ b/app/context/user_account/application/dto/account_response_dto.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from decimal import Decimal + +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO + + +@dataclass(frozen=True) +class AccountResponseDTO: + """Application layer DTO for account responses""" + + account_id: int + user_id: int + name: str + currency: str + balance: Decimal + + @classmethod + def from_domain_dto(cls, domain_dto: UserAccountDTO) -> "AccountResponseDTO": + return cls( + # FIX: Error linting of nullable account_id + account_id=domain_dto.account_id.value, + user_id=domain_dto.user_id.value, + name=domain_dto.name.value, + currency=domain_dto.currency.value, + balance=domain_dto.balance.value, + ) diff --git a/app/context/user_account/application/dto/create_account_result.py b/app/context/user_account/application/dto/create_account_result.py new file mode 100644 index 0000000..b44ed32 --- /dev/null +++ b/app/context/user_account/application/dto/create_account_result.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from enum import Enum + + +class CreateAccountErrorCode(str, Enum): + """Error codes for account creation""" + + NAME_ALREADY_EXISTS = "NAME_ALREADY_EXISTS" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class CreateAccountResult: + """Result of account creation operation""" + + # Success fields - populated when operation succeeds + account_id: int | None = None + account_name: str | None = None + account_balance: float | None = None + + # Error fields - populated when operation fails + error_code: CreateAccountErrorCode | None = None + error_message: str | None = None diff --git a/app/context/user_account/application/dto/delete_account_result.py b/app/context/user_account/application/dto/delete_account_result.py new file mode 100644 index 0000000..9cfadfb --- /dev/null +++ b/app/context/user_account/application/dto/delete_account_result.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from enum import Enum + + +class DeleteAccountErrorCode(str, Enum): + """Error codes for account deletion""" + + NOT_FOUND = "NOT_FOUND" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class DeleteAccountResult: + """Result of account deletion operation""" + + # Success field - populated when operation succeeds + success: bool = False + + # Error fields - populated when operation fails + error_code: DeleteAccountErrorCode | None = None + error_message: str | None = None diff --git a/app/context/user_account/application/dto/find_multiple_accounts_result.py b/app/context/user_account/application/dto/find_multiple_accounts_result.py new file mode 100644 index 0000000..3216de3 --- /dev/null +++ b/app/context/user_account/application/dto/find_multiple_accounts_result.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from enum import Enum + +from app.context.user_account.application.dto import AccountResponseDTO + + +class FindMultipleAccountsErrorCode(str, Enum): + """Error codes when searching for multiple accounts""" + + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class FindMultipleAccountsResult: + "Result when searching for multiple accounts" + + # Success + accounts: list[AccountResponseDTO] | None = None + + # Error fields + error_code: FindMultipleAccountsErrorCode | None = None + error_message: str | None = None diff --git a/app/context/user_account/application/dto/find_single_account_result.py b/app/context/user_account/application/dto/find_single_account_result.py new file mode 100644 index 0000000..940c235 --- /dev/null +++ b/app/context/user_account/application/dto/find_single_account_result.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass +from enum import Enum + +from app.context.user_account.application.dto import AccountResponseDTO + + +class FindSingleAccountErrorCode(str, Enum): + """Error codes when searching for single account""" + + NOT_FOUND = "NOT_FOUND" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class FindSingleAccountResult: + "Result when searching for single account" + + # Success + account: AccountResponseDTO | None = None + + # Error fields + error_code: FindSingleAccountErrorCode | None = None + error_message: str | None = None diff --git a/app/context/user_account/application/dto/update_account_result.py b/app/context/user_account/application/dto/update_account_result.py new file mode 100644 index 0000000..5a982ce --- /dev/null +++ b/app/context/user_account/application/dto/update_account_result.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from enum import Enum + + +class UpdateAccountErrorCode(str, Enum): + """Error codes for account update""" + + NOT_FOUND = "NOT_FOUND" + NAME_ALREADY_EXISTS = "NAME_ALREADY_EXISTS" + MAPPER_ERROR = "MAPPER_ERROR" + UNEXPECTED_ERROR = "UNEXPECTED_ERROR" + + +@dataclass(frozen=True) +class UpdateAccountResult: + """Result of account update operation""" + + # Success fields - populated when operation succeeds + account_id: int | None = None + account_name: str | None = None + account_balance: float | None = None + + # Error fields - populated when operation fails + error_code: UpdateAccountErrorCode | None = None + error_message: str | None = None diff --git a/app/context/user_account/application/handlers/__init__.py b/app/context/user_account/application/handlers/__init__.py new file mode 100644 index 0000000..4549876 --- /dev/null +++ b/app/context/user_account/application/handlers/__init__.py @@ -0,0 +1,13 @@ +from .create_account_handler import CreateAccountHandler +from .delete_account_handler import DeleteAccountHandler +from .find_account_by_id_handler import FindAccountByIdHandler +from .find_accounts_by_user_handler import FindAccountsByUserHandler +from .update_account_handler import UpdateAccountHandler + +__all__ = [ + "CreateAccountHandler", + "FindAccountByIdHandler", + "FindAccountsByUserHandler", + "UpdateAccountHandler", + "DeleteAccountHandler", +] diff --git a/app/context/user_account/application/handlers/create_account_handler.py b/app/context/user_account/application/handlers/create_account_handler.py new file mode 100644 index 0000000..379e537 --- /dev/null +++ b/app/context/user_account/application/handlers/create_account_handler.py @@ -0,0 +1,81 @@ +from app.context.user_account.application.commands import ( + CreateAccountCommand, +) +from app.context.user_account.application.contracts import ( + CreateAccountHandlerContract, +) +from app.context.user_account.application.dto import ( + CreateAccountErrorCode, + CreateAccountResult, +) +from app.context.user_account.domain.contracts.services import ( + CreateAccountServiceContract, +) +from app.context.user_account.domain.exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class CreateAccountHandler(CreateAccountHandlerContract): + """Handler for create account command""" + + def __init__(self, service: CreateAccountServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: + """Execute the create account command""" + + try: + account_dto = await self._service.create_account( + user_id=UserAccountUserID(command.user_id), + name=AccountName(command.name), + currency=UserAccountCurrency(command.currency), + balance=UserAccountBalance.from_float(command.balance), + ) + + if account_dto.account_id is None: + self._logger.error( + "Account creation returned None account_id", + user_id=command.user_id, + name=command.name, + ) + return CreateAccountResult( + error_code=CreateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Error creating account", + ) + + return CreateAccountResult( + account_id=account_dto.account_id.value, + account_name=account_dto.name.value, + account_balance=float(account_dto.balance.value), + ) + except UserAccountNameAlreadyExistError: + return CreateAccountResult( + error_code=CreateAccountErrorCode.NAME_ALREADY_EXISTS, + error_message="Account name already exist", + ) + except UserAccountMapperError: + return CreateAccountResult( + error_code=CreateAccountErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) + except Exception as e: + self._logger.error( + "Unexpected error during account creation", + user_id=command.user_id, + name=command.name, + error=str(e), + ) + return CreateAccountResult( + error_code=CreateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/user_account/application/handlers/delete_account_handler.py b/app/context/user_account/application/handlers/delete_account_handler.py new file mode 100644 index 0000000..c54ab07 --- /dev/null +++ b/app/context/user_account/application/handlers/delete_account_handler.py @@ -0,0 +1,52 @@ +from app.context.user_account.application.commands import ( + DeleteAccountCommand, +) +from app.context.user_account.application.contracts import ( + DeleteAccountHandlerContract, +) +from app.context.user_account.application.dto import ( + DeleteAccountErrorCode, + DeleteAccountResult, +) +from app.context.user_account.domain.contracts.infrastructure import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.value_objects import ( + UserAccountID, + UserAccountUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class DeleteAccountHandler(DeleteAccountHandlerContract): + def __init__(self, repository: UserAccountRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, command: DeleteAccountCommand) -> DeleteAccountResult: + """Execute the delete account command""" + + try: + success = await self._repository.delete_account( + account_id=UserAccountID(command.account_id), + user_id=UserAccountUserID(command.user_id), + ) + + if not success: + return DeleteAccountResult( + error_code=DeleteAccountErrorCode.NOT_FOUND, + error_message="Account not found", + ) + + return DeleteAccountResult(success=True) + except Exception as e: + self._logger.error( + "Unexpected error during account deletion", + account_id=command.account_id, + user_id=command.user_id, + error=str(e), + ) + return DeleteAccountResult( + error_code=DeleteAccountErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/user_account/application/handlers/find_account_by_id_handler.py b/app/context/user_account/application/handlers/find_account_by_id_handler.py new file mode 100644 index 0000000..1b4ba3e --- /dev/null +++ b/app/context/user_account/application/handlers/find_account_by_id_handler.py @@ -0,0 +1,40 @@ +from app.context.user_account.application.contracts.find_account_by_id_handler_contract import ( + FindAccountByIdHandlerContract, +) +from app.context.user_account.application.dto.account_response_dto import ( + AccountResponseDTO, +) +from app.context.user_account.application.dto.find_single_account_result import ( + FindSingleAccountErrorCode, + FindSingleAccountResult, +) +from app.context.user_account.application.queries.find_account_by_id_query import ( + FindAccountByIdQuery, +) +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.value_objects import ( + UserAccountID, + UserAccountUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class FindAccountByIdHandler(FindAccountByIdHandlerContract): + def __init__(self, repository: UserAccountRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: FindAccountByIdQuery) -> FindSingleAccountResult: + account = await self._repository.find_user_accounts( + account_id=UserAccountID(query.account_id), + user_id=UserAccountUserID(query.user_id), + ) + + if not account or account.__len__() < 1: + return FindSingleAccountResult( + error_code=FindSingleAccountErrorCode.NOT_FOUND, error_message="No account found" + ) + + return FindSingleAccountResult(account=AccountResponseDTO.from_domain_dto(account[0])) diff --git a/app/context/user_account/application/handlers/find_accounts_by_user_handler.py b/app/context/user_account/application/handlers/find_accounts_by_user_handler.py new file mode 100644 index 0000000..4dcc055 --- /dev/null +++ b/app/context/user_account/application/handlers/find_accounts_by_user_handler.py @@ -0,0 +1,58 @@ +from app.context.user_account.application.contracts.find_accounts_by_user_handler_contract import ( + FindAccountsByUserHandlerContract, +) +from app.context.user_account.application.dto import AccountResponseDTO +from app.context.user_account.application.dto.find_multiple_accounts_result import ( + FindMultipleAccountsErrorCode, + FindMultipleAccountsResult, +) +from app.context.user_account.application.queries.find_accounts_by_user_query import ( + FindAccountsByUserQuery, +) +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.value_objects import UserAccountUserID +from app.shared.domain.contracts import LoggerContract + + +class FindAccountsByUserHandler(FindAccountsByUserHandlerContract): + def __init__(self, repository: UserAccountRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def handle(self, query: FindAccountsByUserQuery) -> FindMultipleAccountsResult: + self._logger.debug( + "Handling find accounts by user query", + user_id=query.user_id, + ) + + try: + accounts = await self._repository.find_user_accounts(user_id=UserAccountUserID(query.user_id)) + + if accounts is None: + self._logger.debug( + "No accounts found for user", + user_id=query.user_id, + ) + return FindMultipleAccountsResult(accounts=[]) + + self._logger.debug( + "Accounts found for user", + user_id=query.user_id, + account_count=len(accounts), + ) + + account_dtos = [AccountResponseDTO.from_domain_dto(acc) for acc in accounts] + return FindMultipleAccountsResult(accounts=account_dtos) + + except Exception as e: + self._logger.error( + "Unexpected error while finding accounts", + user_id=query.user_id, + error=str(e), + ) + return FindMultipleAccountsResult( + error_code=FindMultipleAccountsErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error while finding accounts", + ) diff --git a/app/context/user_account/application/handlers/update_account_handler.py b/app/context/user_account/application/handlers/update_account_handler.py new file mode 100644 index 0000000..a9d280f --- /dev/null +++ b/app/context/user_account/application/handlers/update_account_handler.py @@ -0,0 +1,87 @@ +from app.context.user_account.application.commands import ( + UpdateAccountCommand, +) +from app.context.user_account.application.contracts import ( + UpdateAccountHandlerContract, +) +from app.context.user_account.application.dto import ( + UpdateAccountErrorCode, + UpdateAccountResult, +) +from app.context.user_account.domain.contracts.services import ( + UpdateAccountServiceContract, +) +from app.context.user_account.domain.exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class UpdateAccountHandler(UpdateAccountHandlerContract): + def __init__(self, service: UpdateAccountServiceContract, logger: LoggerContract): + self._service = service + self._logger = logger + + async def handle(self, command: UpdateAccountCommand) -> UpdateAccountResult: + """Execute the update account command""" + + try: + updated = await self._service.update_account( + account_id=UserAccountID(command.account_id), + user_id=UserAccountUserID(command.user_id), + name=AccountName(command.name), + currency=UserAccountCurrency(command.currency), + balance=UserAccountBalance.from_float(command.balance), + ) + + if updated.account_id is None: + self._logger.error( + "Account update returned None account_id", + account_id=command.account_id, + user_id=command.user_id, + ) + return UpdateAccountResult( + error_code=UpdateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Error updating account", + ) + + return UpdateAccountResult( + account_id=updated.account_id.value, + account_name=updated.name.value, + account_balance=float(updated.balance.value), + ) + except UserAccountNotFoundError: + return UpdateAccountResult( + error_code=UpdateAccountErrorCode.NOT_FOUND, + error_message="Account not found", + ) + except UserAccountNameAlreadyExistError: + return UpdateAccountResult( + error_code=UpdateAccountErrorCode.NAME_ALREADY_EXISTS, + error_message="Account name already exist", + ) + except UserAccountMapperError: + return UpdateAccountResult( + error_code=UpdateAccountErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) + except Exception as e: + self._logger.error( + "Unexpected error during account update", + account_id=command.account_id, + user_id=command.user_id, + error=str(e), + ) + return UpdateAccountResult( + error_code=UpdateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/user_account/application/queries/__init__.py b/app/context/user_account/application/queries/__init__.py new file mode 100644 index 0000000..e39a2d9 --- /dev/null +++ b/app/context/user_account/application/queries/__init__.py @@ -0,0 +1,4 @@ +from .find_account_by_id_query import FindAccountByIdQuery +from .find_accounts_by_user_query import FindAccountsByUserQuery + +__all__ = ["FindAccountByIdQuery", "FindAccountsByUserQuery"] diff --git a/app/context/user_account/application/queries/find_account_by_id_query.py b/app/context/user_account/application/queries/find_account_by_id_query.py new file mode 100644 index 0000000..7af23e7 --- /dev/null +++ b/app/context/user_account/application/queries/find_account_by_id_query.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FindAccountByIdQuery: + account_id: int + user_id: int diff --git a/app/context/user_account/application/queries/find_accounts_by_user_query.py b/app/context/user_account/application/queries/find_accounts_by_user_query.py new file mode 100644 index 0000000..e077ac1 --- /dev/null +++ b/app/context/user_account/application/queries/find_accounts_by_user_query.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FindAccountsByUserQuery: + user_id: int diff --git a/app/context/user_account/domain/__init__.py b/app/context/user_account/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/user_account/domain/contracts/__init__.py b/app/context/user_account/domain/contracts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/user_account/domain/contracts/infrastructure/__init__.py b/app/context/user_account/domain/contracts/infrastructure/__init__.py new file mode 100644 index 0000000..e9896ce --- /dev/null +++ b/app/context/user_account/domain/contracts/infrastructure/__init__.py @@ -0,0 +1,3 @@ +from .user_account_repository_contract import UserAccountRepositoryContract + +__all__ = ["UserAccountRepositoryContract"] diff --git a/app/context/user_account/domain/contracts/infrastructure/user_account_repository_contract.py b/app/context/user_account/domain/contracts/infrastructure/user_account_repository_contract.py new file mode 100644 index 0000000..ab0c9b0 --- /dev/null +++ b/app/context/user_account/domain/contracts/infrastructure/user_account_repository_contract.py @@ -0,0 +1,108 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects import UserAccountUserID +from app.context.user_account.domain.value_objects.account_id import UserAccountID +from app.context.user_account.domain.value_objects.account_name import AccountName + + +class UserAccountRepositoryContract(ABC): + """Contract for user account repository operations""" + + @abstractmethod + async def save_account(self, account: UserAccountDTO) -> UserAccountDTO: + """ + Create a new user account + + Args: + account: The account DTO to save + + Returns: + UserAccountDTO of the created account + + Raises: + UserAccountMapperError if cannot map model to dto + UserAccountNameAlreadyExistError if account name already exist + """ + pass + + @abstractmethod + async def find_account( + self, + account_id: UserAccountID | None = None, + user_id: UserAccountUserID | None = None, + name: AccountName | None = None, + ) -> UserAccountDTO | None: + """ + Find an account by ID or by user_id and name + + Args: + account_id: Account ID to search for + user_id: User ID to search for (combined with name) + name: Account name to search for (combined with user_id) + + Returns: + UserAccountDTO if found, None otherwise + """ + pass + + @abstractmethod + async def find_user_accounts( + self, + user_id: UserAccountUserID, + account_id: UserAccountID | None = None, + name: AccountName | None = None, + only_active: bool | None = True, + ) -> list[UserAccountDTO] | None: + """ + Find user account always filtering by user_id (for user-scoped queries) + + Args: + user_id: User ID to filter accounts for + account_id: Optional account ID to find specific account + name: Optional account name for partial match search + only_active: Whether to exclude soft-deleted accounts (default: True) + + Returns: + UserAccountDTO if found, None otherwise + """ + pass + + @abstractmethod + async def find_user_account_by_id( + self, + user_id: UserAccountUserID, + account_id: UserAccountID, + only_active: bool | None = True, + ) -> UserAccountDTO | None: + pass + + @abstractmethod + async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: + """ + Update an existing account + + Args: + account: The account DTO with updated values + + Returns: + Updated UserAccountDTO + + Raises: + ValueError: If account not found or already deleted + """ + pass + + @abstractmethod + async def delete_account(self, account_id: UserAccountID, user_id: UserAccountUserID) -> bool: + """ + Soft delete an account. Returns True if deleted, False if not found/unauthorized + + Args: + account_id: Account ID to delete + user_id: User ID (for authorization check) + + Returns: + True if successfully deleted, False if not found or unauthorized + """ + pass diff --git a/app/context/user_account/domain/contracts/services/__init__.py b/app/context/user_account/domain/contracts/services/__init__.py new file mode 100644 index 0000000..f69f536 --- /dev/null +++ b/app/context/user_account/domain/contracts/services/__init__.py @@ -0,0 +1,4 @@ +from .create_account_service_contract import CreateAccountServiceContract +from .update_account_service_contract import UpdateAccountServiceContract + +__all__ = ["CreateAccountServiceContract", "UpdateAccountServiceContract"] diff --git a/app/context/user_account/domain/contracts/services/create_account_service_contract.py b/app/context/user_account/domain/contracts/services/create_account_service_contract.py new file mode 100644 index 0000000..bbf4e75 --- /dev/null +++ b/app/context/user_account/domain/contracts/services/create_account_service_contract.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountUserID, +) + + +class CreateAccountServiceContract(ABC): + """Contract for create account service""" + + @abstractmethod + async def create_account( + self, + user_id: UserAccountUserID, + name: AccountName, + currency: UserAccountCurrency, + balance: UserAccountBalance, + ) -> UserAccountDTO: + """ + Create a new user account + + Args: + user_id: ID of the user who owns the account + name: Name of the account + currency: Currency code for the account + balance: Initial balance + + Returns: + AccountID of the created account + + Raises: + UserAccountMapperError if cannot map model to dto + UserAccountNameAlreadyExistError if account name already exist + """ + pass diff --git a/app/context/user_account/domain/contracts/services/update_account_service_contract.py b/app/context/user_account/domain/contracts/services/update_account_service_contract.py new file mode 100644 index 0000000..a96597c --- /dev/null +++ b/app/context/user_account/domain/contracts/services/update_account_service_contract.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) + + +class UpdateAccountServiceContract(ABC): + @abstractmethod + async def update_account( + self, + account_id: UserAccountID, + user_id: UserAccountUserID, + name: AccountName, + currency: UserAccountCurrency, + balance: UserAccountBalance, + ) -> UserAccountDTO: + pass diff --git a/app/context/user_account/domain/dto/__init__.py b/app/context/user_account/domain/dto/__init__.py new file mode 100644 index 0000000..375a12e --- /dev/null +++ b/app/context/user_account/domain/dto/__init__.py @@ -0,0 +1,3 @@ +from .user_account_dto import UserAccountDTO + +__all__ = ["UserAccountDTO"] diff --git a/app/context/user_account/domain/dto/user_account_dto.py b/app/context/user_account/domain/dto/user_account_dto.py new file mode 100644 index 0000000..c31ce5e --- /dev/null +++ b/app/context/user_account/domain/dto/user_account_dto.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountDeletedAt, + UserAccountID, + UserAccountUserID, +) + + +@dataclass(frozen=True) +class UserAccountDTO: + """Domain DTO for user account entity""" + + user_id: UserAccountUserID + name: AccountName + currency: UserAccountCurrency + balance: UserAccountBalance + account_id: UserAccountID | None = None + deleted_at: UserAccountDeletedAt | None = None + + @property + def is_deleted(self) -> bool: + """Check if the account is soft deleted""" + return self.deleted_at is not None diff --git a/app/context/user_account/domain/exceptions/__init__.py b/app/context/user_account/domain/exceptions/__init__.py new file mode 100644 index 0000000..ad9b0af --- /dev/null +++ b/app/context/user_account/domain/exceptions/__init__.py @@ -0,0 +1,11 @@ +from .exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) + +__all__ = [ + "UserAccountMapperError", + "UserAccountNameAlreadyExistError", + "UserAccountNotFoundError", +] diff --git a/app/context/user_account/domain/exceptions/exceptions.py b/app/context/user_account/domain/exceptions/exceptions.py new file mode 100644 index 0000000..fc27a94 --- /dev/null +++ b/app/context/user_account/domain/exceptions/exceptions.py @@ -0,0 +1,10 @@ +class UserAccountMapperError(Exception): + pass + + +class UserAccountNameAlreadyExistError(Exception): + pass + + +class UserAccountNotFoundError(Exception): + pass diff --git a/app/context/user_account/domain/services/__init__.py b/app/context/user_account/domain/services/__init__.py new file mode 100644 index 0000000..646e6d6 --- /dev/null +++ b/app/context/user_account/domain/services/__init__.py @@ -0,0 +1,4 @@ +from .create_account_service import CreateAccountService +from .update_account_service import UpdateAccountService + +__all__ = ["CreateAccountService", "UpdateAccountService"] diff --git a/app/context/user_account/domain/services/create_account_service.py b/app/context/user_account/domain/services/create_account_service.py new file mode 100644 index 0000000..9a8f15d --- /dev/null +++ b/app/context/user_account/domain/services/create_account_service.py @@ -0,0 +1,52 @@ +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.contracts.services.create_account_service_contract import ( + CreateAccountServiceContract, +) +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountBalance, + UserAccountCurrency, + UserAccountUserID, +) +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.shared.domain.contracts import LoggerContract + + +class CreateAccountService(CreateAccountServiceContract): + """Service for creating user accounts""" + + def __init__(self, account_repository: UserAccountRepositoryContract, logger: LoggerContract): + self._account_repository = account_repository + self._logger = logger + + async def create_account( + self, + user_id: UserAccountUserID, + name: AccountName, + currency: UserAccountCurrency, + balance: UserAccountBalance, + ) -> UserAccountDTO: + """Create a new user account with validation""" + + # Create new account DTO (without ID, will be assigned by database) + account_dto = UserAccountDTO( + user_id=user_id, + name=name, + currency=currency, + balance=balance, + ) + + # Save and return the new account ID + created_account = await self._account_repository.save_account(account_dto) + + if created_account.account_id: + self._logger.info( + "Account created successfully", + user_id=user_id.value, + account_id=created_account.account_id.value, + name=name.value, + ) + + return created_account diff --git a/app/context/user_account/domain/services/update_account_service.py b/app/context/user_account/domain/services/update_account_service.py new file mode 100644 index 0000000..dfefebd --- /dev/null +++ b/app/context/user_account/domain/services/update_account_service.py @@ -0,0 +1,76 @@ +from app.context.user_account.domain.contracts.infrastructure import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.contracts.services import ( + UpdateAccountServiceContract, +) +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.exceptions import ( + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) +from app.shared.domain.contracts import LoggerContract + + +class UpdateAccountService(UpdateAccountServiceContract): + def __init__(self, repository: UserAccountRepositoryContract, logger: LoggerContract): + self._repository = repository + self._logger = logger + + async def update_account( + self, + account_id: UserAccountID, + user_id: UserAccountUserID, + name: AccountName, + currency: UserAccountCurrency, + balance: UserAccountBalance, + ) -> UserAccountDTO: + existing = await self._repository.find_user_account_by_id(user_id=user_id, account_id=account_id) + + if not existing: + self._logger.warning( + "Account not found for update", + account_id=account_id.value, + user_id=user_id.value, + ) + raise UserAccountNotFoundError(f"Account with ID {account_id.value} not found for user {user_id.value}") + + # FIX: find_user_accounts use like instead of equal, error prone on this check + if existing.name.value != name.value: + # In this case, we should also search inactive for name repetition + duplicate = await self._repository.find_user_accounts(user_id=user_id, name=name, only_active=False) + + if duplicate and any(acc.account_id and acc.account_id.value != account_id.value for acc in duplicate): + self._logger.warning( + "Duplicate account name detected", + account_id=account_id.value, + user_id=user_id.value, + name=name.value, + ) + raise UserAccountNameAlreadyExistError(f"Account with name '{name.value}' already exists") + + # 3. Update account + updated_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=name, + currency=currency, + balance=balance, + ) + updated = await self._repository.update_account(updated_dto) + + self._logger.info( + "Account updated successfully", + account_id=account_id.value, + user_id=user_id.value, + name=name.value, + ) + + return updated diff --git a/app/context/user_account/domain/value_objects/__init__.py b/app/context/user_account/domain/value_objects/__init__.py new file mode 100644 index 0000000..67aa268 --- /dev/null +++ b/app/context/user_account/domain/value_objects/__init__.py @@ -0,0 +1,15 @@ +from .account_balance import UserAccountBalance +from .account_currency import UserAccountCurrency +from .account_id import UserAccountID +from .account_name import AccountName +from .account_user_id import UserAccountUserID +from .deleted_at import UserAccountDeletedAt + +__all__ = [ + "AccountName", + "UserAccountID", + "UserAccountBalance", + "UserAccountCurrency", + "UserAccountDeletedAt", + "UserAccountUserID", +] diff --git a/app/context/user_account/domain/value_objects/account_balance.py b/app/context/user_account/domain/value_objects/account_balance.py new file mode 100644 index 0000000..fd7a967 --- /dev/null +++ b/app/context/user_account/domain/value_objects/account_balance.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_balance import SharedBalance + + +@dataclass(frozen=True) +class UserAccountBalance(SharedBalance): + pass diff --git a/app/context/user_account/domain/value_objects/account_currency.py b/app/context/user_account/domain/value_objects/account_currency.py new file mode 100644 index 0000000..370a699 --- /dev/null +++ b/app/context/user_account/domain/value_objects/account_currency.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_currency import SharedCurrency + + +@dataclass(frozen=True) +class UserAccountCurrency(SharedCurrency): + pass diff --git a/app/context/user_account/domain/value_objects/account_id.py b/app/context/user_account/domain/value_objects/account_id.py new file mode 100644 index 0000000..71e7f3d --- /dev/null +++ b/app/context/user_account/domain/value_objects/account_id.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects.shared_account_id import SharedAccountID + + +@dataclass(frozen=True) +class UserAccountID(SharedAccountID): + pass diff --git a/app/context/user_account/domain/value_objects/account_name.py b/app/context/user_account/domain/value_objects/account_name.py new file mode 100644 index 0000000..43cef96 --- /dev/null +++ b/app/context/user_account/domain/value_objects/account_name.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class AccountName: + """Value object for user account name""" + + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and not isinstance(self.value, str): + raise ValueError(f"AccountName must be a string, got {type(self.value)}") + if not self._validated and not self.value.strip(): + raise ValueError("AccountName cannot be empty or whitespace") + if not self._validated and len(self.value) > 100: + raise ValueError(f"AccountName cannot exceed 100 characters, got {len(self.value)}") + + @classmethod + def from_trusted_source(cls, value: str) -> "AccountName": + """Create AccountName from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/user_account/domain/value_objects/account_user_id.py b/app/context/user_account/domain/value_objects/account_user_id.py new file mode 100644 index 0000000..d380797 --- /dev/null +++ b/app/context/user_account/domain/value_objects/account_user_id.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedUserID + + +@dataclass(frozen=True) +class UserAccountUserID(SharedUserID): + pass diff --git a/app/context/user_account/domain/value_objects/deleted_at.py b/app/context/user_account/domain/value_objects/deleted_at.py new file mode 100644 index 0000000..7473e75 --- /dev/null +++ b/app/context/user_account/domain/value_objects/deleted_at.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.shared.domain.value_objects import SharedDeletedAt + + +@dataclass(frozen=True) +class UserAccountDeletedAt(SharedDeletedAt): + pass diff --git a/app/context/user_account/infrastructure/__init__.py b/app/context/user_account/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/user_account/infrastructure/dependencies.py b/app/context/user_account/infrastructure/dependencies.py new file mode 100644 index 0000000..5f64f05 --- /dev/null +++ b/app/context/user_account/infrastructure/dependencies.py @@ -0,0 +1,137 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user_account.application.contracts.create_account_handler_contract import ( + CreateAccountHandlerContract, +) +from app.context.user_account.application.contracts.delete_account_handler_contract import ( + DeleteAccountHandlerContract, +) +from app.context.user_account.application.contracts.find_account_by_id_handler_contract import ( + FindAccountByIdHandlerContract, +) +from app.context.user_account.application.contracts.find_accounts_by_user_handler_contract import ( + FindAccountsByUserHandlerContract, +) +from app.context.user_account.application.contracts.update_account_handler_contract import ( + UpdateAccountHandlerContract, +) +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.contracts.services.create_account_service_contract import ( + CreateAccountServiceContract, +) +from app.context.user_account.domain.contracts.services.update_account_service_contract import ( + UpdateAccountServiceContract, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.database import get_db +from app.shared.infrastructure.dependencies import get_logger + + +def get_user_account_repository( + db: Annotated[AsyncSession, Depends(get_db)], +) -> UserAccountRepositoryContract: + """UserAccountRepository dependency injection""" + from app.context.user_account.infrastructure.repositories.user_account_repository import ( + UserAccountRepository, + ) + + return UserAccountRepository(db) + + +# ───────────────────────────────────────────────────────────────── +# COMMAND HANDLERS (Write operations) +# ───────────────────────────────────────────────────────────────── + + +def get_create_account_service( + account_repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateAccountServiceContract: + """CreateAccountService dependency injection""" + from app.context.user_account.domain.services.create_account_service import ( + CreateAccountService, + ) + + return CreateAccountService(account_repository, logger) + + +def get_create_account_handler( + service: Annotated[CreateAccountServiceContract, Depends(get_create_account_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> CreateAccountHandlerContract: + """CreateAccountHandler dependency injection""" + from app.context.user_account.application.handlers.create_account_handler import ( + CreateAccountHandler, + ) + + return CreateAccountHandler(service, logger) + + +def get_update_account_service( + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateAccountServiceContract: + """UpdateAccountService dependency injection""" + from app.context.user_account.domain.services.update_account_service import ( + UpdateAccountService, + ) + + return UpdateAccountService(repository, logger) + + +def get_update_account_handler( + service: Annotated[UpdateAccountServiceContract, Depends(get_update_account_service)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> UpdateAccountHandlerContract: + """UpdateAccountHandler dependency injection""" + from app.context.user_account.application.handlers.update_account_handler import ( + UpdateAccountHandler, + ) + + return UpdateAccountHandler(service, logger) + + +def get_delete_account_handler( + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> DeleteAccountHandlerContract: + """DeleteAccountHandler dependency injection""" + from app.context.user_account.application.handlers.delete_account_handler import ( + DeleteAccountHandler, + ) + + return DeleteAccountHandler(repository, logger) + + +# ───────────────────────────────────────────────────────────────── +# QUERY HANDLERS (Read operations) +# ───────────────────────────────────────────────────────────────── + + +def get_find_account_by_id_handler( + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> FindAccountByIdHandlerContract: + """FindAccountByIdHandler dependency injection""" + from app.context.user_account.application.handlers.find_account_by_id_handler import ( + FindAccountByIdHandler, + ) + + return FindAccountByIdHandler(repository, logger) + + +def get_find_accounts_by_user_handler( + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], + logger: Annotated[LoggerContract, Depends(get_logger)], +) -> FindAccountsByUserHandlerContract: + """FindAccountsByUserHandler dependency injection""" + from app.context.user_account.application.handlers.find_accounts_by_user_handler import ( + FindAccountsByUserHandler, + ) + + return FindAccountsByUserHandler(repository, logger) diff --git a/app/context/user_account/infrastructure/mappers/__init__.py b/app/context/user_account/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..e01c94e --- /dev/null +++ b/app/context/user_account/infrastructure/mappers/__init__.py @@ -0,0 +1,3 @@ +from .user_account_mapper import UserAccountMapper + +__all__ = ["UserAccountMapper"] diff --git a/app/context/user_account/infrastructure/mappers/user_account_mapper.py b/app/context/user_account/infrastructure/mappers/user_account_mapper.py new file mode 100644 index 0000000..e847087 --- /dev/null +++ b/app/context/user_account/infrastructure/mappers/user_account_mapper.py @@ -0,0 +1,52 @@ +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.exceptions import UserAccountMapperError +from app.context.user_account.domain.value_objects import ( + UserAccountBalance, + UserAccountCurrency, + UserAccountDeletedAt, + UserAccountUserID, +) +from app.context.user_account.domain.value_objects.account_id import UserAccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.infrastructure.models.user_account_model import ( + UserAccountModel, +) + + +class UserAccountMapper: + """Mapper for converting between UserAccountModel and UserAccountDTO""" + + @staticmethod + def to_dto(model: UserAccountModel | None) -> UserAccountDTO | None: + """Convert database model to domain DTO""" + return ( + UserAccountDTO( + account_id=UserAccountID.from_trusted_source(model.id), + user_id=UserAccountUserID.from_trusted_source(model.user_id), + name=AccountName.from_trusted_source(model.name), + currency=UserAccountCurrency.from_trusted_source(model.currency), + balance=UserAccountBalance.from_trusted_source(model.balance), + deleted_at=UserAccountDeletedAt.from_optional(model.deleted_at), + ) + if model + else None + ) + + @staticmethod + def to_dto_or_fail(model: UserAccountModel) -> UserAccountDTO: + dto = UserAccountMapper.to_dto(model) + if dto is None: + raise UserAccountMapperError("User account dto cannot be null") + return dto + + @staticmethod + def to_model(dto: UserAccountDTO) -> UserAccountModel: + """Convert domain DTO to database model""" + return UserAccountModel( + id=dto.account_id.value if dto.account_id is not None else None, + user_id=dto.user_id.value, + name=dto.name.value, + currency=dto.currency.value, + balance=dto.balance.value, + deleted_at=dto.deleted_at.value if dto.deleted_at is not None else None, + ) diff --git a/app/context/user_account/infrastructure/models/__init__.py b/app/context/user_account/infrastructure/models/__init__.py new file mode 100644 index 0000000..bdbbb4d --- /dev/null +++ b/app/context/user_account/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .user_account_model import UserAccountModel + +__all__ = ["UserAccountModel"] diff --git a/app/context/user_account/infrastructure/models/user_account_model.py b/app/context/user_account/infrastructure/models/user_account_model.py new file mode 100644 index 0000000..5515164 --- /dev/null +++ b/app/context/user_account/infrastructure/models/user_account_model.py @@ -0,0 +1,18 @@ +from datetime import datetime +from decimal import Decimal + +from sqlalchemy import DECIMAL, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class UserAccountModel(BaseDBModel): + __tablename__ = "user_accounts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + currency: Mapped[str] = mapped_column(String(3), nullable=False) + balance: Mapped[Decimal] = mapped_column(DECIMAL(15, 2), nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) diff --git a/app/context/user_account/infrastructure/repositories/__init__.py b/app/context/user_account/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..163c979 --- /dev/null +++ b/app/context/user_account/infrastructure/repositories/__init__.py @@ -0,0 +1,3 @@ +from .user_account_repository import UserAccountRepository + +__all__ = ["UserAccountRepository"] diff --git a/app/context/user_account/infrastructure/repositories/user_account_repository.py b/app/context/user_account/infrastructure/repositories/user_account_repository.py new file mode 100644 index 0000000..4d264c0 --- /dev/null +++ b/app/context/user_account/infrastructure/repositories/user_account_repository.py @@ -0,0 +1,162 @@ +from typing import Any, cast + +from sqlalchemy import select, update +from sqlalchemy.engine import CursorResult +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user_account.domain.contracts.infrastructure import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.exceptions import ( + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountDeletedAt, + UserAccountID, + UserAccountUserID, +) +from app.context.user_account.infrastructure.mappers import ( + UserAccountMapper, +) +from app.context.user_account.infrastructure.models import ( + UserAccountModel, +) + + +class UserAccountRepository(UserAccountRepositoryContract): + """Repository implementation for user account operations""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def save_account(self, account: UserAccountDTO) -> UserAccountDTO: + """Create a new user account""" + model = UserAccountMapper.to_model(account) + self._db.add(model) + try: + await self._db.commit() + await self._db.refresh(model) + except IntegrityError as e: + await self._db.rollback() + raise UserAccountNameAlreadyExistError( + f"Account with name '{account.name.value}' already exists for this user" + ) from e + + return UserAccountMapper.to_dto_or_fail(model) + + async def find_account( + self, + account_id: UserAccountID | None = None, + user_id: UserAccountUserID | None = None, + name: AccountName | None = None, + only_active: bool | None = True, + ) -> UserAccountDTO | None: + """Find an account by ID or by user_id and name (admin/unrestricted usage)""" + stmt = select(UserAccountModel) + if only_active: + stmt = stmt.where(UserAccountModel.deleted_at.is_(None)) + + if account_id is not None: + stmt = stmt.where(UserAccountModel.id == account_id.value) + elif user_id is not None and name is not None: + stmt = stmt.where( + UserAccountModel.user_id == user_id.value, + UserAccountModel.name == name.value, + ) + else: + raise ValueError("Must provide either account_id or both user_id and name") + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return UserAccountMapper.to_dto(model) if model else None + + async def find_user_accounts( + self, + user_id: UserAccountUserID, + account_id: UserAccountID | None = None, + name: AccountName | None = None, + only_active: bool | None = True, + ) -> list[UserAccountDTO] | None: + """Find user account always filtering by user_id (for user-scoped queries)""" + stmt = select(UserAccountModel).where(UserAccountModel.user_id == user_id.value) + if only_active: + stmt = stmt.where(UserAccountModel.deleted_at.is_(None)) + + if account_id is not None: + stmt = stmt.where(UserAccountModel.id == account_id.value) + else: + if name is not None: + stmt = stmt.where(UserAccountModel.name.like(f"%{name.value}%")) + + models = (await self._db.execute(stmt)).scalars() + return [UserAccountMapper.to_dto_or_fail(model) for model in models] if models else [] + + async def find_user_account_by_id( + self, + user_id: UserAccountUserID, + account_id: UserAccountID, + only_active: bool | None = True, + ) -> UserAccountDTO | None: + stmt = select(UserAccountModel).where( + UserAccountModel.id == account_id.value, + UserAccountModel.user_id == user_id.value, + ) + if only_active: + stmt = stmt.where(UserAccountModel.deleted_at.is_(None)) + + model = (await self._db.execute(stmt)).scalar_one_or_none() + return UserAccountMapper.to_dto(model) + + async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: + """Update an existing account""" + if account.account_id is None: + raise ValueError("Account ID not given") + + stmt = ( + update(UserAccountModel) + .where( + UserAccountModel.id == account.account_id.value, + UserAccountModel.deleted_at.is_(None), + ) + .values( + name=account.name.value, + currency=account.currency.value, + balance=account.balance.value, + ) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + if result.rowcount == 0: + raise UserAccountNotFoundError(f"Account with ID {account.account_id.value} not found or already deleted") + + await self._db.commit() + + return account + + async def delete_account(self, account_id: UserAccountID, user_id: UserAccountUserID) -> bool: + """Soft delete an account""" + # Verify account exists and user owns it + account = await self.find_account(account_id=account_id) + if not account or account.user_id.value != user_id.value: + return False + + # Soft delete: set deleted_at timestamp + stmt = ( + update(UserAccountModel) + .where( + UserAccountModel.id == account_id.value, + UserAccountModel.user_id == user_id.value, + UserAccountModel.deleted_at.is_(None), + ) + .values(deleted_at=UserAccountDeletedAt.now().value) + ) + + result = cast(CursorResult[Any], await self._db.execute(stmt)) + await self._db.commit() + + return result.rowcount > 0 diff --git a/app/context/user_account/interface/__init__.py b/app/context/user_account/interface/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/context/user_account/interface/rest/__init__.py b/app/context/user_account/interface/rest/__init__.py new file mode 100644 index 0000000..b695afc --- /dev/null +++ b/app/context/user_account/interface/rest/__init__.py @@ -0,0 +1,3 @@ +from .routes import user_account_routes + +__all__ = ["user_account_routes"] diff --git a/app/context/user_account/interface/rest/controllers/__init__.py b/app/context/user_account/interface/rest/controllers/__init__.py new file mode 100644 index 0000000..10b65bc --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/__init__.py @@ -0,0 +1,3 @@ +from .create_account_controller import router as create_account_router + +__all__ = ["create_account_router"] diff --git a/app/context/user_account/interface/rest/controllers/create_account_controller.py b/app/context/user_account/interface/rest/controllers/create_account_controller.py new file mode 100644 index 0000000..ef88ab0 --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/create_account_controller.py @@ -0,0 +1,87 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user_account.application.commands import ( + CreateAccountCommand, +) +from app.context.user_account.application.contracts import ( + CreateAccountHandlerContract, +) +from app.context.user_account.application.dto import ( + CreateAccountErrorCode, +) +from app.context.user_account.infrastructure.dependencies import ( + get_create_account_handler, +) +from app.context.user_account.interface.schemas import ( + CreateAccountRequest, + CreateAccountResponse, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.post("", response_model=CreateAccountResponse, status_code=201) +async def create_account( + request: CreateAccountRequest, + handler: Annotated[CreateAccountHandlerContract, Depends(get_create_account_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Create a new user account""" + logger.info("Account creation request", user_id=user_id, name=request.name, currency=request.currency) + + command = CreateAccountCommand( + user_id=user_id, + name=request.name, + currency=request.currency, + balance=request.balance, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + CreateAccountErrorCode.NAME_ALREADY_EXISTS: 409, # Conflict + CreateAccountErrorCode.MAPPER_ERROR: 500, # Internal Server Error + CreateAccountErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 409: + logger.warning( + "Account creation failed - name already exists", + user_id=user_id, + name=request.name, + error_code=result.error_code.value, + ) + else: + logger.error( + "Account creation failed", + user_id=user_id, + name=request.name, + error_code=result.error_code.value, + error_message=result.error_message, + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + logger.info( + "Account created successfully", + user_id=user_id, + account_id=result.account_id, + name=request.name, + ) + + return CreateAccountResponse( + account_id=result.account_id, + account_name=result.account_name, + account_balance=result.account_balance, + ) diff --git a/app/context/user_account/interface/rest/controllers/delete_account_controller.py b/app/context/user_account/interface/rest/controllers/delete_account_controller.py new file mode 100644 index 0000000..177eb5c --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/delete_account_controller.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user_account.application.commands import ( + DeleteAccountCommand, +) +from app.context.user_account.application.contracts import ( + DeleteAccountHandlerContract, +) +from app.context.user_account.application.dto import ( + DeleteAccountErrorCode, +) +from app.context.user_account.infrastructure.dependencies import ( + get_delete_account_handler, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.delete("/{account_id}", status_code=204) +async def delete_account( + account_id: int, + handler: Annotated[DeleteAccountHandlerContract, Depends(get_delete_account_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Delete a user account (soft delete)""" + logger.info("Account deletion request", user_id=user_id, account_id=account_id) + + command = DeleteAccountCommand( + account_id=account_id, + user_id=user_id, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + DeleteAccountErrorCode.NOT_FOUND: 404, # Not Found + DeleteAccountErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 404: + logger.warning( + "Account deletion failed - not found", + user_id=user_id, + account_id=account_id, + error_code=result.error_code.value, + ) + else: + logger.error( + "Account deletion failed", + user_id=user_id, + account_id=account_id, + error_code=result.error_code.value, + error_message=result.error_message, + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return 204 No Content on success + logger.info("Account deleted successfully", user_id=user_id, account_id=account_id) + return diff --git a/app/context/user_account/interface/rest/controllers/find_account_controller.py b/app/context/user_account/interface/rest/controllers/find_account_controller.py new file mode 100644 index 0000000..86ee69f --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -0,0 +1,123 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user_account.application.contracts.find_account_by_id_handler_contract import ( + FindAccountByIdHandlerContract, +) +from app.context.user_account.application.contracts.find_accounts_by_user_handler_contract import ( + FindAccountsByUserHandlerContract, +) +from app.context.user_account.application.dto import ( + FindMultipleAccountsErrorCode, + FindSingleAccountErrorCode, +) +from app.context.user_account.application.queries.find_account_by_id_query import ( + FindAccountByIdQuery, +) +from app.context.user_account.application.queries.find_accounts_by_user_query import ( + FindAccountsByUserQuery, +) +from app.context.user_account.infrastructure.dependencies import ( + get_find_account_by_id_handler, + get_find_accounts_by_user_handler, +) +from app.context.user_account.interface.schemas.account_response import AccountResponse +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.get("/{account_id}", response_model=AccountResponse) +async def get_account( + account_id: int, + handler: Annotated[FindAccountByIdHandlerContract, Depends(get_find_account_by_id_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Get a specific user account by ID""" + query = FindAccountByIdQuery( + account_id=account_id, + user_id=user_id, + ) + + result = await handler.handle(query) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + FindSingleAccountErrorCode.NOT_FOUND: 404, + FindSingleAccountErrorCode.UNEXPECTED_ERROR: 500, + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code != 404: + logger.error( + "Get account failed", + user_id=user_id, + account_id=account_id, + error_code=result.error_code.value, + error_message=result.error_message, + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.account: + logger.error( + "Get account response missing account data", + user_id=user_id, + account_id=account_id, + ) + raise HTTPException(status_code=500, detail="error in response data") + + return AccountResponse( + account_id=result.account.account_id, + name=result.account.name, + currency=result.account.currency, + balance=result.account.balance, + ) + + +@router.get("", response_model=list[AccountResponse]) +async def get_all_accounts( + handler: Annotated[FindAccountsByUserHandlerContract, Depends(get_find_accounts_by_user_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Get all accounts for the authenticated user""" + query = FindAccountsByUserQuery(user_id=user_id) + result = await handler.handle(query) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + FindMultipleAccountsErrorCode.UNEXPECTED_ERROR: 500, + } + + status_code = status_code_map.get(result.error_code, 500) + + logger.error( + "Get all accounts failed", + user_id=user_id, + error_code=result.error_code.value, + error_message=result.error_message, + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return empty list if no accounts (not an error) + if not result.accounts: + return [] + + return [ + AccountResponse( + account_id=r.account_id, + name=r.name, + currency=r.currency, + balance=r.balance, + ) + for r in result.accounts + ] diff --git a/app/context/user_account/interface/rest/controllers/update_account_controller.py b/app/context/user_account/interface/rest/controllers/update_account_controller.py new file mode 100644 index 0000000..e277b40 --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/update_account_controller.py @@ -0,0 +1,106 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user_account.application.commands import ( + UpdateAccountCommand, +) +from app.context.user_account.application.contracts import ( + UpdateAccountHandlerContract, +) +from app.context.user_account.application.dto import ( + UpdateAccountErrorCode, +) +from app.context.user_account.infrastructure.dependencies import ( + get_update_account_handler, +) +from app.context.user_account.interface.schemas import ( + UpdateAccountRequest, + UpdateAccountResponse, +) +from app.shared.domain.contracts import LoggerContract +from app.shared.infrastructure.dependencies import get_logger +from app.shared.infrastructure.middleware import get_current_user_id + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.put("/{account_id}", response_model=UpdateAccountResponse) +async def update_account( + account_id: int, + request: UpdateAccountRequest, + handler: Annotated[UpdateAccountHandlerContract, Depends(get_update_account_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], + logger: Annotated[LoggerContract, Depends(get_logger)], +): + """Update a user account (full update - all fields required)""" + logger.info("Account update request", user_id=user_id, account_id=account_id, name=request.name) + + command = UpdateAccountCommand( + account_id=account_id, + user_id=user_id, + name=request.name, + currency=request.currency, + balance=request.balance, + ) + + result = await handler.handle(command) + + # Check for errors and map error codes to HTTP status codes + if result.error_code: + status_code_map = { + UpdateAccountErrorCode.NOT_FOUND: 404, # Not Found + UpdateAccountErrorCode.NAME_ALREADY_EXISTS: 409, # Conflict + UpdateAccountErrorCode.MAPPER_ERROR: 500, # Internal Server Error + UpdateAccountErrorCode.UNEXPECTED_ERROR: 500, # Internal Server Error + } + + status_code = status_code_map.get(result.error_code, 500) + + if status_code == 404: + logger.warning( + "Account update failed - not found", + user_id=user_id, + account_id=account_id, + error_code=result.error_code.value, + ) + elif status_code == 409: + logger.warning( + "Account update failed - name already exists", + user_id=user_id, + account_id=account_id, + name=request.name, + error_code=result.error_code.value, + ) + else: + logger.error( + "Account update failed", + user_id=user_id, + account_id=account_id, + error_code=result.error_code.value, + error_message=result.error_message, + ) + + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.account_id or not result.account_name or not result.account_balance: + logger.error( + "Account update response missing required fields", + user_id=user_id, + account_id=account_id, + ) + raise HTTPException(status_code=500, detail="error on required response fields") + + # Return success response + logger.info( + "Account updated successfully", + user_id=user_id, + account_id=account_id, + name=request.name, + ) + + return UpdateAccountResponse( + account_id=result.account_id, + account_name=result.account_name, + account_balance=result.account_balance, + ) diff --git a/app/context/user_account/interface/rest/routes.py b/app/context/user_account/interface/rest/routes.py new file mode 100644 index 0000000..f81a33b --- /dev/null +++ b/app/context/user_account/interface/rest/routes.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter + +from app.context.user_account.interface.rest.controllers.create_account_controller import ( + router as create_router, +) +from app.context.user_account.interface.rest.controllers.delete_account_controller import ( + router as delete_router, +) +from app.context.user_account.interface.rest.controllers.find_account_controller import ( + router as find_router, +) +from app.context.user_account.interface.rest.controllers.update_account_controller import ( + router as update_router, +) + +user_account_routes = APIRouter(prefix="/api/user-accounts", tags=["user-accounts"]) + +# Include all controller routers +user_account_routes.include_router(create_router) +user_account_routes.include_router(find_router) +user_account_routes.include_router(update_router) +user_account_routes.include_router(delete_router) diff --git a/app/context/user_account/interface/schemas/__init__.py b/app/context/user_account/interface/schemas/__init__.py new file mode 100644 index 0000000..eb3b890 --- /dev/null +++ b/app/context/user_account/interface/schemas/__init__.py @@ -0,0 +1,13 @@ +from .account_response import AccountResponse +from .create_account_response import CreateAccountResponse +from .create_account_schema import CreateAccountRequest +from .update_account_response import UpdateAccountResponse +from .update_account_schema import UpdateAccountRequest + +__all__ = [ + "CreateAccountRequest", + "CreateAccountResponse", + "AccountResponse", + "UpdateAccountRequest", + "UpdateAccountResponse", +] diff --git a/app/context/user_account/interface/schemas/account_response.py b/app/context/user_account/interface/schemas/account_response.py new file mode 100644 index 0000000..18ad815 --- /dev/null +++ b/app/context/user_account/interface/schemas/account_response.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from decimal import Decimal + + +@dataclass(frozen=True) +class AccountResponse: + account_id: int + name: str + currency: str + balance: Decimal diff --git a/app/context/user_account/interface/schemas/create_account_response.py b/app/context/user_account/interface/schemas/create_account_response.py new file mode 100644 index 0000000..aafe758 --- /dev/null +++ b/app/context/user_account/interface/schemas/create_account_response.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CreateAccountResponse: + """Response schema for account creation""" + + account_id: int | None + account_name: str | None + account_balance: float | None diff --git a/app/context/user_account/interface/schemas/create_account_schema.py b/app/context/user_account/interface/schemas/create_account_schema.py new file mode 100644 index 0000000..53e4f88 --- /dev/null +++ b/app/context/user_account/interface/schemas/create_account_schema.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class CreateAccountRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str = Field(..., min_length=1, max_length=100, description="Account name") + currency: str = Field(..., min_length=3, max_length=3, description="Currency code (ISO 4217)") + balance: float = Field(..., description="Initial account balance") + + @field_validator("currency") + @classmethod + def validate_currency(cls, v: str) -> str: + """Ensure currency is uppercase and exactly 3 characters""" + return v.upper() diff --git a/app/context/user_account/interface/schemas/update_account_response.py b/app/context/user_account/interface/schemas/update_account_response.py new file mode 100644 index 0000000..bb209fe --- /dev/null +++ b/app/context/user_account/interface/schemas/update_account_response.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UpdateAccountResponse: + """Response schema for account update""" + + account_id: int + account_name: str + account_balance: float diff --git a/app/context/user_account/interface/schemas/update_account_schema.py b/app/context/user_account/interface/schemas/update_account_schema.py new file mode 100644 index 0000000..07f7a13 --- /dev/null +++ b/app/context/user_account/interface/schemas/update_account_schema.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class UpdateAccountRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + name: str = Field(..., min_length=1, max_length=100) + currency: str = Field(..., min_length=3, max_length=3) + balance: float = Field(...) + + @field_validator("currency") + @classmethod + def validate_currency(cls, v: str) -> str: + return v.upper() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..3ace51c --- /dev/null +++ b/app/main.py @@ -0,0 +1,34 @@ +from fastapi import FastAPI + +from app.context.auth.interface.rest import auth_routes +from app.context.credit_card.interface.rest import credit_card_routes +from app.context.entry.interface.rest import entry_routes +from app.context.household.interface.rest import household_routes +from app.context.reminder.interface.rest.routes import reminder_context_router +from app.context.user_account.interface.rest import user_account_routes +from app.shared.domain.value_objects.shared_app_env import SharedAppEnv +from app.shared.infrastructure.logging.config import configure_structlog + +# Configure structlog (skip in test environment) +# if "APP_ENV") != "test": +if not SharedAppEnv.isTest(): + configure_structlog(use_json=SharedAppEnv.isProd()) + +app = FastAPI( + title="Homecomp API", + description="API for Homecomp", + version="0.1.0", + # lifespan=lifespan, +) + +app.include_router(auth_routes) +app.include_router(user_account_routes) +app.include_router(credit_card_routes) +app.include_router(entry_routes) +app.include_router(household_routes) +app.include_router(reminder_context_router) + + +@app.get("/") +async def root(): + return {"message": "Welcome to Homecomp API"} diff --git a/app/shared/domain/contracts/__init__.py b/app/shared/domain/contracts/__init__.py new file mode 100644 index 0000000..1e0e5fa --- /dev/null +++ b/app/shared/domain/contracts/__init__.py @@ -0,0 +1,3 @@ +from .logger_contract import LoggerContract + +__all__ = ["LoggerContract"] diff --git a/app/shared/domain/contracts/logger_contract.py b/app/shared/domain/contracts/logger_contract.py new file mode 100644 index 0000000..dbbf050 --- /dev/null +++ b/app/shared/domain/contracts/logger_contract.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod +from typing import Any + + +class LoggerContract(ABC): + """Contract for logging operations across the application.""" + + @abstractmethod + def debug(self, message: str, **kwargs: Any) -> None: + """Log a debug message with optional structured data.""" + pass + + @abstractmethod + def info(self, message: str, **kwargs: Any) -> None: + """Log an info message with optional structured data.""" + pass + + @abstractmethod + def warning(self, message: str, **kwargs: Any) -> None: + """Log a warning message with optional structured data.""" + pass + + @abstractmethod + def error(self, message: str, **kwargs: Any) -> None: + """Log an error message with optional structured data.""" + pass + + @abstractmethod + def critical(self, message: str, **kwargs: Any) -> None: + """Log a critical message with optional structured data.""" + pass + + @abstractmethod + def bind(self, **kwargs: Any) -> "LoggerContract": + """Return a new logger with bound context variables.""" + pass diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py new file mode 100644 index 0000000..f0e185c --- /dev/null +++ b/app/shared/domain/value_objects/__init__.py @@ -0,0 +1,31 @@ +from .shared_account_id import SharedAccountID +from .shared_app_env import SharedAppEnv +from .shared_balance import SharedBalance +from .shared_category_id import SharedCategoryID +from .shared_currency import SharedCurrency +from .shared_date import SharedDateTime +from .shared_deleted_at import SharedDeletedAt +from .shared_email import SharedEmail +from .shared_entry_type import SharedEntryType +from .shared_month import SharedMonth +from .shared_password import SharedPassword +from .shared_user_id import SharedUserID +from .shared_username import SharedUsername +from .shared_year import SharedYear + +__all__ = [ + "SharedDeletedAt", + "SharedEmail", + "SharedEntryType", + "SharedPassword", + "SharedBalance", + "SharedCurrency", + "SharedAccountID", + "SharedUserID", + "SharedDateTime", + "SharedUsername", + "SharedAppEnv", + "SharedMonth", + "SharedYear", + "SharedCategoryID", +] diff --git a/app/shared/domain/value_objects/shared_account_id.py b/app/shared/domain/value_objects/shared_account_id.py new file mode 100644 index 0000000..aa08f70 --- /dev/null +++ b/app/shared/domain/value_objects/shared_account_id.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedAccountID: + """Value object for user account identifier""" + + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and not isinstance(self.value, int): + raise ValueError(f"AccountID must be an integer, got {type(self.value)}") + if not self._validated and self.value <= 0: + raise ValueError(f"AccountID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create AccountID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_amount.py b/app/shared/domain/value_objects/shared_amount.py new file mode 100644 index 0000000..eb23c8f --- /dev/null +++ b/app/shared/domain/value_objects/shared_amount.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Self + + +@dataclass(frozen=True) +class SharedAmount: + """Value object for monetary amounts (must be non-negative)""" + + value: Decimal + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, Decimal): + raise ValueError(f"Amount must be a Decimal, got {type(self.value)}") + if self.value < 0: + raise ValueError(f"Amount must be non-negative, got {self.value}") + if self.value.as_tuple().exponent < -2: + raise ValueError(f"Amount cannot have more than 2 decimal places, got {self.value}") + + @classmethod + def from_float(cls, value: float) -> Self: + """Create Amount from float value""" + return cls(Decimal(str(value))) + + @classmethod + def from_trusted_source(cls, value: Decimal) -> Self: + """Create Amount from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_app_env.py b/app/shared/domain/value_objects/shared_app_env.py new file mode 100644 index 0000000..c7b6f3b --- /dev/null +++ b/app/shared/domain/value_objects/shared_app_env.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from enum import Enum +from os import getenv + + +class Environments(str, Enum): + DEV = "dev" + TEST = "test" + PROD = "prod" + DEBUG = "debug" + + +@dataclass(frozen=True) +class SharedAppEnv: + value: str = getenv("APP_ENV", Environments.PROD.value) + + def __post_init__(self): + if self.value != Environments.DEV or self.value != Environments.TEST or self.value != Environments.PROD: + raise ValueError(f"Invalid environment {self.value}") + + @classmethod + def isTest(cls) -> bool: + return cls.value == Environments.TEST + + @classmethod + def isDev(cls) -> bool: + return cls.value == Environments.DEV + + @classmethod + def isProd(cls) -> bool: + return cls.value == Environments.PROD + + @classmethod + def isDebug(cls) -> bool: + return cls.value == Environments.DEBUG diff --git a/app/shared/domain/value_objects/shared_balance.py b/app/shared/domain/value_objects/shared_balance.py new file mode 100644 index 0000000..a411870 --- /dev/null +++ b/app/shared/domain/value_objects/shared_balance.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Self + + +@dataclass(frozen=True) +class SharedBalance: + """Value object for account balance (can be negative for overdrafts)""" + + value: Decimal + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, Decimal): + raise ValueError(f"Balance must be a Decimal, got {type(self.value)}") + # TODO: Fix this + if self.value.as_tuple().exponent < -2: + raise ValueError(f"Balance cannot have more than 2 decimal places, got {self.value}") + + @classmethod + def from_float(cls, value: float) -> Self: + """Create Balance from float value""" + return cls(Decimal(str(value))) + + @classmethod + def from_trusted_source(cls, value: Decimal) -> Self: + """Create Balance from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_category_id.py b/app/shared/domain/value_objects/shared_category_id.py new file mode 100644 index 0000000..66d99cc --- /dev/null +++ b/app/shared/domain/value_objects/shared_category_id.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedCategoryID: + """Value object for category identifier""" + + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and not isinstance(self.value, int): + raise ValueError(f"CategoryID must be an integer, got {type(self.value)}") + if not self._validated and self.value <= 0: + raise ValueError(f"CategoryID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create CategoryID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_currency.py b/app/shared/domain/value_objects/shared_currency.py new file mode 100644 index 0000000..ec2c125 --- /dev/null +++ b/app/shared/domain/value_objects/shared_currency.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedCurrency: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not isinstance(self.value, str): + raise ValueError(f"Currency must be a string, got {type(self.value)}") + if len(self.value) != 3: + raise ValueError(f"Currency code must be exactly 3 characters, got {len(self.value)}") + if not self.value.isalpha(): + raise ValueError(f"Currency code must contain only letters, got {self.value}") + if not self.value.isupper(): + raise ValueError(f"Currency code must be uppercase, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """Create Currency from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_date.py b/app/shared/domain/value_objects/shared_date.py new file mode 100644 index 0000000..efcf29f --- /dev/null +++ b/app/shared/domain/value_objects/shared_date.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Self + +from app.shared.domain.value_objects.shared_month import SharedMonth +from app.shared.domain.value_objects.shared_year import SharedYear + + +@dataclass(frozen=True) +class SharedDateTime: + value: datetime + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + # Rule 1: Must be timezone-aware + if self.value.tzinfo is None: + raise ValueError(f"{self.__class__.__name__} must be timezone-aware. Naive datetimes are rejected.") + + # Rule 2: Convert to UTC (normalize) + if self.value.tzinfo != UTC: + utc_value = self.value.astimezone(UTC) + object.__setattr__(self, "value", utc_value) + + def to_db_value(self) -> str: + return self.value.isoformat() + + @classmethod + def from_trusted_source(cls, value: datetime) -> Self: + """Skip validation - for database reads""" + return cls(value=value, _validated=True) + + @classmethod + def now(cls) -> Self: + """Create an EntryDate for the current moment (UTC)""" + return cls(value=datetime.now(UTC), _validated=True) + + @classmethod + def start_of_month(cls, month: SharedMonth, year: SharedYear) -> Self: + """Get the first moment of the specified month""" + return cls(value=datetime(year.value, month.value, 1, tzinfo=UTC), _validated=True) + + @classmethod + def start_of_next_month(cls, month: SharedMonth, year: SharedYear) -> Self: + """Get the first moment of the next month (handles year rollover)""" + if month.value == 12: + next_month = 1 + next_year = year.value + 1 + else: + next_month = month.value + 1 + next_year = year.value + + return cls(value=datetime(next_year, next_month, 1, tzinfo=UTC), _validated=True) diff --git a/app/shared/domain/value_objects/shared_deleted_at.py b/app/shared/domain/value_objects/shared_deleted_at.py new file mode 100644 index 0000000..564a959 --- /dev/null +++ b/app/shared/domain/value_objects/shared_deleted_at.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Self + + +@dataclass(frozen=True) +class SharedDeletedAt: + """ + DeletedAt value object for soft delete functionality. + Represents the timestamp when an entity was marked as deleted. + """ + + value: datetime + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + """Validate that deleted_at is not in the future.""" + if not self._validated: + if not isinstance(self.value, datetime): + raise ValueError("DeletedAt must be a datetime object") + + # Ensure timezone-aware comparison + now = datetime.now(UTC) + value_utc = self.value if self.value.tzinfo else self.value.replace(tzinfo=UTC) + + if value_utc > now: + raise ValueError("DeletedAt cannot be in the future") + + @classmethod + def now(cls) -> Self: + """Create a DeletedAt timestamp for the current moment (UTC).""" + return cls(value=datetime.now(UTC), _validated=True) + + @classmethod + def from_trusted_source(cls, value: datetime) -> Self: + """ + Create DeletedAt from trusted source (e.g., database) - skips validation. + Use this to avoid performance overhead when data is already validated. + """ + return cls(value=value, _validated=True) + + @classmethod + def from_optional(cls, value: datetime | None) -> Self | None: + """ + Create DeletedAt from optional datetime. + Returns None if value is None (entity is not deleted). + """ + if value is None: + return None + return cls.from_trusted_source(value) diff --git a/app/shared/domain/value_objects/shared_email.py b/app/shared/domain/value_objects/shared_email.py new file mode 100644 index 0000000..e5f70a0 --- /dev/null +++ b/app/shared/domain/value_objects/shared_email.py @@ -0,0 +1,26 @@ +import re +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedEmail: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated: + if not self.value or not isinstance(self.value, str): + raise ValueError("Email cannot be empty") + + email_pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + if not re.match(email_pattern, self.value): + raise ValueError(f"Invalid email format: {self.value}") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """ + Create Email from trusted source (e.g., database) - skips validation. + Use this to avoid performance overhead when data is already validated. + """ + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_entry_id.py b/app/shared/domain/value_objects/shared_entry_id.py new file mode 100644 index 0000000..09f3f85 --- /dev/null +++ b/app/shared/domain/value_objects/shared_entry_id.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedEntryID: + """Value object for entry identifier""" + + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and not isinstance(self.value, int): + raise ValueError(f"EntryID must be an integer, got {type(self.value)}") + if not self._validated and self.value <= 0: + raise ValueError(f"EntryID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create EntryID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_entry_type.py b/app/shared/domain/value_objects/shared_entry_type.py new file mode 100644 index 0000000..b8ae7fe --- /dev/null +++ b/app/shared/domain/value_objects/shared_entry_type.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import Self + + +class SharedEntryTypeValues(str, Enum): + """Entry type for financial entries""" + + INCOME = "income" + EXPENSE = "expense" + + +@dataclass(frozen=True) +class SharedEntryType: + """Entry type for financial entries""" + + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + """ + Create EntryType from string value. + + Args: + value: String value ("income" or "expense") + + Returns: + EntryType enum value + + Raises: + ValueError: If value is not valid + """ + if self.value != SharedEntryTypeValues.INCOME and self.value != SharedEntryTypeValues.EXPENSE: + raise ValueError(f"Invalid entry type: '{self.value}'. Expected 'income' or 'expense'") + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + return cls(value, _validated=True) + + @classmethod + def expense(cls) -> Self: + return cls(SharedEntryTypeValues.EXPENSE) + + @classmethod + def income(cls) -> Self: + return cls(SharedEntryTypeValues.INCOME) diff --git a/app/shared/domain/value_objects/shared_household_id.py b/app/shared/domain/value_objects/shared_household_id.py new file mode 100644 index 0000000..7762241 --- /dev/null +++ b/app/shared/domain/value_objects/shared_household_id.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedHouseholdID: + """Value object for household identifier""" + + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + if not self._validated and not isinstance(self.value, int): + raise ValueError(f"HouseholdID must be an integer, got {type(self.value)}") + if not self._validated and self.value <= 0: + raise ValueError(f"HouseholdID must be positive, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create HouseholdID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_month.py b/app/shared/domain/value_objects/shared_month.py new file mode 100644 index 0000000..499291d --- /dev/null +++ b/app/shared/domain/value_objects/shared_month.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class SharedMonth: + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + def __post_init__(self): + """Validate month""" + if not self._validated: + if self.value < 1 or self.value > 12: + raise ValueError("Month must be a value between 1 and 12") diff --git a/app/shared/domain/value_objects/shared_password.py b/app/shared/domain/value_objects/shared_password.py new file mode 100644 index 0000000..abea633 --- /dev/null +++ b/app/shared/domain/value_objects/shared_password.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass +from typing import Self + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError + + +@dataclass(frozen=True) +class SharedPassword: + """ + Password value object. It will always represent hashed password in the domain + """ + + value: str + + @classmethod + def from_plain_text(cls, plain_password: str) -> Self: + ph = PasswordHasher() + return cls(value=ph.hash(plain_password)) + + @classmethod + def from_hash(cls, hashed_password: str) -> Self: + return cls(value=hashed_password) + + @classmethod + def keep_plain(cls, plain_password: str) -> Self: + return cls(value=plain_password) + + def verify(self, plain_password: str) -> bool: + ph = PasswordHasher() + try: + return ph.verify(self.value, plain_password) + except VerifyMismatchError: + # TODO: Logger here? + return False + + @staticmethod + def validate(plain_password: str) -> bool: + return len(plain_password) >= 8 diff --git a/app/shared/domain/value_objects/shared_user_id.py b/app/shared/domain/value_objects/shared_user_id.py new file mode 100644 index 0000000..33bea01 --- /dev/null +++ b/app/shared/domain/value_objects/shared_user_id.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedUserID: + value: int + _validated: bool = field(default=False, repr=False, compare=False) + + @classmethod + def from_trusted_source(cls, value: int) -> Self: + """Create Balance from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_username.py b/app/shared/domain/value_objects/shared_username.py new file mode 100644 index 0000000..385edc1 --- /dev/null +++ b/app/shared/domain/value_objects/shared_username.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field +from typing import Self + + +@dataclass(frozen=True) +class SharedUsername: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + @classmethod + def from_trusted_source(cls, value: str) -> Self: + """Create Balance from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_year.py b/app/shared/domain/value_objects/shared_year.py new file mode 100644 index 0000000..c196d32 --- /dev/null +++ b/app/shared/domain/value_objects/shared_year.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SharedYear: + value: int diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py new file mode 100644 index 0000000..e293e82 --- /dev/null +++ b/app/shared/infrastructure/database.py @@ -0,0 +1,67 @@ +# pyright: ignore[reportUnusedImport] +# ruff: noqa: F401, E402 +from os import getenv + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import declarative_base + +from app.shared.domain.value_objects.shared_app_env import SharedAppEnv + +# Database configuration - use TEST_ prefixed variables when environment is tests +DB_HOST = getenv("DB_HOST") +DB_PORT = getenv("DB_PORT") +DB_USER = getenv("DB_USER") +DB_PASS = getenv("DB_PASS") +DB_NAME = getenv("DB_NAME") + +if SharedAppEnv.isTest(): + DB_HOST = getenv("TEST_DB_HOST") + DB_PORT = getenv("TEST_DB_PORT") + DB_USER = getenv("TEST_DB_USER") + DB_PASS = getenv("TEST_DB_PASS") + DB_NAME = getenv("TEST_DB_NAME") + +DATABASE_URL = getenv( + "DATABASE_URL", + f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", +) + +async_engine = create_async_engine( + DATABASE_URL, + echo=SharedAppEnv.isDebug(), # Log SQL queries (disable in production) + pool_size=10, # Keep 10 persistent connections + max_overflow=20, # Allow 20 more if needed + pool_pre_ping=True, # Verify connection is alive before using + pool_recycle=3600, # Recycle connections after 1 hour +) + +AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) + +Base = declarative_base() + + +async def get_db(): + async with AsyncSessionLocal() as session: + yield session + + +# ────────────────────────────────────────────────────────────────────────────── +# Import all models to register them with SQLAlchemy metadata +# IMPORTANT: Order matters! Parent tables must be imported before child tables +# ────────────────────────────────────────────────────────────────────────────── + +from app.context.auth.infrastructure.models.session_model import SessionModel # noqa: F401, E402 +from app.context.category.infrastructure.models.category_model import CategoryModel # noqa: F401, E402 +from app.context.credit_card.infrastructure.models.credit_card_model import ( # noqa: F401, E402 + CreditCardModel, +) +from app.context.entry.infrastructure.models.entry_model import EntryModel # noqa: F401, E402 +from app.context.household.infrastructure.models.household_model import ( # noqa: F401, E402 + HouseholdMemberModel, + HouseholdModel, +) +from app.context.reminder.infrastructure.models import ReminderModel, ReminderOccurrenceModel +from app.context.user.infrastructure.models.user_model import UserModel # noqa: F401, E402 +from app.context.user_account.infrastructure.models.user_account_model import ( # noqa: F401, E402 + UserAccountModel, +) diff --git a/app/shared/infrastructure/dependencies/__init__.py b/app/shared/infrastructure/dependencies/__init__.py new file mode 100644 index 0000000..0c04b88 --- /dev/null +++ b/app/shared/infrastructure/dependencies/__init__.py @@ -0,0 +1,3 @@ +from .logger_dependency import get_logger + +__all__ = ["get_logger"] diff --git a/app/shared/infrastructure/dependencies/logger_dependency.py b/app/shared/infrastructure/dependencies/logger_dependency.py new file mode 100644 index 0000000..3a4aef3 --- /dev/null +++ b/app/shared/infrastructure/dependencies/logger_dependency.py @@ -0,0 +1,18 @@ +from app.shared.domain.contracts.logger_contract import LoggerContract +from app.shared.domain.value_objects import SharedAppEnv +from app.shared.infrastructure.logging import NullLogger, StructlogLogger + + +def get_logger() -> LoggerContract: + """ + Factory function for dependency injection of the logger. + + Returns: + LoggerContract: The configured logger implementation. + - In test environment (APP_ENV=test): Returns NullLogger + - Otherwise: Returns StructlogLogger + """ + if SharedAppEnv.isTest(): + return NullLogger() + + return StructlogLogger() diff --git a/app/shared/infrastructure/logging/__init__.py b/app/shared/infrastructure/logging/__init__.py new file mode 100644 index 0000000..ff81a1d --- /dev/null +++ b/app/shared/infrastructure/logging/__init__.py @@ -0,0 +1,4 @@ +from .null_logger import NullLogger +from .structlog_logger import StructlogLogger + +__all__ = ["StructlogLogger", "NullLogger"] diff --git a/app/shared/infrastructure/logging/config.py b/app/shared/infrastructure/logging/config.py new file mode 100644 index 0000000..53cdbc4 --- /dev/null +++ b/app/shared/infrastructure/logging/config.py @@ -0,0 +1,88 @@ +import logging +import sys + +import structlog + +from app.shared.domain.value_objects import SharedAppEnv + + +def configure_structlog(use_json: bool = False) -> None: + """ + Configure structlog for the application. + + Args: + use_json: If True, use JSON formatting (recommended for production). + If False, use console formatting (recommended for development). + """ + # Structlog processors - shared across all handlers + shared_processors = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + ] + + if use_json: + # Production: JSON formatting for Docker logs + renderer = structlog.processors.JSONRenderer() + else: + # Development: Console formatting with colors + renderer = structlog.dev.ConsoleRenderer() + + # Configure structlog to use stdlib logging as backend + structlog.configure( + processors=shared_processors + + [ + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, + ) + + # Configure stdlib logging handlers + logging.root.setLevel(logging.INFO) + logging.root.handlers = [] # Clear any existing handlers + + # Console handler with formatting + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + processor=renderer, + foreign_pre_chain=shared_processors, + ) + ) + logging.root.addHandler(console_handler) + + # Add Loki handler in development (sends structured data directly) + if not SharedAppEnv.isProd(): + try: + import logging_loki + + # Create a custom processor that returns the event dict as JSON string + # This is needed because logging_loki expects a formatted message + def json_message_processor(logger, method_name, event_dict): + """Convert event dict to JSON string for Loki""" + import json + + # Extract the message and include all structured data + return json.dumps(event_dict, default=str) + + loki_handler = logging_loki.LokiHandler( + url="http://localhost:3100/loki/api/v1/push", + tags={"app": "homecomp-api", "env": "dev"}, + version="1", + ) + # Use ProcessorFormatter with custom JSON processor + loki_handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + processor=json_message_processor, + foreign_pre_chain=shared_processors, + ) + ) + logging.root.addHandler(loki_handler) + except ImportError: + # python-logging-loki not installed (production) + pass diff --git a/app/shared/infrastructure/logging/null_logger.py b/app/shared/infrastructure/logging/null_logger.py new file mode 100644 index 0000000..6fe4f63 --- /dev/null +++ b/app/shared/infrastructure/logging/null_logger.py @@ -0,0 +1,34 @@ +from typing import Any + +from app.shared.domain.contracts.logger_contract import LoggerContract + + +class NullLogger(LoggerContract): + """Null logger implementation that suppresses all log output. + + Useful for testing environments where log output is not desired. + """ + + def debug(self, message: str, **kwargs: Any) -> None: + """No-op debug logging.""" + pass + + def info(self, message: str, **kwargs: Any) -> None: + """No-op info logging.""" + pass + + def warning(self, message: str, **kwargs: Any) -> None: + """No-op warning logging.""" + pass + + def error(self, message: str, **kwargs: Any) -> None: + """No-op error logging.""" + pass + + def critical(self, message: str, **kwargs: Any) -> None: + """No-op critical logging.""" + pass + + def bind(self, **kwargs: Any) -> LoggerContract: + """Return the same null logger (binding has no effect).""" + return self diff --git a/app/shared/infrastructure/logging/structlog_logger.py b/app/shared/infrastructure/logging/structlog_logger.py new file mode 100644 index 0000000..d4e7d50 --- /dev/null +++ b/app/shared/infrastructure/logging/structlog_logger.py @@ -0,0 +1,43 @@ +from typing import Any + +import structlog + +from app.shared.domain.contracts.logger_contract import LoggerContract + + +class StructlogLogger(LoggerContract): + """Structlog implementation of the logger contract.""" + + def __init__(self, logger: structlog.BoundLogger | None = None): + """ + Initialize the structlog logger. + + Args: + logger: Optional pre-configured structlog logger. If None, creates a new one. + """ + self._logger = logger if logger is not None else structlog.get_logger() + + def debug(self, message: str, **kwargs: Any) -> None: + """Log a debug message with optional structured data.""" + self._logger.debug(message, **kwargs) + + def info(self, message: str, **kwargs: Any) -> None: + """Log an info message with optional structured data.""" + self._logger.info(message, **kwargs) + + def warning(self, message: str, **kwargs: Any) -> None: + """Log a warning message with optional structured data.""" + self._logger.warning(message, **kwargs) + + def error(self, message: str, **kwargs: Any) -> None: + """Log an error message with optional structured data.""" + self._logger.error(message, **kwargs) + + def critical(self, message: str, **kwargs: Any) -> None: + """Log a critical message with optional structured data.""" + self._logger.critical(message, **kwargs) + + def bind(self, **kwargs: Any) -> LoggerContract: + """Return a new logger with bound context variables.""" + bound_logger = self._logger.bind(**kwargs) + return StructlogLogger(logger=bound_logger) diff --git a/app/shared/infrastructure/middleware/__init__.py b/app/shared/infrastructure/middleware/__init__.py new file mode 100644 index 0000000..39860b1 --- /dev/null +++ b/app/shared/infrastructure/middleware/__init__.py @@ -0,0 +1,6 @@ +from .session_auth_dependency import ( + get_current_user_id, + get_current_user_id_optional, +) + +__all__ = ["get_current_user_id", "get_current_user_id_optional"] diff --git a/app/shared/infrastructure/middleware/session_auth_dependency.py b/app/shared/infrastructure/middleware/session_auth_dependency.py new file mode 100644 index 0000000..eebaef4 --- /dev/null +++ b/app/shared/infrastructure/middleware/session_auth_dependency.py @@ -0,0 +1,136 @@ +""" +Session Authentication Dependency + +Provides FastAPI dependency for extracting and validating session tokens +from HTTP-only cookies. Used to protect routes that require authentication. +""" + +from typing import Annotated + +from fastapi import Cookie, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.auth.domain.contracts.session_repository_contract import ( + SessionRepositoryContract, +) +from app.context.auth.domain.value_objects.session_token import SessionToken +from app.context.auth.infrastructure.repositories.session_repository import ( + SessionRepository, +) +from app.shared.infrastructure.database import get_db + + +def get_session_repository_for_auth( + db: Annotated[AsyncSession, Depends(get_db)], +) -> SessionRepositoryContract: + """ + Factory function to create session repository for authentication. + Separate from the main dependencies to avoid circular imports. + """ + return SessionRepository(db) + + +async def get_current_user_id( + session_repo: Annotated[SessionRepositoryContract, Depends(get_session_repository_for_auth)], + access_token: Annotated[str | None, Cookie()] = None, +) -> int: + """ + Extract and validate session token from HTTP-only cookie. + + Args: + access_token: Session token from cookie (automatically extracted by FastAPI) + session_repo: Session repository for database lookups + + Returns: + int: The authenticated user's ID + + Raises: + HTTPException 401: If token is missing, invalid, or session not found + """ + if not access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + # Wrap token string in SessionToken value object + token = SessionToken(access_token) + + # Query session by token + session = await session_repo.getSession(token=token) + + if not session: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired session", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Return the user_id as an integer + return session.user_id.value + + except ValueError as e: + # SessionToken validation failed + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token format: {str(e)}", + headers={"WWW-Authenticate": "Bearer"}, + ) from ValueError + except Exception: + # Unexpected error during session lookup + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Authentication service unavailable", + ) from Exception + + +async def get_current_user_id_optional( + session_repo: Annotated[SessionRepositoryContract, Depends(get_session_repository_for_auth)], + access_token: Annotated[str | None, Cookie()] = None, +) -> int | None: + """ + Extract and validate session token from HTTP-only cookie (optional version). + + This dependency returns None if no token is present, allowing routes + to have optional authentication (e.g., public content with personalization). + + Args: + access_token: Session token from cookie (automatically extracted by FastAPI) + session_repo: Session repository for database lookups + + Returns: + Optional[int]: The authenticated user's ID, or None if not authenticated + + Raises: + HTTPException 401: If token is present but invalid + """ + if not access_token: + return None + + try: + token = SessionToken(access_token) + session = await session_repo.getSession(token=token) + + if not session: + # Token provided but invalid - this is suspicious, so we raise + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired session", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return session.user_id.value + + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid token format: {str(e)}", + headers={"WWW-Authenticate": "Bearer"}, + ) from ValueError + except Exception: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Authentication service unavailable", + ) from Exception diff --git a/app/shared/infrastructure/models/__init__.py b/app/shared/infrastructure/models/__init__.py new file mode 100644 index 0000000..612d2f0 --- /dev/null +++ b/app/shared/infrastructure/models/__init__.py @@ -0,0 +1,3 @@ +from .base_model import BaseDBModel as BaseDBModel + +__all__ = ["BaseDBModel"] diff --git a/app/shared/infrastructure/models/base_model.py b/app/shared/infrastructure/models/base_model.py new file mode 100644 index 0000000..4398a38 --- /dev/null +++ b/app/shared/infrastructure/models/base_model.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class BaseDBModel(DeclarativeBase): + pass diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..aac4414 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,159 @@ +services: + # FastAPI Application + api: + build: + context: . + dockerfile: Dockerfile + container_name: homecomp-api + ports: + - "8090:8090" + environment: + - APP_ENV=${APP_ENV:-dev} + - DB_HOST=postgres + - DB_PORT=5432 + - DB_USER=${DB_USER} + - DB_PASS=${DB_PASS} + - DB_NAME=${DB_NAME} + - VALKEY_HOST=valkey + - VALKEY_PORT=6379 + depends_on: + postgres: + condition: service_healthy + valkey: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8090/').read()"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + # Mount source code for development (comment out for production) + volumes: + - ./app:/app/app:ro + networks: + - homecomp-network + + postgres: + image: postgres:16-alpine + container_name: homecomp-postgres + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASS} + POSTGRES_DB: ${DB_NAME} + ports: + - "${DB_PORT}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - homecomp-network + + postgres-test: + image: postgres:16-alpine + container_name: homecomp-postgres-test + environment: + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASS} + POSTGRES_DB: homecomp_test + ports: + - "5433:5432" + volumes: + - postgres_test_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d homecomp_test"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - homecomp-network + + valkey: + image: valkey/valkey:8-alpine + container_name: homecomp-valkey + ports: + - "${VALKEY_PORT:-6379}:6379" + volumes: + - valkey_data:/data + restart: unless-stopped + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - homecomp-network + + loki: + image: grafana/loki:3.2.0 + container_name: homecomp-loki + ports: + - "3100:3100" + command: -config.file=/etc/loki/local-config.yaml + volumes: + - ./docker/loki/loki-config.yaml:/etc/loki/local-config.yaml + - loki_data:/loki + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3100/ready"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - homecomp-network + + promtail: + image: grafana/promtail:3.2.0 + container_name: homecomp-promtail + volumes: + - ./docker/promtail/promtail-config.yaml:/etc/promtail/config.yaml + - /var/log:/var/log:ro + # Production: Uncomment to scrape Docker container logs + # - /var/run/docker.sock:/var/run/docker.sock:ro + command: -config.file=/etc/promtail/config.yaml + restart: unless-stopped + depends_on: + - loki + networks: + - homecomp-network + + grafana: + image: grafana/grafana:11.4.0 + container_name: homecomp-grafana + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin} + - GF_USERS_ALLOW_SIGN_UP=false + volumes: + - grafana_data:/var/lib/grafana + - ./docker/grafana/provisioning:/etc/grafana/provisioning + - ./docker/grafana/dashboards:/etc/grafana/dashboards + restart: unless-stopped + depends_on: + - loki + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - homecomp-network + +networks: + homecomp-network: + driver: bridge + +volumes: + postgres_data: + postgres_test_data: + valkey_data: + loki_data: + grafana_data: diff --git a/docker/grafana/dashboards/homecomp-api-logs.json b/docker/grafana/dashboards/homecomp-api-logs.json new file mode 100644 index 0000000..e14088e --- /dev/null +++ b/docker/grafana/dashboards/homecomp-api-logs.json @@ -0,0 +1,954 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum by (level) (count_over_time({app=\"homecomp-api\"} | json | __error__=\"\" [$__interval]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Log Volume by Level", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 10 + }, + { + "color": "red", + "value": 50 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(count_over_time({app=\"homecomp-api\", level=\"error\"} [$__interval]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Total Errors (Current Interval)", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 5 + }, + { + "color": "red", + "value": 20 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "background", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": ["lastNotNull"], + "fields": "" + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(count_over_time({app=\"homecomp-api\", level=\"warning\"} [$__interval]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Total Warnings (Current Interval)", + "type": "stat" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 100, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "success" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "green", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "invalid_credentials" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "yellow", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "blocked" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "red", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 4 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(count_over_time({app=\"homecomp-api\"} |= \"Login successful\" [$__interval])) by ()", + "legendFormat": "success", + "queryType": "range", + "refId": "A" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(count_over_time({app=\"homecomp-api\"} |= \"Login failed - invalid credentials\" [$__interval])) by ()", + "legendFormat": "invalid_credentials", + "queryType": "range", + "refId": "B" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(count_over_time({app=\"homecomp-api\"} |= \"Login failed - account blocked\" [$__interval])) by ()", + "legendFormat": "blocked", + "queryType": "range", + "refId": "C" + } + ], + "title": "Login Attempts (Success vs Failure)", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 5, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "{app=\"homecomp-api\", level=\"error\"}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Recent Errors", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Count" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 6, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Count" + } + ] + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "topk(10, sum by (email) (count_over_time({app=\"homecomp-api\"} |= \"Login failed\" | json | __error__=\"\" [$__range])))", + "legendFormat": "{{email}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Top 10 Failed Login Attempts by Email", + "transformations": [ + { + "id": "reduce", + "options": { + "includeTimeField": false, + "mode": "reduceFields", + "reducers": ["last"] + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 7, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": false, + "showCommonLabels": false, + "showLabels": false, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "{app=\"homecomp-api\", level=\"warning\"}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Recent Warnings (Security Events)", + "type": "logs" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 8, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(count_over_time({app=\"homecomp-api\"} |= \"Account blocked\" [$__interval]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Account Blocks Over Time", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 9, + "options": { + "legend": { + "calcs": ["last", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum by (logger) (count_over_time({app=\"homecomp-api\"} | json | __error__=\"\" [$__interval]))", + "queryType": "range", + "refId": "A" + } + ], + "title": "Activity by Logger/Component", + "type": "timeseries" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "inspect": false + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Count" + }, + "properties": [ + { + "id": "custom.width", + "value": 100 + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 28 + }, + "id": 10, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": ["sum"], + "show": false + }, + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Count" + } + ] + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "topk(20, sum by (event) (count_over_time({app=\"homecomp-api\", level=\"error\"} | json | __error__=\"\" | line_format \"{{.event}}\" [$__range])))", + "legendFormat": "{{event}}", + "queryType": "range", + "refId": "A" + } + ], + "title": "Top 20 Error Events", + "transformations": [ + { + "id": "reduce", + "options": { + "includeTimeField": false, + "mode": "reduceFields", + "reducers": ["last"] + } + } + ], + "type": "table" + }, + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "gridPos": { + "h": 10, + "w": 24, + "x": 0, + "y": 32 + }, + "id": 11, + "options": { + "dedupStrategy": "none", + "enableLogDetails": true, + "prettifyLogMessage": true, + "showCommonLabels": false, + "showLabels": true, + "showTime": true, + "sortOrder": "Descending", + "wrapLogMessage": false + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "{app=\"homecomp-api\"} | json | __error__=\"\" | level=~\"$log_level\" | line_format \"{{.timestamp}} [{{.level}}] {{.logger}}: {{.event}} {{.msg}}\"", + "queryType": "range", + "refId": "A" + } + ], + "title": "All Application Logs (Filterable)", + "type": "logs" + } + ], + "refresh": "10s", + "schemaVersion": 38, + "style": "dark", + "tags": ["homecomp-api", "loki", "logs"], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": "Loki", + "value": "loki-datasource-uid" + }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "loki", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + }, + { + "allValue": ".*", + "current": { + "selected": true, + "text": ["All"], + "value": ["$__all"] + }, + "hide": 0, + "includeAll": true, + "label": "Log Level", + "multi": true, + "name": "log_level", + "options": [ + { + "selected": true, + "text": "All", + "value": "$__all" + }, + { + "selected": false, + "text": "debug", + "value": "debug" + }, + { + "selected": false, + "text": "info", + "value": "info" + }, + { + "selected": false, + "text": "warning", + "value": "warning" + }, + { + "selected": false, + "text": "error", + "value": "error" + }, + { + "selected": false, + "text": "critical", + "value": "critical" + } + ], + "query": "debug,info,warning,error,critical", + "queryValue": "", + "skipUrlSync": false, + "type": "custom" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"] + }, + "timezone": "browser", + "title": "HomeComp API - Application Logs", + "uid": "homecomp-api-logs", + "version": 1, + "weekStart": "" +} diff --git a/docker/grafana/provisioning/dashboards/dashboards.yaml b/docker/grafana/provisioning/dashboards/dashboards.yaml new file mode 100644 index 0000000..d7e493d --- /dev/null +++ b/docker/grafana/provisioning/dashboards/dashboards.yaml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'HomeComp API Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /etc/grafana/dashboards diff --git a/docker/grafana/provisioning/datasources/datasources.yaml b/docker/grafana/provisioning/datasources/datasources.yaml new file mode 100644 index 0000000..afbd306 --- /dev/null +++ b/docker/grafana/provisioning/datasources/datasources.yaml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Loki + type: loki + access: proxy + url: http://loki:3100 + isDefault: true + editable: true + jsonData: + maxLines: 1000 diff --git a/docker/loki/loki-config.yaml b/docker/loki/loki-config.yaml new file mode 100644 index 0000000..eb10d22 --- /dev/null +++ b/docker/loki/loki-config.yaml @@ -0,0 +1,52 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + +common: + instance_addr: 127.0.0.1 + path_prefix: /loki + storage: + filesystem: + chunks_directory: /loki/chunks + rules_directory: /loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +ruler: + alertmanager_url: http://localhost:9093 + +limits_config: + retention_period: 744h # 31 days + ingestion_rate_mb: 10 + ingestion_burst_size_mb: 20 + per_stream_rate_limit: 5MB + per_stream_rate_limit_burst: 15MB + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 150 + delete_request_store: filesystem diff --git a/docker/promtail/promtail-config.yaml b/docker/promtail/promtail-config.yaml new file mode 100644 index 0000000..b0dfa76 --- /dev/null +++ b/docker/promtail/promtail-config.yaml @@ -0,0 +1,47 @@ +server: + http_listen_port: 9080 + grpc_listen_port: 0 + +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://loki:3100/loki/api/v1/push + +scrape_configs: + # Production: Scrape Docker container logs + # Uncomment and configure when deploying to production + # - job_name: docker + # docker_sd_configs: + # - host: unix:///var/run/docker.sock + # refresh_interval: 5s + # filters: + # - name: label + # values: ["com.docker.compose.project=homecomp-api"] + # relabel_configs: + # - source_labels: ['__meta_docker_container_name'] + # regex: '/(.*)' + # target_label: 'container' + # - source_labels: ['__meta_docker_container_log_stream'] + # target_label: 'stream' + # pipeline_stages: + # - json: + # expressions: + # level: level + # message: message + # timestamp: time + # - labels: + # level: + # stream: + # - timestamp: + # source: timestamp + # format: RFC3339 + + # Development: Optional system logs monitoring + - job_name: system + static_configs: + - targets: + - localhost + labels: + job: system + __path__: /var/log/*log diff --git a/justfile b/justfile new file mode 100644 index 0000000..95a50ee --- /dev/null +++ b/justfile @@ -0,0 +1,102 @@ +# List all available commands +default: + @just --list + +# ============================================================================ +# Development Server +# ============================================================================ + +# Start development server with auto-reload +dev: + uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 + +# Start development server with test environment +dev-test: + APP_ENV=test uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 + +# ============================================================================ +# Database Migrations +# ============================================================================ + +# Generate a new database migration +migrate-new comment: + alembic revision -m "{{comment}}" + +# Run pending migrations +migrate: + uv run alembic upgrade head + +# Run pending migrations (test environment) +migrate-test: + APP_ENV=test uv run alembic upgrade head + +# ============================================================================ +# Database Operations +# ============================================================================ + +# Connect to PostgreSQL database (dev) +db: + pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:$DB_PORT/$DB_NAME + +# Connect to PostgreSQL database (test) +db-test: + pgcli postgresql://$TEST_DB_USER:$TEST_DB_PASS@$TEST_DB_HOST:$TEST_DB_PORT/$TEST_DB_NAME + +# Clear all data from database +db-clear: + uv run python scripts/db_clear.py + +# Seed database with initial data +db-seed: + uv run python scripts/seed.py + +# Clear and reseed database +db-reset: + just db-clear + just db-seed + +# ============================================================================ +# Testing +# ============================================================================ + +# Run unit tests +test: + uv run pytest -m unit --ignore=tests/integration + +# Run unit tests with coverage report +test-cov: + uv run pytest -m unit --ignore=tests/integration --cov --cov-report=term --cov-report=html + +# Run integration tests +test-integration: + APP_ENV=test uv run pytest -m integration + +# ============================================================================ +# Code Quality +# ============================================================================ + +# Check code with linter +lint: + uv run ruff check . + +# Check code formatting +fmt-check: + uv run ruff format --check . + +# Run all checks (lint + format) +check: + uv run ruff check . + uv run ruff format --check . + +# Auto-fix linting issues +lint-fix: + uv run ruff check --fix . + +# Auto-format code +fmt: + uv run ruff format . + +# Auto-fix linting and formatting +fix: + uv run ruff check --fix . + uv run ruff format . diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..d513605 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,95 @@ +from logging.config import fileConfig +from os import getenv + +from alembic import context +from sqlalchemy import engine_from_config, pool + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +# Config connection url +DB_HOST = getenv("DB_HOST") +DB_PORT = getenv("DB_PORT") +DB_USER = getenv("DB_USER") +DB_PASS = getenv("DB_PASS") +DB_NAME = getenv("DB_NAME") +if getenv("APP_ENV") == "test": + DB_HOST = getenv("TEST_DB_HOST") + DB_PORT = getenv("TEST_DB_PORT") + DB_USER = getenv("TEST_DB_USER") + DB_PASS = getenv("TEST_DB_PASS") + DB_NAME = getenv("TEST_DB_NAME") + +DATABASE_URL = getenv( + "DATABASE_URL", + f"postgresql+psycopg2://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", +) +config.set_main_option("sqlalchemy.url", DATABASE_URL) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +target_metadata = None + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/01770bb99438_create_household_tables.py b/migrations/versions/01770bb99438_create_household_tables.py new file mode 100644 index 0000000..f032d2f --- /dev/null +++ b/migrations/versions/01770bb99438_create_household_tables.py @@ -0,0 +1,145 @@ +"""Create household tables + +Revision ID: 01770bb99438 +Revises: e19a954402db +Create Date: 2025-12-27 15:30:16.811732 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "01770bb99438" +down_revision: str | Sequence[str] | None = "e19a954402db" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Create households table + op.create_table( + "households", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("owner_user_id", sa.Integer, nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "deleted_at", + sa.DateTime(timezone=True), + nullable=True, + ), + # Foreign key to users table + sa.ForeignKeyConstraint( + ["owner_user_id"], + ["users.id"], + name="fk_households_owner_user", + ondelete="RESTRICT", + ), + # Composite unique constraint: owner can't have duplicate household names + sa.UniqueConstraint( + "owner_user_id", + "name", + name="uq_households_owner_name", + ), + ) + + # Create household_members join table + op.create_table( + "household_members", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("household_id", sa.Integer, nullable=False), + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column( + "role", + sa.String(20), + nullable=False, + server_default="participant", + ), + sa.Column( + "joined_at", + sa.DateTime(timezone=True), + nullable=True, # NULL for invited members, set when they accept + ), + sa.Column("invited_by_user_id", sa.Integer, nullable=True), + sa.Column( + "invited_at", + sa.DateTime(timezone=True), + nullable=True, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.ForeignKeyConstraint( + ["household_id"], + ["households.id"], + name="fk_household_members_household", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name="fk_household_members_user", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["invited_by_user_id"], + ["users.id"], + name="fk_household_members_inviter", + ondelete="RESTRICT", + ), + # Composite unique constraint - user can only have one record per household + sa.UniqueConstraint("household_id", "user_id", name="uq_household_user"), + ) + + # Partial index: Get user's active households + # Used for: "What households do I have access to?" + op.create_index( + "ix_household_members_user_active", + "household_members", + ["user_id"], + postgresql_where=sa.text("joined_at IS NOT NULL"), + ) + + # Partial index: Check if user has access to specific household + # Used for: "Does user X have access to household Y?" + op.create_index( + "ix_household_members_access_check", + "household_members", + ["household_id", "user_id"], + postgresql_where=sa.text("joined_at IS NOT NULL"), + ) + + # Partial index: Get user's pending invites + # Used for: "What households has user been invited to?" + op.create_index( + "ix_household_members_pending_invites", + "household_members", + ["user_id"], + postgresql_where=sa.text("joined_at IS NULL"), + ) + + # Partial index: Get household's pending invites (for owner to see) + # Used for: "Who has owner invited to this household?" + op.create_index( + "ix_household_members_household_pending", + "household_members", + ["household_id"], + postgresql_where=sa.text("joined_at IS NULL"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_household_members_household_pending", table_name="household_members") + op.drop_index("ix_household_members_pending_invites", table_name="household_members") + op.drop_index("ix_household_members_access_check", table_name="household_members") + op.drop_index("ix_household_members_user_active", table_name="household_members") + op.drop_table("household_members") + op.drop_table("households") diff --git a/migrations/versions/1cc608a3625d_create_entry_table.py b/migrations/versions/1cc608a3625d_create_entry_table.py new file mode 100644 index 0000000..f7976bc --- /dev/null +++ b/migrations/versions/1cc608a3625d_create_entry_table.py @@ -0,0 +1,115 @@ +"""Create Entry Table + +Revision ID: 1cc608a3625d +Revises: 93fa43c670c2 +Create Date: 2025-12-30 20:43:25.432057 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "1cc608a3625d" +down_revision: str | Sequence[str] | None = "93fa43c670c2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Create entries table + op.create_table( + "entries", + sa.Column( + "id", + sa.BigInteger, + primary_key=True, + autoincrement=True, + nullable=False, + ), + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column("account_id", sa.Integer, nullable=False), + sa.Column("category_id", sa.Integer, nullable=False), + sa.Column( + "entry_type", + sa.String(20), + nullable=False, + ), + sa.Column( + "entry_date", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "amount", + sa.DECIMAL(15, 2), + nullable=False, + ), + sa.Column( + "description", + sa.String(500), + nullable=False, + ), + sa.Column( + "household_id", + sa.Integer, + nullable=True, # Optional - for shared household entries + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + # Foreign key constraints + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name="fk_entries_user", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["account_id"], + ["user_accounts.id"], + name="fk_entries_account", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["household_id"], + ["households.id"], + name="fk_entries_household", + ondelete="SET NULL", + ), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + name="fk_entries_category", + ondelete="RESTRICT", + ), + ) + + # Create indexes for common queries + op.create_index("ix_entries_user_id", "entries", ["user_id"]) + op.create_index("ix_entries_account_id", "entries", ["account_id"]) + op.create_index("ix_entries_entry_date", "entries", ["entry_date"]) + op.create_index("ix_entries_household_id", "entries", ["household_id"]) + + # Composite index for common query pattern: user's entries by date + op.create_index( + "ix_entries_user_account_date", + "entries", + ["user_id", "account_id", "entry_date"], + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_entries_user_date", table_name="entries") + op.drop_index("ix_entries_household_id", table_name="entries") + op.drop_index("ix_entries_entry_date", table_name="entries") + op.drop_index("ix_entries_account_id", table_name="entries") + op.drop_index("ix_entries_user_id", table_name="entries") + op.drop_table("entries") diff --git a/migrations/versions/93fa43c670c2_create_categories_table.py b/migrations/versions/93fa43c670c2_create_categories_table.py new file mode 100644 index 0000000..808e572 --- /dev/null +++ b/migrations/versions/93fa43c670c2_create_categories_table.py @@ -0,0 +1,83 @@ +"""create categories table + +Revision ID: 93fa43c670c2 +Revises: 01770bb99438 +Create Date: 2025-12-30 21:05:27.004533 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "93fa43c670c2" +down_revision: str | Sequence[str] | None = "01770bb99438" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "categories", + sa.Column( + "id", + sa.Integer, + primary_key=True, + autoincrement=True, + nullable=False, + ), + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("color", sa.String(7), nullable=False), # Hex color code: #RRGGBB + sa.Column( + "household_id", + sa.Integer, + nullable=True, # Optional - for shared household categories + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "deleted_at", + sa.DateTime(timezone=True), + nullable=True, + ), + # Foreign key constraints + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name="fk_categories_user", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["household_id"], + ["households.id"], + name="fk_categories_household", + ondelete="SET NULL", + ), + # Unique constraint: user can't have duplicate category names + sa.UniqueConstraint( + "user_id", + "name", + name="uq_categories_user_name", + ), + ) + + # Create indexes for common queries + op.create_index("ix_categories_user_id", "categories", ["user_id"]) + op.create_index("ix_categories_deleted_at", "categories", ["deleted_at"]) + op.create_index("ix_categories_household_id", "categories", ["household_id"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_categories_household_id", table_name="categories") + op.drop_index("ix_categories_deleted_at", table_name="categories") + op.drop_index("ix_categories_user_id", table_name="categories") + op.drop_table("categories") diff --git a/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py b/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py new file mode 100644 index 0000000..d732e0a --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py @@ -0,0 +1,193 @@ +"""Create Reminders and Reminder Occurrences Tables + +Revision ID: a1b2c3d4e5f6 +Revises: 1cc608a3625d +Create Date: 2025-12-31 10:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a1b2c3d4e5f6" +down_revision: str | Sequence[str] | None = "1cc608a3625d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Create reminders table (stores reminder definitions/templates) + op.create_table( + "reminders", + sa.Column( + "id", + sa.BigInteger, + primary_key=True, + autoincrement=True, + nullable=False, + ), + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column( + "entry_type", + sa.String(20), + nullable=False, + ), + sa.Column( + "currency", + sa.String(3), + nullable=False, + ), + sa.Column( + "amount", + sa.DECIMAL(15, 2), + nullable=False, + ), + sa.Column( + "frequency", + sa.String(50), + nullable=False, + ), + sa.Column( + "start_date", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "end_date", + sa.DateTime(timezone=True), + nullable=True, # Optional - for reminders that expire + ), + sa.Column( + "category_id", + sa.Integer, + nullable=False, + ), + sa.Column( + "household_id", + sa.Integer, + nullable=True, # Optional - for shared household reminders + ), + sa.Column( + "description", + sa.String(500), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + # Foreign key constraints + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name="fk_reminders_user", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["category_id"], + ["categories.id"], + name="fk_reminders_category", + ondelete="RESTRICT", + ), + sa.ForeignKeyConstraint( + ["household_id"], + ["households.id"], + name="fk_reminders_household", + ondelete="SET NULL", + ), + ) + + # Create indexes for reminders + op.create_index("ix_reminders_user_id", "reminders", ["user_id"]) + op.create_index("ix_reminders_household_id", "reminders", ["household_id"]) + op.create_index("ix_reminders_start_date", "reminders", ["start_date"]) + op.create_index("ix_reminders_frequency", "reminders", ["frequency"]) + + # Create reminder_occurrences table (stores generated instances) + op.create_table( + "reminder_occurrences", + sa.Column( + "id", + sa.BigInteger, + primary_key=True, + autoincrement=True, + nullable=False, + ), + sa.Column( + "reminder_id", + sa.BigInteger, + nullable=False, + ), + sa.Column( + "scheduled_date", + sa.DateTime(timezone=True), + nullable=False, + ), + sa.Column( + "amount", + sa.DECIMAL(15, 2), + nullable=False, + ), + sa.Column( + "status", + sa.String(20), + nullable=False, + server_default="pending", + ), + sa.Column( + "entry_id", + sa.BigInteger, + nullable=True, # Set when occurrence is converted to an entry + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + # Foreign key constraints + sa.ForeignKeyConstraint( + ["reminder_id"], + ["reminders.id"], + name="fk_reminder_occurrences_reminder", + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["entry_id"], + ["entries.id"], + name="fk_reminder_occurrences_entry", + ondelete="SET NULL", + ), + ) + + # Create indexes for reminder_occurrences + op.create_index("ix_reminder_occurrences_reminder_id", "reminder_occurrences", ["reminder_id"]) + op.create_index("ix_reminder_occurrences_scheduled_date", "reminder_occurrences", ["scheduled_date"]) + op.create_index("ix_reminder_occurrences_status", "reminder_occurrences", ["status"]) + # Composite index for finding pending occurrences by date + op.create_index( + "ix_reminder_occurrences_status_date", + "reminder_occurrences", + ["status", "scheduled_date"], + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_reminder_occurrences_status_date", table_name="reminder_occurrences") + op.drop_index("ix_reminder_occurrences_status", table_name="reminder_occurrences") + op.drop_index("ix_reminder_occurrences_scheduled_date", table_name="reminder_occurrences") + op.drop_index("ix_reminder_occurrences_reminder_id", table_name="reminder_occurrences") + op.drop_table("reminder_occurrences") + + op.drop_index("ix_reminders_frequency", table_name="reminders") + op.drop_index("ix_reminders_start_date", table_name="reminders") + op.drop_index("ix_reminders_household_id", table_name="reminders") + op.drop_index("ix_reminders_user_id", table_name="reminders") + op.drop_table("reminders") diff --git a/migrations/versions/b8067948709f_create_session_table.py b/migrations/versions/b8067948709f_create_session_table.py new file mode 100644 index 0000000..4426aec --- /dev/null +++ b/migrations/versions/b8067948709f_create_session_table.py @@ -0,0 +1,33 @@ +"""Create session table + +Revision ID: b8067948709f +Revises: f8333e5b2bac +Create Date: 2025-12-22 11:43:45.188730 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b8067948709f" +down_revision: str | Sequence[str] | None = "f8333e5b2bac" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "sessions", + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column("token", sa.String(100), nullable=True, unique=True, index=True), + sa.Column("failed_attempts", sa.Integer, default=0), + sa.Column("blocked_until", sa.DateTime, nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + ) + + +def downgrade() -> None: + op.drop_table("sessions") diff --git a/migrations/versions/d756346442c3_create_user_accounts_table.py b/migrations/versions/d756346442c3_create_user_accounts_table.py new file mode 100644 index 0000000..8ee4b6d --- /dev/null +++ b/migrations/versions/d756346442c3_create_user_accounts_table.py @@ -0,0 +1,39 @@ +"""Create user accounts table + +Revision ID: d756346442c3 +Revises: b8067948709f +Create Date: 2025-12-25 18:44:45.911971 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d756346442c3" +down_revision: str | Sequence[str] | None = "b8067948709f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "user_accounts", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("currency", sa.String(3), nullable=False), + sa.Column("balance", sa.DECIMAL(15, 2), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("user_id", "name", name="uq_user_accounts_user_id_name"), + ) + op.create_index("ix_user_accounts_deleted_at", "user_accounts", ["deleted_at"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_user_accounts_deleted_at", table_name="user_accounts") + op.drop_table("user_accounts") diff --git a/migrations/versions/e19a954402db_create_credit_cards_table.py b/migrations/versions/e19a954402db_create_credit_cards_table.py new file mode 100644 index 0000000..249d0b6 --- /dev/null +++ b/migrations/versions/e19a954402db_create_credit_cards_table.py @@ -0,0 +1,42 @@ +"""Create credit-cards table + +Revision ID: e19a954402db +Revises: d756346442c3 +Create Date: 2025-12-25 20:59:00.164701 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e19a954402db" +down_revision: str | Sequence[str] | None = "d756346442c3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "credit_cards", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("user_id", sa.Integer, nullable=False), + sa.Column("account_id", sa.Integer, nullable=False), + sa.Column("name", sa.String(100)), + sa.Column("currency", sa.String(3), nullable=False), + sa.Column("limit", sa.DECIMAL(15, 2), nullable=False), + sa.Column("used", sa.DECIMAL(15, 2), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["account_id"], ["user_accounts.id"], ondelete="CASCADE"), + sa.UniqueConstraint("user_id", "name", name="uq_credit_cards_user_id_name"), + ) + op.create_index("ix_credit_cards_deleted_at", "credit_cards", ["deleted_at"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_credit_cards_deleted_at", table_name="credit_cards") + op.drop_table("credit_cards") diff --git a/migrations/versions/f8333e5b2bac_create_user_table.py b/migrations/versions/f8333e5b2bac_create_user_table.py new file mode 100644 index 0000000..2c6f430 --- /dev/null +++ b/migrations/versions/f8333e5b2bac_create_user_table.py @@ -0,0 +1,47 @@ +"""Create user table + +Revision ID: f8333e5b2bac +Revises: +Create Date: 2025-12-21 01:00:30.079504 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f8333e5b2bac" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "users", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("email", sa.String(100), unique=True, nullable=False), + sa.Column("password", sa.String(150), nullable=False), + sa.Column("username", sa.String(100), nullable=True), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True, default=None), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("users") diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..2ecef97 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": [".claude/rules/*.md"] +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c0b8545 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[project] +name = "homecomp-api" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "alembic>=1.17.2", + "argon2-cffi>=25.1.0", + "asyncpg>=0.31.0", + "fastapi>=0.125.0", + "greenlet>=3.3.0", + "psycopg2-binary>=2.9.11", + "pydantic[email]>=2.12.5", + "sqlalchemy>=2.0.45", + "structlog>=25.5.0", + "uvicorn>=0.38.0", +] + +[dependency-groups] +dev = [ + "httpx>=0.28.1", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=6.0.0", + "python-logging-loki>=0.3.1", + "ruff>=0.14.10", +] + +[tool.pytest.ini_options] +markers = [ + "unit: marks tests as unit tests (fast, no external dependencies)", + "integration: marks tests as integration tests (slower, uses database)", +] +testpaths = ["tests"] +python_files = ["test_*.py", "*_test.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] + +[tool.coverage.run] +source = ["app"] +omit = [ + "*/tests/*", + "*/__pycache__/*", + "*/migrations/*", + "*/.venv/*", +] + +[tool.coverage.report] +precision = 2 +show_missing = true +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise AssertionError", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", + "@abstractmethod", + "@abc.abstractmethod", +] + +[tool.ruff] +line-length = 120 +target-version = "py313" + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" diff --git a/requests/base.sh b/requests/base.sh new file mode 100644 index 0000000..40469ad --- /dev/null +++ b/requests/base.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +export MYAPP_URL="http://localhost:8080/" + +# Color codes for output +export GREEN='\033[0;32m' +export RED='\033[0;31m' +export YELLOW='\033[1;33m' +export BLUE='\033[0;34m' +export NC='\033[0m' # No Color diff --git a/requests/credit_card_create.sh b/requests/credit_card_create.sh new file mode 100755 index 0000000..c091717 --- /dev/null +++ b/requests/credit_card_create.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./credit_card_create.sh +# Example: ./credit_card_create.sh 1 "Visa Gold" USD 5000 + +ACCOUNT_ID=${1:-1} +NAME=${2:-"My Credit Card"} +CURRENCY=${3:-"USD"} +LIMIT=${4:-1000} + +http --session=local_session POST "${MYAPP_URL}/api/credit-cards/cards" \ + account_id:=${ACCOUNT_ID} \ + name="${NAME}" \ + currency="${CURRENCY}" \ + limit="${LIMIT}" diff --git a/requests/credit_card_delete.sh b/requests/credit_card_delete.sh new file mode 100755 index 0000000..3c56053 --- /dev/null +++ b/requests/credit_card_delete.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./credit_card_delete.sh +# Example: ./credit_card_delete.sh 8 + +CARD_ID=${1:-1} + +http --session=local_session DELETE "${MYAPP_URL}/api/credit-cards/cards/${CARD_ID}" diff --git a/requests/credit_card_list.sh b/requests/credit_card_list.sh new file mode 100755 index 0000000..e557b74 --- /dev/null +++ b/requests/credit_card_list.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + + http --session=local_session GET "${MYAPP_URL}/api/credit-cards/cards" diff --git a/requests/credit_card_update.sh b/requests/credit_card_update.sh new file mode 100755 index 0000000..1d8f4d0 --- /dev/null +++ b/requests/credit_card_update.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./credit_card_update.sh +# Example: ./credit_card_update.sh 8 2 "Mastercard Premium" EUR 10000 + +CARD_ID=${1:-1} +ACCOUNT_ID=${2:-1} +NAME=${3:-"Updated Card"} +CURRENCY=${4:-"USD"} +LIMIT=${5:-1000} + +http --session=local_session PUT "${MYAPP_URL}/api/credit-cards/cards/${CARD_ID}" \ + account_id:=${ACCOUNT_ID} \ + name="${NAME}" \ + currency="${CURRENCY}" \ + limit="${LIMIT}" diff --git a/requests/household_accept_invite.sh b/requests/household_accept_invite.sh new file mode 100755 index 0000000..eaa8bf0 --- /dev/null +++ b/requests/household_accept_invite.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_accept_invite.sh +# Example: ./household_accept_invite.sh 1 + +HOUSEHOLD_ID=${1:-1} + +http --session=local_session POST "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}/invites/accept" diff --git a/requests/household_create.sh b/requests/household_create.sh new file mode 100755 index 0000000..5fb9164 --- /dev/null +++ b/requests/household_create.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +http --session=local_session POST "${MYAPP_URL}/api/households/" \ + name="My Household" diff --git a/requests/household_decline_invite.sh b/requests/household_decline_invite.sh new file mode 100755 index 0000000..5632340 --- /dev/null +++ b/requests/household_decline_invite.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_decline_invite.sh +# Example: ./household_decline_invite.sh 1 + +HOUSEHOLD_ID=${1:-1} + +http --session=local_session POST "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}/invites/decline" diff --git a/requests/household_delete.sh b/requests/household_delete.sh new file mode 100755 index 0000000..bb60d50 --- /dev/null +++ b/requests/household_delete.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_delete.sh +# Example: ./household_delete.sh 1 + +HOUSEHOLD_ID=${1:-1} + +http --session=local_session DELETE "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}" diff --git a/requests/household_get.sh b/requests/household_get.sh new file mode 100755 index 0000000..04345dd --- /dev/null +++ b/requests/household_get.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_get.sh +# Example: ./household_get.sh 1 + +HOUSEHOLD_ID=${1:-1} + +http --session=local_session GET "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}" diff --git a/requests/household_invite_user.sh b/requests/household_invite_user.sh new file mode 100755 index 0000000..bdf7391 --- /dev/null +++ b/requests/household_invite_user.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_invite_user.sh +# Example: ./household_invite_user.sh 1 2 participant + +HOUSEHOLD_ID=${1:-1} +INVITEE_USER_ID=${2:-2} +ROLE=${3:-participant} + +http --session=local_session POST "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}/invites" \ + invitee_user_id:=${INVITEE_USER_ID} \ + role="${ROLE}" diff --git a/requests/household_list.sh b/requests/household_list.sh new file mode 100755 index 0000000..d710e54 --- /dev/null +++ b/requests/household_list.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +http --session=local_session GET "${MYAPP_URL}/api/households/" diff --git a/requests/household_list_invites.sh b/requests/household_list_invites.sh new file mode 100755 index 0000000..d6ddda7 --- /dev/null +++ b/requests/household_list_invites.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_list_invites.sh +# Example: ./household_list_invites.sh 1 + +HOUSEHOLD_ID=${1:-1} + +http --session=local_session GET "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}/invites" diff --git a/requests/household_my_invites.sh b/requests/household_my_invites.sh new file mode 100755 index 0000000..6203001 --- /dev/null +++ b/requests/household_my_invites.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_my_invites.sh +# Lists all pending household invitations for the authenticated user + +http --session=local_session GET "${MYAPP_URL}/api/households/invites/pending" diff --git a/requests/household_remove_member.sh b/requests/household_remove_member.sh new file mode 100755 index 0000000..0c792e8 --- /dev/null +++ b/requests/household_remove_member.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_remove_member.sh +# Example: ./household_remove_member.sh 1 2 + +HOUSEHOLD_ID=${1:-1} +MEMBER_USER_ID=${2:-2} + +http --session=local_session DELETE "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}/members/${MEMBER_USER_ID}" diff --git a/requests/household_update.sh b/requests/household_update.sh new file mode 100755 index 0000000..3aaab00 --- /dev/null +++ b/requests/household_update.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./household_update.sh +# Example: ./household_update.sh 1 "Updated Household Name" + +HOUSEHOLD_ID=${1:-1} +NAME=${2:-"Updated Household"} + +http --session=local_session PUT "${MYAPP_URL}/api/households/${HOUSEHOLD_ID}" \ + name="${NAME}" diff --git a/requests/login.sh b/requests/login.sh new file mode 100755 index 0000000..8c3e94d --- /dev/null +++ b/requests/login.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./login.sh +# Example: ./login.sh user1@test.com mypassword + +EMAIL=${1:-"john.doe@example.com"} +PASSWORD=${2:-"testonga"} + +http --session=local_session POST "${MYAPP_URL}/api/auth/login" \ + email="${EMAIL}" \ + password="${PASSWORD}" diff --git a/requests/user_account_create.sh b/requests/user_account_create.sh new file mode 100755 index 0000000..46aef62 --- /dev/null +++ b/requests/user_account_create.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./user_account_create.sh +# Example: ./user_account_create.sh "Savings Account" 1000.50 USD + +NAME=${1:-"My Account"} +BALANCE=${2:-0} +CURRENCY=${3:-"USD"} + +http --session=local_session POST "${MYAPP_URL}/api/user-accounts/accounts" \ + name="${NAME}" \ + balance="${BALANCE}" \ + currency="${CURRENCY}" diff --git a/requests/user_account_delete.sh b/requests/user_account_delete.sh new file mode 100755 index 0000000..38bbeaf --- /dev/null +++ b/requests/user_account_delete.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./user_account_delete.sh +# Example: ./user_account_delete.sh 3 + +ACCOUNT_ID=${1:-1} + +http --session=local_session DELETE "${MYAPP_URL}/api/user-accounts/accounts/${ACCOUNT_ID}" diff --git a/requests/user_account_list.sh b/requests/user_account_list.sh new file mode 100755 index 0000000..fbe97e7 --- /dev/null +++ b/requests/user_account_list.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + + http --session=local_session GET "${MYAPP_URL}/api/user-accounts/accounts" diff --git a/requests/user_account_update.sh b/requests/user_account_update.sh new file mode 100755 index 0000000..4b634f4 --- /dev/null +++ b/requests/user_account_update.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" + +# Usage: ./user_account_update.sh +# Example: ./user_account_update.sh 2 "Updated Savings" 5000.00 EUR + +ACCOUNT_ID=${1:-1} +NAME=${2:-"Updated Account"} +BALANCE=${3:-0} +CURRENCY=${4:-"USD"} + +http --session=local_session PUT "${MYAPP_URL}/api/user-accounts/accounts/${ACCOUNT_ID}" \ + name="${NAME}" \ + balance="${BALANCE}" \ + currency="${CURRENCY}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f56b0a7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.0 +asyncpg==0.31.0 +certifi==2025.11.12 +click==8.3.1 +dnspython==2.8.0 +email-validator==2.3.0 +fastapi==0.124.4 +fastapi-cli==0.0.16 +fastapi-cloud-cli==0.6.0 +fastar==0.8.0 +greenlet==3.3.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +Jinja2==3.1.6 +markdown-it-py==4.0.0 +MarkupSafe==3.0.3 +mdurl==0.1.2 +pydantic==2.12.5 +pydantic_core==2.41.5 +Pygments==2.19.2 +python-dotenv==1.2.1 +python-multipart==0.0.20 +PyYAML==6.0.3 +rich==14.2.0 +rich-toolkit==0.17.0 +rignore==0.7.6 +sentry-sdk==2.47.0 +shellingham==1.5.4 +SQLAlchemy==2.0.45 +starlette==0.50.0 +typer==0.20.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.2 +uvicorn==0.38.0 +uvloop==0.22.1 +watchfiles==1.1.1 +websockets==15.0.1 diff --git a/scripts/db_clear.py b/scripts/db_clear.py new file mode 100644 index 0000000..8b0c9d8 --- /dev/null +++ b/scripts/db_clear.py @@ -0,0 +1,46 @@ +import asyncio +import sys +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import text + +from app.shared.infrastructure.database import AsyncSessionLocal + + +async def clear_database(): + """ + Clear all data from the database tables in reverse dependency order. + This allows re-seeding without dropping and recreating the database. + """ + async with AsyncSessionLocal() as session: + print("\n🗑️ Clearing database...\n") + + try: + # Truncate in reverse dependency order (children before parents) + tables = [ + "credit_cards", + "household_members", + "user_accounts", + "households", + "sessions", + "users", + ] + + for table in tables: + await session.execute(text(f"TRUNCATE TABLE {table} CASCADE")) + print(f" ✓ Cleared {table}") + + await session.commit() + print("\n✅ Database cleared successfully!\n") + + except Exception as e: + await session.rollback() + print(f"\n❌ Clear failed: {e}\n") + raise + + +if __name__ == "__main__": + asyncio.run(clear_database()) diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..4544e29 --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,44 @@ +import asyncio +import sys +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +# Import all seed functions +from seeds import ( + seed_credit_cards, + seed_household_members, + seed_households, + seed_user_accounts, + seed_users, +) + +from app.shared.infrastructure.database import AsyncSessionLocal + + +async def seed(): + async with AsyncSessionLocal() as session: + print("\n🌱 Starting database seeding...\n") + + try: + # Seed in dependency order + users = await seed_users(session) + households = await seed_households(session, users) + accounts = await seed_user_accounts(session, users) + await seed_household_members(session, users, households) + await seed_credit_cards(session, users, accounts) + + # Commit all changes + await session.commit() + + print("\n✅ Database seeding completed successfully!\n") + + except Exception as e: + await session.rollback() + print(f"\n❌ Seeding failed: {e}\n") + raise + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/scripts/seeds/README.md b/scripts/seeds/README.md new file mode 100644 index 0000000..364d208 --- /dev/null +++ b/scripts/seeds/README.md @@ -0,0 +1,101 @@ +# Database Seeds + +Modular seed data for development and testing. + +## Structure + +Each table has its own seed file: + +- `seed_users.py` - User accounts with hashed passwords +- `seed_households.py` - Household groups +- `seed_household_members.py` - Household memberships (including invitations) +- `seed_user_accounts.py` - Bank accounts in multiple currencies +- `seed_credit_cards.py` - Credit cards with limits and usage + +**Note:** Sessions are NOT seeded - they are created dynamically on user login. + +## Seed Data + +### Users (5 total) +- **john.doe@example.com** (johndoe) - Owner of "Doe Family" +- **jane.smith@example.com** (janesmith) - Owner of "Smith Household" +- **bob.johnson@example.com** (bobjohnson) - Member of "Smith Household" +- **alice.williams@example.com** (alicew) - Owner of "Williams Home" +- **charlie.brown@example.com** (charlieb) - Invited to "Doe Family" (pending) + +All users have password: `testonga` + +### Households (3 total) +1. **Doe Family** - 3 members (2 active, 1 pending invitation) +2. **Smith Household** - 2 members (both active) +3. **Williams Home** - 1 member (owner only) + +### User Accounts (8 total) +- Multi-currency support: USD, EUR, GBP +- Realistic balances ranging from $2,100 to $25,000 + +### Credit Cards (5 total) +- Various cards with different limits ($3,000 - $15,000) +- Different usage amounts for realistic testing + +## Commands + +```bash +# Clear all data and reseed +just db-reset + +# Clear data only +just db-clear + +# Seed data only (will fail if data exists) +just seed +``` + +## Dependency Order + +Seeds are executed in this order to respect foreign key constraints: + +1. Users (no dependencies) +2. Households (→ users) +3. User Accounts (→ users) +4. Household Members (→ households, users) +5. Credit Cards (→ users, user_accounts) + +**Note:** Sessions are created on login and are not seeded. + +## Adding New Seeds + +1. Create a new file: `scripts/seeds/seed_.py` +2. Define an async function: `async def seed_(session, ...dependencies)` +3. Add import to `scripts/seeds/__init__.py` +4. Call the function in `scripts/seed.py` in the correct dependency order +5. Add the table to `scripts/db_clear.py` truncate list (reverse dependency order) + +### Template + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from app.context..infrastructure.models import + + +async def seed_( + session: AsyncSession, + # Add dependencies as needed (e.g., users: dict[str, UserModel]) +) -> dict[str, ]: + """Seed table with test data""" + print(f" → Seeding ...") + + data = [ + # Your seed data here + ] + + records = [(**item) for item in data] + session.add_all(records) + await session.flush() + + # Return mapping for other seeds to reference + records_map = {record.key_field: record for record in records} + + print(f" ✓ Created {len(records)} ") + return records_map +``` diff --git a/scripts/seeds/__init__.py b/scripts/seeds/__init__.py new file mode 100644 index 0000000..abd1cf5 --- /dev/null +++ b/scripts/seeds/__init__.py @@ -0,0 +1,13 @@ +from .seed_credit_cards import seed_credit_cards +from .seed_household_members import seed_household_members +from .seed_households import seed_households +from .seed_user_accounts import seed_user_accounts +from .seed_users import seed_users + +__all__ = [ + "seed_users", + "seed_households", + "seed_user_accounts", + "seed_household_members", + "seed_credit_cards", +] diff --git a/scripts/seeds/seed_credit_cards.py b/scripts/seeds/seed_credit_cards.py new file mode 100644 index 0000000..60d1b5a --- /dev/null +++ b/scripts/seeds/seed_credit_cards.py @@ -0,0 +1,69 @@ +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.credit_card.infrastructure.models import CreditCardModel +from app.context.user.infrastructure.models import UserModel +from app.context.user_account.infrastructure.models import UserAccountModel + + +async def seed_credit_cards( + session: AsyncSession, + users: dict[str, UserModel], + accounts: dict[str, UserAccountModel], +) -> None: + """Seed credit_cards table with test data""" + print(" → Seeding credit cards...") + + cards_data = [ + # John's credit cards + { + "user_id": users["john.doe@example.com"].id, + "account_id": accounts["john.doe@example.com:Checking Account"].id, + "name": "Visa Platinum", + "currency": "USD", + "limit": Decimal("10000.00"), + "used": Decimal("2500.50"), + }, + { + "user_id": users["john.doe@example.com"].id, + "account_id": accounts["john.doe@example.com:Checking Account"].id, + "name": "Mastercard Gold", + "currency": "USD", + "limit": Decimal("5000.00"), + "used": Decimal("1200.00"), + }, + # Jane's credit card + { + "user_id": users["jane.smith@example.com"].id, + "account_id": accounts["jane.smith@example.com:Main Account"].id, + "name": "Amex Blue", + "currency": "EUR", + "limit": Decimal("8000.00"), + "used": Decimal("3500.75"), + }, + # Alice's credit card + { + "user_id": users["alice.williams@example.com"].id, + "account_id": accounts["alice.williams@example.com:Checking"].id, + "name": "Chase Sapphire", + "currency": "USD", + "limit": Decimal("15000.00"), + "used": Decimal("4200.00"), + }, + # Bob's credit card + { + "user_id": users["bob.johnson@example.com"].id, + "account_id": accounts["bob.johnson@example.com:Personal Account"].id, + "name": "Discover Card", + "currency": "GBP", + "limit": Decimal("3000.00"), + "used": Decimal("800.25"), + }, + ] + + cards = [CreditCardModel(**data) for data in cards_data] + session.add_all(cards) + await session.flush() + + print(f" ✓ Created {len(cards)} credit cards") diff --git a/scripts/seeds/seed_household_members.py b/scripts/seeds/seed_household_members.py new file mode 100644 index 0000000..51f8680 --- /dev/null +++ b/scripts/seeds/seed_household_members.py @@ -0,0 +1,157 @@ +from datetime import UTC, datetime + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.household.infrastructure.models import ( + HouseholdMemberModel, + HouseholdModel, +) +from app.context.user.infrastructure.models import UserModel + + +async def seed_household_members( + session: AsyncSession, + users: dict[str, UserModel], + households: dict[str, HouseholdModel], +) -> None: + """Seed household_members table with test data""" + print(" → Seeding household members...") + + now = datetime.now(UTC) + + members_data = [ + # Doe Family members + { + "household_id": households["Doe Family"].id, + "user_id": users["jane.smith@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["john.doe@example.com"].id, + "invited_at": now, + }, + { + "household_id": households["Doe Family"].id, + "user_id": users["alice.williams@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["john.doe@example.com"].id, + "invited_at": now, + }, + { + "household_id": households["Doe Family"].id, + "user_id": users["charlie.brown@example.com"].id, + "role": "participant", + "joined_at": None, # Pending invite + "invited_by_user_id": users["john.doe@example.com"].id, + "invited_at": now, + }, + # Smith Household members + { + "household_id": households["Smith Household"].id, + "user_id": users["bob.johnson@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["jane.smith@example.com"].id, + "invited_at": now, + }, + { + "household_id": households["Smith Household"].id, + "user_id": users["john.doe@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["jane.smith@example.com"].id, + "invited_at": now, + }, + # Williams Home members + { + "household_id": households["Williams Home"].id, + "user_id": users["bob.johnson@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["alice.williams@example.com"].id, + "invited_at": now, + }, + { + "household_id": households["Williams Home"].id, + "user_id": users["charlie.brown@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["alice.williams@example.com"].id, + "invited_at": now, + }, + # Johnson's Place members + { + "household_id": households["Johnson's Place"].id, + "user_id": users["john.doe@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["bob.johnson@example.com"].id, + "invited_at": now, + }, + { + "household_id": households["Johnson's Place"].id, + "user_id": users["alice.williams@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["bob.johnson@example.com"].id, + "invited_at": now, + }, + # Brown Residence members + { + "household_id": households["Brown Residence"].id, + "user_id": users["jane.smith@example.com"].id, + "role": "participant", + "joined_at": None, # Pending invite + "invited_by_user_id": users["charlie.brown@example.com"].id, + "invited_at": now, + }, + # Vacation Home members (John is owner, so testing if also being a member) + { + "household_id": households["Vacation Home"].id, + "user_id": users["bob.johnson@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["john.doe@example.com"].id, + "invited_at": now, + }, + { + "household_id": households["Vacation Home"].id, + "user_id": users["charlie.brown@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["john.doe@example.com"].id, + "invited_at": now, + }, + # Office Space members + { + "household_id": households["Office Space"].id, + "user_id": users["alice.williams@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["jane.smith@example.com"].id, + "invited_at": now, + }, + # Beach House members + { + "household_id": households["Beach House"].id, + "user_id": users["john.doe@example.com"].id, + "role": "participant", + "joined_at": None, # Pending invite + "invited_by_user_id": users["alice.williams@example.com"].id, + "invited_at": now, + }, + { + "household_id": households["Beach House"].id, + "user_id": users["jane.smith@example.com"].id, + "role": "participant", + "joined_at": now, + "invited_by_user_id": users["alice.williams@example.com"].id, + "invited_at": now, + }, + ] + + members = [HouseholdMemberModel(**data) for data in members_data] + session.add_all(members) + await session.flush() + + print(f" ✓ Created {len(members)} household members") diff --git a/scripts/seeds/seed_households.py b/scripts/seeds/seed_households.py new file mode 100644 index 0000000..416792f --- /dev/null +++ b/scripts/seeds/seed_households.py @@ -0,0 +1,54 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.household.infrastructure.models import HouseholdModel +from app.context.user.infrastructure.models import UserModel + + +async def seed_households(session: AsyncSession, users: dict[str, UserModel]) -> dict[str, HouseholdModel]: + """Seed households table with test data""" + print(" → Seeding households...") + + households_data = [ + { + "owner_user_id": users["john.doe@example.com"].id, + "name": "Doe Family", + }, + { + "owner_user_id": users["jane.smith@example.com"].id, + "name": "Smith Household", + }, + { + "owner_user_id": users["alice.williams@example.com"].id, + "name": "Williams Home", + }, + { + "owner_user_id": users["bob.johnson@example.com"].id, + "name": "Johnson's Place", + }, + { + "owner_user_id": users["charlie.brown@example.com"].id, + "name": "Brown Residence", + }, + { + "owner_user_id": users["john.doe@example.com"].id, + "name": "Vacation Home", + }, + { + "owner_user_id": users["jane.smith@example.com"].id, + "name": "Office Space", + }, + { + "owner_user_id": users["alice.williams@example.com"].id, + "name": "Beach House", + }, + ] + + households = [HouseholdModel(**data) for data in households_data] + session.add_all(households) + await session.flush() + + # Return households as dict for easy reference + households_map = {household.name: household for household in households} + + print(f" ✓ Created {len(households)} households") + return households_map diff --git a/scripts/seeds/seed_user_accounts.py b/scripts/seeds/seed_user_accounts.py new file mode 100644 index 0000000..a8f1ddf --- /dev/null +++ b/scripts/seeds/seed_user_accounts.py @@ -0,0 +1,82 @@ +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user.infrastructure.models import UserModel +from app.context.user_account.infrastructure.models import UserAccountModel + + +async def seed_user_accounts(session: AsyncSession, users: dict[str, UserModel]) -> dict[str, UserAccountModel]: + """Seed user_accounts table with test data""" + print(" → Seeding user accounts...") + + accounts_data = [ + # John's accounts + { + "user_id": users["john.doe@example.com"].id, + "name": "Checking Account", + "currency": "USD", + "balance": Decimal("5000.00"), + }, + { + "user_id": users["john.doe@example.com"].id, + "name": "Savings Account", + "currency": "USD", + "balance": Decimal("15000.50"), + }, + # Jane's accounts + { + "user_id": users["jane.smith@example.com"].id, + "name": "Main Account", + "currency": "EUR", + "balance": Decimal("8500.75"), + }, + { + "user_id": users["jane.smith@example.com"].id, + "name": "Investment Account", + "currency": "EUR", + "balance": Decimal("25000.00"), + }, + # Bob's account + { + "user_id": users["bob.johnson@example.com"].id, + "name": "Personal Account", + "currency": "GBP", + "balance": Decimal("3200.25"), + }, + # Alice's accounts + { + "user_id": users["alice.williams@example.com"].id, + "name": "Checking", + "currency": "USD", + "balance": Decimal("7500.00"), + }, + { + "user_id": users["alice.williams@example.com"].id, + "name": "Emergency Fund", + "currency": "USD", + "balance": Decimal("10000.00"), + }, + # Charlie's account + { + "user_id": users["charlie.brown@example.com"].id, + "name": "Main Account", + "currency": "USD", + "balance": Decimal("2100.50"), + }, + ] + + accounts = [UserAccountModel(**data) for data in accounts_data] + session.add_all(accounts) + await session.flush() + + # Return accounts as dict for easy reference (key: user_email + account_name) + accounts_map = {} + for account in accounts: + # Find user email by user_id + user_email = next(email for email, user in users.items() if user.id == account.user_id) + key = f"{user_email}:{account.name}" + accounts_map[key] = account + + print(f" ✓ Created {len(accounts)} user accounts") + return accounts_map diff --git a/scripts/seeds/seed_users.py b/scripts/seeds/seed_users.py new file mode 100644 index 0000000..6e07bb5 --- /dev/null +++ b/scripts/seeds/seed_users.py @@ -0,0 +1,50 @@ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user.domain.value_objects import UserPassword +from app.context.user.infrastructure.models import UserModel + + +async def seed_users(session: AsyncSession) -> dict[str, UserModel]: + """Seed users table with test data""" + print(" → Seeding users...") + + # Create password once for all test users + password = UserPassword.from_plain_text("testonga") + + users_data = [ + { + "email": "john.doe@example.com", + "username": "johndoe", + "password": password.value, + }, + { + "email": "jane.smith@example.com", + "username": "janesmith", + "password": password.value, + }, + { + "email": "bob.johnson@example.com", + "username": "bobjohnson", + "password": password.value, + }, + { + "email": "alice.williams@example.com", + "username": "alicew", + "password": password.value, + }, + { + "email": "charlie.brown@example.com", + "username": "charlieb", + "password": password.value, + }, + ] + + users = [UserModel(**data) for data in users_data] + session.add_all(users) + await session.flush() # Flush to get IDs without committing + + # Return users as dict for easy reference by other seeders + users_map = {user.email: user for user in users} + + print(f" ✓ Created {len(users)} users") + return users_map diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3143f62 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,57 @@ +"""Global test configuration and fixture imports. + +This file imports fixtures from the organized fixtures/ directory structure. +Add global fixtures here if they're truly shared across all contexts. +""" + +# Import shared fixtures +# Import auth context fixtures +from tests.fixtures.auth import ( + MockLoginService, + MockSessionRepository, + mock_login_service, + mock_session_repository, +) +from tests.fixtures.shared import mock_logger + +# Import user context fixtures +from tests.fixtures.user import MockFindUserHandler, mock_find_user_handler + +# Import user_account context fixtures +from tests.fixtures.user_account import ( + sample_account_dto, + sample_account_id, + sample_account_model, + sample_account_name, + sample_balance, + sample_currency, + sample_deleted_account_dto, + sample_deleted_account_model, + sample_new_account_dto, + sample_user_id, +) + +# Make fixtures available to pytest +__all__ = [ + # Shared fixtures + "mock_logger", + # Auth fixtures + "MockSessionRepository", + "mock_session_repository", + "MockLoginService", + "mock_login_service", + # User fixtures + "MockFindUserHandler", + "mock_find_user_handler", + # User account fixtures + "sample_user_id", + "sample_account_id", + "sample_account_name", + "sample_currency", + "sample_balance", + "sample_account_dto", + "sample_new_account_dto", + "sample_deleted_account_dto", + "sample_account_model", + "sample_deleted_account_model", +] diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..64a3a49 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Test fixtures organized by bounded context.""" diff --git a/tests/fixtures/auth/__init__.py b/tests/fixtures/auth/__init__.py new file mode 100644 index 0000000..7932685 --- /dev/null +++ b/tests/fixtures/auth/__init__.py @@ -0,0 +1,11 @@ +"""Auth context test fixtures.""" + +from tests.fixtures.auth.repositories import MockSessionRepository, mock_session_repository +from tests.fixtures.auth.services import MockLoginService, mock_login_service + +__all__ = [ + "MockSessionRepository", + "mock_session_repository", + "MockLoginService", + "mock_login_service", +] diff --git a/tests/fixtures/auth/repositories.py b/tests/fixtures/auth/repositories.py new file mode 100644 index 0000000..464a45e --- /dev/null +++ b/tests/fixtures/auth/repositories.py @@ -0,0 +1,35 @@ +"""Mock repositories for auth context testing.""" + +from unittest.mock import AsyncMock + +import pytest + +from app.context.auth.domain.contracts import SessionRepositoryContract +from app.context.auth.domain.dto.session_dto import SessionDTO +from app.context.auth.domain.value_objects import AuthUserID, SessionToken + + +class MockSessionRepository(SessionRepositoryContract): + """Mock implementation of SessionRepositoryContract for testing.""" + + def __init__(self): + self.get_session_mock = AsyncMock(return_value=None) + self.create_session_mock = AsyncMock() + self.update_session_mock = AsyncMock() + + async def getSession( + self, user_id: AuthUserID | None = None, token: SessionToken | None = None + ) -> SessionDTO | None: + return await self.get_session_mock(user_id=user_id, token=token) + + async def createSession(self, session: SessionDTO) -> SessionDTO: + return await self.create_session_mock(session) + + async def updateSession(self, session: SessionDTO) -> SessionDTO: + return await self.update_session_mock(session) + + +@pytest.fixture +def mock_session_repository(): + """Fixture providing a mock session repository.""" + return MockSessionRepository() diff --git a/tests/fixtures/auth/services.py b/tests/fixtures/auth/services.py new file mode 100644 index 0000000..b298d60 --- /dev/null +++ b/tests/fixtures/auth/services.py @@ -0,0 +1,25 @@ +"""Mock services for auth context testing.""" + +from unittest.mock import AsyncMock + +import pytest + +from app.context.auth.domain.contracts import LoginServiceContract +from app.context.auth.domain.dto import AuthUserDTO +from app.context.auth.domain.value_objects import AuthPassword, SessionToken + + +class MockLoginService(LoginServiceContract): + """Mock implementation of LoginServiceContract for testing.""" + + def __init__(self): + self.handle_mock = AsyncMock() + + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> SessionToken: + return await self.handle_mock(user_password=user_password, db_user=db_user) + + +@pytest.fixture +def mock_login_service(): + """Fixture providing a mock login service.""" + return MockLoginService() diff --git a/tests/fixtures/credit_card/credit_card_fixtures.py b/tests/fixtures/credit_card/credit_card_fixtures.py new file mode 100644 index 0000000..8d08dbc --- /dev/null +++ b/tests/fixtures/credit_card/credit_card_fixtures.py @@ -0,0 +1,221 @@ +"""Test fixtures for credit card context""" + +from datetime import datetime +from decimal import Decimal + +import pytest + +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardDeletedAt, + CreditCardID, + CreditCardName, + CreditCardUserID, +) +from app.context.credit_card.infrastructure.models import CreditCardModel + + +@pytest.fixture +def valid_credit_card_dto(): + """Create a valid credit card DTO for testing""" + return CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Test Credit Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + used=CardUsed(Decimal("1500.50")), + deleted_at=None, + ) + + +@pytest.fixture +def new_credit_card_dto(): + """Create a credit card DTO without ID (for creation tests)""" + return CreditCardDTO( + credit_card_id=None, # New card + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("New Card"), + currency=CreditCardCurrency("EUR"), + limit=CardLimit(Decimal("3000.00")), + used=None, + deleted_at=None, + ) + + +@pytest.fixture +def deleted_credit_card_dto(): + """Create a soft-deleted credit card DTO for testing""" + return CreditCardDTO( + credit_card_id=CreditCardID(2), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Deleted Card"), + currency=CreditCardCurrency("GBP"), + limit=CardLimit(Decimal("2000.00")), + used=CardUsed(Decimal("0.00")), + deleted_at=CreditCardDeletedAt.now(), + ) + + +@pytest.fixture +def credit_card_model(): + """Create a credit card model for testing""" + return CreditCardModel( + id=1, + user_id=100, + account_id=10, + name="Test Credit Card", + currency="USD", + limit=Decimal("5000.00"), + used=Decimal("1500.50"), + deleted_at=None, + ) + + +@pytest.fixture +def multiple_credit_card_dtos(): + """Create multiple credit card DTOs for list testing""" + return [ + CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Visa"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + used=CardUsed(Decimal("1500.00")), + ), + CreditCardDTO( + credit_card_id=CreditCardID(2), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Mastercard"), + currency=CreditCardCurrency("EUR"), + limit=CardLimit(Decimal("3000.00")), + used=CardUsed(Decimal("500.00")), + ), + CreditCardDTO( + credit_card_id=CreditCardID(3), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(11), + name=CreditCardName("Amex"), + currency=CreditCardCurrency("GBP"), + limit=CardLimit(Decimal("10000.00")), + used=CardUsed(Decimal("0.00")), + ), + ] + + +@pytest.fixture +def zero_used_credit_card_dto(): + """Create a credit card DTO with zero usage""" + return CreditCardDTO( + credit_card_id=CreditCardID(5), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Unused Card"), + currency=CreditCardCurrency("JPY"), + limit=CardLimit(Decimal("100000.00")), + used=CardUsed(Decimal("0.00")), + ) + + +@pytest.fixture +def maxed_out_credit_card_dto(): + """Create a credit card DTO at its limit""" + limit_value = Decimal("2000.00") + return CreditCardDTO( + credit_card_id=CreditCardID(6), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Maxed Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(limit_value), + used=CardUsed(limit_value), # Same as limit + ) + + +# Helper functions + + +def create_credit_card_dto( + card_id: int = 1, + user_id: int = 100, + account_id: int = 10, + name: str = "Test Card", + currency: str = "USD", + limit: float = 5000.00, + used: float = 0.00, + deleted_at: datetime = None, +) -> CreditCardDTO: + """ + Helper function to create a credit card DTO with custom values. + + Args: + card_id: Credit card ID (None for new cards) + user_id: User ID + account_id: Account ID + name: Card name + currency: Currency code (3-letter) + limit: Credit limit + used: Used amount + deleted_at: Deletion timestamp (None if not deleted) + + Returns: + CreditCardDTO with specified values + """ + return CreditCardDTO( + credit_card_id=CreditCardID(card_id) if card_id else None, + user_id=CreditCardUserID(user_id), + account_id=CreditCardAccountID(account_id), + name=CreditCardName(name), + currency=CreditCardCurrency(currency), + limit=CardLimit.from_float(limit), + used=CardUsed.from_float(used) if used is not None else None, + deleted_at=CreditCardDeletedAt.from_trusted_source(deleted_at) if deleted_at else None, + ) + + +def create_credit_card_model( + card_id: int = 1, + user_id: int = 100, + account_id: int = 10, + name: str = "Test Card", + currency: str = "USD", + limit: Decimal = Decimal("5000.00"), + used: Decimal = Decimal("0.00"), + deleted_at: datetime = None, +) -> CreditCardModel: + """ + Helper function to create a credit card model with custom values. + + Args: + card_id: Credit card ID + user_id: User ID + account_id: Account ID + name: Card name + currency: Currency code + limit: Credit limit + used: Used amount + deleted_at: Deletion timestamp + + Returns: + CreditCardModel with specified values + """ + return CreditCardModel( + id=card_id, + user_id=user_id, + account_id=account_id, + name=name, + currency=currency, + limit=limit, + used=used, + deleted_at=deleted_at, + ) diff --git a/tests/fixtures/household/__init__.py b/tests/fixtures/household/__init__.py new file mode 100644 index 0000000..01eefab --- /dev/null +++ b/tests/fixtures/household/__init__.py @@ -0,0 +1 @@ +"""Household test fixtures""" diff --git a/tests/fixtures/shared/__init__.py b/tests/fixtures/shared/__init__.py new file mode 100644 index 0000000..09038e4 --- /dev/null +++ b/tests/fixtures/shared/__init__.py @@ -0,0 +1,5 @@ +"""Shared test fixtures.""" + +from tests.fixtures.shared.logger import mock_logger + +__all__ = ["mock_logger"] diff --git a/tests/fixtures/shared/logger.py b/tests/fixtures/shared/logger.py new file mode 100644 index 0000000..570a66e --- /dev/null +++ b/tests/fixtures/shared/logger.py @@ -0,0 +1,11 @@ +"""Mock logger for testing.""" + +import pytest + +from app.shared.infrastructure.logging.null_logger import NullLogger + + +@pytest.fixture +def mock_logger(): + """Fixture providing a NullLogger for tests.""" + return NullLogger() diff --git a/tests/fixtures/user/__init__.py b/tests/fixtures/user/__init__.py new file mode 100644 index 0000000..04e1b0f --- /dev/null +++ b/tests/fixtures/user/__init__.py @@ -0,0 +1,8 @@ +"""User context test fixtures.""" + +from tests.fixtures.user.handlers import MockFindUserHandler, mock_find_user_handler + +__all__ = [ + "MockFindUserHandler", + "mock_find_user_handler", +] diff --git a/tests/fixtures/user/handlers.py b/tests/fixtures/user/handlers.py new file mode 100644 index 0000000..5e87894 --- /dev/null +++ b/tests/fixtures/user/handlers.py @@ -0,0 +1,25 @@ +"""Mock handlers for user context testing.""" + +from unittest.mock import AsyncMock + +import pytest + +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.dto import FindUserResult +from app.context.user.application.queries import FindUserQuery + + +class MockFindUserHandler(FindUserHandlerContract): + """Mock implementation of FindUserHandlerContract for testing.""" + + def __init__(self): + self.handle_mock = AsyncMock(return_value=None) + + async def handle(self, query: FindUserQuery) -> FindUserResult: + return await self.handle_mock(query) + + +@pytest.fixture +def mock_find_user_handler(): + """Fixture providing a mock find user handler.""" + return MockFindUserHandler() diff --git a/tests/fixtures/user_account/__init__.py b/tests/fixtures/user_account/__init__.py new file mode 100644 index 0000000..3587885 --- /dev/null +++ b/tests/fixtures/user_account/__init__.py @@ -0,0 +1,159 @@ +"""User account context test fixtures""" + +from datetime import datetime +from decimal import Decimal + +import pytest + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountDeletedAt, + UserAccountID, + UserAccountUserID, +) +from app.context.user_account.infrastructure.models.user_account_model import ( + UserAccountModel, +) + +# ────────────────────────────────────────────────────────────────────────────── +# Domain Value Object Fixtures +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def sample_user_id() -> UserAccountUserID: + """Create a sample user ID""" + return UserAccountUserID(1) + + +@pytest.fixture +def sample_account_id() -> UserAccountID: + """Create a sample account ID""" + return UserAccountID(10) + + +@pytest.fixture +def sample_account_name() -> AccountName: + """Create a sample account name""" + return AccountName("My Checking Account") + + +@pytest.fixture +def sample_currency() -> UserAccountCurrency: + """Create a sample currency""" + return UserAccountCurrency("USD") + + +@pytest.fixture +def sample_balance() -> UserAccountBalance: + """Create a sample balance""" + return UserAccountBalance(Decimal("1000.00")) + + +# ────────────────────────────────────────────────────────────────────────────── +# Domain DTO Fixtures +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def sample_account_dto( + sample_user_id: UserAccountUserID, + sample_account_id: UserAccountID, + sample_account_name: AccountName, + sample_currency: UserAccountCurrency, + sample_balance: UserAccountBalance, +) -> UserAccountDTO: + """Create a sample UserAccountDTO""" + return UserAccountDTO( + user_id=sample_user_id, + account_id=sample_account_id, + name=sample_account_name, + currency=sample_currency, + balance=sample_balance, + deleted_at=None, + ) + + +@pytest.fixture +def sample_new_account_dto( + sample_user_id: UserAccountUserID, + sample_account_name: AccountName, + sample_currency: UserAccountCurrency, + sample_balance: UserAccountBalance, +) -> UserAccountDTO: + """Create a sample UserAccountDTO for a new account (no ID)""" + return UserAccountDTO( + user_id=sample_user_id, + name=sample_account_name, + currency=sample_currency, + balance=sample_balance, + account_id=None, + deleted_at=None, + ) + + +@pytest.fixture +def sample_deleted_account_dto( + sample_user_id: UserAccountUserID, + sample_account_id: UserAccountID, + sample_account_name: AccountName, + sample_currency: UserAccountCurrency, +) -> UserAccountDTO: + """Create a sample deleted UserAccountDTO""" + return UserAccountDTO( + user_id=sample_user_id, + account_id=sample_account_id, + name=sample_account_name, + currency=sample_currency, + balance=UserAccountBalance(Decimal("0.00")), + deleted_at=UserAccountDeletedAt(datetime.now()), + ) + + +# ────────────────────────────────────────────────────────────────────────────── +# Infrastructure Model Fixtures +# ────────────────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def sample_account_model() -> UserAccountModel: + """Create a sample UserAccountModel""" + return UserAccountModel( + id=10, + user_id=1, + name="My Checking Account", + currency="USD", + balance=Decimal("1000.00"), + deleted_at=None, + ) + + +@pytest.fixture +def sample_deleted_account_model() -> UserAccountModel: + """Create a sample deleted UserAccountModel""" + return UserAccountModel( + id=10, + user_id=1, + name="Deleted Account", + currency="USD", + balance=Decimal("0.00"), + deleted_at=datetime.now(), + ) + + +# Export all fixtures +__all__ = [ + "sample_user_id", + "sample_account_id", + "sample_account_name", + "sample_currency", + "sample_balance", + "sample_account_dto", + "sample_new_account_dto", + "sample_deleted_account_dto", + "sample_account_model", + "sample_deleted_account_model", +] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..10fd17d --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,31 @@ +"""Integration test configuration and fixture imports. + +This file imports fixtures from the organized fixtures/ directory structure. +Add global integration test fixtures here if they're truly shared across all integration tests. +""" + +# Import database fixtures +# Import client fixtures +from tests.integration.fixtures.client import test_client +from tests.integration.fixtures.database import ( + test_db_session, + test_engine, +) + +# Import user fixtures +from tests.integration.fixtures.users import ( + blocked_user, + test_user, +) + +# Make fixtures available to pytest +__all__ = [ + # Database fixtures + "test_engine", + "test_db_session", + # Client fixtures + "test_client", + # User fixtures + "test_user", + "blocked_user", +] diff --git a/tests/integration/fixtures/__init__.py b/tests/integration/fixtures/__init__.py new file mode 100644 index 0000000..69b9215 --- /dev/null +++ b/tests/integration/fixtures/__init__.py @@ -0,0 +1,27 @@ +"""Integration test fixtures organized by category.""" + +# Import database fixtures +# Import client fixtures +from tests.integration.fixtures.client import test_client +from tests.integration.fixtures.database import ( + test_db_session, + test_engine, +) + +# Import user fixtures +from tests.integration.fixtures.users import ( + blocked_user, + test_user, +) + +# Make fixtures available to pytest +__all__ = [ + # Database fixtures + "test_engine", + "test_db_session", + # Client fixtures + "test_client", + # User fixtures + "test_user", + "blocked_user", +] diff --git a/tests/integration/fixtures/client.py b/tests/integration/fixtures/client.py new file mode 100644 index 0000000..3d2a986 --- /dev/null +++ b/tests/integration/fixtures/client.py @@ -0,0 +1,30 @@ +"""HTTP client fixtures for integration testing. + +Provides fixtures for FastAPI test client with database dependency overrides. +""" + +from collections.abc import AsyncGenerator + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.main import app +from app.shared.infrastructure.database import get_db + + +@pytest_asyncio.fixture(scope="function") +async def test_client( + test_db_session: AsyncSession, +) -> AsyncGenerator[AsyncClient]: + """Create a test client with overridden database dependency.""" + + async def override_get_db(): + yield test_db_session + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + yield client + + app.dependency_overrides.clear() diff --git a/tests/integration/fixtures/database.py b/tests/integration/fixtures/database.py new file mode 100644 index 0000000..d021168 --- /dev/null +++ b/tests/integration/fixtures/database.py @@ -0,0 +1,50 @@ +"""Database fixtures for integration testing. + +Provides fixtures for database engine and session management. +""" + +import os +from collections.abc import AsyncGenerator + +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.shared.infrastructure.models import BaseDBModel + +# Test database configuration - using TEST_ prefixed environment variables +TEST_DB_HOST = os.getenv("TEST_DB_HOST", "localhost") +TEST_DB_PORT = os.getenv("TEST_DB_PORT", 5433) +TEST_DB_USER = os.getenv("TEST_DB_USER", "uhomecomp") +TEST_DB_PASS = os.getenv("TEST_DB_PASS", "homecomppass") +TEST_DB_NAME = os.getenv("TEST_DB_NAME", "homecomp_test") + +# Construct test database URL +TEST_DB_URL = f"postgresql+asyncpg://{TEST_DB_USER}:{TEST_DB_PASS}@{TEST_DB_HOST}:{TEST_DB_PORT}/{TEST_DB_NAME}" + + +@pytest_asyncio.fixture(scope="function") +async def test_engine(): + """Create a test database engine.""" + engine = create_async_engine(TEST_DB_URL, echo=False, pool_pre_ping=True) + + # Create all tables + async with engine.begin() as conn: + await conn.run_sync(BaseDBModel.metadata.create_all) + + yield engine + + # Drop all tables after test + async with engine.begin() as conn: + await conn.run_sync(BaseDBModel.metadata.drop_all) + + await engine.dispose() + + +@pytest_asyncio.fixture(scope="function") +async def test_db_session(test_engine) -> AsyncGenerator[AsyncSession]: + """Create a test database session.""" + async_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session_maker() as session: + yield session + await session.rollback() diff --git a/tests/integration/fixtures/users.py b/tests/integration/fixtures/users.py new file mode 100644 index 0000000..d18b126 --- /dev/null +++ b/tests/integration/fixtures/users.py @@ -0,0 +1,72 @@ +"""User fixtures for integration testing. + +Provides fixtures for creating test users in the database. +""" + +import pytest_asyncio +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user.infrastructure.models import UserModel +from app.shared.domain.value_objects import SharedPassword + + +@pytest_asyncio.fixture +async def test_user(test_db_session: AsyncSession) -> dict: + """Create a test user in the database. + + Returns: + dict with user credentials and id: + - email: str + - password: str (plain text for testing) + - user_id: int + - hashed_password: str + """ + email = "testuser@example.com" + password = "SecurePassword123!" + hashed_password = SharedPassword.from_plain_text(password).value + + user = UserModel( + email=email, + password=hashed_password, + username="testuser", + ) + + test_db_session.add(user) + await test_db_session.commit() + await test_db_session.refresh(user) + + return { + "email": email, + "password": password, + "user_id": user.id, + "hashed_password": hashed_password, + } + + +@pytest_asyncio.fixture +async def blocked_user(test_db_session: AsyncSession) -> dict: + """Create a blocked user (multiple failed login attempts). + + Note: This is a placeholder. Actual blocking mechanism depends on + the login throttling implementation (in-memory or Redis-based). + """ + email = "blocked@example.com" + password = "BlockedPassword123!" + hashed_password = SharedPassword.from_plain_text(password).value + + user = UserModel( + email=email, + password=hashed_password, + username="blockeduser", + ) + + test_db_session.add(user) + await test_db_session.commit() + await test_db_session.refresh(user) + + return { + "email": email, + "password": password, + "user_id": user.id, + "hashed_password": hashed_password, + } diff --git a/tests/integration/test_login_flow.py b/tests/integration/test_login_flow.py new file mode 100644 index 0000000..ef4ec11 --- /dev/null +++ b/tests/integration/test_login_flow.py @@ -0,0 +1,240 @@ +"""Integration tests for the login flow. + +Tests the complete login flow from HTTP request through to database, +including authentication, cookie handling, and error scenarios. +""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.integration +@pytest.mark.asyncio +class TestLoginFlow: + """Integration tests for login endpoint.""" + + async def test_successful_login_returns_200_and_sets_cookie(self, test_client: AsyncClient, test_user: dict): + """Test successful login returns 200 status and sets access_token cookie.""" + # Arrange + login_payload = { + "email": test_user["email"], + "password": test_user["password"], + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert + assert response.status_code == 200 + assert response.json() == {"message": "Login successful"} + + # Verify cookie is set + assert "access_token" in response.cookies + access_token = response.cookies["access_token"] + assert access_token is not None + assert len(access_token) > 0 + + # Verify cookie attributes (security settings) + cookie_header = response.headers.get("set-cookie", "") + assert "HttpOnly" in cookie_header + assert "SameSite=lax" in cookie_header + assert "Max-Age=3600" in cookie_header # 1 hour + + async def test_login_with_invalid_email_returns_401(self, test_client: AsyncClient): + """Test login with non-existent email returns 401.""" + # Arrange + login_payload = { + "email": "nonexistent@example.com", + "password": "SomePassword123!", + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid username or password" + + # Verify no cookie is set + assert "access_token" not in response.cookies + + async def test_login_with_wrong_password_returns_401(self, test_client: AsyncClient, test_user: dict): + """Test login with correct email but wrong password returns 401.""" + # Arrange + login_payload = { + "email": test_user["email"], + "password": "WrongPassword123!", + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid username or password" + + # Verify no cookie is set + assert "access_token" not in response.cookies + + async def test_login_with_invalid_email_format_returns_422(self, test_client: AsyncClient): + """Test login with invalid email format returns 422 (validation error).""" + # Arrange + login_payload = { + "email": "not-an-email", + "password": "SomePassword123!", + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert + assert response.status_code == 422 # Pydantic validation error + assert "detail" in response.json() + + async def test_login_with_missing_password_returns_422(self, test_client: AsyncClient, test_user: dict): + """Test login without password returns 422 (validation error).""" + # Arrange + login_payload = { + "email": test_user["email"], + # password is missing + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert + assert response.status_code == 422 + assert "detail" in response.json() + + async def test_login_with_missing_email_returns_422(self, test_client: AsyncClient): + """Test login without email returns 422 (validation error).""" + # Arrange + login_payload = { + # email is missing + "password": "SomePassword123!", + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert + assert response.status_code == 422 + assert "detail" in response.json() + + async def test_login_with_empty_payload_returns_422(self, test_client: AsyncClient): + """Test login with empty payload returns 422.""" + # Arrange + login_payload = {} + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert + assert response.status_code == 422 + + async def test_multiple_successful_logins_same_user(self, test_client: AsyncClient, test_user: dict): + """Test that the same user can login multiple times successfully.""" + # Arrange + login_payload = { + "email": test_user["email"], + "password": test_user["password"], + } + + # Act - First login + response1 = await test_client.post("/api/auth/login", json=login_payload) + + # Assert - First login successful + assert response1.status_code == 200 + token1 = response1.cookies.get("access_token") + assert token1 is not None + + # Act - Second login + response2 = await test_client.post("/api/auth/login", json=login_payload) + + # Assert - Second login successful (may have different token) + assert response2.status_code == 200 + token2 = response2.cookies.get("access_token") + assert token2 is not None + + async def test_login_with_case_sensitive_email(self, test_client: AsyncClient, test_user: dict): + """Test that email is case-sensitive (or case-insensitive based on implementation). + + Note: Adjust this test based on your email matching logic. + Current implementation treats emails as case-sensitive. + """ + # Arrange - Use uppercase version of email + login_payload = { + "email": test_user["email"].upper(), + "password": test_user["password"], + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert - Should fail if email is case-sensitive + assert response.status_code == 401 + assert response.json()["detail"] == "Invalid username or password" + + async def test_login_with_extra_whitespace_in_email(self, test_client: AsyncClient, test_user: dict): + """Test that extra whitespace in email is handled correctly.""" + # Arrange - Add whitespace around email + login_payload = { + "email": f" {test_user['email']} ", + "password": test_user["password"], + } + + # Act + response = await test_client.post("/api/auth/login", json=login_payload) + + # Assert - Pydantic EmailStr should strip whitespace, so this should succeed + # If it doesn't, you may need to add explicit validation + assert response.status_code in [200, 401] # Depends on implementation + + async def test_login_endpoint_uses_post_method(self, test_client: AsyncClient): + """Test that login endpoint only accepts POST requests.""" + # Act - Try GET request + response = await test_client.get("/api/auth/login") + + # Assert - Should return 405 Method Not Allowed + assert response.status_code == 405 + + async def test_concurrent_logins_different_users(self, test_client: AsyncClient, test_user: dict, test_db_session): + """Test that multiple users can login concurrently without conflicts.""" + # Arrange - Create a second user + from app.context.user.infrastructure.models import UserModel + from app.shared.domain.value_objects import SharedPassword + + second_user_email = "seconduser@example.com" + second_user_password = "AnotherPassword123!" + second_user_hashed = SharedPassword.from_plain_text(second_user_password).value + + second_user_model = UserModel( + email=second_user_email, + password=second_user_hashed, + username="seconduser", + ) + test_db_session.add(second_user_model) + await test_db_session.commit() + + # Act - Login both users + response1 = await test_client.post( + "/api/auth/login", + json={"email": test_user["email"], "password": test_user["password"]}, + ) + + response2 = await test_client.post( + "/api/auth/login", + json={"email": second_user_email, "password": second_user_password}, + ) + + # Assert - Both logins should succeed + assert response1.status_code == 200 + assert response2.status_code == 200 + + token1 = response1.cookies.get("access_token") + token2 = response2.cookies.get("access_token") + + assert token1 is not None + assert token2 is not None + # Tokens should be different for different users + assert token1 != token2 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/__init__.py b/tests/unit/context/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/auth/__init__.py b/tests/unit/context/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/auth/application/__init__.py b/tests/unit/context/auth/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/auth/application/get_session_handler_test.py b/tests/unit/context/auth/application/get_session_handler_test.py new file mode 100644 index 0000000..0d5a838 --- /dev/null +++ b/tests/unit/context/auth/application/get_session_handler_test.py @@ -0,0 +1,208 @@ +from datetime import datetime, timedelta + +import pytest + +from app.context.auth.application.dto.get_session_result_dto import GetSessionResultDTO +from app.context.auth.application.handlers.get_session_handler import GetSessionHandler +from app.context.auth.application.queries import GetSessionQuery +from app.context.auth.domain.dto.session_dto import SessionDTO +from app.context.auth.domain.value_objects import ( + AuthUserID, + FailedLoginAttempts, + SessionToken, +) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestGetSessionHandler: + """Unit tests for GetSessionHandler.""" + + async def test_handle_with_user_id_returns_session(self, mock_session_repository): + """Test that handler returns session when found by user_id.""" + # Arrange + user_id = 1 + token_value = "test-token-123" + blocked_time = datetime.now() + timedelta(minutes=5) + + expected_session = SessionDTO( + user_id=AuthUserID(user_id), + token=SessionToken(token_value), + failed_attempts=FailedLoginAttempts(2), + blocked_until=BlockedTime(blocked_time), + ) + + mock_session_repository.get_session_mock.return_value = expected_session + handler = GetSessionHandler(mock_session_repository) + query = GetSessionQuery(user_id=user_id) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert isinstance(result, GetSessionResultDTO) + assert result.user_id == user_id + assert result.token == token_value + assert result.failed_attempts == 2 + assert result.blocked_until == blocked_time.isoformat() + + # Verify repository was called with correct parameters + mock_session_repository.get_session_mock.assert_called_once() + call_args = mock_session_repository.get_session_mock.call_args + assert call_args.kwargs["user_id"] == AuthUserID(user_id) + assert call_args.kwargs["token"] is None + + async def test_handle_with_token_returns_session(self, mock_session_repository): + """Test that handler returns session when found by token.""" + # Arrange + user_id = 42 + token_value = "secure-token-xyz" + + expected_session = SessionDTO( + user_id=AuthUserID(user_id), + token=SessionToken(token_value), + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + + mock_session_repository.get_session_mock.return_value = expected_session + handler = GetSessionHandler(mock_session_repository) + query = GetSessionQuery(token=token_value) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert isinstance(result, GetSessionResultDTO) + assert result.user_id == user_id + assert result.token == token_value + assert result.failed_attempts == 0 + assert result.blocked_until is None + + # Verify repository was called with correct parameters + mock_session_repository.get_session_mock.assert_called_once() + call_args = mock_session_repository.get_session_mock.call_args + assert call_args.kwargs["user_id"] is None + assert call_args.kwargs["token"] == SessionToken(token_value) + + async def test_handle_with_both_user_id_and_token(self, mock_session_repository): + """Test that handler works when both user_id and token are provided.""" + # Arrange + user_id = 99 + token_value = "combined-token-456" + + expected_session = SessionDTO( + user_id=AuthUserID(user_id), + token=SessionToken(token_value), + failed_attempts=FailedLoginAttempts(3), + blocked_until=None, + ) + + mock_session_repository.get_session_mock.return_value = expected_session + handler = GetSessionHandler(mock_session_repository) + query = GetSessionQuery(user_id=user_id, token=token_value) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.user_id == user_id + assert result.token == token_value + assert result.failed_attempts == 3 + + # Verify both parameters were passed + call_args = mock_session_repository.get_session_mock.call_args + assert call_args.kwargs["user_id"] == AuthUserID(user_id) + assert call_args.kwargs["token"] == SessionToken(token_value) + + async def test_handle_returns_none_when_session_not_found(self, mock_session_repository): + """Test that handler returns None when repository returns None.""" + # Arrange + mock_session_repository.get_session_mock.return_value = None + handler = GetSessionHandler(mock_session_repository) + query = GetSessionQuery(user_id=999) + + # Act + result = await handler.handle(query) + + # Assert + assert result is None + mock_session_repository.get_session_mock.assert_called_once() + + async def test_handle_with_no_token_in_session(self, mock_session_repository): + """Test that handler correctly handles session with None token.""" + # Arrange + user_id = 10 + + expected_session = SessionDTO( + user_id=AuthUserID(user_id), + token=None, # No token assigned yet + failed_attempts=FailedLoginAttempts(1), + blocked_until=None, + ) + + mock_session_repository.get_session_mock.return_value = expected_session + handler = GetSessionHandler(mock_session_repository) + query = GetSessionQuery(user_id=user_id) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.user_id == user_id + assert result.token is None + assert result.failed_attempts == 1 + assert result.blocked_until is None + + async def test_handle_with_empty_query(self, mock_session_repository): + """Test that handler works with empty query (both fields None).""" + # Arrange + mock_session_repository.get_session_mock.return_value = None + handler = GetSessionHandler(mock_session_repository) + query = GetSessionQuery() + + # Act + result = await handler.handle(query) + + # Assert + assert result is None + + # Verify repository was called with None values + call_args = mock_session_repository.get_session_mock.call_args + assert call_args.kwargs["user_id"] is None + assert call_args.kwargs["token"] is None + + async def test_handle_converts_value_objects_correctly(self, mock_session_repository): + """Test that handler correctly converts domain value objects to primitives.""" + # Arrange + user_id = 555 + token_value = "value-object-test-token" + blocked_time = datetime(2025, 12, 25, 10, 30, 0) + + expected_session = SessionDTO( + user_id=AuthUserID(user_id), + token=SessionToken(token_value), + failed_attempts=FailedLoginAttempts(4), + blocked_until=BlockedTime(blocked_time), + ) + + mock_session_repository.get_session_mock.return_value = expected_session + handler = GetSessionHandler(mock_session_repository) + query = GetSessionQuery(user_id=user_id) + + # Act + result = await handler.handle(query) + + # Assert + # Verify all value objects are converted to primitives + assert result is not None + assert isinstance(result.user_id, int) + assert isinstance(result.token, str) + assert isinstance(result.failed_attempts, int) + assert isinstance(result.blocked_until, str) + assert result.blocked_until == blocked_time.isoformat() diff --git a/tests/unit/context/auth/application/login_handler_test.py b/tests/unit/context/auth/application/login_handler_test.py new file mode 100644 index 0000000..1957a31 --- /dev/null +++ b/tests/unit/context/auth/application/login_handler_test.py @@ -0,0 +1,271 @@ +from datetime import datetime, timedelta + +import pytest + +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.dto import ( + LoginHandlerResultDTO, + LoginHandlerResultStatus, +) +from app.context.auth.application.handlers.login_handler import LoginHandler +from app.context.auth.domain.exceptions import ( + AccountBlockedException, + InvalidCredentialsException, +) +from app.context.auth.domain.value_objects import SessionToken +from app.context.user.application.dto import FindUserResult + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestLoginHandler: + """Unit tests for LoginHandler.""" + + async def test_handle_successful_login(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test successful login returns token and user_id.""" + # Arrange + email = "test@example.com" + password = "password123" + user_id = 42 + hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" + token_value = "secure-session-token-xyz" + + mock_user = FindUserResult(user_id=user_id, email=email, password=hashed_password) + mock_find_user_handler.handle_mock.return_value = mock_user + mock_login_service.handle_mock.return_value = SessionToken(token_value) + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + result = await handler.handle(command) + + # Assert + assert result is not None + assert isinstance(result, LoginHandlerResultDTO) + assert result.status == LoginHandlerResultStatus.SUCCESS + assert result.token == token_value + assert result.user_id == user_id + assert result.error_msg is None + assert result.retry_after is None + + # Verify find user handler was called correctly + mock_find_user_handler.handle_mock.assert_called_once() + call_args = mock_find_user_handler.handle_mock.call_args[0][0] + assert call_args.email == email + + # Verify login service was called with correct parameters + mock_login_service.handle_mock.assert_called_once() + + async def test_handle_user_not_found(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test that login fails when user is not found.""" + # Arrange + email = "nonexistent@example.com" + password = "password123" + + mock_find_user_handler.handle_mock.return_value = None + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + result = await handler.handle(command) + + # Assert + assert result is not None + assert result.status == LoginHandlerResultStatus.INVALID_CREDENTIALS + assert result.error_msg == "Invalid username or password" + assert result.token is None + assert result.user_id is None + assert result.retry_after is None + + # Verify login service was NOT called since user not found + mock_login_service.handle_mock.assert_not_called() + + async def test_handle_invalid_credentials_exception(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test that InvalidCredentialsException is handled correctly.""" + # Arrange + email = "test@example.com" + password = "wrongpassword" + user_id = 10 + hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" + + mock_user = FindUserResult(user_id=user_id, email=email, password=hashed_password) + mock_find_user_handler.handle_mock.return_value = mock_user + mock_login_service.handle_mock.side_effect = InvalidCredentialsException() + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + result = await handler.handle(command) + + # Assert + assert result is not None + assert result.status == LoginHandlerResultStatus.INVALID_CREDENTIALS + assert result.error_msg == "Invalid username or password" + assert result.token is None + assert result.user_id is None + assert result.retry_after is None + + # Verify login service was called + mock_login_service.handle_mock.assert_called_once() + + async def test_handle_account_blocked_exception(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test that AccountBlockedException is handled correctly.""" + # Arrange + email = "blocked@example.com" + password = "password123" + user_id = 99 + hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" + blocked_until = datetime.now() + timedelta(minutes=15) + + mock_user = FindUserResult(user_id=user_id, email=email, password=hashed_password) + mock_find_user_handler.handle_mock.return_value = mock_user + mock_login_service.handle_mock.side_effect = AccountBlockedException(blocked_until) + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + result = await handler.handle(command) + + # Assert + assert result is not None + assert result.status == LoginHandlerResultStatus.ACCOUNT_BLOCKED + assert result.error_msg == "Account is blocked, try again later" + assert result.retry_after == blocked_until + assert result.token is None + assert result.user_id is None + + # Verify login service was called + mock_login_service.handle_mock.assert_called_once() + + async def test_handle_unexpected_error(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test that unexpected exceptions are handled gracefully.""" + # Arrange + email = "error@example.com" + password = "password123" + user_id = 50 + hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" + + mock_user = FindUserResult(user_id=user_id, email=email, password=hashed_password) + mock_find_user_handler.handle_mock.return_value = mock_user + mock_login_service.handle_mock.side_effect = RuntimeError("Unexpected error") + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + result = await handler.handle(command) + + # Assert + assert result is not None + assert result.status == LoginHandlerResultStatus.UNEXPECTED_ERROR + assert result.error_msg == "Unexpected error" + assert result.token is None + assert result.user_id is None + assert result.retry_after is None + + # Verify login service was called + mock_login_service.handle_mock.assert_called_once() + + async def test_handle_database_exception(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test that database-related exceptions are handled as unexpected errors.""" + # Arrange + email = "db-error@example.com" + password = "password123" + user_id = 25 + hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" + + mock_user = FindUserResult(user_id=user_id, email=email, password=hashed_password) + mock_find_user_handler.handle_mock.return_value = mock_user + mock_login_service.handle_mock.side_effect = Exception("Database connection lost") + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + result = await handler.handle(command) + + # Assert + assert result is not None + assert result.status == LoginHandlerResultStatus.UNEXPECTED_ERROR + assert result.error_msg == "Unexpected error" + + async def test_handle_passes_correct_auth_user_dto(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test that handler correctly constructs AuthUserDTO from UserContextDTO.""" + # Arrange + email = "dto-test@example.com" + password = "password123" + user_id = 777 + hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$correcthash" + + mock_user = FindUserResult(user_id=user_id, email=email, password=hashed_password) + mock_find_user_handler.handle_mock.return_value = mock_user + mock_login_service.handle_mock.return_value = SessionToken("test-token") + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + await handler.handle(command) + + # Assert - verify login service received correctly constructed AuthUserDTO + mock_login_service.handle_mock.assert_called_once() + call_kwargs = mock_login_service.handle_mock.call_args.kwargs + + # Verify user_password + assert call_kwargs["user_password"].value == password + + # Verify db_user structure + db_user = call_kwargs["db_user"] + assert db_user.user_id.value == user_id + assert db_user.email.value == email + assert db_user.password.value == hashed_password + + async def test_handle_with_different_user_scenarios(self, mock_find_user_handler, mock_login_service, mock_logger): + """Test login with various user scenarios.""" + # Arrange + test_cases = [ + { + "email": "admin@example.com", + "user_id": 1, + "description": "admin user", + }, + { + "email": "user@domain.co.uk", + "user_id": 9999, + "description": "high user id", + }, + { + "email": "test+tag@example.com", + "user_id": 123, + "description": "email with plus addressing", + }, + ] + + for test_case in test_cases: + # Reset mocks for each test case + mock_find_user_handler.handle_mock.reset_mock() + mock_login_service.handle_mock.reset_mock() + + email = test_case["email"] + user_id = test_case["user_id"] + password = "testpassword" + hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$hash" + + mock_user = FindUserResult(user_id=user_id, email=email, password=hashed_password) + mock_find_user_handler.handle_mock.return_value = mock_user + mock_login_service.handle_mock.return_value = SessionToken(f"token-{user_id}") + + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) + command = LoginCommand(email=email, password=password) + + # Act + result = await handler.handle(command) + + # Assert + assert result.status == LoginHandlerResultStatus.SUCCESS + assert result.user_id == user_id + assert result.token == f"token-{user_id}" diff --git a/tests/unit/context/auth/domain/__init__.py b/tests/unit/context/auth/domain/__init__.py new file mode 100644 index 0000000..24e62ec --- /dev/null +++ b/tests/unit/context/auth/domain/__init__.py @@ -0,0 +1 @@ +"""Auth domain layer unit tests.""" diff --git a/tests/unit/context/auth/domain/auth_email_test.py b/tests/unit/context/auth/domain/auth_email_test.py new file mode 100644 index 0000000..b71cfba --- /dev/null +++ b/tests/unit/context/auth/domain/auth_email_test.py @@ -0,0 +1,74 @@ +"""Unit tests for AuthEmail value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.auth.domain.value_objects import AuthEmail + + +@pytest.mark.unit +class TestAuthEmail: + """Tests for AuthEmail value object""" + + def test_valid_email_creation(self): + """Test creating valid email addresses""" + email = AuthEmail("user@example.com") + assert email.value == "user@example.com" + + def test_various_valid_email_formats(self): + """Test that various valid email formats are accepted""" + valid_emails = [ + "simple@example.com", + "user.name@example.com", + "user+tag@example.co.uk", + "test123@subdomain.example.org", + "test@domain.co", + ] + for email_str in valid_emails: + email = AuthEmail(email_str) + assert email.value == email_str + + def test_invalid_email_missing_at_symbol_raises_error(self): + """Test that email without @ raises ValueError""" + with pytest.raises(ValueError, match="Invalid email"): + AuthEmail("notanemail.com") + + def test_invalid_email_missing_domain_raises_error(self): + """Test that email without domain raises ValueError""" + with pytest.raises(ValueError, match="Invalid email"): + AuthEmail("user@") + + def test_invalid_email_missing_local_part_raises_error(self): + """Test that email without local part raises ValueError""" + with pytest.raises(ValueError, match="Invalid email"): + AuthEmail("@example.com") + + def test_invalid_email_empty_string_raises_error(self): + """Test that empty string raises ValueError""" + with pytest.raises(ValueError, match="Email cannot be empty"): + AuthEmail("") + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid email + email = AuthEmail.from_trusted_source("not-a-valid-email") + assert email.value == "not-a-valid-email" + + def test_immutability(self): + """Test that value object is immutable""" + email = AuthEmail("test@example.com") + with pytest.raises(FrozenInstanceError): + email.value = "changed@example.com" + + def test_equality(self): + """Test that two AuthEmail objects with same value are equal""" + email1 = AuthEmail("same@example.com") + email2 = AuthEmail("same@example.com") + assert email1 == email2 + + def test_inequality(self): + """Test that two AuthEmail objects with different values are not equal""" + email1 = AuthEmail("user1@example.com") + email2 = AuthEmail("user2@example.com") + assert email1 != email2 diff --git a/tests/unit/context/auth/domain/auth_password_test.py b/tests/unit/context/auth/domain/auth_password_test.py new file mode 100644 index 0000000..1101ae0 --- /dev/null +++ b/tests/unit/context/auth/domain/auth_password_test.py @@ -0,0 +1,84 @@ +"""Unit tests for AuthPassword value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.auth.domain.value_objects import AuthPassword + + +@pytest.mark.unit +class TestAuthPassword: + """Tests for AuthPassword value object""" + + def test_from_plain_text_creates_hashed_password(self): + """Test that from_plain_text creates a hashed password""" + plain_password = "SecurePassword123" + password = AuthPassword.from_plain_text(plain_password) + + # Verify it's hashed (Argon2 hashes start with $argon2) + assert password.value.startswith("$argon2") + assert password.value != plain_password + + def test_from_hash_creates_password_from_existing_hash(self): + """Test that from_hash creates password from existing hash""" + existing_hash = "$argon2id$v=19$m=65536,t=3,p=4$somehash" + password = AuthPassword.from_hash(existing_hash) + + assert password.value == existing_hash + + def test_verify_correct_password_returns_true(self): + """Test that verify returns True for correct password""" + plain_password = "MyPassword123" + hashed_password = AuthPassword.from_plain_text(plain_password) + + # Verify with correct password + assert hashed_password.verify(plain_password) is True + + def test_verify_incorrect_password_returns_false(self): + """Test that verify returns False for incorrect password""" + plain_password = "MyPassword123" + wrong_password = "WrongPassword456" + hashed_password = AuthPassword.from_plain_text(plain_password) + + # Verify with wrong password + assert hashed_password.verify(wrong_password) is False + + def test_different_hashes_for_same_password(self): + """Test that hashing same password twice produces different hashes (due to salt)""" + plain_password = "SamePassword" + password1 = AuthPassword.from_plain_text(plain_password) + password2 = AuthPassword.from_plain_text(plain_password) + + # Hashes should be different due to random salt + assert password1.value != password2.value + + # But both should verify the same password + assert password1.verify(plain_password) is True + assert password2.verify(plain_password) is True + + def test_keep_plain_creates_unhashed_password(self): + """Test that keep_plain creates password without hashing""" + plain_password = "PlainPassword123" + password = AuthPassword.keep_plain(plain_password) + + # Value should be the plain text (used for comparison during login) + assert password.value == plain_password + + def test_immutability(self): + """Test that value object is immutable""" + password = AuthPassword.from_plain_text("test123") + with pytest.raises(FrozenInstanceError): + password.value = "changed" + + def test_verify_with_empty_password_returns_false(self): + """Test that verify returns False for empty password""" + hashed_password = AuthPassword.from_plain_text("MyPassword123") + assert hashed_password.verify("") is False + + def test_from_hash_accepts_any_string(self): + """Test that from_hash accepts any string without validation""" + # from_hash is used for database values, so it doesn't validate + raw_value = "raw-unhashed-value" + password = AuthPassword.from_hash(raw_value) + assert password.value == raw_value diff --git a/tests/unit/context/auth/domain/auth_user_id_test.py b/tests/unit/context/auth/domain/auth_user_id_test.py new file mode 100644 index 0000000..9935409 --- /dev/null +++ b/tests/unit/context/auth/domain/auth_user_id_test.py @@ -0,0 +1,68 @@ +"""Unit tests for AuthUserID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.auth.domain.value_objects import AuthUserID + + +@pytest.mark.unit +class TestAuthUserID: + """Tests for AuthUserID value object""" + + def test_valid_user_id_creation(self): + """Test creating valid user IDs""" + user_id = AuthUserID(42) + assert user_id.value == 42 + + def test_various_valid_user_ids(self): + """Test that various valid user IDs are accepted""" + valid_ids = [1, 100, 999, 123456, 9999999] + for id_value in valid_ids: + user_id = AuthUserID(id_value) + assert user_id.value == id_value + + def test_zero_user_id(self): + """Test that zero is a valid user ID""" + user_id = AuthUserID(0) + assert user_id.value == 0 + + def test_immutability(self): + """Test that value object is immutable""" + user_id = AuthUserID(123) + with pytest.raises(FrozenInstanceError): + user_id.value = 456 + + def test_equality(self): + """Test that two AuthUserID objects with same value are equal""" + user_id1 = AuthUserID(42) + user_id2 = AuthUserID(42) + assert user_id1 == user_id2 + + def test_inequality(self): + """Test that two AuthUserID objects with different values are not equal""" + user_id1 = AuthUserID(1) + user_id2 = AuthUserID(2) + assert user_id1 != user_id2 + + def test_hash_consistency(self): + """Test that hash is consistent for same value""" + user_id1 = AuthUserID(100) + user_id2 = AuthUserID(100) + assert hash(user_id1) == hash(user_id2) + + def test_can_be_used_in_set(self): + """Test that AuthUserID can be used in sets""" + user_ids = {AuthUserID(1), AuthUserID(2), AuthUserID(1)} + # Should only have 2 unique values + assert len(user_ids) == 2 + + def test_can_be_used_as_dict_key(self): + """Test that AuthUserID can be used as dictionary key""" + user_dict = { + AuthUserID(1): "User One", + AuthUserID(2): "User Two", + } + assert user_dict[AuthUserID(1)] == "User One" + assert user_dict[AuthUserID(2)] == "User Two" diff --git a/tests/unit/context/auth/domain/blocked_time_test.py b/tests/unit/context/auth/domain/blocked_time_test.py new file mode 100644 index 0000000..85b108d --- /dev/null +++ b/tests/unit/context/auth/domain/blocked_time_test.py @@ -0,0 +1,131 @@ +"""Unit tests for BlockedTime value object""" + +from datetime import datetime, timedelta + +import pytest + +from app.context.auth.domain.value_objects.blocked_time import BlockedTime + + +@pytest.mark.unit +class TestBlockedTime: + """Tests for BlockedTime value object""" + + def test_creation_with_datetime(self): + """Test creating BlockedTime with specific datetime""" + future_time = datetime.now() + timedelta(minutes=10) + blocked_time = BlockedTime(future_time) + + assert blocked_time.value == future_time + + def test_set_blocked_creates_future_time(self): + """Test that setBlocked creates a time 15 minutes in the future""" + before = datetime.now() + blocked_time = BlockedTime.setBlocked() + after = datetime.now() + + # Blocked time should be approximately 15 minutes from now + expected_min = before + timedelta(minutes=14, seconds=55) + expected_max = after + timedelta(minutes=15, seconds=5) + + assert expected_min <= blocked_time.value <= expected_max + + def test_set_blocked_uses_correct_duration(self): + """Test that setBlocked uses BLOCK_MINUTES constant""" + blocked_time = BlockedTime.setBlocked() + now = datetime.now() + + # Should be approximately BLOCK_MINUTES (15) in the future + time_diff = blocked_time.value - now + assert 14.9 <= time_diff.total_seconds() / 60 <= 15.1 + + def test_is_over_returns_false_for_future_time(self): + """Test that isOver returns False when block is still active""" + future_time = datetime.now() + timedelta(minutes=10) + blocked_time = BlockedTime(future_time) + + assert blocked_time.isOver() is False + + def test_is_over_returns_true_for_past_time(self): + """Test that isOver returns True when block has expired""" + past_time = datetime.now() - timedelta(minutes=10) + blocked_time = BlockedTime(past_time) + + assert blocked_time.isOver() is True + + def test_is_over_returns_true_for_current_time(self): + """Test that isOver returns True for time that just passed""" + # This is a race condition test - time just before now should be over + almost_now = datetime.now() - timedelta(microseconds=1) + blocked_time = BlockedTime(almost_now) + + assert blocked_time.isOver() is True + + def test_to_string_returns_iso_format(self): + """Test that toString returns ISO formatted datetime string""" + test_time = datetime(2025, 12, 25, 15, 30, 45) + blocked_time = BlockedTime(test_time) + + iso_string = blocked_time.toString() + + assert isinstance(iso_string, str) + assert iso_string == test_time.isoformat() + + def test_to_string_can_be_parsed_back(self): + """Test that toString output can be parsed back to datetime""" + original_time = datetime.now() + blocked_time = BlockedTime(original_time) + + iso_string = blocked_time.toString() + parsed_time = datetime.fromisoformat(iso_string) + + # Times should be equal (accounting for microseconds) + assert abs((parsed_time - original_time).total_seconds()) < 0.001 + + def test_block_minutes_constant(self): + """Test that BLOCK_MINUTES constant is set correctly""" + assert BlockedTime.BLOCK_MINUTES == 15 + + def test_equality(self): + """Test that two BlockedTime objects with same value are equal""" + time_value = datetime(2025, 12, 25, 10, 0, 0) + blocked1 = BlockedTime(time_value) + blocked2 = BlockedTime(time_value) + + assert blocked1 == blocked2 + + def test_inequality(self): + """Test that two BlockedTime objects with different values are not equal""" + time1 = datetime(2025, 12, 25, 10, 0, 0) + time2 = datetime(2025, 12, 25, 11, 0, 0) + blocked1 = BlockedTime(time1) + blocked2 = BlockedTime(time2) + + assert blocked1 != blocked2 + + def test_expired_block_scenario(self): + """Test a complete scenario: block set, time passes, becomes expired""" + # Set a block for 15 minutes + blocked_time = BlockedTime.setBlocked() + + # Should not be over now + assert blocked_time.isOver() is False + + # Simulate time passing (create new block in the past) + past_block = BlockedTime(datetime.now() - timedelta(seconds=1)) + + # Should be over + assert past_block.isOver() is True + + def test_active_block_scenario(self): + """Test a complete scenario: block is still active""" + # Create block that expires 5 minutes from now + future_block = BlockedTime(datetime.now() + timedelta(minutes=5)) + + # Should not be over + assert future_block.isOver() is False + + # ISO string should be a future time + iso_string = future_block.toString() + assert iso_string is not None + assert len(iso_string) > 0 diff --git a/tests/unit/context/auth/domain/failed_login_attempts_test.py b/tests/unit/context/auth/domain/failed_login_attempts_test.py new file mode 100644 index 0000000..2660813 --- /dev/null +++ b/tests/unit/context/auth/domain/failed_login_attempts_test.py @@ -0,0 +1,109 @@ +"""Unit tests for FailedLoginAttempts value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.auth.domain.value_objects import FailedLoginAttempts + + +@pytest.mark.unit +class TestFailedLoginAttempts: + """Tests for FailedLoginAttempts value object""" + + def test_zero_attempts_creation(self): + """Test creating attempts with zero value""" + attempts = FailedLoginAttempts(0) + assert attempts.value == 0 + + def test_various_attempt_counts(self): + """Test creating attempts with various values""" + for count in [0, 1, 2, 3, 4, 5]: + attempts = FailedLoginAttempts(count) + assert attempts.value == count + + def test_has_reach_max_attempts_returns_false_below_max(self): + """Test that hasReachMaxAttempts returns False below max""" + # Max is 4, so 0-3 should return False + for count in [0, 1, 2, 3]: + attempts = FailedLoginAttempts(count) + assert attempts.hasReachMaxAttempts() is False + + def test_has_reach_max_attempts_returns_true_at_max(self): + """Test that hasReachMaxAttempts returns True at max (4)""" + attempts = FailedLoginAttempts(4) + assert attempts.hasReachMaxAttempts() is True + + def test_has_reach_max_attempts_returns_true_above_max(self): + """Test that hasReachMaxAttempts returns True above max""" + attempts = FailedLoginAttempts(5) + assert attempts.hasReachMaxAttempts() is True + + def test_get_attempt_delay_first_attempt(self): + """Test that first attempt (0) has no delay""" + attempts = FailedLoginAttempts(0) + assert attempts.getAttemptDelay() == 0 + + def test_get_attempt_delay_second_attempt(self): + """Test that second attempt (1) has no delay""" + attempts = FailedLoginAttempts(1) + assert attempts.getAttemptDelay() == 0 + + def test_get_attempt_delay_third_attempt(self): + """Test that third attempt (2) has 2 second delay""" + attempts = FailedLoginAttempts(2) + assert attempts.getAttemptDelay() == 2 + + def test_get_attempt_delay_fourth_attempt(self): + """Test that fourth attempt (3) has 4 second delay""" + attempts = FailedLoginAttempts(3) + assert attempts.getAttemptDelay() == 4 + + def test_get_attempt_delay_beyond_max(self): + """Test that attempts beyond max return 4 second delay""" + # Attempts 4 and beyond should return 4 seconds + for count in [4, 5, 10, 100]: + attempts = FailedLoginAttempts(count) + assert attempts.getAttemptDelay() == 4 + + def test_reset_creates_zero_attempts(self): + """Test that reset() creates FailedLoginAttempts with zero value""" + attempts = FailedLoginAttempts.reset() + assert attempts.value == 0 + assert attempts.hasReachMaxAttempts() is False + assert attempts.getAttemptDelay() == 0 + + def test_immutability(self): + """Test that value object is immutable""" + attempts = FailedLoginAttempts(2) + with pytest.raises(FrozenInstanceError): + attempts.value = 3 + + def test_equality(self): + """Test that two FailedLoginAttempts with same value are equal""" + attempts1 = FailedLoginAttempts(3) + attempts2 = FailedLoginAttempts(3) + assert attempts1 == attempts2 + + def test_inequality(self): + """Test that two FailedLoginAttempts with different values are not equal""" + attempts1 = FailedLoginAttempts(1) + attempts2 = FailedLoginAttempts(2) + assert attempts1 != attempts2 + + def test_progressive_delays(self): + """Test that delays increase progressively""" + delays = [FailedLoginAttempts(i).getAttemptDelay() for i in range(5)] + + # Delays should be: [0, 0, 2, 4, 4] + assert delays == [0, 0, 2, 4, 4] + + def test_max_attempts_threshold(self): + """Test the exact threshold for max attempts""" + # Just below max + attempts_3 = FailedLoginAttempts(3) + assert attempts_3.hasReachMaxAttempts() is False + + # At max + attempts_4 = FailedLoginAttempts(4) + assert attempts_4.hasReachMaxAttempts() is True diff --git a/tests/unit/context/auth/domain/login_service_test.py b/tests/unit/context/auth/domain/login_service_test.py new file mode 100644 index 0000000..c7d3af6 --- /dev/null +++ b/tests/unit/context/auth/domain/login_service_test.py @@ -0,0 +1,476 @@ +"""Unit tests for LoginService.""" + +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch + +import pytest + +from app.context.auth.domain.dto import AuthUserDTO, SessionDTO +from app.context.auth.domain.exceptions import ( + AccountBlockedException, + InvalidCredentialsException, +) +from app.context.auth.domain.services.login_service import LoginService +from app.context.auth.domain.value_objects import ( + AuthEmail, + AuthPassword, + AuthUserID, + FailedLoginAttempts, + SessionToken, +) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestLoginService: + """Unit tests for LoginService domain service.""" + + async def test_successful_login_no_existing_session(self, mock_session_repository, mock_logger): + """Test successful login when no session exists - creates new session.""" + # Arrange + user_id = AuthUserID(42) + plain_password = "correct_password" + hashed_password = AuthPassword.from_plain_text(plain_password) + user_password = AuthPassword.keep_plain(plain_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("test@example.com"), + password=hashed_password, + ) + + # Mock repository to return None (no session exists) + mock_session_repository.get_session_mock.return_value = None + + # Mock createSession to return new session + new_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + mock_session_repository.create_session_mock.return_value = new_session + + # Mock updateSession to return updated session + mock_session_repository.update_session_mock.return_value = SessionDTO( + user_id=user_id, + token=SessionToken("mocked-token"), + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + + service = LoginService(mock_session_repository, mock_logger) + + # Act + with patch.object(SessionToken, "generate", return_value=SessionToken("mocked-token")): + result = await service.handle(user_password, db_user) + + # Assert + assert isinstance(result, SessionToken) + assert result.value == "mocked-token" + + # Verify getSession was called + # mock_session_repository.get_session_mock.assert_called_once() + mock_session_repository.get_session_mock.assert_called_once_with(user_id=user_id, token=None) + + # Verify createSession was called + mock_session_repository.create_session_mock.assert_called_once() + created_session = mock_session_repository.create_session_mock.call_args[0][0] + assert created_session.user_id == user_id + assert created_session.token is None + assert created_session.failed_attempts.value == 0 + assert created_session.blocked_until is None + + # Verify updateSession was called with token and reset attempts + mock_session_repository.update_session_mock.assert_called_once() + updated_session = mock_session_repository.update_session_mock.call_args[0][0] + assert updated_session.user_id == user_id + assert updated_session.token.value == "mocked-token" + assert updated_session.failed_attempts.value == 0 + assert updated_session.blocked_until is None + + async def test_successful_login_with_existing_session(self, mock_session_repository, mock_logger): + """Test successful login when session already exists.""" + # Arrange + user_id = AuthUserID(99) + plain_password = "secure_password" + hashed_password = AuthPassword.from_plain_text(plain_password) + user_password = AuthPassword.keep_plain(plain_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("existing@example.com"), + password=hashed_password, + ) + + # Mock existing session + existing_session = SessionDTO( + user_id=user_id, + token=SessionToken("old-token"), + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act + with patch.object(SessionToken, "generate", return_value=SessionToken("new-token")): + result = await service.handle(user_password, db_user) + + # Assert + assert isinstance(result, SessionToken) + assert result.value == "new-token" + + # Verify createSession was NOT called + mock_session_repository.create_session_mock.assert_not_called() + + # Verify updateSession was called + mock_session_repository.update_session_mock.assert_called_once() + + async def test_failed_login_wrong_password_first_attempt(self, mock_session_repository, mock_logger): + """Test failed login increments attempt counter on first wrong password.""" + # Arrange + user_id = AuthUserID(10) + correct_password = "correct_password" + wrong_password = "wrong_password" + hashed_password = AuthPassword.from_plain_text(correct_password) + user_password = AuthPassword.keep_plain(wrong_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("user@example.com"), + password=hashed_password, + ) + + # Mock existing session with 0 failed attempts + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act & Assert + with pytest.raises(InvalidCredentialsException): + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await service.handle(user_password, db_user) + + # Verify sleep was called with correct delay (0 for first attempt) + mock_sleep.assert_called_once_with(0) + + # Verify updateSession was called with incremented attempts + mock_session_repository.update_session_mock.assert_called_once() + updated_session = mock_session_repository.update_session_mock.call_args[0][0] + assert updated_session.failed_attempts.value == 1 + assert updated_session.blocked_until is None + assert updated_session.token is None + + async def test_failed_login_third_attempt_with_delay(self, mock_session_repository, mock_logger): + """Test failed login on third attempt has correct delay.""" + # Arrange + user_id = AuthUserID(15) + correct_password = "correct_password" + wrong_password = "wrong_password" + hashed_password = AuthPassword.from_plain_text(correct_password) + user_password = AuthPassword.keep_plain(wrong_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("delayed@example.com"), + password=hashed_password, + ) + + # Mock session with 2 failed attempts (next will be 3rd) + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(2), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act & Assert + with pytest.raises(InvalidCredentialsException): + with patch("asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + await service.handle(user_password, db_user) + + # Verify sleep was called with 2 seconds (3rd attempt delay) + mock_sleep.assert_called_once_with(2) + + # Verify attempts incremented to 3 + updated_session = mock_session_repository.update_session_mock.call_args[0][0] + assert updated_session.failed_attempts.value == 3 + + async def test_failed_login_max_attempts_blocks_account(self, mock_session_repository, mock_logger): + """Test that reaching max attempts blocks the account.""" + # Arrange + user_id = AuthUserID(20) + correct_password = "correct_password" + wrong_password = "wrong_password" + hashed_password = AuthPassword.from_plain_text(correct_password) + user_password = AuthPassword.keep_plain(wrong_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("blocked@example.com"), + password=hashed_password, + ) + + # Mock session with 3 failed attempts (next will be 4th = max) + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(3), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act & Assert + with pytest.raises(InvalidCredentialsException): + with patch("asyncio.sleep", new_callable=AsyncMock): + await service.handle(user_password, db_user) + + # Verify updateSession was called with blocked_until set + updated_session = mock_session_repository.update_session_mock.call_args[0][0] + assert updated_session.failed_attempts.value == 4 + assert updated_session.blocked_until is not None + assert isinstance(updated_session.blocked_until, BlockedTime) + # Verify blocked time is in the future + assert updated_session.blocked_until.value > datetime.now() + + async def test_login_blocked_account_raises_exception(self, mock_session_repository, mock_logger): + """Test that login attempt on blocked account raises AccountBlockedException.""" + # Arrange + user_id = AuthUserID(25) + plain_password = "any_password" + hashed_password = AuthPassword.from_plain_text(plain_password) + user_password = AuthPassword.keep_plain(plain_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("blocked@example.com"), + password=hashed_password, + ) + + # Mock session with active block + blocked_until = datetime.now() + timedelta(minutes=10) + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(4), + blocked_until=BlockedTime(blocked_until), + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act & Assert + with pytest.raises(AccountBlockedException) as exc_info: + await service.handle(user_password, db_user) + + # Verify exception details + assert exc_info.value.blocked_until == blocked_until + assert "blocked until" in str(exc_info.value).lower() + + # Verify updateSession was NOT called (blocked before password check) + mock_session_repository.update_session_mock.assert_not_called() + + async def test_login_expired_block_allows_login(self, mock_session_repository, mock_logger): + """Test that expired block allows successful login.""" + # Arrange + user_id = AuthUserID(30) + plain_password = "correct_password" + hashed_password = AuthPassword.from_plain_text(plain_password) + user_password = AuthPassword.keep_plain(plain_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("unblocked@example.com"), + password=hashed_password, + ) + + # Mock session with expired block (in the past) + blocked_until = datetime.now() - timedelta(minutes=1) + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(4), + blocked_until=BlockedTime(blocked_until), + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act + with patch.object(SessionToken, "generate", return_value=SessionToken("unblock-token")): + result = await service.handle(user_password, db_user) + + # Assert + assert isinstance(result, SessionToken) + assert result.value == "unblock-token" + + # Verify session was updated with reset attempts and cleared block + updated_session = mock_session_repository.update_session_mock.call_args[0][0] + assert updated_session.failed_attempts.value == 0 + assert updated_session.blocked_until is None + assert updated_session.token.value == "unblock-token" + + async def test_successful_login_resets_failed_attempts(self, mock_session_repository, mock_logger): + """Test that successful login resets failed attempts counter.""" + # Arrange + user_id = AuthUserID(35) + plain_password = "correct_password" + hashed_password = AuthPassword.from_plain_text(plain_password) + user_password = AuthPassword.keep_plain(plain_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("reset@example.com"), + password=hashed_password, + ) + + # Mock session with some failed attempts + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(2), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act + with patch.object(SessionToken, "generate", return_value=SessionToken("reset-token")): + result = await service.handle(user_password, db_user) + + # Assert + assert isinstance(result, SessionToken) + + # Verify failed attempts were reset to 0 + updated_session = mock_session_repository.update_session_mock.call_args[0][0] + assert updated_session.failed_attempts.value == 0 + assert updated_session.blocked_until is None + assert updated_session.token is not None + + async def test_password_verification_called_correctly(self, mock_session_repository, mock_logger): + """Test that password verification is called with correct parameters.""" + # Arrange + user_id = AuthUserID(40) + plain_password = "test_password" + hashed_password = AuthPassword.from_plain_text(plain_password) + user_password = AuthPassword.keep_plain(plain_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("verify@example.com"), + password=hashed_password, + ) + + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act + with patch.object(SessionToken, "generate", return_value=SessionToken("verify-token")): + # Mock the password verify method to track calls + with patch.object(AuthPassword, "verify", return_value=True) as mock_verify: + await service.handle(user_password, db_user) + + # Verify password.verify was called with user_password.value + mock_verify.assert_called_once_with(plain_password) + + async def test_session_token_generation(self, mock_session_repository, mock_logger): + """Test that session token is generated on successful login.""" + # Arrange + user_id = AuthUserID(45) + plain_password = "password" + hashed_password = AuthPassword.from_plain_text(plain_password) + user_password = AuthPassword.keep_plain(plain_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("token@example.com"), + password=hashed_password, + ) + + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + service = LoginService(mock_session_repository, mock_logger) + + # Act + generated_token = SessionToken("unique-secure-token-xyz") + with patch.object(SessionToken, "generate", return_value=generated_token) as mock_generate: + result = await service.handle(user_password, db_user) + + # Assert token generation was called + mock_generate.assert_called_once() + assert result == generated_token + + async def test_multiple_failed_attempts_sequence(self, mock_session_repository, mock_logger): + """Test sequence of multiple failed login attempts.""" + # Arrange + user_id = AuthUserID(50) + correct_password = "correct" + wrong_password = "wrong" + hashed_password = AuthPassword.from_plain_text(correct_password) + + db_user = AuthUserDTO( + user_id=user_id, + email=AuthEmail("sequence@example.com"), + password=hashed_password, + ) + + service = LoginService(mock_session_repository, mock_logger) + + # Test attempts 1-4 + for attempt in range(4): + # Reset mock for each iteration + mock_session_repository.update_session_mock.reset_mock() + + # Mock session with current attempt count + existing_session = SessionDTO( + user_id=user_id, + token=None, + failed_attempts=FailedLoginAttempts(attempt), + blocked_until=None, + ) + mock_session_repository.get_session_mock.return_value = existing_session + + user_password = AuthPassword.keep_plain(wrong_password) + + # Act & Assert + with pytest.raises(InvalidCredentialsException): + with patch("asyncio.sleep", new_callable=AsyncMock): + await service.handle(user_password, db_user) + + # Verify attempt count increased + updated_session = mock_session_repository.update_session_mock.call_args[0][0] + assert updated_session.failed_attempts.value == attempt + 1 + + # Check if blocked on 4th attempt + if attempt + 1 >= 4: + assert updated_session.blocked_until is not None + else: + assert updated_session.blocked_until is None diff --git a/tests/unit/context/auth/domain/session_token_test.py b/tests/unit/context/auth/domain/session_token_test.py new file mode 100644 index 0000000..68af1a8 --- /dev/null +++ b/tests/unit/context/auth/domain/session_token_test.py @@ -0,0 +1,83 @@ +"""Unit tests for SessionToken value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.auth.domain.value_objects import SessionToken + + +@pytest.mark.unit +class TestSessionToken: + """Tests for SessionToken value object""" + + def test_generate_creates_valid_token(self): + """Test that generate creates a valid token string""" + token = SessionToken.generate() + + assert token.value is not None + assert isinstance(token.value, str) + assert len(token.value) > 0 + + def test_generate_creates_unique_tokens(self): + """Test that generate creates unique tokens each time""" + token1 = SessionToken.generate() + token2 = SessionToken.generate() + token3 = SessionToken.generate() + + # All tokens should be unique + assert token1.value != token2.value + assert token2.value != token3.value + assert token1.value != token3.value + + def test_generate_creates_url_safe_tokens(self): + """Test that generated tokens are URL-safe""" + token = SessionToken.generate() + + # URL-safe tokens should not contain special characters like +, /, = + assert "+" not in token.value + assert "/" not in token.value + # Note: = padding is removed in urlsafe_b64encode + + def test_from_string_creates_token_from_existing_string(self): + """Test that from_string creates token from existing string""" + existing_token = "my-existing-token-123" + token = SessionToken.from_string(existing_token) + + assert token.value == existing_token + + def test_direct_construction(self): + """Test that token can be constructed directly with value""" + token_value = "direct-token-value" + token = SessionToken(value=token_value) + + assert token.value == token_value + + def test_immutability(self): + """Test that value object is immutable""" + token = SessionToken.generate() + with pytest.raises(FrozenInstanceError): + token.value = "modified-token" + + def test_equality(self): + """Test that two SessionToken objects with same value are equal""" + token_value = "same-token-value" + token1 = SessionToken(value=token_value) + token2 = SessionToken(value=token_value) + + assert token1 == token2 + + def test_inequality(self): + """Test that two SessionToken objects with different values are not equal""" + token1 = SessionToken.generate() + token2 = SessionToken.generate() + + assert token1 != token2 + + def test_generated_token_has_sufficient_entropy(self): + """Test that generated tokens have sufficient length for security""" + token = SessionToken.generate() + + # tokens generated with secrets.token_urlsafe(32) should be ~43 chars + # (32 bytes -> 256 bits of entropy) + assert len(token.value) >= 40 # Reasonable minimum for security diff --git a/tests/unit/context/auth/domain/throttle_time_test.py b/tests/unit/context/auth/domain/throttle_time_test.py new file mode 100644 index 0000000..9e638dd --- /dev/null +++ b/tests/unit/context/auth/domain/throttle_time_test.py @@ -0,0 +1,85 @@ +"""Unit tests for ThrottleTime value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.auth.domain.value_objects import FailedLoginAttempts +from app.context.auth.domain.value_objects.throttle_time import ThrottleTime + + +@pytest.mark.unit +class TestThrottleTime: + """Tests for ThrottleTime value object""" + + def test_creation_with_direct_value(self): + """Test creating ThrottleTime with direct value""" + throttle = ThrottleTime(value=5) + assert throttle.value == 5 + + def test_from_attempts_zero_attempts(self): + """Test that zero attempts results in 0 second throttle""" + attempts = FailedLoginAttempts(0) + throttle = ThrottleTime.fromAttempts(attempts) + + assert throttle.value == 0 + + def test_from_attempts_one_attempt(self): + """Test that one attempt results in 2 second throttle""" + attempts = FailedLoginAttempts(1) + throttle = ThrottleTime.fromAttempts(attempts) + + assert throttle.value == 2 + + def test_from_attempts_two_attempts(self): + """Test that two attempts results in 4 second throttle""" + attempts = FailedLoginAttempts(2) + throttle = ThrottleTime.fromAttempts(attempts) + + assert throttle.value == 4 + + def test_from_attempts_three_attempts(self): + """Test that three attempts results in 8 second throttle""" + attempts = FailedLoginAttempts(3) + throttle = ThrottleTime.fromAttempts(attempts) + + assert throttle.value == 8 + + def test_throttle_time_tuple(self): + """Test that throttle times match expected pattern""" + # _throttleTimeSeconds = (0, 2, 4, 8) + expected_times = [0, 2, 4, 8] + + for index, expected_time in enumerate(expected_times): + attempts = FailedLoginAttempts(index) + throttle = ThrottleTime.fromAttempts(attempts) + assert throttle.value == expected_time + + def test_immutability(self): + """Test that value object is immutable""" + throttle = ThrottleTime(value=2) + with pytest.raises(FrozenInstanceError): + throttle.value = 4 + + def test_equality(self): + """Test that two ThrottleTime objects with same value are equal""" + throttle1 = ThrottleTime(value=4) + throttle2 = ThrottleTime(value=4) + assert throttle1 == throttle2 + + def test_inequality(self): + """Test that two ThrottleTime objects with different values are not equal""" + throttle1 = ThrottleTime(value=2) + throttle2 = ThrottleTime(value=4) + assert throttle1 != throttle2 + + def test_progressive_throttling(self): + """Test that throttle times increase with more attempts""" + throttle_times = [ThrottleTime.fromAttempts(FailedLoginAttempts(i)).value for i in range(4)] + + # Should be [0, 2, 4, 8] + assert throttle_times == [0, 2, 4, 8] + + # Each should be greater than or equal to previous + for i in range(1, len(throttle_times)): + assert throttle_times[i] >= throttle_times[i - 1] diff --git a/tests/unit/context/auth/infrastructure/__init__.py b/tests/unit/context/auth/infrastructure/__init__.py new file mode 100644 index 0000000..222b6ff --- /dev/null +++ b/tests/unit/context/auth/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Auth infrastructure layer unit tests.""" diff --git a/tests/unit/context/auth/infrastructure/session_mapper_test.py b/tests/unit/context/auth/infrastructure/session_mapper_test.py new file mode 100644 index 0000000..8bdafd8 --- /dev/null +++ b/tests/unit/context/auth/infrastructure/session_mapper_test.py @@ -0,0 +1,258 @@ +"""Unit tests for SessionMapper""" + +from datetime import datetime, timedelta + +import pytest + +from app.context.auth.domain.dto import SessionDTO +from app.context.auth.domain.value_objects import ( + AuthUserID, + FailedLoginAttempts, + SessionToken, +) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime +from app.context.auth.infrastructure.mappers import SessionMapper +from app.context.auth.infrastructure.models import SessionModel + + +@pytest.mark.unit +class TestSessionMapper: + """Tests for SessionMapper""" + + def test_to_dto_with_complete_session_model(self): + """Test mapping complete SessionModel to SessionDTO""" + # Arrange + user_id = 42 + token_value = "test-token-123" + failed_attempts = 2 + blocked_until = datetime.now() + timedelta(minutes=10) + + model = SessionModel( + user_id=user_id, + token=token_value, + failed_attempts=failed_attempts, + blocked_until=blocked_until, + ) + + # Act + dto = SessionMapper.toDTO(model) + + # Assert + assert dto is not None + assert isinstance(dto, SessionDTO) + assert dto.user_id.value == user_id + assert dto.token.value == token_value + assert dto.failed_attempts.value == failed_attempts + assert dto.blocked_until.value == blocked_until + + def test_to_dto_with_none_token(self): + """Test mapping SessionModel with None token""" + # Arrange + model = SessionModel( + user_id=10, + token=None, # No token assigned yet + failed_attempts=1, + blocked_until=None, + ) + + # Act + dto = SessionMapper.toDTO(model) + + # Assert + assert dto is not None + assert dto.user_id.value == 10 + assert dto.token is None + assert dto.failed_attempts.value == 1 + assert dto.blocked_until is None + + def test_to_dto_with_none_blocked_until(self): + """Test mapping SessionModel with None blocked_until""" + # Arrange + model = SessionModel( + user_id=20, + token="active-token", + failed_attempts=0, + blocked_until=None, # Not blocked + ) + + # Act + dto = SessionMapper.toDTO(model) + + # Assert + assert dto is not None + assert dto.user_id.value == 20 + assert dto.token.value == "active-token" + assert dto.failed_attempts.value == 0 + assert dto.blocked_until is None + + def test_to_dto_with_none_model_returns_none(self): + """Test that toDTO returns None when model is None""" + # Act + dto = SessionMapper.toDTO(None) + + # Assert + assert dto is None + + def test_to_dto_creates_value_objects(self): + """Test that toDTO creates proper value objects""" + # Arrange + model = SessionModel( + user_id=99, + token="value-object-token", + failed_attempts=3, + blocked_until=datetime(2025, 12, 25, 10, 0, 0), + ) + + # Act + dto = SessionMapper.toDTO(model) + + # Assert + assert isinstance(dto.user_id, AuthUserID) + assert isinstance(dto.token, SessionToken) + assert isinstance(dto.failed_attempts, FailedLoginAttempts) + assert isinstance(dto.blocked_until, BlockedTime) + + def test_to_model_with_complete_session_dto(self): + """Test mapping complete SessionDTO to SessionModel""" + # Arrange + user_id = AuthUserID(42) + token = SessionToken("dto-token") + failed_attempts = FailedLoginAttempts(2) + blocked_until = BlockedTime(datetime(2025, 12, 25, 15, 30, 0)) + + dto = SessionDTO( + user_id=user_id, + token=token, + failed_attempts=failed_attempts, + blocked_until=blocked_until, + ) + + # Act + model = SessionMapper.toModel(dto) + + # Assert + assert isinstance(model, SessionModel) + assert model.user_id == 42 + assert model.token == "dto-token" + assert model.failed_attempts == 2 + assert model.blocked_until == blocked_until # BlockedTime object + + def test_to_model_with_none_token(self): + """Test mapping SessionDTO with None token""" + # Arrange + dto = SessionDTO( + user_id=AuthUserID(10), + token=None, + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + + # Act + model = SessionMapper.toModel(dto) + + # Assert + assert model.user_id == 10 + assert model.token is None + assert model.failed_attempts == 0 + assert model.blocked_until is None + + def test_to_model_extracts_primitives(self): + """Test that toModel extracts primitive values from value objects""" + # Arrange + dto = SessionDTO( + user_id=AuthUserID(999), + token=SessionToken("test-primitive-extraction"), + failed_attempts=FailedLoginAttempts(4), + blocked_until=None, + ) + + # Act + model = SessionMapper.toModel(dto) + + # Assert + # Verify primitives are extracted from value objects + assert isinstance(model.user_id, int) + assert isinstance(model.token, str) + assert isinstance(model.failed_attempts, int) + assert model.user_id == 999 + assert model.token == "test-primitive-extraction" + assert model.failed_attempts == 4 + + def test_round_trip_conversion(self): + """Test that converting model -> dto -> model preserves data""" + # Arrange + blocked_datetime = datetime(2025, 12, 25, 12, 0, 0) + original_model = SessionModel( + user_id=123, + token="round-trip-token", + failed_attempts=2, + blocked_until=blocked_datetime, + ) + + # Act + dto = SessionMapper.toDTO(original_model) + converted_model = SessionMapper.toModel(dto) + + # Assert + assert converted_model.user_id == original_model.user_id + assert converted_model.token == original_model.token + assert converted_model.failed_attempts == original_model.failed_attempts + # Note: toModel returns BlockedTime object, not datetime + assert isinstance(converted_model.blocked_until, BlockedTime) + assert converted_model.blocked_until.value == blocked_datetime + + def test_to_dto_with_zero_failed_attempts(self): + """Test mapping with zero failed attempts""" + # Arrange + model = SessionModel( + user_id=1, + token="zero-attempts", + failed_attempts=0, + blocked_until=None, + ) + + # Act + dto = SessionMapper.toDTO(model) + + # Assert + assert dto.failed_attempts.value == 0 + assert dto.failed_attempts.hasReachMaxAttempts() is False + + def test_to_dto_with_max_failed_attempts(self): + """Test mapping with max failed attempts (4)""" + # Arrange + model = SessionModel( + user_id=1, + token=None, + failed_attempts=4, + blocked_until=datetime.now() + timedelta(minutes=15), + ) + + # Act + dto = SessionMapper.toDTO(model) + + # Assert + assert dto.failed_attempts.value == 4 + assert dto.failed_attempts.hasReachMaxAttempts() is True + + def test_to_model_preserves_blocked_time_object(self): + """Test that toModel preserves BlockedTime as an object""" + # Arrange + blocked_datetime = datetime(2025, 12, 25, 10, 0, 0) + blocked_time = BlockedTime(blocked_datetime) + + dto = SessionDTO( + user_id=AuthUserID(50), + token=None, + failed_attempts=FailedLoginAttempts(4), + blocked_until=blocked_time, + ) + + # Act + model = SessionMapper.toModel(dto) + + # Assert + # Note: The mapper currently passes BlockedTime object directly + # This matches the implementation in session_mapper.py line 36 + assert model.blocked_until == blocked_time + assert isinstance(model.blocked_until, BlockedTime) diff --git a/tests/unit/context/credit_card/application/create_credit_card_handler_test.py b/tests/unit/context/credit_card/application/create_credit_card_handler_test.py new file mode 100644 index 0000000..f087ba0 --- /dev/null +++ b/tests/unit/context/credit_card/application/create_credit_card_handler_test.py @@ -0,0 +1,184 @@ +"""Unit tests for CreateCreditCardHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.credit_card.application.commands import CreateCreditCardCommand +from app.context.credit_card.application.dto import CreateCreditCardErrorCode +from app.context.credit_card.application.handlers.create_credit_card_handler import ( + CreateCreditCardHandler, +) +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import ( + CreditCardMapperError, + CreditCardNameAlreadyExistError, +) +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CreditCardAccountID, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateCreditCardHandler: + """Tests for CreateCreditCardHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service and logger""" + return CreateCreditCardHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_create_credit_card_success(self, handler, mock_service): + """Test successful credit card creation""" + # Arrange + command = CreateCreditCardCommand( + user_id=1, + account_id=10, + name="My Credit Card", + currency="USD", + limit=5000.00, + ) + + card_dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Credit Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + credit_card_id=CreditCardID(100), + ) + + mock_service.create_credit_card = AsyncMock(return_value=card_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.credit_card_id == 100 + mock_service.create_credit_card.assert_called_once() + + @pytest.mark.asyncio + async def test_create_credit_card_without_id_returns_error(self, handler, mock_service): + """Test that missing credit_card_id in result returns error""" + # Arrange + command = CreateCreditCardCommand(user_id=1, account_id=10, name="My Card", currency="USD", limit=1000.00) + + card_dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + credit_card_id=None, # Missing ID + ) + + mock_service.create_credit_card = AsyncMock(return_value=card_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateCreditCardErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Error creating credit card" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_create_credit_card_duplicate_name_error(self, handler, mock_service): + """Test handling of duplicate name exception""" + # Arrange + command = CreateCreditCardCommand(user_id=1, account_id=10, name="Duplicate", currency="USD", limit=1000.00) + + mock_service.create_credit_card = AsyncMock(side_effect=CreditCardNameAlreadyExistError("Duplicate name")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateCreditCardErrorCode.NAME_ALREADY_EXISTS + assert result.error_message == "Credit card name already exists" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_create_credit_card_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + command = CreateCreditCardCommand(user_id=1, account_id=10, name="Test", currency="USD", limit=1000.00) + + mock_service.create_credit_card = AsyncMock(side_effect=CreditCardMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateCreditCardErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping model to dto" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_create_credit_card_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + command = CreateCreditCardCommand(user_id=1, account_id=10, name="Test", currency="USD", limit=1000.00) + + mock_service.create_credit_card = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateCreditCardErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_create_credit_card_converts_primitives_to_value_objects(self, handler, mock_service): + """Test that handler converts command primitives to value objects""" + # Arrange + command = CreateCreditCardCommand( + user_id=1, + account_id=10, + name="Test Card", + currency="EUR", + limit=2500.50, + ) + + card_dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("Test Card"), + currency=CreditCardCurrency("EUR"), + limit=CardLimit(Decimal("2500.50")), + credit_card_id=CreditCardID(100), + ) + + mock_service.create_credit_card = AsyncMock(return_value=card_dto) + + # Act + await handler.handle(command) + + # Assert - verify service was called with value objects + call_args = mock_service.create_credit_card.call_args + assert isinstance(call_args.kwargs["user_id"], CreditCardUserID) + assert isinstance(call_args.kwargs["account_id"], CreditCardAccountID) + assert isinstance(call_args.kwargs["name"], CreditCardName) + assert isinstance(call_args.kwargs["currency"], CreditCardCurrency) + assert isinstance(call_args.kwargs["limit"], CardLimit) diff --git a/tests/unit/context/credit_card/application/delete_credit_card_handler_test.py b/tests/unit/context/credit_card/application/delete_credit_card_handler_test.py new file mode 100644 index 0000000..06566af --- /dev/null +++ b/tests/unit/context/credit_card/application/delete_credit_card_handler_test.py @@ -0,0 +1,97 @@ +"""Unit tests for DeleteCreditCardHandler""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.credit_card.application.commands import DeleteCreditCardCommand +from app.context.credit_card.application.dto import DeleteCreditCardErrorCode +from app.context.credit_card.application.handlers.delete_credit_card_handler import ( + DeleteCreditCardHandler, +) +from app.context.credit_card.domain.exceptions import CreditCardNotFoundError + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestDeleteCreditCardHandler: + """Tests for DeleteCreditCardHandler""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_repository, mock_logger): + """Create handler with mocked repository and logger""" + return DeleteCreditCardHandler(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_delete_credit_card_success(self, handler, mock_repository): + """Test successful credit card deletion""" + # Arrange + command = DeleteCreditCardCommand(credit_card_id=1, user_id=100) + + mock_repository.delete_credit_card = AsyncMock(return_value=True) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.success is True + mock_repository.delete_credit_card.assert_called_once() + + @pytest.mark.asyncio + async def test_delete_credit_card_not_found_returns_error(self, handler, mock_repository): + """Test that deleting non-existent card returns error""" + # Arrange + command = DeleteCreditCardCommand(credit_card_id=999, user_id=100) + + mock_repository.delete_credit_card = AsyncMock(return_value=False) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == DeleteCreditCardErrorCode.NOT_FOUND + assert result.error_message == "Credit card not found" + assert result.success is False + + @pytest.mark.asyncio + async def test_delete_credit_card_not_found_exception(self, handler, mock_repository): + """Test handling of not found exception""" + # Arrange + command = DeleteCreditCardCommand(credit_card_id=999, user_id=100) + + mock_repository.delete_credit_card = AsyncMock(side_effect=CreditCardNotFoundError("Card not found")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == DeleteCreditCardErrorCode.NOT_FOUND + assert result.error_message == "Credit card not found" + assert result.success is False + + @pytest.mark.asyncio + async def test_delete_credit_card_unexpected_error(self, handler, mock_repository): + """Test handling of unexpected exception""" + # Arrange + command = DeleteCreditCardCommand(credit_card_id=1, user_id=100) + + mock_repository.delete_credit_card = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == DeleteCreditCardErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.success is False diff --git a/tests/unit/context/credit_card/application/find_credit_card_by_id_handler_test.py b/tests/unit/context/credit_card/application/find_credit_card_by_id_handler_test.py new file mode 100644 index 0000000..5008d5b --- /dev/null +++ b/tests/unit/context/credit_card/application/find_credit_card_by_id_handler_test.py @@ -0,0 +1,130 @@ +"""Unit tests for FindCreditCardByIdHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.credit_card.application.handlers.find_credit_card_by_id_handler import ( + FindCreditCardByIdHandler, +) +from app.context.credit_card.application.queries import FindCreditCardByIdQuery +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestFindCreditCardByIdHandler: + """Tests for FindCreditCardByIdHandler""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_repository, mock_logger): + """Create handler with mocked repository and logger""" + return FindCreditCardByIdHandler(mock_repository, mock_logger) + + @pytest.fixture + def sample_card_dto(self): + """Create a sample card DTO""" + return CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Credit Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + used=CardUsed(Decimal("1500.50")), + ) + + @pytest.mark.asyncio + async def test_find_credit_card_by_id_success(self, handler, mock_repository, sample_card_dto): + """Test successful credit card lookup""" + # Arrange + query = FindCreditCardByIdQuery(user_id=100, credit_card_id=1) + + mock_repository.find_user_credit_card_by_id = AsyncMock(return_value=sample_card_dto) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.credit_card_id == 1 + assert result.user_id == 100 + assert result.account_id == 10 + assert result.name == "My Credit Card" + assert result.currency == "USD" + assert result.limit == Decimal("5000.00") + assert result.used == Decimal("1500.50") + mock_repository.find_user_credit_card_by_id.assert_called_once() + + @pytest.mark.asyncio + async def test_find_credit_card_by_id_not_found(self, handler, mock_repository): + """Test credit card not found returns None""" + # Arrange + query = FindCreditCardByIdQuery(user_id=100, credit_card_id=999) + + mock_repository.find_user_credit_card_by_id = AsyncMock(return_value=None) + + # Act + result = await handler.handle(query) + + # Assert + assert result is None + mock_repository.find_user_credit_card_by_id.assert_called_once() + + @pytest.mark.asyncio + async def test_find_credit_card_by_id_converts_primitives(self, handler, mock_repository, sample_card_dto): + """Test that handler converts query primitives to value objects""" + # Arrange + query = FindCreditCardByIdQuery(user_id=100, credit_card_id=1) + + mock_repository.find_user_credit_card_by_id = AsyncMock(return_value=sample_card_dto) + + # Act + await handler.handle(query) + + # Assert - verify repository was called with value objects + call_args = mock_repository.find_user_credit_card_by_id.call_args + assert isinstance(call_args.kwargs["user_id"], CreditCardUserID) + assert call_args.kwargs["user_id"].value == 100 + assert isinstance(call_args.kwargs["card_id"], CreditCardID) + assert call_args.kwargs["card_id"].value == 1 + + @pytest.mark.asyncio + async def test_find_credit_card_by_id_returns_application_dto(self, handler, mock_repository, sample_card_dto): + """Test that handler returns application layer DTO (not domain DTO)""" + # Arrange + query = FindCreditCardByIdQuery(user_id=100, credit_card_id=1) + + mock_repository.find_user_credit_card_by_id = AsyncMock(return_value=sample_card_dto) + + # Act + result = await handler.handle(query) + + # Assert - verify result has primitive types (not value objects) + assert isinstance(result.credit_card_id, int) + assert isinstance(result.user_id, int) + assert isinstance(result.account_id, int) + assert isinstance(result.name, str) + assert isinstance(result.currency, str) + assert isinstance(result.limit, Decimal) + assert isinstance(result.used, Decimal) diff --git a/tests/unit/context/credit_card/application/find_credit_cards_by_user_handler_test.py b/tests/unit/context/credit_card/application/find_credit_cards_by_user_handler_test.py new file mode 100644 index 0000000..6e71a3f --- /dev/null +++ b/tests/unit/context/credit_card/application/find_credit_cards_by_user_handler_test.py @@ -0,0 +1,179 @@ +"""Unit tests for FindCreditCardsByUserHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.credit_card.application.handlers.find_credit_cards_by_user_handler import ( + FindCreditCardsByUserHandler, +) +from app.context.credit_card.application.queries import FindCreditCardsByUserQuery +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestFindCreditCardsByUserHandler: + """Tests for FindCreditCardsByUserHandler""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_repository, mock_logger): + """Create handler with mocked repository and logger""" + return FindCreditCardsByUserHandler(mock_repository, mock_logger) + + @pytest.fixture + def sample_card_dtos(self): + """Create sample card DTOs""" + return [ + CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Card 1"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + used=CardUsed(Decimal("1500.00")), + ), + CreditCardDTO( + credit_card_id=CreditCardID(2), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Card 2"), + currency=CreditCardCurrency("EUR"), + limit=CardLimit(Decimal("3000.00")), + used=CardUsed(Decimal("500.00")), + ), + ] + + @pytest.mark.asyncio + async def test_find_credit_cards_by_user_success(self, handler, mock_repository, sample_card_dtos): + """Test successful credit cards lookup""" + # Arrange + query = FindCreditCardsByUserQuery(user_id=100) + + mock_repository.find_user_credit_cards = AsyncMock(return_value=sample_card_dtos) + + # Act + result = await handler.handle(query) + + # Assert + assert len(result) == 2 + assert result[0].credit_card_id == 1 + assert result[0].name == "Card 1" + assert result[1].credit_card_id == 2 + assert result[1].name == "Card 2" + mock_repository.find_user_credit_cards.assert_called_once() + + @pytest.mark.asyncio + async def test_find_credit_cards_by_user_empty_list(self, handler, mock_repository): + """Test user with no cards returns empty list""" + # Arrange + query = FindCreditCardsByUserQuery(user_id=100) + + mock_repository.find_user_credit_cards = AsyncMock(return_value=[]) + + # Act + result = await handler.handle(query) + + # Assert + assert result == [] + mock_repository.find_user_credit_cards.assert_called_once() + + @pytest.mark.asyncio + async def test_find_credit_cards_by_user_none_returns_empty_list(self, handler, mock_repository): + """Test that None result returns empty list""" + # Arrange + query = FindCreditCardsByUserQuery(user_id=100) + + mock_repository.find_user_credit_cards = AsyncMock(return_value=None) + + # Act + result = await handler.handle(query) + + # Assert + assert result == [] + mock_repository.find_user_credit_cards.assert_called_once() + + @pytest.mark.asyncio + async def test_find_credit_cards_by_user_converts_primitives(self, handler, mock_repository, sample_card_dtos): + """Test that handler converts query primitives to value objects""" + # Arrange + query = FindCreditCardsByUserQuery(user_id=100) + + mock_repository.find_user_credit_cards = AsyncMock(return_value=sample_card_dtos) + + # Act + await handler.handle(query) + + # Assert - verify repository was called with value objects + call_args = mock_repository.find_user_credit_cards.call_args + assert isinstance(call_args.kwargs["user_id"], CreditCardUserID) + assert call_args.kwargs["user_id"].value == 100 + + @pytest.mark.asyncio + async def test_find_credit_cards_by_user_returns_application_dtos(self, handler, mock_repository, sample_card_dtos): + """Test that handler returns application layer DTOs (not domain DTOs)""" + # Arrange + query = FindCreditCardsByUserQuery(user_id=100) + + mock_repository.find_user_credit_cards = AsyncMock(return_value=sample_card_dtos) + + # Act + result = await handler.handle(query) + + # Assert - verify results have primitive types (not value objects) + for card in result: + assert isinstance(card.credit_card_id, int) + assert isinstance(card.user_id, int) + assert isinstance(card.account_id, int) + assert isinstance(card.name, str) + assert isinstance(card.currency, str) + assert isinstance(card.limit, Decimal) + assert isinstance(card.used, Decimal) + + @pytest.mark.asyncio + async def test_find_credit_cards_by_user_single_card(self, handler, mock_repository): + """Test finding single card for user""" + # Arrange + query = FindCreditCardsByUserQuery(user_id=100) + + single_card = [ + CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Only Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("0.00")), + ) + ] + + mock_repository.find_user_credit_cards = AsyncMock(return_value=single_card) + + # Act + result = await handler.handle(query) + + # Assert + assert len(result) == 1 + assert result[0].name == "Only Card" diff --git a/tests/unit/context/credit_card/application/update_credit_card_handler_test.py b/tests/unit/context/credit_card/application/update_credit_card_handler_test.py new file mode 100644 index 0000000..edfd31c --- /dev/null +++ b/tests/unit/context/credit_card/application/update_credit_card_handler_test.py @@ -0,0 +1,309 @@ +"""Unit tests for UpdateCreditCardHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.credit_card.application.commands import UpdateCreditCardCommand +from app.context.credit_card.application.dto import UpdateCreditCardErrorCode +from app.context.credit_card.application.handlers.update_credit_card_handler import ( + UpdateCreditCardHandler, +) +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import ( + CreditCardMapperError, + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, +) +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestUpdateCreditCardHandler: + """Tests for UpdateCreditCardHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service and logger""" + return UpdateCreditCardHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_update_credit_card_success(self, handler, mock_service): + """Test successful credit card update""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name="Updated Name", + currency=None, + limit=None, + used=None, + ) + + updated_dto = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Updated Name"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("0.00")), + ) + + mock_service.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.credit_card_id == 1 + assert result.credit_card_name == "Updated Name" + mock_service.update_credit_card.assert_called_once() + + @pytest.mark.asyncio + async def test_update_credit_card_limit_success(self, handler, mock_service): + """Test successful credit card limit update""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name=None, + currency=None, + limit=5000.00, + used=None, + ) + + updated_dto = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + used=CardUsed(Decimal("0.00")), + ) + + mock_service.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.credit_card_id == 1 + + @pytest.mark.asyncio + async def test_update_credit_card_used_success(self, handler, mock_service): + """Test successful credit card used amount update""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name=None, + currency=None, + limit=None, + used=500.00, + ) + + updated_dto = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("500.00")), + ) + + mock_service.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.credit_card_id == 1 + + @pytest.mark.asyncio + async def test_update_credit_card_not_found_error(self, handler, mock_service): + """Test handling of not found exception""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=999, + user_id=100, + name="New Name", + currency=None, + limit=None, + used=None, + ) + + mock_service.update_credit_card = AsyncMock(side_effect=CreditCardNotFoundError("Card not found")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateCreditCardErrorCode.NOT_FOUND + assert result.error_message == "Credit card not found" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_update_credit_card_duplicate_name_error(self, handler, mock_service): + """Test handling of duplicate name exception""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name="Duplicate", + currency=None, + limit=None, + used=None, + ) + + mock_service.update_credit_card = AsyncMock(side_effect=CreditCardNameAlreadyExistError("Duplicate name")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateCreditCardErrorCode.NAME_ALREADY_EXISTS + assert result.error_message == "Credit card name already exists" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_update_credit_card_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name="Test", + currency=None, + limit=None, + used=None, + ) + + mock_service.update_credit_card = AsyncMock(side_effect=CreditCardMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateCreditCardErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping model to dto" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_update_credit_card_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name="Test", + currency=None, + limit=None, + used=None, + ) + + mock_service.update_credit_card = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateCreditCardErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.credit_card_id is None + + @pytest.mark.asyncio + async def test_update_credit_card_converts_primitives_to_value_objects(self, handler, mock_service): + """Test that handler converts command primitives to value objects""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name="Updated", + currency="EUR", + limit=2500.50, + used=500.25, + ) + + updated_dto = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Updated"), + currency=CreditCardCurrency("EUR"), + limit=CardLimit(Decimal("2500.50")), + used=CardUsed(Decimal("500.25")), + ) + + mock_service.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + await handler.handle(command) + + # Assert - verify service was called with value objects + call_args = mock_service.update_credit_card.call_args + assert isinstance(call_args.kwargs["credit_card_id"], CreditCardID) + assert isinstance(call_args.kwargs["user_id"], CreditCardUserID) + assert isinstance(call_args.kwargs["name"], CreditCardName) + assert isinstance(call_args.kwargs["currency"], CreditCardCurrency) + assert isinstance(call_args.kwargs["limit"], CardLimit) + assert isinstance(call_args.kwargs["used"], CardUsed) + + @pytest.mark.asyncio + async def test_update_credit_card_with_none_values_converts_correctly(self, handler, mock_service): + """Test that handler handles None values correctly""" + # Arrange + command = UpdateCreditCardCommand( + credit_card_id=1, + user_id=100, + name="Updated", + currency=None, # None values should result in None, not value objects + limit=None, + used=None, + ) + + updated_dto = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Updated"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("0.00")), + ) + + mock_service.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + await handler.handle(command) + + # Assert - verify None values are passed as None + call_args = mock_service.update_credit_card.call_args + assert call_args.kwargs["currency"] is None + assert call_args.kwargs["limit"] is None + assert call_args.kwargs["used"] is None diff --git a/tests/unit/context/credit_card/domain/card_limit_test.py b/tests/unit/context/credit_card/domain/card_limit_test.py new file mode 100644 index 0000000..1feb27f --- /dev/null +++ b/tests/unit/context/credit_card/domain/card_limit_test.py @@ -0,0 +1,84 @@ +"""Unit tests for CardLimit value object""" + +from dataclasses import FrozenInstanceError +from decimal import Decimal + +import pytest + +from app.context.credit_card.domain.exceptions import ( + InvalidCardLimitFormatError, + InvalidCardLimitPrecisionError, + InvalidCardLimitTypeError, + InvalidCardLimitValueError, +) +from app.context.credit_card.domain.value_objects import CardLimit + + +@pytest.mark.unit +class TestCardLimit: + """Tests for CardLimit value object""" + + def test_valid_limit_creation(self): + """Test creating valid card limits""" + limit = CardLimit(Decimal("1000.00")) + assert limit.value == Decimal("1000.00") + + def test_limit_with_one_decimal_place(self): + """Test limit with one decimal place""" + limit = CardLimit(Decimal("500.5")) + assert limit.value == Decimal("500.5") + + def test_limit_with_no_decimal_places(self): + """Test limit with no decimal places""" + limit = CardLimit(Decimal("1000")) + assert limit.value == Decimal("1000") + + def test_from_float_converts_correctly(self): + """Test from_float class method""" + limit = CardLimit.from_float(1234.56) + assert limit.value == Decimal("1234.56") + + def test_from_float_rounds_to_two_decimals(self): + """Test that from_float rounds to 2 decimal places""" + limit = CardLimit.from_float(1234.567) + assert limit.value == Decimal("1234.57") + + def test_invalid_type_raises_error(self): + """Test that invalid types raise InvalidCardLimitTypeError""" + with pytest.raises(InvalidCardLimitTypeError, match="CardLimit must be a Decimal"): + CardLimit(1000) # int instead of Decimal + + def test_negative_limit_raises_error(self): + """Test that negative limits raise InvalidCardLimitValueError""" + with pytest.raises(InvalidCardLimitValueError, match="CardLimit must be positive"): + CardLimit(Decimal("-100.00")) + + def test_zero_limit_raises_error(self): + """Test that zero limit raises InvalidCardLimitValueError""" + with pytest.raises(InvalidCardLimitValueError, match="CardLimit must be positive"): + CardLimit(Decimal("0.00")) + + def test_too_many_decimal_places_raises_error(self): + """Test that more than 2 decimal places raises InvalidCardLimitPrecisionError""" + with pytest.raises( + InvalidCardLimitPrecisionError, + match="CardLimit must have at most 2 decimal places", + ): + CardLimit(Decimal("100.123")) + + def test_from_float_with_invalid_value_raises_error(self): + """Test that from_float with invalid value raises InvalidCardLimitFormatError""" + with pytest.raises(InvalidCardLimitFormatError, match="Invalid CardLimit value"): + CardLimit.from_float(float("nan")) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + limit = CardLimit.from_trusted_source(Decimal("-100.123")) + assert limit.value == Decimal("-100.123") + + def test_immutability(self): + """Test that value object is immutable""" + limit = CardLimit(Decimal("1000.00")) + with pytest.raises(FrozenInstanceError): + limit.value = Decimal("2000.00") diff --git a/tests/unit/context/credit_card/domain/card_used_test.py b/tests/unit/context/credit_card/domain/card_used_test.py new file mode 100644 index 0000000..82bc81f --- /dev/null +++ b/tests/unit/context/credit_card/domain/card_used_test.py @@ -0,0 +1,84 @@ +"""Unit tests for CardUsed value object""" + +from dataclasses import FrozenInstanceError +from decimal import Decimal + +import pytest + +from app.context.credit_card.domain.exceptions import ( + InvalidCardUsedFormatError, + InvalidCardUsedPrecisionError, + InvalidCardUsedTypeError, + InvalidCardUsedValueError, +) +from app.context.credit_card.domain.value_objects import CardUsed + + +@pytest.mark.unit +class TestCardUsed: + """Tests for CardUsed value object""" + + def test_valid_used_creation(self): + """Test creating valid card used amounts""" + used = CardUsed(Decimal("500.00")) + assert used.value == Decimal("500.00") + + def test_zero_used_is_valid(self): + """Test that zero used amount is valid""" + used = CardUsed(Decimal("0.00")) + assert used.value == Decimal("0.00") + + def test_used_with_one_decimal_place(self): + """Test used with one decimal place""" + used = CardUsed(Decimal("250.5")) + assert used.value == Decimal("250.5") + + def test_used_with_no_decimal_places(self): + """Test used with no decimal places""" + used = CardUsed(Decimal("500")) + assert used.value == Decimal("500") + + def test_from_float_converts_correctly(self): + """Test from_float class method""" + used = CardUsed.from_float(123.45) + assert used.value == Decimal("123.45") + + def test_from_float_rounds_to_two_decimals(self): + """Test that from_float rounds to 2 decimal places""" + used = CardUsed.from_float(123.456) + assert used.value == Decimal("123.46") + + def test_invalid_type_raises_error(self): + """Test that invalid types raise InvalidCardUsedTypeError""" + with pytest.raises(InvalidCardUsedTypeError, match="CardUsed must be a Decimal"): + CardUsed(500) # int instead of Decimal + + def test_negative_used_raises_error(self): + """Test that negative used amounts raise InvalidCardUsedValueError""" + with pytest.raises(InvalidCardUsedValueError, match="CardUsed must be non-negative"): + CardUsed(Decimal("-50.00")) + + def test_too_many_decimal_places_raises_error(self): + """Test that more than 2 decimal places raises InvalidCardUsedPrecisionError""" + with pytest.raises( + InvalidCardUsedPrecisionError, + match="CardUsed must have at most 2 decimal places", + ): + CardUsed(Decimal("50.123")) + + def test_from_float_with_invalid_value_raises_error(self): + """Test that from_float with invalid value raises InvalidCardUsedFormatError""" + with pytest.raises(InvalidCardUsedFormatError, match="Invalid CardUsed value"): + CardUsed.from_float(float("nan")) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + used = CardUsed.from_trusted_source(Decimal("-50.123")) + assert used.value == Decimal("-50.123") + + def test_immutability(self): + """Test that value object is immutable""" + used = CardUsed(Decimal("500.00")) + with pytest.raises(FrozenInstanceError): + used.value = Decimal("1000.00") diff --git a/tests/unit/context/credit_card/domain/create_credit_card_service_test.py b/tests/unit/context/credit_card/domain/create_credit_card_service_test.py new file mode 100644 index 0000000..c6ff5fb --- /dev/null +++ b/tests/unit/context/credit_card/domain/create_credit_card_service_test.py @@ -0,0 +1,157 @@ +"""Unit tests for CreateCreditCardService""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.services.create_credit_card_service import ( + CreateCreditCardService, +) +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CreditCardAccountID, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateCreditCardService: + """Tests for CreateCreditCardService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return CreateCreditCardService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_create_credit_card_success(self, service, mock_repository): + """Test successful credit card creation""" + # Arrange + user_id = CreditCardUserID(1) + account_id = CreditCardAccountID(10) + name = CreditCardName("My Credit Card") + currency = CreditCardCurrency("USD") + limit = CardLimit(Decimal("5000.00")) + + expected_dto = CreditCardDTO( + user_id=user_id, + account_id=account_id, + name=name, + currency=currency, + limit=limit, + credit_card_id=CreditCardID(100), + ) + + mock_repository.save_credit_card = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_credit_card( + user_id=user_id, + account_id=account_id, + name=name, + currency=currency, + limit=limit, + ) + + # Assert + assert result == expected_dto + mock_repository.save_credit_card.assert_called_once() + + # Verify the DTO passed to save_credit_card has correct values + call_args = mock_repository.save_credit_card.call_args[0][0] + assert call_args.user_id == user_id + assert call_args.account_id == account_id + assert call_args.name == name + assert call_args.currency == currency + assert call_args.limit == limit + assert call_args.credit_card_id is None # New card, no ID yet + + @pytest.mark.asyncio + async def test_create_credit_card_with_different_currencies(self, service, mock_repository): + """Test creating cards with different currency codes""" + currencies = ["USD", "EUR", "GBP", "JPY"] + + for currency_code in currencies: + currency = CreditCardCurrency(currency_code) + + expected_dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=currency, + limit=CardLimit(Decimal("1000.00")), + credit_card_id=CreditCardID(100), + ) + + mock_repository.save_credit_card = AsyncMock(return_value=expected_dto) + + result = await service.create_credit_card( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=currency, + limit=CardLimit(Decimal("1000.00")), + ) + + assert result.currency.value == currency_code + + @pytest.mark.asyncio + async def test_create_credit_card_with_high_limit(self, service, mock_repository): + """Test creating card with high credit limit""" + # Arrange + high_limit = CardLimit(Decimal("100000.00")) + + expected_dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("Premium Card"), + currency=CreditCardCurrency("USD"), + limit=high_limit, + credit_card_id=CreditCardID(100), + ) + + mock_repository.save_credit_card = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_credit_card( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("Premium Card"), + currency=CreditCardCurrency("USD"), + limit=high_limit, + ) + + # Assert + assert result.limit.value == Decimal("100000.00") + + @pytest.mark.asyncio + async def test_create_credit_card_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + mock_repository.save_credit_card = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.create_credit_card( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + ) diff --git a/tests/unit/context/credit_card/domain/credit_card_account_id_test.py b/tests/unit/context/credit_card/domain/credit_card_account_id_test.py new file mode 100644 index 0000000..4045320 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_account_id_test.py @@ -0,0 +1,44 @@ +"""Unit tests for CreditCardAccountID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.credit_card.domain.value_objects import CreditCardAccountID + + +@pytest.mark.unit +class TestCreditCardAccountID: + """Tests for CreditCardAccountID value object""" + + def test_valid_id_creation(self): + """Test creating valid account IDs""" + account_id = CreditCardAccountID(1) + assert account_id.value == 1 + + def test_invalid_type_raises_error(self): + """Test that invalid types raise ValueError""" + with pytest.raises(ValueError, match="AccountID must be an integer"): + CreditCardAccountID("not_an_int") + + def test_negative_id_raises_error(self): + """Test that negative IDs raise ValueError""" + with pytest.raises(ValueError, match="AccountID must be positive"): + CreditCardAccountID(-1) + + def test_zero_id_raises_error(self): + """Test that zero ID raises ValueError""" + with pytest.raises(ValueError, match="AccountID must be positive"): + CreditCardAccountID(0) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + account_id = CreditCardAccountID.from_trusted_source(-999) + assert account_id.value == -999 + + def test_immutability(self): + """Test that value object is immutable""" + account_id = CreditCardAccountID(1) + with pytest.raises(FrozenInstanceError): + account_id.value = 2 diff --git a/tests/unit/context/credit_card/domain/credit_card_currency_test.py b/tests/unit/context/credit_card/domain/credit_card_currency_test.py new file mode 100644 index 0000000..dff5011 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_currency_test.py @@ -0,0 +1,71 @@ +"""Unit tests for CreditCardCurrency value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.credit_card.domain.value_objects import CreditCardCurrency + + +@pytest.mark.unit +class TestCreditCardCurrency: + """Tests for CreditCardCurrency value object""" + + def test_valid_currency_creation(self): + """Test creating valid currencies""" + currency = CreditCardCurrency("USD") + assert currency.value == "USD" + + def test_three_letter_uppercase_currency(self): + """Test that 3-letter uppercase currencies are valid""" + currencies = ["EUR", "GBP", "JPY", "CHF", "CAD"] + for code in currencies: + currency = CreditCardCurrency(code) + assert currency.value == code + + def test_invalid_type_raises_error(self): + """Test that invalid types raise ValueError""" + with pytest.raises(ValueError, match="Currency must be a string"): + CreditCardCurrency(123) + + def test_too_short_currency_raises_error(self): + """Test that currency codes shorter than 3 characters raise error""" + with pytest.raises(ValueError, match="Currency code must be exactly 3 characters"): + CreditCardCurrency("US") + + def test_too_long_currency_raises_error(self): + """Test that currency codes longer than 3 characters raise error""" + with pytest.raises(ValueError, match="Currency code must be exactly 3 characters"): + CreditCardCurrency("USDX") + + def test_lowercase_currency_raises_error(self): + """Test that lowercase currency codes raise error""" + with pytest.raises(ValueError, match="Currency code must be uppercase"): + CreditCardCurrency("usd") + + def test_mixed_case_currency_raises_error(self): + """Test that mixed case currency codes raise error""" + with pytest.raises(ValueError, match="Currency code must be uppercase"): + CreditCardCurrency("Usd") + + def test_currency_with_numbers_raises_error(self): + """Test that currency codes with numbers raise error""" + with pytest.raises(ValueError, match="Currency code must contain only letters"): + CreditCardCurrency("US1") + + def test_currency_with_special_chars_raises_error(self): + """Test that currency codes with special characters raise error""" + with pytest.raises(ValueError, match="Currency code must contain only letters"): + CreditCardCurrency("US$") + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + currency = CreditCardCurrency.from_trusted_source("invalid") + assert currency.value == "invalid" + + def test_immutability(self): + """Test that value object is immutable""" + currency = CreditCardCurrency("USD") + with pytest.raises(FrozenInstanceError): + currency.value = "EUR" diff --git a/tests/unit/context/credit_card/domain/credit_card_deleted_at_test.py b/tests/unit/context/credit_card/domain/credit_card_deleted_at_test.py new file mode 100644 index 0000000..5f38c68 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_deleted_at_test.py @@ -0,0 +1,67 @@ +"""Unit tests for CreditCardDeletedAt value object""" + +from dataclasses import FrozenInstanceError +from datetime import UTC, datetime, timedelta + +import pytest + +from app.context.credit_card.domain.value_objects import CreditCardDeletedAt + + +@pytest.mark.unit +class TestCreditCardDeletedAt: + """Tests for CreditCardDeletedAt value object""" + + def test_valid_deleted_at_creation(self): + """Test creating valid deleted_at timestamps""" + now = datetime.now(UTC) + deleted_at = CreditCardDeletedAt(now) + assert deleted_at.value == now + + def test_past_datetime_is_valid(self): + """Test that past datetime values are valid""" + past = datetime.now(UTC) - timedelta(days=1) + deleted_at = CreditCardDeletedAt(past) + assert deleted_at.value == past + + def test_now_class_method(self): + """Test the now() class method creates a current timestamp""" + deleted_at = CreditCardDeletedAt.now() + assert isinstance(deleted_at.value, datetime) + assert deleted_at.value.tzinfo is not None # Should be timezone-aware + + def test_future_datetime_raises_error(self): + """Test that future datetime raises ValueError""" + future = datetime.now(UTC) + timedelta(days=1) + with pytest.raises(ValueError, match="DeletedAt cannot be in the future"): + CreditCardDeletedAt(future) + + def test_invalid_type_raises_error(self): + """Test that invalid types raise ValueError""" + with pytest.raises(ValueError, match="DeletedAt must be a datetime object"): + CreditCardDeletedAt("2025-01-01") + + def test_from_optional_with_none_returns_none(self): + """Test that from_optional returns None when given None""" + result = CreditCardDeletedAt.from_optional(None) + assert result is None + + def test_from_optional_with_datetime_returns_deleted_at(self): + """Test that from_optional returns DeletedAt when given datetime""" + now = datetime.now(UTC) + result = CreditCardDeletedAt.from_optional(now) + assert isinstance(result, CreditCardDeletedAt) + assert result.value == now + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data (future date) + future = datetime.now(UTC) + timedelta(days=100) + deleted_at = CreditCardDeletedAt.from_trusted_source(future) + assert deleted_at.value == future + + def test_immutability(self): + """Test that value object is immutable""" + deleted_at = CreditCardDeletedAt.now() + with pytest.raises(FrozenInstanceError): + deleted_at.value = datetime.now(UTC) diff --git a/tests/unit/context/credit_card/domain/credit_card_dto_test.py b/tests/unit/context/credit_card/domain/credit_card_dto_test.py new file mode 100644 index 0000000..0d1c698 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_dto_test.py @@ -0,0 +1,129 @@ +"""Unit tests for CreditCardDTO""" + +from dataclasses import FrozenInstanceError +from decimal import Decimal + +import pytest + +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardDeletedAt, + CreditCardID, + CreditCardName, + CreditCardUserID, +) + + +@pytest.mark.unit +class TestCreditCardDTO: + """Tests for CreditCardDTO""" + + def test_minimal_dto_creation(self): + """Test creating DTO with minimal required fields""" + dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + ) + + assert dto.user_id.value == 1 + assert dto.account_id.value == 10 + assert dto.name.value == "My Card" + assert dto.currency.value == "USD" + assert dto.limit.value == Decimal("1000.00") + assert dto.used is None + assert dto.credit_card_id is None + assert dto.deleted_at is None + + def test_full_dto_creation(self): + """Test creating DTO with all fields""" + dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("500.00")), + credit_card_id=CreditCardID(5), + deleted_at=CreditCardDeletedAt.now(), + ) + + assert dto.user_id.value == 1 + assert dto.account_id.value == 10 + assert dto.name.value == "My Card" + assert dto.currency.value == "USD" + assert dto.limit.value == Decimal("1000.00") + assert dto.used.value == Decimal("500.00") + assert dto.credit_card_id.value == 5 + assert dto.deleted_at is not None + + def test_is_deleted_property_when_deleted(self): + """Test is_deleted property returns True when deleted_at is set""" + dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + deleted_at=CreditCardDeletedAt.now(), + ) + + assert dto.is_deleted is True + + def test_is_deleted_property_when_not_deleted(self): + """Test is_deleted property returns False when deleted_at is None""" + dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + ) + + assert dto.is_deleted is False + + def test_immutability(self): + """Test that DTO is immutable""" + dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + ) + + with pytest.raises(FrozenInstanceError): + dto.name = CreditCardName("New Name") + + def test_zero_used_amount(self): + """Test DTO with zero used amount""" + dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("0.00")), + ) + + assert dto.used.value == Decimal("0.00") + + def test_different_currencies(self): + """Test DTO with different currency codes""" + currencies = ["USD", "EUR", "GBP", "JPY"] + + for currency_code in currencies: + dto = CreditCardDTO( + user_id=CreditCardUserID(1), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency(currency_code), + limit=CardLimit(Decimal("1000.00")), + ) + assert dto.currency.value == currency_code diff --git a/tests/unit/context/credit_card/domain/credit_card_id_test.py b/tests/unit/context/credit_card/domain/credit_card_id_test.py new file mode 100644 index 0000000..bb7328f --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_id_test.py @@ -0,0 +1,48 @@ +"""Unit tests for CreditCardID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.credit_card.domain.exceptions import ( + InvalidCreditCardIdTypeError, + InvalidCreditCardIdValueError, +) +from app.context.credit_card.domain.value_objects import CreditCardID + + +@pytest.mark.unit +class TestCreditCardID: + """Tests for CreditCardID value object""" + + def test_valid_id_creation(self): + """Test creating valid credit card IDs""" + card_id = CreditCardID(1) + assert card_id.value == 1 + + def test_invalid_type_raises_error(self): + """Test that invalid types raise InvalidCreditCardIdTypeError""" + with pytest.raises(InvalidCreditCardIdTypeError, match="CreditCardID must be an integer"): + CreditCardID("not_an_int") + + def test_negative_id_raises_error(self): + """Test that negative IDs raise InvalidCreditCardIdValueError""" + with pytest.raises(InvalidCreditCardIdValueError, match="CreditCardID must be positive"): + CreditCardID(-1) + + def test_zero_id_raises_error(self): + """Test that zero ID raises InvalidCreditCardIdValueError""" + with pytest.raises(InvalidCreditCardIdValueError, match="CreditCardID must be positive"): + CreditCardID(0) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + card_id = CreditCardID.from_trusted_source(-999) + assert card_id.value == -999 + + def test_immutability(self): + """Test that value object is immutable""" + card_id = CreditCardID(1) + with pytest.raises(FrozenInstanceError): + card_id.value = 2 diff --git a/tests/unit/context/credit_card/domain/credit_card_name_test.py b/tests/unit/context/credit_card/domain/credit_card_name_test.py new file mode 100644 index 0000000..cc5cbca --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_name_test.py @@ -0,0 +1,66 @@ +"""Unit tests for CreditCardName value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.credit_card.domain.exceptions import ( + InvalidCreditCardNameLengthError, + InvalidCreditCardNameTypeError, +) +from app.context.credit_card.domain.value_objects import CreditCardName + + +@pytest.mark.unit +class TestCreditCardName: + """Tests for CreditCardName value object""" + + def test_valid_name_creation(self): + """Test creating valid credit card names""" + name = CreditCardName("My Card") + assert name.value == "My Card" + + def test_minimum_length_name(self): + """Test that minimum length (3 characters) is accepted""" + name = CreditCardName("Abc") + assert name.value == "Abc" + + def test_maximum_length_name(self): + """Test that maximum length (100 characters) is accepted""" + long_name = "A" * 100 + name = CreditCardName(long_name) + assert name.value == long_name + + def test_invalid_type_raises_error(self): + """Test that invalid types raise InvalidCreditCardNameTypeError""" + with pytest.raises(InvalidCreditCardNameTypeError, match="CreditCardName must be a string"): + CreditCardName(123) + + def test_too_short_name_raises_error(self): + """Test that names shorter than 3 characters raise error""" + with pytest.raises( + InvalidCreditCardNameLengthError, + match="CreditCardName must be at least 3 characters", + ): + CreditCardName("Ab") + + def test_too_long_name_raises_error(self): + """Test that names longer than 100 characters raise error""" + long_name = "A" * 101 + with pytest.raises( + InvalidCreditCardNameLengthError, + match="CreditCardName must be at most 100 characters", + ): + CreditCardName(long_name) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + name = CreditCardName.from_trusted_source("X") # Too short + assert name.value == "X" + + def test_immutability(self): + """Test that value object is immutable""" + name = CreditCardName("My Card") + with pytest.raises(FrozenInstanceError): + name.value = "New Name" diff --git a/tests/unit/context/credit_card/domain/credit_card_user_id_test.py b/tests/unit/context/credit_card/domain/credit_card_user_id_test.py new file mode 100644 index 0000000..bc4d51a --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_user_id_test.py @@ -0,0 +1,33 @@ +"""Unit tests for CreditCardUserID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.credit_card.domain.value_objects import CreditCardUserID + + +@pytest.mark.unit +class TestCreditCardUserID: + """Tests for CreditCardUserID value object""" + + def test_valid_id_creation(self): + """Test creating valid user IDs""" + user_id = CreditCardUserID(1) + assert user_id.value == 1 + + def test_large_id_creation(self): + """Test creating large user IDs""" + user_id = CreditCardUserID(999999) + assert user_id.value == 999999 + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + user_id = CreditCardUserID.from_trusted_source(12345) + assert user_id.value == 12345 + + def test_immutability(self): + """Test that value object is immutable""" + user_id = CreditCardUserID(1) + with pytest.raises(FrozenInstanceError): + user_id.value = 2 diff --git a/tests/unit/context/credit_card/domain/update_credit_card_service_test.py b/tests/unit/context/credit_card/domain/update_credit_card_service_test.py new file mode 100644 index 0000000..f9d7e89 --- /dev/null +++ b/tests/unit/context/credit_card/domain/update_credit_card_service_test.py @@ -0,0 +1,282 @@ +"""Unit tests for UpdateCreditCardService""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import ( + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, + CreditCardUnauthorizedAccessError, + CreditCardUsedExceedsLimitError, +) +from app.context.credit_card.domain.services.update_credit_card_service import ( + UpdateCreditCardService, +) +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestUpdateCreditCardService: + """Tests for UpdateCreditCardService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return UpdateCreditCardService(mock_repository, mock_logger) + + @pytest.fixture + def existing_card_dto(self): + """Create an existing card DTO for testing""" + return CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Old Name"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("0.00")), + ) + + @pytest.mark.asyncio + async def test_update_credit_card_name_success(self, service, mock_repository, existing_card_dto): + """Test successful credit card name update""" + # Arrange + new_name = CreditCardName("New Name") + + # First call returns existing card, second call returns None (no duplicate) + mock_repository.find_credit_card = AsyncMock(side_effect=[existing_card_dto, None]) + + updated_dto = CreditCardDTO( + credit_card_id=existing_card_dto.credit_card_id, + user_id=existing_card_dto.user_id, + account_id=existing_card_dto.account_id, + name=new_name, + currency=existing_card_dto.currency, + limit=existing_card_dto.limit, + used=existing_card_dto.used, + ) + + mock_repository.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + result = await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + name=new_name, + ) + + # Assert + assert result.name.value == "New Name" + assert mock_repository.find_credit_card.call_count == 2 # Once for card, once for duplicate check + mock_repository.update_credit_card.assert_called_once() + + @pytest.mark.asyncio + async def test_update_credit_card_limit_success(self, service, mock_repository, existing_card_dto): + """Test successful credit card limit update""" + # Arrange + new_limit = CardLimit(Decimal("5000.00")) + + mock_repository.find_credit_card = AsyncMock(return_value=existing_card_dto) + + updated_dto = CreditCardDTO( + credit_card_id=existing_card_dto.credit_card_id, + user_id=existing_card_dto.user_id, + account_id=existing_card_dto.account_id, + name=existing_card_dto.name, + currency=existing_card_dto.currency, + limit=new_limit, + used=existing_card_dto.used, + ) + + mock_repository.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + result = await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + limit=new_limit, + ) + + # Assert + assert result.limit.value == Decimal("5000.00") + + @pytest.mark.asyncio + async def test_update_credit_card_used_success(self, service, mock_repository, existing_card_dto): + """Test successful credit card used amount update""" + # Arrange + new_used = CardUsed(Decimal("500.00")) + + mock_repository.find_credit_card = AsyncMock(return_value=existing_card_dto) + + updated_dto = CreditCardDTO( + credit_card_id=existing_card_dto.credit_card_id, + user_id=existing_card_dto.user_id, + account_id=existing_card_dto.account_id, + name=existing_card_dto.name, + currency=existing_card_dto.currency, + limit=existing_card_dto.limit, + used=new_used, + ) + + mock_repository.update_credit_card = AsyncMock(return_value=updated_dto) + + # Act + result = await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + used=new_used, + ) + + # Assert + assert result.used.value == Decimal("500.00") + + @pytest.mark.asyncio + async def test_update_credit_card_not_found_raises_error(self, service, mock_repository): + """Test that updating non-existent card raises CreditCardNotFoundError""" + # Arrange + mock_repository.find_credit_card = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(CreditCardNotFoundError, match="Credit card with ID 999 not found"): + await service.update_credit_card( + credit_card_id=CreditCardID(999), + user_id=CreditCardUserID(100), + name=CreditCardName("New Name"), + ) + + @pytest.mark.asyncio + async def test_update_credit_card_unauthorized_access_raises_error( + self, service, mock_repository, existing_card_dto + ): + """Test that updating another user's card raises CreditCardUnauthorizedAccessError""" + # Arrange + mock_repository.find_credit_card = AsyncMock(return_value=existing_card_dto) + + # Act & Assert + with pytest.raises( + CreditCardUnauthorizedAccessError, + match="User 999 is not authorized to update credit card 1", + ): + await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(999), # Different user + name=CreditCardName("New Name"), + ) + + @pytest.mark.asyncio + async def test_update_credit_card_duplicate_name_raises_error(self, service, mock_repository, existing_card_dto): + """Test that updating to duplicate name raises CreditCardNameAlreadyExistError""" + # Arrange + new_name = CreditCardName("Duplicate Name") + + mock_repository.find_credit_card = AsyncMock( + side_effect=[ + existing_card_dto, # First call - find existing card + CreditCardDTO( # Second call - find duplicate name + credit_card_id=CreditCardID(2), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=new_name, + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + ), + ] + ) + + # Act & Assert + with pytest.raises( + CreditCardNameAlreadyExistError, + match="Credit card with name 'Duplicate Name' already exists for this user", + ): + await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + name=new_name, + ) + + @pytest.mark.asyncio + async def test_update_credit_card_used_exceeds_limit_raises_error( + self, service, mock_repository, existing_card_dto + ): + """Test that setting used > limit raises CreditCardUsedExceedsLimitError""" + # Arrange + mock_repository.find_credit_card = AsyncMock(return_value=existing_card_dto) + + # Act & Assert + with pytest.raises( + CreditCardUsedExceedsLimitError, + match="Used amount \\(2000.00\\) cannot exceed limit \\(1000.00\\)", + ): + await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + used=CardUsed(Decimal("2000.00")), # Exceeds limit of 1000.00 + ) + + @pytest.mark.asyncio + async def test_update_credit_card_limit_below_used_raises_error(self, service, mock_repository): + """Test that setting limit < used raises CreditCardUsedExceedsLimitError""" + # Arrange + card_with_usage = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + used=CardUsed(Decimal("3000.00")), # Currently using 3000 + ) + + mock_repository.find_credit_card = AsyncMock(return_value=card_with_usage) + + # Act & Assert + with pytest.raises( + CreditCardUsedExceedsLimitError, + match="Used amount \\(3000.00\\) cannot exceed limit \\(1000.00\\)", + ): + await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + limit=CardLimit(Decimal("1000.00")), # New limit below used + ) + + @pytest.mark.asyncio + async def test_update_credit_card_same_name_no_duplicate_check(self, service, mock_repository, existing_card_dto): + """Test that updating with same name doesn't check for duplicates""" + # Arrange + same_name = CreditCardName("Old Name") # Same as existing + + mock_repository.find_credit_card = AsyncMock(return_value=existing_card_dto) + mock_repository.update_credit_card = AsyncMock(return_value=existing_card_dto) + + # Act + await service.update_credit_card( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + name=same_name, + ) + + # Assert - should only call find_credit_card once (not twice for duplicate check) + assert mock_repository.find_credit_card.call_count == 1 diff --git a/tests/unit/context/credit_card/infrastructure/credit_card_mapper_test.py b/tests/unit/context/credit_card/infrastructure/credit_card_mapper_test.py new file mode 100644 index 0000000..5c6686c --- /dev/null +++ b/tests/unit/context/credit_card/infrastructure/credit_card_mapper_test.py @@ -0,0 +1,224 @@ +"""Unit tests for CreditCardMapper""" + +from datetime import UTC, datetime +from decimal import Decimal + +import pytest + +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import CreditCardMapperError +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CardUsed, + CreditCardAccountID, + CreditCardCurrency, + CreditCardDeletedAt, + CreditCardID, + CreditCardName, + CreditCardUserID, +) +from app.context.credit_card.infrastructure.mappers.credit_card_mapper import ( + CreditCardMapper, +) +from app.context.credit_card.infrastructure.models import CreditCardModel + + +@pytest.mark.unit +class TestCreditCardMapper: + """Tests for CreditCardMapper""" + + @pytest.fixture + def sample_model(self): + """Create a sample credit card model""" + return CreditCardModel( + id=1, + user_id=100, + account_id=10, + name="My Credit Card", + currency="USD", + limit=Decimal("5000.00"), + used=Decimal("1500.50"), + deleted_at=None, + ) + + @pytest.fixture + def sample_dto(self): + """Create a sample credit card DTO""" + return CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Credit Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("5000.00")), + used=CardUsed(Decimal("1500.50")), + deleted_at=None, + ) + + def test_to_dto_converts_model_to_dto(self, sample_model): + """Test converting model to DTO""" + dto = CreditCardMapper.to_dto(sample_model) + + assert dto is not None + assert dto.credit_card_id.value == 1 + assert dto.user_id.value == 100 + assert dto.account_id.value == 10 + assert dto.name.value == "My Credit Card" + assert dto.currency.value == "USD" + assert dto.limit.value == Decimal("5000.00") + assert dto.used.value == Decimal("1500.50") + assert dto.deleted_at is None + + def test_to_dto_with_none_returns_none(self): + """Test that to_dto returns None when given None""" + dto = CreditCardMapper.to_dto(None) + assert dto is None + + def test_to_dto_with_deleted_at(self): + """Test converting model with deleted_at to DTO""" + deleted_time = datetime.now(UTC) + model = CreditCardModel( + id=1, + user_id=100, + account_id=10, + name="Deleted Card", + currency="USD", + limit=Decimal("1000.00"), + used=Decimal("0.00"), + deleted_at=deleted_time, + ) + + dto = CreditCardMapper.to_dto(model) + + assert dto is not None + assert dto.deleted_at is not None + assert dto.deleted_at.value == deleted_time + assert dto.is_deleted is True + + def test_to_dto_with_zero_used(self): + """Test converting model with zero used amount""" + model = CreditCardModel( + id=1, + user_id=100, + account_id=10, + name="New Card", + currency="EUR", + limit=Decimal("2000.00"), + used=Decimal("0.00"), + deleted_at=None, + ) + + dto = CreditCardMapper.to_dto(model) + + assert dto is not None + assert dto.used.value == Decimal("0.00") + + def test_to_dto_or_fail_success(self, sample_model): + """Test to_dto_or_fail with valid model""" + dto = CreditCardMapper.to_dto_or_fail(sample_model) + + assert dto is not None + assert dto.credit_card_id.value == 1 + assert dto.name.value == "My Credit Card" + + def test_to_dto_or_fail_raises_error_on_none(self): + """Test that to_dto_or_fail raises CreditCardMapperError on None""" + with pytest.raises(CreditCardMapperError, match="Credit card dto cannot be null"): + CreditCardMapper.to_dto_or_fail(None) + + def test_to_model_converts_dto_to_model(self, sample_dto): + """Test converting DTO to model""" + model = CreditCardMapper.to_model(sample_dto) + + assert model.id == 1 + assert model.user_id == 100 + assert model.account_id == 10 + assert model.name == "My Credit Card" + assert model.currency == "USD" + assert model.limit == Decimal("5000.00") + assert model.used == Decimal("1500.50") + assert model.deleted_at is None + + def test_to_model_with_none_credit_card_id(self): + """Test converting DTO with None credit_card_id (new card)""" + dto = CreditCardDTO( + credit_card_id=None, # New card + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("New Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=CardUsed(Decimal("0.00")), + ) + + model = CreditCardMapper.to_model(dto) + + assert model.id is None + assert model.name == "New Card" + + def test_to_model_with_none_used(self): + """Test converting DTO with None used amount""" + dto = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("My Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + used=None, # No usage yet + ) + + model = CreditCardMapper.to_model(dto) + + assert model.used == 0 # Defaults to 0 + + def test_to_model_with_deleted_at(self): + """Test converting DTO with deleted_at to model""" + deleted_time = datetime.now(UTC) + dto = CreditCardDTO( + credit_card_id=CreditCardID(1), + user_id=CreditCardUserID(100), + account_id=CreditCardAccountID(10), + name=CreditCardName("Deleted Card"), + currency=CreditCardCurrency("USD"), + limit=CardLimit(Decimal("1000.00")), + deleted_at=CreditCardDeletedAt.from_trusted_source(deleted_time), + ) + + model = CreditCardMapper.to_model(dto) + + assert model.deleted_at == deleted_time + + def test_roundtrip_conversion(self, sample_model): + """Test converting model -> DTO -> model maintains data""" + dto = CreditCardMapper.to_dto(sample_model) + model = CreditCardMapper.to_model(dto) + + assert model.id == sample_model.id + assert model.user_id == sample_model.user_id + assert model.account_id == sample_model.account_id + assert model.name == sample_model.name + assert model.currency == sample_model.currency + assert model.limit == sample_model.limit + assert model.used == sample_model.used + assert model.deleted_at == sample_model.deleted_at + + def test_to_dto_uses_from_trusted_source(self, sample_model): + """Test that to_dto uses from_trusted_source to skip validation""" + # This model would fail validation if not using from_trusted_source + # (e.g., name too short), but should work because mapper uses trusted source + model = CreditCardModel( + id=1, + user_id=100, + account_id=10, + name="AB", # Too short for normal validation + currency="USD", + limit=Decimal("1000.00"), + used=Decimal("0.00"), + deleted_at=None, + ) + + # Should not raise because mapper uses from_trusted_source + dto = CreditCardMapper.to_dto(model) + assert dto is not None + assert dto.name.value == "AB" diff --git a/tests/unit/context/entry/application/create_entry_handler_test.py b/tests/unit/context/entry/application/create_entry_handler_test.py new file mode 100644 index 0000000..e0933db --- /dev/null +++ b/tests/unit/context/entry/application/create_entry_handler_test.py @@ -0,0 +1,350 @@ +"""Unit tests for CreateEntryHandler""" + +from datetime import UTC, datetime +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.entry.application.commands import CreateEntryCommand +from app.context.entry.application.dto import CreateEntryErrorCode +from app.context.entry.application.handlers.create_entry_handler import CreateEntryHandler +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotBelongsToUserError, + EntryCategoryNotFoundError, + EntryMapperError, +) +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.shared.domain.value_objects.shared_entry_type import SharedEntryTypeValues + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateEntryHandler: + """Tests for CreateEntryHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service and logger""" + return CreateEntryHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_create_entry_success(self, handler, mock_service): + """Test successful entry creation""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=150.50, + description="Grocery shopping", + household_id=3, + ) + + entry_dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("150.50")), + description=EntryDescription("Grocery shopping"), + household_id=EntryHouseholdID(3), + ) + + mock_service.create_entry = AsyncMock(return_value=entry_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.entry_id == 100 + assert result.account_id == 10 + assert result.category_id == 5 + assert result.entry_type == "expense" + assert result.amount == 150.50 + assert result.description == "Grocery shopping" + mock_service.create_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_create_entry_without_household_id(self, handler, mock_service): + """Test creating entry without household_id""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=5, + entry_type="income", + entry_date=entry_date, + amount=1000.00, + description="Salary", + household_id=None, + ) + + entry_dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.INCOME), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("1000.00")), + description=EntryDescription("Salary"), + household_id=None, + ) + + mock_service.create_entry = AsyncMock(return_value=entry_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.entry_id == 100 + # Verify household_id was None in service call + call_args = mock_service.create_entry.call_args + assert call_args.kwargs["household_id"] is None + + @pytest.mark.asyncio + async def test_create_entry_without_entry_id_returns_error(self, handler, mock_service): + """Test that missing entry_id in result returns error""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + household_id=None, + ) + + entry_dto = EntryDTO( + entry_id=None, # Missing ID + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + household_id=None, + ) + + mock_service.create_entry = AsyncMock(return_value=entry_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateEntryErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Error creating entry" + assert result.entry_id is None + + @pytest.mark.asyncio + async def test_create_entry_account_not_belongs_to_user_error(self, handler, mock_service): + """Test handling of account not belonging to user exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=999, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + household_id=None, + ) + + mock_service.create_entry = AsyncMock( + side_effect=EntryAccountNotBelongsToUserError("Account does not belong to user") + ) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateEntryErrorCode.ACCOUNT_NOT_BELONGS_TO_USER + assert result.error_message == "Account does not belong to user" + assert result.entry_id is None + + @pytest.mark.asyncio + async def test_create_entry_category_not_found_error(self, handler, mock_service): + """Test handling of category not found exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=999, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + household_id=None, + ) + + mock_service.create_entry = AsyncMock(side_effect=EntryCategoryNotFoundError("Category not found")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateEntryErrorCode.CATEGORY_NOT_FOUND + assert result.error_message == "Category not found" + assert result.entry_id is None + + @pytest.mark.asyncio + async def test_create_entry_category_not_belongs_to_user_error(self, handler, mock_service): + """Test handling of category not belonging to user exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=999, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + household_id=None, + ) + + mock_service.create_entry = AsyncMock( + side_effect=EntryCategoryNotBelongsToUserError("Category does not belong to user") + ) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateEntryErrorCode.CATEGORY_NOT_BELONGS_TO_USER + assert result.error_message == "Category does not belong to user" + assert result.entry_id is None + + @pytest.mark.asyncio + async def test_create_entry_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + household_id=None, + ) + + mock_service.create_entry = AsyncMock(side_effect=EntryMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateEntryErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping entry data" + assert result.entry_id is None + + @pytest.mark.asyncio + async def test_create_entry_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + household_id=None, + ) + + mock_service.create_entry = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateEntryErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.entry_id is None + + @pytest.mark.asyncio + async def test_create_entry_converts_primitives_to_value_objects(self, handler, mock_service): + """Test that handler converts command primitives to value objects""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = CreateEntryCommand( + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=250.75, + description="Test Entry", + household_id=3, + ) + + entry_dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("250.75")), + description=EntryDescription("Test Entry"), + household_id=EntryHouseholdID(3), + ) + + mock_service.create_entry = AsyncMock(return_value=entry_dto) + + # Act + await handler.handle(command) + + # Assert - verify service was called with value objects + call_args = mock_service.create_entry.call_args + assert isinstance(call_args.kwargs["user_id"], EntryUserID) + assert isinstance(call_args.kwargs["account_id"], EntryAccountID) + assert isinstance(call_args.kwargs["category_id"], EntryCategoryID) + assert isinstance(call_args.kwargs["entry_type"], EntryType) + assert isinstance(call_args.kwargs["entry_date"], EntryDate) + assert isinstance(call_args.kwargs["amount"], EntryAmount) + assert isinstance(call_args.kwargs["description"], EntryDescription) + assert isinstance(call_args.kwargs["household_id"], EntryHouseholdID) diff --git a/tests/unit/context/entry/application/update_entry_handler_test.py b/tests/unit/context/entry/application/update_entry_handler_test.py new file mode 100644 index 0000000..f11ed8a --- /dev/null +++ b/tests/unit/context/entry/application/update_entry_handler_test.py @@ -0,0 +1,347 @@ +"""Unit tests for UpdateEntryHandler""" + +from datetime import UTC, datetime +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.entry.application.commands import UpdateEntryCommand +from app.context.entry.application.dto import UpdateEntryErrorCode +from app.context.entry.application.handlers.update_entry_handler import UpdateEntryHandler +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotFoundError, + EntryMapperError, + EntryNotFoundError, +) +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.shared.domain.value_objects.shared_entry_type import SharedEntryTypeValues + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestUpdateEntryHandler: + """Tests for UpdateEntryHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service and logger""" + return UpdateEntryHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_update_entry_success(self, handler, mock_service): + """Test successful entry update""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=200.00, + description="Updated description", + ) + + updated_dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("200.00")), + description=EntryDescription("Updated description"), + household_id=EntryHouseholdID(3), + ) + + mock_service.update_entry = AsyncMock(return_value=updated_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.entry is not None + assert result.entry.entry_id == 100 + assert result.entry.account_id == 10 + assert result.entry.category_id == 5 + assert result.entry.entry_type == "expense" + assert result.entry.amount == 200.00 + assert result.entry.description == "Updated description" + assert result.entry.household_id == 3 + mock_service.update_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_update_entry_without_household_id(self, handler, mock_service): + """Test updating entry without household_id""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=150.00, + description="Test", + ) + + updated_dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("150.00")), + description=EntryDescription("Test"), + household_id=None, + ) + + mock_service.update_entry = AsyncMock(return_value=updated_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.entry.household_id is None + + @pytest.mark.asyncio + async def test_update_entry_without_entry_id_returns_error(self, handler, mock_service): + """Test that missing entry_id in result returns error""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + ) + + updated_dto = EntryDTO( + entry_id=None, # Missing ID + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + household_id=None, + ) + + mock_service.update_entry = AsyncMock(return_value=updated_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateEntryErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Error updating entry" + assert result.entry is None + + @pytest.mark.asyncio + async def test_update_entry_not_found_error(self, handler, mock_service): + """Test handling of entry not found exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=999, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + ) + + mock_service.update_entry = AsyncMock(side_effect=EntryNotFoundError("Entry not found")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateEntryErrorCode.NOT_FOUND + assert result.error_message == "Entry not found" + assert result.entry is None + + @pytest.mark.asyncio + async def test_update_entry_account_not_belongs_to_user_error(self, handler, mock_service): + """Test handling of account not belonging to user exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=999, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + ) + + mock_service.update_entry = AsyncMock( + side_effect=EntryAccountNotBelongsToUserError("Account does not belong to user") + ) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateEntryErrorCode.ACCOUNT_NOT_BELONGS_TO_USER + assert result.error_message == "Account does not belong to user" + assert result.entry is None + + @pytest.mark.asyncio + async def test_update_entry_category_not_found_error(self, handler, mock_service): + """Test handling of category not found exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=10, + category_id=999, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + ) + + mock_service.update_entry = AsyncMock(side_effect=EntryCategoryNotFoundError("Category not found")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateEntryErrorCode.CATEGORY_NOT_FOUND + assert result.error_message == "Category not found" + assert result.entry is None + + @pytest.mark.asyncio + async def test_update_entry_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + ) + + mock_service.update_entry = AsyncMock(side_effect=EntryMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateEntryErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping entry data" + assert result.entry is None + + @pytest.mark.asyncio + async def test_update_entry_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=100.00, + description="Test", + ) + + mock_service.update_entry = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateEntryErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.entry is None + + @pytest.mark.asyncio + async def test_update_entry_converts_primitives_to_value_objects(self, handler, mock_service): + """Test that handler converts command primitives to value objects""" + # Arrange + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + command = UpdateEntryCommand( + entry_id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=entry_date, + amount=250.75, + description="Updated Entry", + ) + + updated_dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(entry_date), + amount=EntryAmount(Decimal("250.75")), + description=EntryDescription("Updated Entry"), + household_id=None, + ) + + mock_service.update_entry = AsyncMock(return_value=updated_dto) + + # Act + await handler.handle(command) + + # Assert - verify service was called with value objects + call_args = mock_service.update_entry.call_args + assert isinstance(call_args.kwargs["entry_id"], EntryID) + assert isinstance(call_args.kwargs["user_id"], EntryUserID) + assert isinstance(call_args.kwargs["account_id"], EntryAccountID) + assert isinstance(call_args.kwargs["category_id"], EntryCategoryID) + assert isinstance(call_args.kwargs["entry_type"], EntryType) + assert isinstance(call_args.kwargs["entry_date"], EntryDate) + assert isinstance(call_args.kwargs["amount"], EntryAmount) + assert isinstance(call_args.kwargs["description"], EntryDescription) diff --git a/tests/unit/context/entry/domain/create_entry_service_test.py b/tests/unit/context/entry/domain/create_entry_service_test.py new file mode 100644 index 0000000..105814d --- /dev/null +++ b/tests/unit/context/entry/domain/create_entry_service_test.py @@ -0,0 +1,300 @@ +"""Unit tests for CreateEntryService""" + +from datetime import UTC, datetime +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotFoundError, +) +from app.context.entry.domain.services.create_entry_service import CreateEntryService +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.shared.domain.value_objects.shared_entry_type import SharedEntryTypeValues + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateEntryService: + """Tests for CreateEntryService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return CreateEntryService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_create_entry_success(self, service, mock_repository): + """Test successful entry creation""" + # Arrange + user_id = EntryUserID(1) + account_id = EntryAccountID(10) + category_id = EntryCategoryID(5) + entry_type = EntryType(SharedEntryTypeValues.EXPENSE) + entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)) + amount = EntryAmount(Decimal("150.50")) + description = EntryDescription("Grocery shopping") + household_id = EntryHouseholdID(3) + + expected_dto = EntryDTO( + entry_id=EntryID(100), + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=household_id, + ) + + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.save_entry = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_entry( + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=household_id, + ) + + # Assert + assert result == expected_dto + assert result.entry_id == EntryID(100) + mock_repository.verify_account_belongs_to_user.assert_called_once_with( + account_id=account_id, + user_id=user_id, + ) + mock_repository.verify_category_belongs_to_user.assert_called_once_with( + category_id=category_id, + user_id=user_id, + ) + mock_repository.save_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_create_entry_without_household_id(self, service, mock_repository): + """Test creating entry without household_id""" + # Arrange + user_id = EntryUserID(1) + account_id = EntryAccountID(10) + category_id = EntryCategoryID(5) + entry_type = EntryType(SharedEntryTypeValues.INCOME) + entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)) + amount = EntryAmount(Decimal("1000.00")) + description = EntryDescription("Salary") + + expected_dto = EntryDTO( + entry_id=EntryID(100), + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=None, + ) + + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.save_entry = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_entry( + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=None, + ) + + # Assert + assert result.household_id is None + mock_repository.save_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_create_entry_account_not_belongs_to_user(self, service, mock_repository): + """Test that creating entry with account not belonging to user raises exception""" + # Arrange + user_id = EntryUserID(1) + account_id = EntryAccountID(999) # Account doesn't belong to user + + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=False) + + # Act & Assert + with pytest.raises( + EntryAccountNotBelongsToUserError, + match="Account 999 does not belong to user 1", + ): + await service.create_entry( + user_id=user_id, + account_id=account_id, + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + ) + + # Verify category was not checked + mock_repository.verify_category_belongs_to_user.assert_not_called() + mock_repository.save_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_create_entry_category_not_found(self, service, mock_repository): + """Test that creating entry with invalid category raises exception""" + # Arrange + user_id = EntryUserID(1) + account_id = EntryAccountID(10) + category_id = EntryCategoryID(999) # Category doesn't exist or doesn't belong to user + + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=False) + + # Act & Assert + with pytest.raises( + EntryCategoryNotFoundError, + match="Category 999 not found or does not belong to user", + ): + await service.create_entry( + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + ) + + # Verify save was not called + mock_repository.save_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_create_entry_with_zero_amount(self, service, mock_repository): + """Test creating entry with zero amount""" + # Arrange + user_id = EntryUserID(1) + account_id = EntryAccountID(10) + category_id = EntryCategoryID(5) + entry_type = EntryType(SharedEntryTypeValues.EXPENSE) + entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)) + amount = EntryAmount(Decimal("0.00")) # Zero amount + description = EntryDescription("Refund") + + expected_dto = EntryDTO( + entry_id=EntryID(100), + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=None, + ) + + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.save_entry = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_entry( + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + ) + + # Assert + assert result.amount.value == Decimal("0.00") + + @pytest.mark.asyncio + async def test_create_entry_with_income_type(self, service, mock_repository): + """Test creating entry with INCOME type""" + # Arrange + entry_type = EntryType(SharedEntryTypeValues.INCOME) + + expected_dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=entry_type, + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("2000.00")), + description=EntryDescription("Monthly salary"), + household_id=None, + ) + + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.save_entry = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_entry( + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=entry_type, + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("2000.00")), + description=EntryDescription("Monthly salary"), + ) + + # Assert + assert result.entry_type.value == SharedEntryTypeValues.INCOME + + @pytest.mark.asyncio + async def test_create_entry_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.save_entry = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.create_entry( + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + ) diff --git a/tests/unit/context/entry/domain/entry_amount_test.py b/tests/unit/context/entry/domain/entry_amount_test.py new file mode 100644 index 0000000..0ed59bf --- /dev/null +++ b/tests/unit/context/entry/domain/entry_amount_test.py @@ -0,0 +1,95 @@ +"""Unit tests for EntryAmount value object""" + +from dataclasses import FrozenInstanceError +from decimal import Decimal + +import pytest + +from app.context.entry.domain.value_objects import EntryAmount + + +@pytest.mark.unit +class TestEntryAmount: + """Tests for EntryAmount value object""" + + def test_valid_amount_from_decimal(self): + """Test creating amount from Decimal""" + amount = EntryAmount(Decimal("100.50")) + assert amount.value == Decimal("100.50") + + def test_valid_amount_from_string_raises_error(self): + """Test that strings raise ValueError""" + with pytest.raises(ValueError, match="Amount must be a Decimal"): + EntryAmount("250.75") + + def test_valid_amount_from_int_raises_error(self): + """Test that integers raise ValueError""" + with pytest.raises(ValueError, match="Amount must be a Decimal"): + EntryAmount(1000) + + def test_valid_amount_from_float_raises_error(self): + """Test that floats raise ValueError""" + with pytest.raises(ValueError, match="Amount must be a Decimal"): + EntryAmount(99.99) + + def test_zero_amount(self): + """Test that zero amount is valid""" + amount = EntryAmount(Decimal("0")) + assert amount.value == Decimal("0") + + def test_negative_amount_raises_error(self): + """Test that negative amounts raise ValueError""" + with pytest.raises(ValueError, match="Amount must be non-negative"): + EntryAmount(Decimal("-50.25")) + + def test_large_amount(self): + """Test handling of large amounts""" + large_amount = EntryAmount(Decimal("999999999.99")) + assert large_amount.value == Decimal("999999999.99") + + def test_precision_limited_to_two_decimal_places(self): + """Test that more than 2 decimal places raises ValueError""" + with pytest.raises(ValueError, match="Amount cannot have more than 2 decimal places"): + EntryAmount(Decimal("100.123")) + + def test_two_decimal_places(self): + """Test amount with exactly 2 decimal places""" + amount = EntryAmount(Decimal("50.00")) + assert amount.value == Decimal("50.00") + + def test_invalid_type_raises_error(self): + """Test that invalid types raise ValueError""" + with pytest.raises(ValueError, match="Amount must be a Decimal"): + EntryAmount("not-a-number") + + def test_none_raises_error(self): + """Test that None raises ValueError""" + with pytest.raises((ValueError, TypeError)): + EntryAmount(None) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # Should work even with pre-validated data + amount = EntryAmount.from_trusted_source(Decimal("100.50")) + assert amount.value == Decimal("100.50") + + def test_from_float_conversion(self): + """Test from_float class method for safe float conversion""" + amount = EntryAmount.from_float(123.45) + assert amount.value == Decimal("123.45") + + def test_from_float_with_zero(self): + """Test from_float with zero""" + amount = EntryAmount.from_float(0.0) + assert amount.value == Decimal("0") + + def test_from_float_with_negative_raises_error(self): + """Test from_float with negative number raises error""" + with pytest.raises(ValueError, match="Amount must be non-negative"): + EntryAmount.from_float(-99.99) + + def test_immutability(self): + """Test that value object is immutable""" + amount = EntryAmount(Decimal("100.00")) + with pytest.raises(FrozenInstanceError): + amount.value = Decimal("200.00") diff --git a/tests/unit/context/entry/domain/entry_date_test.py b/tests/unit/context/entry/domain/entry_date_test.py new file mode 100644 index 0000000..2656499 --- /dev/null +++ b/tests/unit/context/entry/domain/entry_date_test.py @@ -0,0 +1,89 @@ +"""Unit tests for EntryDate value object""" + +from dataclasses import FrozenInstanceError +from datetime import UTC, datetime, timezone + +import pytest + +from app.context.entry.domain.value_objects import EntryDate + + +@pytest.mark.unit +class TestEntryDate: + """Tests for EntryDate value object""" + + def test_valid_timezone_aware_datetime(self): + """Test creating entry date with timezone-aware datetime""" + dt = datetime(2024, 12, 31, 15, 30, 0, tzinfo=UTC) + entry_date = EntryDate(dt) + assert entry_date.value == dt + + def test_timezone_aware_with_offset(self): + """Test timezone-aware datetime with specific offset""" + from datetime import timedelta + + tz = timezone(timedelta(hours=-5)) # EST + dt = datetime(2024, 6, 15, 10, 0, 0, tzinfo=tz) + entry_date = EntryDate(dt) + assert entry_date.value == dt + assert entry_date.value.tzinfo is not None + + def test_timezone_naive_datetime_raises_error(self): + """Test that timezone-naive datetime raises ValueError""" + dt_naive = datetime(2024, 12, 31, 15, 30, 0) # No timezone + with pytest.raises(ValueError, match="must be timezone-aware"): + EntryDate(dt_naive) + + def test_invalid_type_raises_error(self): + """Test that non-datetime types raise ValueError""" + with pytest.raises(ValueError, match="EntryDate must be a datetime object"): + EntryDate("2024-12-31") + + def test_invalid_type_int_raises_error(self): + """Test that integer raises ValueError""" + with pytest.raises(ValueError, match="EntryDate must be a datetime object"): + EntryDate(1234567890) + + def test_none_raises_error(self): + """Test that None raises ValueError""" + with pytest.raises(ValueError, match="EntryDate must be a datetime object"): + EntryDate(None) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # Should work even with timezone-naive datetime + dt_naive = datetime(2024, 12, 31, 15, 30, 0) + entry_date = EntryDate.from_trusted_source(dt_naive) + assert entry_date.value == dt_naive + + def test_from_trusted_source_with_timezone_aware(self): + """Test from_trusted_source with timezone-aware datetime""" + dt = datetime(2024, 12, 31, 15, 30, 0, tzinfo=UTC) + entry_date = EntryDate.from_trusted_source(dt) + assert entry_date.value == dt + + def test_immutability(self): + """Test that value object is immutable""" + dt = datetime(2024, 12, 31, 15, 30, 0, tzinfo=UTC) + entry_date = EntryDate(dt) + with pytest.raises(FrozenInstanceError): + entry_date.value = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC) + + def test_past_date(self): + """Test entry date in the past""" + dt = datetime(2020, 1, 1, 0, 0, 0, tzinfo=UTC) + entry_date = EntryDate(dt) + assert entry_date.value == dt + + def test_future_date(self): + """Test entry date in the future""" + dt = datetime(2030, 12, 31, 23, 59, 59, tzinfo=UTC) + entry_date = EntryDate(dt) + assert entry_date.value == dt + + def test_current_datetime(self): + """Test creating entry date with current datetime""" + now = datetime.now(UTC) + entry_date = EntryDate(now) + assert entry_date.value == now + assert entry_date.value.tzinfo is not None diff --git a/tests/unit/context/entry/domain/entry_description_test.py b/tests/unit/context/entry/domain/entry_description_test.py new file mode 100644 index 0000000..8732ce2 --- /dev/null +++ b/tests/unit/context/entry/domain/entry_description_test.py @@ -0,0 +1,85 @@ +"""Unit tests for EntryDescription value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.entry.domain.value_objects import EntryDescription + + +@pytest.mark.unit +class TestEntryDescription: + """Tests for EntryDescription value object""" + + def test_valid_description_creation(self): + """Test creating valid entry description""" + description = EntryDescription("Grocery shopping at Whole Foods") + assert description.value == "Grocery shopping at Whole Foods" + + def test_empty_string_is_valid(self): + """Test that empty string is accepted""" + description = EntryDescription("") + assert description.value == "" + + def test_maximum_length_description(self): + """Test that maximum length (500 characters) is accepted""" + long_description = "A" * 500 + description = EntryDescription(long_description) + assert description.value == long_description + assert len(description.value) == 500 + + def test_description_with_special_characters(self): + """Test description with special characters""" + description = EntryDescription("Payment for rent - $1,500.00 (Dec 2024)") + assert description.value == "Payment for rent - $1,500.00 (Dec 2024)" + + def test_description_with_unicode(self): + """Test description with unicode characters""" + description = EntryDescription("Café lunch €15.50") + assert description.value == "Café lunch €15.50" + + def test_invalid_type_raises_error(self): + """Test that invalid types raise ValueError""" + with pytest.raises(ValueError, match="EntryDescription must be a string"): + EntryDescription(123) + + def test_invalid_type_none_raises_error(self): + """Test that None raises ValueError""" + with pytest.raises(ValueError, match="EntryDescription must be a string"): + EntryDescription(None) + + def test_too_long_description_raises_error(self): + """Test that descriptions longer than 500 characters raise error""" + long_description = "A" * 501 + with pytest.raises( + ValueError, + match="EntryDescription cannot exceed 500 characters, got 501", + ): + EntryDescription(long_description) + + def test_very_long_description_raises_error(self): + """Test that very long descriptions raise error""" + very_long_description = "A" * 1000 + with pytest.raises( + ValueError, + match="EntryDescription cannot exceed 500 characters, got 1000", + ): + EntryDescription(very_long_description) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + description = EntryDescription.from_trusted_source("A" * 501) # Too long + assert description.value == "A" * 501 + + def test_from_trusted_source_with_non_string(self): + """Test that from_trusted_source even works with non-string (for DB data)""" + # This bypasses validation entirely + description = EntryDescription.from_trusted_source(123) + assert description.value == 123 + + def test_immutability(self): + """Test that value object is immutable""" + description = EntryDescription("Immutable description") + with pytest.raises(FrozenInstanceError): + description.value = "New description" diff --git a/tests/unit/context/entry/domain/update_entry_service_test.py b/tests/unit/context/entry/domain/update_entry_service_test.py new file mode 100644 index 0000000..fc6403b --- /dev/null +++ b/tests/unit/context/entry/domain/update_entry_service_test.py @@ -0,0 +1,324 @@ +"""Unit tests for UpdateEntryService""" + +from datetime import UTC, datetime +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import ( + EntryAccountNotBelongsToUserError, + EntryCategoryNotFoundError, + EntryNotFoundError, +) +from app.context.entry.domain.services.update_entry_service import UpdateEntryService +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.shared.domain.value_objects.shared_entry_type import SharedEntryTypeValues + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestUpdateEntryService: + """Tests for UpdateEntryService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def mock_logger(self): + """Create a mock logger""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return UpdateEntryService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_update_entry_success(self, service, mock_repository): + """Test successful entry update""" + # Arrange + entry_id = EntryID(100) + user_id = EntryUserID(1) + account_id = EntryAccountID(10) + category_id = EntryCategoryID(5) + entry_type = EntryType(SharedEntryTypeValues.EXPENSE) + entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)) + amount = EntryAmount(Decimal("200.00")) + description = EntryDescription("Updated description") + household_id = EntryHouseholdID(3) + + # Existing entry with household_id + existing_entry = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(4), # Different category + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Old description"), + household_id=household_id, + ) + + updated_dto = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + household_id=household_id, # Preserved from existing + ) + + mock_repository.find_entry_by_id = AsyncMock(return_value=existing_entry) + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.update_entry = AsyncMock(return_value=updated_dto) + + # Act + result = await service.update_entry( + entry_id=entry_id, + user_id=user_id, + account_id=account_id, + category_id=category_id, + entry_type=entry_type, + entry_date=entry_date, + amount=amount, + description=description, + ) + + # Assert + assert result == updated_dto + assert result.household_id == household_id # Preserved + mock_repository.find_entry_by_id.assert_called_once_with( + entry_id=entry_id, + user_id=user_id, + ) + mock_repository.verify_account_belongs_to_user.assert_called_once() + mock_repository.verify_category_belongs_to_user.assert_called_once() + mock_repository.update_entry.assert_called_once() + + @pytest.mark.asyncio + async def test_update_entry_not_found(self, service, mock_repository): + """Test updating non-existent entry raises exception""" + # Arrange + entry_id = EntryID(999) + user_id = EntryUserID(1) + + mock_repository.find_entry_by_id = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises( + EntryNotFoundError, + match="Entry 999 not found or does not belong to user", + ): + await service.update_entry( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + ) + + # Verify subsequent checks were not performed + mock_repository.verify_account_belongs_to_user.assert_not_called() + mock_repository.verify_category_belongs_to_user.assert_not_called() + mock_repository.update_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_update_entry_account_not_belongs_to_user(self, service, mock_repository): + """Test updating entry with account not belonging to user raises exception""" + # Arrange + entry_id = EntryID(100) + user_id = EntryUserID(1) + account_id = EntryAccountID(999) # Account doesn't belong to user + + existing_entry = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Old"), + household_id=None, + ) + + mock_repository.find_entry_by_id = AsyncMock(return_value=existing_entry) + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=False) + + # Act & Assert + with pytest.raises( + EntryAccountNotBelongsToUserError, + match="Account 999 does not belong to user 1", + ): + await service.update_entry( + entry_id=entry_id, + user_id=user_id, + account_id=account_id, + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + ) + + # Verify category and update were not called + mock_repository.verify_category_belongs_to_user.assert_not_called() + mock_repository.update_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_update_entry_category_not_found(self, service, mock_repository): + """Test updating entry with invalid category raises exception""" + # Arrange + entry_id = EntryID(100) + user_id = EntryUserID(1) + category_id = EntryCategoryID(999) # Category doesn't exist + + existing_entry = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Old"), + household_id=None, + ) + + mock_repository.find_entry_by_id = AsyncMock(return_value=existing_entry) + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=False) + + # Act & Assert + with pytest.raises( + EntryCategoryNotFoundError, + match="Category 999 not found or does not belong to user", + ): + await service.update_entry( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=category_id, + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + ) + + # Verify update was not called + mock_repository.update_entry.assert_not_called() + + @pytest.mark.asyncio + async def test_update_entry_preserves_household_id(self, service, mock_repository): + """Test that update preserves household_id from existing entry""" + # Arrange + entry_id = EntryID(100) + user_id = EntryUserID(1) + household_id = EntryHouseholdID(5) + + existing_entry = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Old"), + household_id=household_id, # Has household_id + ) + + updated_dto = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("200.00")), + description=EntryDescription("Updated"), + household_id=household_id, # Preserved + ) + + mock_repository.find_entry_by_id = AsyncMock(return_value=existing_entry) + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.update_entry = AsyncMock(return_value=updated_dto) + + # Act + result = await service.update_entry( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("200.00")), + description=EntryDescription("Updated"), + household_id=EntryHouseholdID(5), + ) + + # Assert + assert result.household_id == household_id + # Verify the DTO passed to update_entry had household_id preserved + call_args = mock_repository.update_entry.call_args[0][0] + assert call_args.household_id == household_id + + @pytest.mark.asyncio + async def test_update_entry_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + entry_id = EntryID(100) + user_id = EntryUserID(1) + + existing_entry = EntryDTO( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Old"), + household_id=None, + ) + + mock_repository.find_entry_by_id = AsyncMock(return_value=existing_entry) + mock_repository.verify_account_belongs_to_user = AsyncMock(return_value=True) + mock_repository.verify_category_belongs_to_user = AsyncMock(return_value=True) + mock_repository.update_entry = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.update_entry( + entry_id=entry_id, + user_id=user_id, + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + ) diff --git a/tests/unit/context/entry/infrastructure/entry_mapper_test.py b/tests/unit/context/entry/infrastructure/entry_mapper_test.py new file mode 100644 index 0000000..24c7376 --- /dev/null +++ b/tests/unit/context/entry/infrastructure/entry_mapper_test.py @@ -0,0 +1,308 @@ +"""Unit tests for EntryMapper""" + +from datetime import UTC, datetime +from decimal import Decimal + +import pytest + +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.exceptions import EntryMapperError +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryAmount, + EntryCategoryID, + EntryDate, + EntryDescription, + EntryHouseholdID, + EntryID, + EntryType, + EntryUserID, +) +from app.context.entry.infrastructure.mappers.entry_mapper import EntryMapper +from app.context.entry.infrastructure.models import EntryModel +from app.shared.domain.value_objects.shared_entry_type import SharedEntryTypeValues + + +@pytest.mark.unit +class TestEntryMapper: + """Tests for EntryMapper""" + + def test_to_dto_with_valid_model(self): + """Test converting valid model to DTO""" + # Arrange + model = EntryModel( + id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), + amount=Decimal("150.50"), + description="Grocery shopping", + household_id=3, + ) + + # Act + dto = EntryMapper.to_dto(model) + + # Assert + assert dto is not None + assert isinstance(dto, EntryDTO) + assert dto.entry_id == EntryID.from_trusted_source(100) + assert dto.user_id == EntryUserID.from_trusted_source(1) + assert dto.account_id == EntryAccountID.from_trusted_source(10) + assert dto.category_id == EntryCategoryID.from_trusted_source(5) + assert dto.entry_type.value == SharedEntryTypeValues.EXPENSE + assert dto.entry_date.value == datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + assert dto.amount.value == Decimal("150.50") + assert dto.description.value == "Grocery shopping" + assert dto.household_id == EntryHouseholdID.from_trusted_source(3) + + def test_to_dto_with_none_model(self): + """Test converting None model returns None""" + # Act + dto = EntryMapper.to_dto(None) + + # Assert + assert dto is None + + def test_to_dto_without_household_id(self): + """Test converting model without household_id""" + # Arrange + model = EntryModel( + id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="income", + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), + amount=Decimal("1000.00"), + description="Salary", + household_id=None, + ) + + # Act + dto = EntryMapper.to_dto(model) + + # Assert + assert dto is not None + assert dto.household_id is None + + def test_to_dto_with_income_type(self): + """Test converting model with income type""" + # Arrange + model = EntryModel( + id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="income", + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), + amount=Decimal("2000.00"), + description="Monthly salary", + household_id=None, + ) + + # Act + dto = EntryMapper.to_dto(model) + + # Assert + assert dto is not None + assert dto.entry_type.value == SharedEntryTypeValues.INCOME + + def test_to_dto_uses_from_trusted_source(self): + """Test that to_dto uses from_trusted_source for performance""" + # Arrange - data that would normally fail validation + model = EntryModel( + id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), + amount=Decimal("150.50"), + description="Test", # Valid, but testing trusted source path + household_id=3, + ) + + # Act + dto = EntryMapper.to_dto(model) + + # Assert - should succeed because from_trusted_source skips validation + assert dto is not None + assert dto.entry_id.value == 100 + + def test_to_dto_or_fail_with_valid_model(self): + """Test to_dto_or_fail with valid model""" + # Arrange + model = EntryModel( + id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), + amount=Decimal("150.50"), + description="Test", + household_id=None, + ) + + # Act + dto = EntryMapper.to_dto_or_fail(model) + + # Assert + assert dto is not None + assert isinstance(dto, EntryDTO) + assert dto.entry_id.value == 100 + + def test_to_dto_or_fail_with_none_raises_error(self): + """Test that to_dto_or_fail with None raises EntryMapperError""" + # Act & Assert + with pytest.raises(EntryMapperError, match="Entry DTO cannot be null"): + EntryMapper.to_dto_or_fail(None) + + def test_to_model_with_all_fields(self): + """Test converting DTO to model with all fields""" + # Arrange + dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("150.50")), + description=EntryDescription("Grocery shopping"), + household_id=EntryHouseholdID(3), + ) + + # Act + model = EntryMapper.to_model(dto) + + # Assert + assert isinstance(model, EntryModel) + assert model.id == 100 + assert model.user_id == 1 + assert model.account_id == 10 + assert model.category_id == 5 + assert model.entry_type == "expense" + assert model.entry_date == datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) + assert model.amount == Decimal("150.50") + assert model.description == "Grocery shopping" + assert model.household_id == 3 + + def test_to_model_without_entry_id(self): + """Test converting DTO to model without entry_id (new entry)""" + # Arrange + dto = EntryDTO( + entry_id=None, + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("100.00")), + description=EntryDescription("Test"), + household_id=None, + ) + + # Act + model = EntryMapper.to_model(dto) + + # Assert + assert model.id is None + assert model.user_id == 1 + + def test_to_model_without_household_id(self): + """Test converting DTO to model without household_id""" + # Arrange + dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.INCOME), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("1000.00")), + description=EntryDescription("Salary"), + household_id=None, + ) + + # Act + model = EntryMapper.to_model(dto) + + # Assert + assert model.household_id is None + + def test_to_model_with_income_type(self): + """Test converting DTO with income type to model""" + # Arrange + dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.INCOME), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("2000.00")), + description=EntryDescription("Monthly salary"), + household_id=None, + ) + + # Act + model = EntryMapper.to_model(dto) + + # Assert + assert model.entry_type == "income" + + def test_to_model_preserves_precision(self): + """Test that decimal precision is preserved in model conversion""" + # Arrange + dto = EntryDTO( + entry_id=EntryID(100), + user_id=EntryUserID(1), + account_id=EntryAccountID(10), + category_id=EntryCategoryID(5), + entry_type=EntryType(SharedEntryTypeValues.EXPENSE), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), + amount=EntryAmount(Decimal("123.45")), + description=EntryDescription("Test"), + household_id=None, + ) + + # Act + model = EntryMapper.to_model(dto) + + # Assert + assert model.amount == Decimal("123.45") + assert str(model.amount) == "123.45" + + def test_roundtrip_conversion(self): + """Test that model -> DTO -> model preserves data""" + # Arrange + original_model = EntryModel( + id=100, + user_id=1, + account_id=10, + category_id=5, + entry_type="expense", + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), + amount=Decimal("150.50"), + description="Grocery shopping", + household_id=3, + ) + + # Act + dto = EntryMapper.to_dto(original_model) + converted_model = EntryMapper.to_model(dto) + + # Assert + assert converted_model.id == original_model.id + assert converted_model.user_id == original_model.user_id + assert converted_model.account_id == original_model.account_id + assert converted_model.category_id == original_model.category_id + assert converted_model.entry_type == original_model.entry_type + assert converted_model.entry_date == original_model.entry_date + assert converted_model.amount == original_model.amount + assert converted_model.description == original_model.description + assert converted_model.household_id == original_model.household_id diff --git a/tests/unit/context/household/__init__.py b/tests/unit/context/household/__init__.py new file mode 100644 index 0000000..26a9b24 --- /dev/null +++ b/tests/unit/context/household/__init__.py @@ -0,0 +1 @@ +"""Household context unit tests""" diff --git a/tests/unit/context/household/application/__init__.py b/tests/unit/context/household/application/__init__.py new file mode 100644 index 0000000..7dee21e --- /dev/null +++ b/tests/unit/context/household/application/__init__.py @@ -0,0 +1 @@ +"""Household application layer unit tests""" diff --git a/tests/unit/context/household/application/create_household_handler_test.py b/tests/unit/context/household/application/create_household_handler_test.py new file mode 100644 index 0000000..0e9f278 --- /dev/null +++ b/tests/unit/context/household/application/create_household_handler_test.py @@ -0,0 +1,176 @@ +"""Unit tests for CreateHouseholdHandler""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.application.commands import CreateHouseholdCommand +from app.context.household.application.dto import CreateHouseholdErrorCode +from app.context.household.application.handlers.create_household_handler import ( + CreateHouseholdHandler, +) +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.exceptions import ( + HouseholdMapperError, + HouseholdNameAlreadyExistError, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateHouseholdHandler: + """Tests for CreateHouseholdHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service and logger""" + return CreateHouseholdHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_create_household_success(self, handler, mock_service): + """Test successful household creation""" + # Arrange + command = CreateHouseholdCommand(user_id=1, name="Smith Family") + + household_dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=HouseholdUserID(1), + name=HouseholdName("Smith Family"), + ) + + mock_service.create_household = AsyncMock(return_value=household_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.household_id == 10 + assert result.household_name == "Smith Family" + mock_service.create_household.assert_called_once() + + @pytest.mark.asyncio + async def test_create_household_without_id_returns_error(self, handler, mock_service): + """Test that missing household_id in result returns error""" + # Arrange + command = CreateHouseholdCommand(user_id=1, name="Smith Family") + + household_dto = HouseholdDTO( + household_id=None, # Missing ID + owner_user_id=HouseholdUserID(1), + name=HouseholdName("Smith Family"), + ) + + mock_service.create_household = AsyncMock(return_value=household_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateHouseholdErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Error creating household" + assert result.household_id is None + + @pytest.mark.asyncio + async def test_create_household_duplicate_name_error(self, handler, mock_service): + """Test handling of duplicate name exception""" + # Arrange + command = CreateHouseholdCommand(user_id=1, name="Duplicate") + + mock_service.create_household = AsyncMock(side_effect=HouseholdNameAlreadyExistError("Duplicate name")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateHouseholdErrorCode.NAME_ALREADY_EXISTS + assert result.error_message == "Household name already exists" + assert result.household_id is None + + @pytest.mark.asyncio + async def test_create_household_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + command = CreateHouseholdCommand(user_id=1, name="Test") + + mock_service.create_household = AsyncMock(side_effect=HouseholdMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateHouseholdErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping model to DTO" + assert result.household_id is None + + @pytest.mark.asyncio + async def test_create_household_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + command = CreateHouseholdCommand(user_id=1, name="Test") + + mock_service.create_household = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateHouseholdErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.household_id is None + + @pytest.mark.asyncio + async def test_create_household_converts_primitives_to_value_objects(self, handler, mock_service): + """Test that handler converts command primitives to value objects""" + # Arrange + command = CreateHouseholdCommand(user_id=1, name="Test Household") + + household_dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=HouseholdUserID(1), + name=HouseholdName("Test Household"), + ) + + mock_service.create_household = AsyncMock(return_value=household_dto) + + # Act + await handler.handle(command) + + # Assert - verify service was called with value objects + call_args = mock_service.create_household.call_args + assert isinstance(call_args.kwargs["name"], HouseholdName) + assert isinstance(call_args.kwargs["creator_user_id"], HouseholdUserID) + assert call_args.kwargs["name"].value == "Test Household" + assert call_args.kwargs["creator_user_id"].value == 1 + + @pytest.mark.asyncio + async def test_create_household_with_long_name(self, handler, mock_service): + """Test creating household with maximum length name""" + # Arrange + long_name = "H" * 100 + command = CreateHouseholdCommand(user_id=1, name=long_name) + + household_dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=HouseholdUserID(1), + name=HouseholdName(long_name), + ) + + mock_service.create_household = AsyncMock(return_value=household_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.household_name == long_name diff --git a/tests/unit/context/household/application/invite_user_handler_test.py b/tests/unit/context/household/application/invite_user_handler_test.py new file mode 100644 index 0000000..dd0d0c1 --- /dev/null +++ b/tests/unit/context/household/application/invite_user_handler_test.py @@ -0,0 +1,204 @@ +"""Unit tests for InviteUserHandler""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.application.commands import InviteUserCommand +from app.context.household.application.dto import InviteUserErrorCode +from app.context.household.application.handlers.invite_user_handler import ( + InviteUserHandler, +) +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + HouseholdMapperError, + OnlyOwnerCanInviteError, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdRole, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestInviteUserHandler: + """Tests for InviteUserHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service and logger""" + return InviteUserHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_invite_user_success(self, handler, mock_service): + """Test successful user invitation""" + # Arrange + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") + + member_dto = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=HouseholdID(10), + user_id=HouseholdUserID(2), + role=HouseholdRole("participant"), + joined_at=None, + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_service.invite_user = AsyncMock(return_value=member_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.member_id == 1 + assert result.household_id == 10 + assert result.user_id == 2 + assert result.role == "participant" + mock_service.invite_user.assert_called_once() + + @pytest.mark.asyncio + async def test_invite_user_without_member_id_returns_error(self, handler, mock_service): + """Test that missing member_id in result returns error""" + # Arrange + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") + + member_dto = HouseholdMemberDTO( + member_id=None, # Missing ID + household_id=HouseholdID(10), + user_id=HouseholdUserID(2), + role=HouseholdRole("participant"), + joined_at=None, + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_service.invite_user = AsyncMock(return_value=member_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == InviteUserErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Error creating invitation" + assert result.member_id is None + + @pytest.mark.asyncio + async def test_invite_user_only_owner_can_invite_error(self, handler, mock_service): + """Test handling of non-owner attempting to invite""" + # Arrange + command = InviteUserCommand(inviter_user_id=99, household_id=10, invitee_user_id=2, role="participant") + + mock_service.invite_user = AsyncMock(side_effect=OnlyOwnerCanInviteError("Only owner can invite")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == InviteUserErrorCode.ONLY_OWNER_CAN_INVITE + assert result.error_message == "Only the household owner can invite users" + assert result.member_id is None + + @pytest.mark.asyncio + async def test_invite_user_already_active_member_error(self, handler, mock_service): + """Test handling of already active member exception""" + # Arrange + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") + + mock_service.invite_user = AsyncMock(side_effect=AlreadyActiveMemberError("Already active")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == InviteUserErrorCode.ALREADY_ACTIVE_MEMBER + assert result.error_message == "User is already an active member of this household" + assert result.member_id is None + + @pytest.mark.asyncio + async def test_invite_user_already_invited_error(self, handler, mock_service): + """Test handling of already invited exception""" + # Arrange + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") + + mock_service.invite_user = AsyncMock(side_effect=AlreadyInvitedError("Already invited")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == InviteUserErrorCode.ALREADY_INVITED + assert result.error_message == "User already has a pending invite to this household" + assert result.member_id is None + + @pytest.mark.asyncio + async def test_invite_user_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") + + mock_service.invite_user = AsyncMock(side_effect=HouseholdMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == InviteUserErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping model to DTO" + assert result.member_id is None + + @pytest.mark.asyncio + async def test_invite_user_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") + + mock_service.invite_user = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == InviteUserErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.member_id is None + + @pytest.mark.asyncio + async def test_invite_user_converts_primitives_to_value_objects(self, handler, mock_service): + """Test that handler converts command primitives to value objects""" + # Arrange + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") + + member_dto = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=HouseholdID(10), + user_id=HouseholdUserID(2), + role=HouseholdRole("participant"), + joined_at=None, + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_service.invite_user = AsyncMock(return_value=member_dto) + + # Act + await handler.handle(command) + + # Assert - verify service was called with value objects + call_args = mock_service.invite_user.call_args + assert isinstance(call_args.kwargs["inviter_user_id"], HouseholdUserID) + assert isinstance(call_args.kwargs["household_id"], HouseholdID) + assert isinstance(call_args.kwargs["invitee_user_id"], HouseholdUserID) + assert isinstance(call_args.kwargs["role"], HouseholdRole) diff --git a/tests/unit/context/household/domain/__init__.py b/tests/unit/context/household/domain/__init__.py new file mode 100644 index 0000000..d64b5c5 --- /dev/null +++ b/tests/unit/context/household/domain/__init__.py @@ -0,0 +1 @@ +"""Household domain layer unit tests""" diff --git a/tests/unit/context/household/domain/accept_invite_service_test.py b/tests/unit/context/household/domain/accept_invite_service_test.py new file mode 100644 index 0000000..87155d5 --- /dev/null +++ b/tests/unit/context/household/domain/accept_invite_service_test.py @@ -0,0 +1,137 @@ +"""Unit tests for AcceptInviteService""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.exceptions import NotInvitedError +from app.context.household.domain.services.accept_invite_service import ( + AcceptInviteService, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdRole, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestAcceptInviteService: + """Tests for AcceptInviteService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return AcceptInviteService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_accept_invite_success(self, service, mock_repository): + """Test successful invite acceptance""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + # Pending invite + pending_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=user_id, + role=HouseholdRole("participant"), + joined_at=None, # Pending + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + # Accepted member (with joined_at set) + accepted_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=user_id, + role=HouseholdRole("participant"), + joined_at=datetime.now(UTC), # Accepted + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_repository.find_member = AsyncMock(return_value=pending_member) + mock_repository.accept_invite = AsyncMock(return_value=accepted_member) + + # Act + result = await service.accept_invite(user_id=user_id, household_id=household_id) + + # Assert + assert result == accepted_member + assert result.is_active is True + assert result.is_invited is False + mock_repository.find_member.assert_called_once_with(household_id, user_id) + mock_repository.accept_invite.assert_called_once_with(household_id, user_id) + + @pytest.mark.asyncio + async def test_accept_invite_no_invite_raises_error(self, service, mock_repository): + """Test that accepting without invite raises NotInvitedError""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + mock_repository.find_member = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(NotInvitedError, match="No pending invite found for this household"): + await service.accept_invite(user_id=user_id, household_id=household_id) + + @pytest.mark.asyncio + async def test_accept_invite_already_active_raises_error(self, service, mock_repository): + """Test that accepting when already active raises error""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + # Already active member + active_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=user_id, + role=HouseholdRole("participant"), + joined_at=datetime.now(UTC), # Already active + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_repository.find_member = AsyncMock(return_value=active_member) + + # Act & Assert + with pytest.raises(NotInvitedError, match="No pending invite found for this household"): + await service.accept_invite(user_id=user_id, household_id=household_id) + + @pytest.mark.asyncio + async def test_accept_invite_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + pending_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=user_id, + role=HouseholdRole("participant"), + joined_at=None, + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_repository.find_member = AsyncMock(return_value=pending_member) + mock_repository.accept_invite = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.accept_invite(user_id=user_id, household_id=household_id) diff --git a/tests/unit/context/household/domain/create_household_service_test.py b/tests/unit/context/household/domain/create_household_service_test.py new file mode 100644 index 0000000..47f0263 --- /dev/null +++ b/tests/unit/context/household/domain/create_household_service_test.py @@ -0,0 +1,137 @@ +"""Unit tests for CreateHouseholdService""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.exceptions import HouseholdNameAlreadyExistError +from app.context.household.domain.services.create_household_service import ( + CreateHouseholdService, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateHouseholdService: + """Tests for CreateHouseholdService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return CreateHouseholdService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_create_household_success(self, service, mock_repository): + """Test successful household creation""" + # Arrange + name = HouseholdName("Smith Family") + creator_user_id = HouseholdUserID(1) + + expected_dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=creator_user_id, + name=name, + ) + + mock_repository.create_household = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_household(name=name, creator_user_id=creator_user_id) + + # Assert + assert result == expected_dto + assert result.household_id == HouseholdID(10) + assert result.owner_user_id == creator_user_id + assert result.name == name + mock_repository.create_household.assert_called_once() + + # Verify the DTO passed to create_household + call_args = mock_repository.create_household.call_args + passed_dto = call_args.kwargs["household_dto"] + assert passed_dto.household_id is None # New household, no ID yet + assert passed_dto.owner_user_id == creator_user_id + assert passed_dto.name == name + + @pytest.mark.asyncio + async def test_create_household_duplicate_name_raises_error(self, service, mock_repository): + """Test that duplicate household name raises HouseholdNameAlreadyExistError""" + # Arrange + name = HouseholdName("Existing Household") + creator_user_id = HouseholdUserID(1) + + mock_repository.create_household = AsyncMock( + side_effect=HouseholdNameAlreadyExistError("Household name already exists") + ) + + # Act & Assert + with pytest.raises(HouseholdNameAlreadyExistError, match="Household name already exists"): + await service.create_household(name=name, creator_user_id=creator_user_id) + + @pytest.mark.asyncio + async def test_create_household_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + mock_repository.create_household = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.create_household( + name=HouseholdName("Test"), + creator_user_id=HouseholdUserID(1), + ) + + @pytest.mark.asyncio + async def test_create_household_with_long_name(self, service, mock_repository): + """Test creating household with maximum length name""" + # Arrange + long_name = "H" * 100 + name = HouseholdName(long_name) + creator_user_id = HouseholdUserID(1) + + expected_dto = HouseholdDTO( + household_id=HouseholdID(20), + owner_user_id=creator_user_id, + name=name, + ) + + mock_repository.create_household = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_household(name=name, creator_user_id=creator_user_id) + + # Assert + assert result.name.value == long_name + mock_repository.create_household.assert_called_once() + + @pytest.mark.asyncio + async def test_create_household_with_special_characters(self, service, mock_repository): + """Test creating household with special characters in name""" + # Arrange + name = HouseholdName("Smith's Household #1") + creator_user_id = HouseholdUserID(1) + + expected_dto = HouseholdDTO( + household_id=HouseholdID(30), + owner_user_id=creator_user_id, + name=name, + ) + + mock_repository.create_household = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_household(name=name, creator_user_id=creator_user_id) + + # Assert + assert result.name == name + mock_repository.create_household.assert_called_once() diff --git a/tests/unit/context/household/domain/decline_invite_service_test.py b/tests/unit/context/household/domain/decline_invite_service_test.py new file mode 100644 index 0000000..af7a5c9 --- /dev/null +++ b/tests/unit/context/household/domain/decline_invite_service_test.py @@ -0,0 +1,124 @@ +"""Unit tests for DeclineInviteService""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.exceptions import NotInvitedError +from app.context.household.domain.services.decline_invite_service import ( + DeclineInviteService, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdRole, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestDeclineInviteService: + """Tests for DeclineInviteService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return DeclineInviteService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_decline_invite_success(self, service, mock_repository): + """Test successful invite decline""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + # Pending invite + pending_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=user_id, + role=HouseholdRole("participant"), + joined_at=None, # Pending + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_repository.find_member = AsyncMock(return_value=pending_member) + mock_repository.revoke_or_remove = AsyncMock(return_value=None) + + # Act + result = await service.decline_invite(user_id=user_id, household_id=household_id) + + # Assert + assert result is None + mock_repository.find_member.assert_called_once_with(household_id, user_id) + mock_repository.revoke_or_remove.assert_called_once_with(household_id, user_id) + + @pytest.mark.asyncio + async def test_decline_invite_no_invite_raises_error(self, service, mock_repository): + """Test that declining without invite raises NotInvitedError""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + mock_repository.find_member = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(NotInvitedError, match="No pending invite found for this household"): + await service.decline_invite(user_id=user_id, household_id=household_id) + + @pytest.mark.asyncio + async def test_decline_invite_already_active_raises_error(self, service, mock_repository): + """Test that declining when already active raises error""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + # Already active member + active_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=user_id, + role=HouseholdRole("participant"), + joined_at=datetime.now(UTC), # Already active + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_repository.find_member = AsyncMock(return_value=active_member) + + # Act & Assert + with pytest.raises(NotInvitedError, match="No pending invite found for this household"): + await service.decline_invite(user_id=user_id, household_id=household_id) + + @pytest.mark.asyncio + async def test_decline_invite_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + user_id = HouseholdUserID(2) + household_id = HouseholdID(10) + + pending_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=user_id, + role=HouseholdRole("participant"), + joined_at=None, + invited_by_user_id=HouseholdUserID(1), + invited_at=datetime.now(UTC), + ) + + mock_repository.find_member = AsyncMock(return_value=pending_member) + mock_repository.revoke_or_remove = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.decline_invite(user_id=user_id, household_id=household_id) diff --git a/tests/unit/context/household/domain/household_id_test.py b/tests/unit/context/household/domain/household_id_test.py new file mode 100644 index 0000000..1245206 --- /dev/null +++ b/tests/unit/context/household/domain/household_id_test.py @@ -0,0 +1,48 @@ +"""Unit tests for HouseholdID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.household.domain.value_objects import HouseholdID + + +@pytest.mark.unit +class TestHouseholdID: + """Tests for HouseholdID value object""" + + def test_valid_id_creation(self): + """Test creating valid household ID""" + household_id = HouseholdID(1) + assert household_id.value == 1 + + def test_large_id_creation(self): + """Test creating household ID with large number""" + household_id = HouseholdID(999999) + assert household_id.value == 999999 + + def test_zero_id_raises_error(self): + """Test that zero raises ValueError""" + with pytest.raises(ValueError, match="HouseholdID must be positive"): + HouseholdID(0) + + def test_negative_id_raises_error(self): + """Test that negative number raises ValueError""" + with pytest.raises(ValueError, match="HouseholdID must be positive"): + HouseholdID(-1) + + def test_non_integer_raises_error(self): + """Test that non-integer type raises ValueError""" + with pytest.raises(ValueError, match="HouseholdID must be an integer"): + HouseholdID("123") + + def test_float_raises_error(self): + """Test that float raises ValueError""" + with pytest.raises(ValueError, match="HouseholdID must be an integer"): + HouseholdID(1.5) + + def test_immutability(self): + """Test that value object is immutable""" + household_id = HouseholdID(1) + with pytest.raises(FrozenInstanceError): + household_id.value = 2 diff --git a/tests/unit/context/household/domain/household_member_id_test.py b/tests/unit/context/household/domain/household_member_id_test.py new file mode 100644 index 0000000..6086c14 --- /dev/null +++ b/tests/unit/context/household/domain/household_member_id_test.py @@ -0,0 +1,48 @@ +"""Unit tests for HouseholdMemberID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.household.domain.value_objects import HouseholdMemberID + + +@pytest.mark.unit +class TestHouseholdMemberID: + """Tests for HouseholdMemberID value object""" + + def test_valid_id_creation(self): + """Test creating valid household member ID""" + member_id = HouseholdMemberID(1) + assert member_id.value == 1 + + def test_large_id_creation(self): + """Test creating household member ID with large number""" + member_id = HouseholdMemberID(999999) + assert member_id.value == 999999 + + def test_zero_id_raises_error(self): + """Test that zero raises ValueError""" + with pytest.raises(ValueError, match="Household member ID must be a positive integer"): + HouseholdMemberID(0) + + def test_negative_id_raises_error(self): + """Test that negative number raises ValueError""" + with pytest.raises(ValueError, match="Household member ID must be a positive integer"): + HouseholdMemberID(-1) + + def test_non_integer_raises_error(self): + """Test that non-integer type raises ValueError""" + with pytest.raises(ValueError, match="Household member ID must be an integer"): + HouseholdMemberID("123") + + def test_float_raises_error(self): + """Test that float raises ValueError""" + with pytest.raises(ValueError, match="Household member ID must be an integer"): + HouseholdMemberID(1.5) + + def test_immutability(self): + """Test that value object is immutable""" + member_id = HouseholdMemberID(1) + with pytest.raises(FrozenInstanceError): + member_id.value = 2 diff --git a/tests/unit/context/household/domain/household_name_test.py b/tests/unit/context/household/domain/household_name_test.py new file mode 100644 index 0000000..d8f9cd4 --- /dev/null +++ b/tests/unit/context/household/domain/household_name_test.py @@ -0,0 +1,70 @@ +"""Unit tests for HouseholdName value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.household.domain.value_objects import HouseholdName + + +@pytest.mark.unit +class TestHouseholdName: + """Tests for HouseholdName value object""" + + def test_valid_name_creation(self): + """Test creating valid household name""" + name = HouseholdName("My Household") + assert name.value == "My Household" + + def test_single_character_name(self): + """Test minimum length name""" + name = HouseholdName("H") + assert name.value == "H" + + def test_max_length_name(self): + """Test maximum length name (100 characters)""" + long_name = "H" * 100 + name = HouseholdName(long_name) + assert name.value == long_name + + def test_name_with_special_characters(self): + """Test name with special characters""" + name = HouseholdName("Smith's Household #1") + assert name.value == "Smith's Household #1" + + def test_empty_string_raises_error(self): + """Test that empty string raises ValueError""" + with pytest.raises(ValueError, match="Household name cannot be empty"): + HouseholdName("") + + def test_whitespace_only_raises_error(self): + """Test that whitespace-only string raises ValueError""" + with pytest.raises(ValueError, match="Household name cannot be empty"): + HouseholdName(" ") + + def test_tab_only_raises_error(self): + """Test that tab-only string raises ValueError""" + with pytest.raises(ValueError, match="Household name cannot be empty"): + HouseholdName("\t") + + def test_exceeds_max_length_raises_error(self): + """Test that names over 100 characters raise ValueError""" + with pytest.raises(ValueError, match="Household name cannot exceed 100 characters"): + HouseholdName("H" * 101) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # Should work even with empty string + name = HouseholdName.from_trusted_source("") + assert name.value == "" + + # Should work even with too long string + long_name = "H" * 200 + name = HouseholdName.from_trusted_source(long_name) + assert name.value == long_name + + def test_immutability(self): + """Test that value object is immutable""" + name = HouseholdName("Test") + with pytest.raises(FrozenInstanceError): + name.value = "Changed" diff --git a/tests/unit/context/household/domain/household_role_test.py b/tests/unit/context/household/domain/household_role_test.py new file mode 100644 index 0000000..da49e4d --- /dev/null +++ b/tests/unit/context/household/domain/household_role_test.py @@ -0,0 +1,58 @@ +"""Unit tests for HouseholdRole value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.household.domain.value_objects import HouseholdRole + + +@pytest.mark.unit +class TestHouseholdRole: + """Tests for HouseholdRole value object""" + + def test_valid_participant_role_creation(self): + """Test creating valid participant role""" + role = HouseholdRole("participant") + assert role.value == "participant" + + def test_empty_string_raises_error(self): + """Test that empty string raises ValueError""" + with pytest.raises(ValueError, match="Role cannot be empty"): + HouseholdRole("") + + def test_invalid_role_raises_error(self): + """Test that invalid role raises ValueError""" + with pytest.raises(ValueError, match="Invalid role: 'admin'. Must be one of"): + HouseholdRole("admin") + + def test_owner_role_raises_error(self): + """Test that owner role raises ValueError (owner is implicit)""" + with pytest.raises(ValueError, match="Invalid role: 'owner'. Must be one of"): + HouseholdRole("owner") + + def test_case_sensitive_role(self): + """Test that role validation is case sensitive""" + with pytest.raises(ValueError, match="Invalid role: 'Participant'. Must be one of"): + HouseholdRole("Participant") + + def test_valid_roles_constant(self): + """Test that VALID_ROLES contains expected roles""" + assert "participant" in HouseholdRole.VALID_ROLES + assert len(HouseholdRole.VALID_ROLES) == 1 + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # Should work even with invalid role + role = HouseholdRole.from_trusted_source("invalid_role") + assert role.value == "invalid_role" + + # Should work even with empty string + role = HouseholdRole.from_trusted_source("") + assert role.value == "" + + def test_immutability(self): + """Test that value object is immutable""" + role = HouseholdRole("participant") + with pytest.raises(FrozenInstanceError): + role.value = "admin" diff --git a/tests/unit/context/household/domain/household_user_id_test.py b/tests/unit/context/household/domain/household_user_id_test.py new file mode 100644 index 0000000..d66c197 --- /dev/null +++ b/tests/unit/context/household/domain/household_user_id_test.py @@ -0,0 +1,48 @@ +"""Unit tests for HouseholdUserID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.household.domain.value_objects import HouseholdUserID + + +@pytest.mark.unit +class TestHouseholdUserID: + """Tests for HouseholdUserID value object""" + + def test_valid_id_creation(self): + """Test creating valid household user ID""" + user_id = HouseholdUserID(1) + assert user_id.value == 1 + + def test_large_id_creation(self): + """Test creating household user ID with large number""" + user_id = HouseholdUserID(999999) + assert user_id.value == 999999 + + def test_zero_id_raises_error(self): + """Test that zero raises ValueError""" + with pytest.raises(ValueError, match="HouseholdUserID must be positive"): + HouseholdUserID(0) + + def test_negative_id_raises_error(self): + """Test that negative number raises ValueError""" + with pytest.raises(ValueError, match="HouseholdUserID must be positive"): + HouseholdUserID(-1) + + def test_non_integer_raises_error(self): + """Test that non-integer type raises ValueError""" + with pytest.raises(ValueError, match="HouseholdUserID must be an integer"): + HouseholdUserID("123") + + def test_float_raises_error(self): + """Test that float raises ValueError""" + with pytest.raises(ValueError, match="HouseholdUserID must be an integer"): + HouseholdUserID(1.5) + + def test_immutability(self): + """Test that value object is immutable""" + user_id = HouseholdUserID(1) + with pytest.raises(FrozenInstanceError): + user_id.value = 2 diff --git a/tests/unit/context/household/domain/household_user_name_test.py b/tests/unit/context/household/domain/household_user_name_test.py new file mode 100644 index 0000000..304ec36 --- /dev/null +++ b/tests/unit/context/household/domain/household_user_name_test.py @@ -0,0 +1,33 @@ +"""Unit tests for HouseholdUserName value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.household.domain.value_objects import HouseholdUserName + + +@pytest.mark.unit +class TestHouseholdUserName: + """Tests for HouseholdUserName value object""" + + def test_valid_username_creation(self): + """Test creating valid household username""" + username = HouseholdUserName("john_doe") + assert username.value == "john_doe" + + def test_username_with_special_characters(self): + """Test username with special characters""" + username = HouseholdUserName("user@example.com") + assert username.value == "user@example.com" + + def test_numeric_username(self): + """Test username with numbers""" + username = HouseholdUserName("user123") + assert username.value == "user123" + + def test_immutability(self): + """Test that value object is immutable""" + username = HouseholdUserName("test_user") + with pytest.raises(FrozenInstanceError): + username.value = "changed" diff --git a/tests/unit/context/household/domain/invite_user_service_test.py b/tests/unit/context/household/domain/invite_user_service_test.py new file mode 100644 index 0000000..7f07309 --- /dev/null +++ b/tests/unit/context/household/domain/invite_user_service_test.py @@ -0,0 +1,258 @@ +"""Unit tests for InviteUserService""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + OnlyOwnerCanInviteError, +) +from app.context.household.domain.services.invite_user_service import InviteUserService +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestInviteUserService: + """Tests for InviteUserService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return InviteUserService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_invite_user_success(self, service, mock_repository): + """Test successful user invitation""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + role = HouseholdRole("participant") + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + expected_member_dto = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=invitee_id, + role=role, + joined_at=None, # Pending invite + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=None) # Not a member yet + mock_repository.create_member = AsyncMock(return_value=expected_member_dto) + + # Act + result = await service.invite_user( + inviter_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + role=role, + ) + + # Assert + assert result == expected_member_dto + assert result.is_invited is True + assert result.is_active is False + mock_repository.find_household_by_id.assert_called_once_with(household_id) + mock_repository.find_member.assert_called_once_with(household_id, invitee_id) + mock_repository.create_member.assert_called_once() + + @pytest.mark.asyncio + async def test_invite_user_non_owner_raises_error(self, service, mock_repository): + """Test that non-owner cannot invite users""" + # Arrange + owner_id = HouseholdUserID(1) + non_owner_id = HouseholdUserID(99) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + role = HouseholdRole("participant") + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, # Actual owner + name=HouseholdName("Smith Family"), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + + # Act & Assert + with pytest.raises(OnlyOwnerCanInviteError, match="Only the household owner can invite users"): + await service.invite_user( + inviter_user_id=non_owner_id, # Not the owner + household_id=household_id, + invitee_user_id=invitee_id, + role=role, + ) + + @pytest.mark.asyncio + async def test_invite_user_household_not_found_raises_error(self, service, mock_repository): + """Test that non-existent household raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(999) + invitee_id = HouseholdUserID(2) + role = HouseholdRole("participant") + + mock_repository.find_household_by_id = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(OnlyOwnerCanInviteError, match="Only the household owner can invite users"): + await service.invite_user( + inviter_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + role=role, + ) + + @pytest.mark.asyncio + async def test_invite_user_already_active_raises_error(self, service, mock_repository): + """Test that inviting an already active member raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + role = HouseholdRole("participant") + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + # Existing active member + existing_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=invitee_id, + role=role, + joined_at=datetime.now(UTC), # Active (has joined_at) + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=existing_member) + + # Act & Assert + with pytest.raises( + AlreadyActiveMemberError, + match="User is already an active member of this household", + ): + await service.invite_user( + inviter_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + role=role, + ) + + @pytest.mark.asyncio + async def test_invite_user_already_invited_raises_error(self, service, mock_repository): + """Test that inviting an already invited user raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + role = HouseholdRole("participant") + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + # Existing pending invite + existing_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=invitee_id, + role=role, + joined_at=None, # Pending invite + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=existing_member) + + # Act & Assert + with pytest.raises( + AlreadyInvitedError, + match="User already has a pending invite to this household", + ): + await service.invite_user( + inviter_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + role=role, + ) + + @pytest.mark.asyncio + async def test_invite_user_creates_member_with_correct_data(self, service, mock_repository): + """Test that invite creates member DTO with correct structure""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + role = HouseholdRole("participant") + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + expected_member_dto = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=invitee_id, + role=role, + joined_at=None, + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=None) + mock_repository.create_member = AsyncMock(return_value=expected_member_dto) + + # Act + await service.invite_user( + inviter_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + role=role, + ) + + # Assert - verify the DTO passed to create_member + call_args = mock_repository.create_member.call_args[0][0] + assert call_args.member_id is None # New member, no ID yet + assert call_args.household_id == household_id + assert call_args.user_id == invitee_id + assert call_args.role == role + assert call_args.joined_at is None # Pending invite + assert call_args.invited_by_user_id == owner_id + assert call_args.invited_at is not None diff --git a/tests/unit/context/household/domain/remove_member_service_test.py b/tests/unit/context/household/domain/remove_member_service_test.py new file mode 100644 index 0000000..3b66ea6 --- /dev/null +++ b/tests/unit/context/household/domain/remove_member_service_test.py @@ -0,0 +1,253 @@ +"""Unit tests for RemoveMemberService""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.exceptions import ( + CannotRemoveSelfError, + InviteNotFoundError, + OnlyOwnerCanRemoveMemberError, +) +from app.context.household.domain.services.remove_member_service import ( + RemoveMemberService, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestRemoveMemberService: + """Tests for RemoveMemberService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return RemoveMemberService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_remove_member_success(self, service, mock_repository): + """Test successful member removal""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + member_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + # Active member + active_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=member_id, + role=HouseholdRole("participant"), + joined_at=datetime.now(UTC), # Active + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=active_member) + mock_repository.revoke_or_remove = AsyncMock(return_value=None) + + # Act + result = await service.remove_member( + remover_user_id=owner_id, + household_id=household_id, + member_user_id=member_id, + ) + + # Assert + assert result is None + mock_repository.find_household_by_id.assert_called_once_with(household_id) + mock_repository.find_member.assert_called_once_with(household_id, member_id) + mock_repository.revoke_or_remove.assert_called_once_with(household_id, member_id) + + @pytest.mark.asyncio + async def test_remove_member_non_owner_raises_error(self, service, mock_repository): + """Test that non-owner cannot remove members""" + # Arrange + owner_id = HouseholdUserID(1) + non_owner_id = HouseholdUserID(99) + household_id = HouseholdID(10) + member_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, # Actual owner + name=HouseholdName("Smith Family"), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + + # Act & Assert + with pytest.raises( + OnlyOwnerCanRemoveMemberError, + match="Only the household owner can remove members", + ): + await service.remove_member( + remover_user_id=non_owner_id, # Not the owner + household_id=household_id, + member_user_id=member_id, + ) + + @pytest.mark.asyncio + async def test_remove_member_household_not_found_raises_error(self, service, mock_repository): + """Test that non-existent household raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(999) + member_id = HouseholdUserID(2) + + mock_repository.find_household_by_id = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises( + OnlyOwnerCanRemoveMemberError, + match="Only the household owner can remove members", + ): + await service.remove_member( + remover_user_id=owner_id, + household_id=household_id, + member_user_id=member_id, + ) + + @pytest.mark.asyncio + async def test_remove_member_cannot_remove_self_raises_error(self, service, mock_repository): + """Test that owner cannot remove themselves""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + + # Act & Assert + with pytest.raises( + CannotRemoveSelfError, + match="Owner cannot remove themselves from the household", + ): + await service.remove_member( + remover_user_id=owner_id, + household_id=household_id, + member_user_id=owner_id, # Same as owner + ) + + @pytest.mark.asyncio + async def test_remove_member_not_found_raises_error(self, service, mock_repository): + """Test that removing non-existent member raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + member_id = HouseholdUserID(999) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(InviteNotFoundError, match="No active member found with this user ID"): + await service.remove_member( + remover_user_id=owner_id, + household_id=household_id, + member_user_id=member_id, + ) + + @pytest.mark.asyncio + async def test_remove_member_not_active_raises_error(self, service, mock_repository): + """Test that removing pending invite (not active) raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + member_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + # Pending member (not active) + pending_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=member_id, + role=HouseholdRole("participant"), + joined_at=None, # Not active + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=pending_member) + + # Act & Assert + with pytest.raises(InviteNotFoundError, match="No active member found with this user ID"): + await service.remove_member( + remover_user_id=owner_id, + household_id=household_id, + member_user_id=member_id, + ) + + @pytest.mark.asyncio + async def test_remove_member_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + member_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + active_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=member_id, + role=HouseholdRole("participant"), + joined_at=datetime.now(UTC), + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=active_member) + mock_repository.revoke_or_remove = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.remove_member( + remover_user_id=owner_id, + household_id=household_id, + member_user_id=member_id, + ) diff --git a/tests/unit/context/household/domain/revoke_invite_service_test.py b/tests/unit/context/household/domain/revoke_invite_service_test.py new file mode 100644 index 0000000..d1d7a76 --- /dev/null +++ b/tests/unit/context/household/domain/revoke_invite_service_test.py @@ -0,0 +1,226 @@ +"""Unit tests for RevokeInviteService""" + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.exceptions import ( + InviteNotFoundError, + OnlyOwnerCanRevokeError, +) +from app.context.household.domain.services.revoke_invite_service import ( + RevokeInviteService, +) +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestRevokeInviteService: + """Tests for RevokeInviteService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository and logger""" + return RevokeInviteService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_revoke_invite_success(self, service, mock_repository): + """Test successful invite revocation""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + # Pending invite + pending_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=invitee_id, + role=HouseholdRole("participant"), + joined_at=None, # Pending + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=pending_member) + mock_repository.revoke_or_remove = AsyncMock(return_value=None) + + # Act + result = await service.revoke_invite( + revoker_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + ) + + # Assert + assert result is None + mock_repository.find_household_by_id.assert_called_once_with(household_id) + mock_repository.find_member.assert_called_once_with(household_id, invitee_id) + mock_repository.revoke_or_remove.assert_called_once_with(household_id, invitee_id) + + @pytest.mark.asyncio + async def test_revoke_invite_non_owner_raises_error(self, service, mock_repository): + """Test that non-owner cannot revoke invites""" + # Arrange + owner_id = HouseholdUserID(1) + non_owner_id = HouseholdUserID(99) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, # Actual owner + name=HouseholdName("Smith Family"), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + + # Act & Assert + with pytest.raises( + OnlyOwnerCanRevokeError, + match="Only the household owner can revoke invites", + ): + await service.revoke_invite( + revoker_user_id=non_owner_id, # Not the owner + household_id=household_id, + invitee_user_id=invitee_id, + ) + + @pytest.mark.asyncio + async def test_revoke_invite_household_not_found_raises_error(self, service, mock_repository): + """Test that non-existent household raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(999) + invitee_id = HouseholdUserID(2) + + mock_repository.find_household_by_id = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises( + OnlyOwnerCanRevokeError, + match="Only the household owner can revoke invites", + ): + await service.revoke_invite( + revoker_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + ) + + @pytest.mark.asyncio + async def test_revoke_invite_not_found_raises_error(self, service, mock_repository): + """Test that revoking non-existent invite raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(999) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(InviteNotFoundError, match="No pending invite found for this user"): + await service.revoke_invite( + revoker_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + ) + + @pytest.mark.asyncio + async def test_revoke_invite_already_active_raises_error(self, service, mock_repository): + """Test that revoking active member (not invite) raises error""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + # Active member (not pending) + active_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=invitee_id, + role=HouseholdRole("participant"), + joined_at=datetime.now(UTC), # Active + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=active_member) + + # Act & Assert + with pytest.raises(InviteNotFoundError, match="No pending invite found for this user"): + await service.revoke_invite( + revoker_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + ) + + @pytest.mark.asyncio + async def test_revoke_invite_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + owner_id = HouseholdUserID(1) + household_id = HouseholdID(10) + invitee_id = HouseholdUserID(2) + + household_dto = HouseholdDTO( + household_id=household_id, + owner_user_id=owner_id, + name=HouseholdName("Smith Family"), + ) + + pending_member = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=household_id, + user_id=invitee_id, + role=HouseholdRole("participant"), + joined_at=None, + invited_by_user_id=owner_id, + invited_at=datetime.now(UTC), + ) + + mock_repository.find_household_by_id = AsyncMock(return_value=household_dto) + mock_repository.find_member = AsyncMock(return_value=pending_member) + mock_repository.revoke_or_remove = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.revoke_invite( + revoker_user_id=owner_id, + household_id=household_id, + invitee_user_id=invitee_id, + ) diff --git a/tests/unit/context/household/infrastructure/__init__.py b/tests/unit/context/household/infrastructure/__init__.py new file mode 100644 index 0000000..797be90 --- /dev/null +++ b/tests/unit/context/household/infrastructure/__init__.py @@ -0,0 +1 @@ +"""Household infrastructure layer unit tests""" diff --git a/tests/unit/context/household/infrastructure/household_mapper_test.py b/tests/unit/context/household/infrastructure/household_mapper_test.py new file mode 100644 index 0000000..9edcd52 --- /dev/null +++ b/tests/unit/context/household/infrastructure/household_mapper_test.py @@ -0,0 +1,199 @@ +"""Unit tests for HouseholdMapper""" + +from datetime import UTC, datetime + +import pytest + +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.exceptions import HouseholdMapperError +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) +from app.context.household.infrastructure.mappers.household_mapper import HouseholdMapper +from app.context.household.infrastructure.models import HouseholdModel + + +@pytest.mark.unit +class TestHouseholdMapper: + """Tests for HouseholdMapper""" + + def test_to_dto_converts_model_to_dto(self): + """Test converting database model to domain DTO""" + # Arrange + now = datetime.now(UTC) + model = HouseholdModel( + id=10, + owner_user_id=1, + name="Smith Family", + created_at=now, + ) + + # Act + dto = HouseholdMapper.to_dto(model) + + # Assert + assert dto is not None + assert isinstance(dto, HouseholdDTO) + assert dto.household_id == HouseholdID(10) + assert dto.owner_user_id == HouseholdUserID(1) + assert dto.name.value == "Smith Family" + assert dto.created_at == now + + def test_to_dto_with_none_model_returns_none(self): + """Test that None model returns None DTO""" + # Act + dto = HouseholdMapper.to_dto(None) + + # Assert + assert dto is None + + def test_to_dto_uses_trusted_source_for_name(self): + """Test that to_dto uses from_trusted_source for performance""" + # Arrange - create model with empty name that would fail validation + model = HouseholdModel( + id=10, + owner_user_id=1, + name="", # Would fail validation if not using from_trusted_source + created_at=datetime.now(UTC), + ) + + # Act - should not raise because using from_trusted_source + dto = HouseholdMapper.to_dto(model) + + # Assert + assert dto is not None + assert dto.name.value == "" # Empty name preserved + + def test_to_dto_or_fail_with_valid_model(self): + """Test to_dto_or_fail with valid model""" + # Arrange + model = HouseholdModel( + id=10, + owner_user_id=1, + name="Smith Family", + created_at=datetime.now(UTC), + ) + + # Act + dto = HouseholdMapper.to_dto_or_fail(model) + + # Assert + assert dto is not None + assert isinstance(dto, HouseholdDTO) + assert dto.household_id == HouseholdID(10) + + def test_to_dto_or_fail_with_none_raises_error(self): + """Test to_dto_or_fail raises error when model is None""" + # Act & Assert + with pytest.raises(HouseholdMapperError, match="Error mapping HouseholdModel to DTO"): + HouseholdMapper.to_dto_or_fail(None) + + def test_to_model_converts_dto_to_model(self): + """Test converting domain DTO to database model""" + # Arrange + now = datetime.now(UTC) + dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=HouseholdUserID(1), + name=HouseholdName("Smith Family"), + created_at=now, + ) + + # Act + model = HouseholdMapper.to_model(dto) + + # Assert + assert isinstance(model, HouseholdModel) + assert model.id == 10 + assert model.owner_user_id == 1 + assert model.name == "Smith Family" + assert model.created_at == now + + def test_to_model_with_none_household_id(self): + """Test converting DTO without household_id (new entity)""" + # Arrange + dto = HouseholdDTO( + household_id=None, # New household, no ID yet + owner_user_id=HouseholdUserID(1), + name=HouseholdName("New Household"), + ) + + # Act + model = HouseholdMapper.to_model(dto) + + # Assert + # id attribute won't exist if household_id is None + assert not hasattr(model, "id") or model.id is None + assert model.owner_user_id == 1 + assert model.name == "New Household" + + def test_to_model_with_none_created_at(self): + """Test converting DTO with None created_at""" + # Arrange + dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=HouseholdUserID(1), + name=HouseholdName("Test Household"), + created_at=None, # Will be set by database default + ) + + # Act + model = HouseholdMapper.to_model(dto) + + # Assert + # created_at won't be set if None in DTO (database will set it) + assert not hasattr(model, "created_at") or model.created_at is None + + def test_roundtrip_conversion(self): + """Test converting model to DTO and back to model""" + # Arrange + now = datetime.now(UTC) + original_model = HouseholdModel( + id=10, + owner_user_id=1, + name="Test Household", + created_at=now, + ) + + # Act - convert to DTO and back + dto = HouseholdMapper.to_dto(original_model) + final_model = HouseholdMapper.to_model(dto) + + # Assert - values should be preserved + assert final_model.id == original_model.id + assert final_model.owner_user_id == original_model.owner_user_id + assert final_model.name == original_model.name + assert final_model.created_at == original_model.created_at + + def test_to_model_with_special_characters_in_name(self): + """Test that special characters in name are preserved""" + # Arrange + dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=HouseholdUserID(1), + name=HouseholdName("Smith's Household #1"), + ) + + # Act + model = HouseholdMapper.to_model(dto) + + # Assert + assert model.name == "Smith's Household #1" + + def test_to_model_with_max_length_name(self): + """Test that maximum length name is preserved""" + # Arrange + long_name = "H" * 100 + dto = HouseholdDTO( + household_id=HouseholdID(10), + owner_user_id=HouseholdUserID(1), + name=HouseholdName(long_name), + ) + + # Act + model = HouseholdMapper.to_model(dto) + + # Assert + assert model.name == long_name diff --git a/tests/unit/context/household/infrastructure/household_member_mapper_test.py b/tests/unit/context/household/infrastructure/household_member_mapper_test.py new file mode 100644 index 0000000..d331f4f --- /dev/null +++ b/tests/unit/context/household/infrastructure/household_member_mapper_test.py @@ -0,0 +1,346 @@ +"""Unit tests for HouseholdMemberMapper""" + +from datetime import UTC, datetime + +import pytest + +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, + HouseholdUserName, +) +from app.context.household.infrastructure.mappers.household_member_mapper import ( + HouseholdMemberMapper, +) +from app.context.household.infrastructure.models import ( + HouseholdMemberModel, + HouseholdModel, +) +from app.context.user.infrastructure.models import UserModel + + +@pytest.mark.unit +class TestHouseholdMemberMapper: + """Tests for HouseholdMemberMapper""" + + def test_to_dto_converts_model_to_dto(self): + """Test converting database model to domain DTO""" + # Arrange + now = datetime.now(UTC) + model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, + invited_by_user_id=1, + invited_at=now, + ) + + # Act + dto = HouseholdMemberMapper.to_dto(model) + + # Assert + assert dto is not None + assert isinstance(dto, HouseholdMemberDTO) + assert dto.member_id == HouseholdMemberID(1) + assert dto.household_id == HouseholdID(10) + assert dto.user_id == HouseholdUserID(2) + assert dto.role.value == "participant" + assert dto.joined_at == now + assert dto.invited_by_user_id == HouseholdUserID(1) + assert dto.invited_at == now + assert dto.household_name is None # Not provided + assert dto.inviter_username is None # Not provided + + def test_to_dto_with_household_model(self): + """Test converting model with household model populated""" + # Arrange + now = datetime.now(UTC) + member_model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, + invited_by_user_id=1, + invited_at=now, + ) + + household_model = HouseholdModel( + id=10, + owner_user_id=1, + name="Smith Family", + created_at=now, + ) + + # Act + dto = HouseholdMemberMapper.to_dto(member_model, household_model=household_model) + + # Assert + assert dto.household_name is not None + assert isinstance(dto.household_name, HouseholdName) + assert dto.household_name.value == "Smith Family" + + def test_to_dto_with_user_model_username(self): + """Test converting model with user model (username available)""" + # Arrange + now = datetime.now(UTC) + member_model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, + invited_by_user_id=1, + invited_at=now, + ) + + user_model = UserModel( + id=1, + email="john@example.com", + username="john_doe", + password="hashed_password", + ) + + # Act + dto = HouseholdMemberMapper.to_dto(member_model, user_model=user_model) + + # Assert + assert dto.inviter_username is not None + assert isinstance(dto.inviter_username, HouseholdUserName) + assert dto.inviter_username.value == "john_doe" + + def test_to_dto_with_user_model_no_username_fallback_to_email(self): + """Test that email is used when username is None""" + # Arrange + now = datetime.now(UTC) + member_model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, + invited_by_user_id=1, + invited_at=now, + ) + + user_model = UserModel( + id=1, + email="john@example.com", + username=None, # No username + password="hashed_password", + ) + + # Act + dto = HouseholdMemberMapper.to_dto(member_model, user_model=user_model) + + # Assert + assert dto.inviter_username is not None + assert dto.inviter_username.value == "john@example.com" + + def test_to_dto_pending_invite(self): + """Test converting model for pending invite (joined_at is None)""" + # Arrange + now = datetime.now(UTC) + model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=None, # Pending + invited_by_user_id=1, + invited_at=now, + ) + + # Act + dto = HouseholdMemberMapper.to_dto(model) + + # Assert + assert dto.joined_at is None + assert dto.is_invited is True + assert dto.is_active is False + + def test_to_dto_active_member(self): + """Test converting model for active member (joined_at is set)""" + # Arrange + now = datetime.now(UTC) + model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, # Active + invited_by_user_id=1, + invited_at=now, + ) + + # Act + dto = HouseholdMemberMapper.to_dto(model) + + # Assert + assert dto.joined_at is not None + assert dto.is_invited is False + assert dto.is_active is True + + def test_to_dto_with_none_invited_by_user_id(self): + """Test converting model with None invited_by_user_id""" + # Arrange + now = datetime.now(UTC) + model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, + invited_by_user_id=None, # No inviter + invited_at=now, + ) + + # Act + dto = HouseholdMemberMapper.to_dto(model) + + # Assert + assert dto.invited_by_user_id is None + + def test_to_dto_uses_trusted_source_for_role(self): + """Test that to_dto uses from_trusted_source for role""" + # Arrange + now = datetime.now(UTC) + model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="invalid_role", # Would fail validation if not using from_trusted_source + joined_at=now, + invited_by_user_id=1, + invited_at=now, + ) + + # Act - should not raise because using from_trusted_source + dto = HouseholdMemberMapper.to_dto(model) + + # Assert + assert dto is not None + assert dto.role.value == "invalid_role" + + def test_to_model_converts_dto_to_model(self): + """Test converting domain DTO to database model""" + # Arrange + now = datetime.now(UTC) + dto = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=HouseholdID(10), + user_id=HouseholdUserID(2), + role=HouseholdRole("participant"), + joined_at=now, + invited_by_user_id=HouseholdUserID(1), + invited_at=now, + ) + + # Act + model = HouseholdMemberMapper.to_model(dto) + + # Assert + assert isinstance(model, HouseholdMemberModel) + assert model.id == 1 + assert model.household_id == 10 + assert model.user_id == 2 + assert model.role == "participant" + assert model.joined_at == now + assert model.invited_by_user_id == 1 + assert model.invited_at == now + + def test_to_model_with_none_member_id(self): + """Test converting DTO without member_id (new entity)""" + # Arrange + now = datetime.now(UTC) + dto = HouseholdMemberDTO( + member_id=None, # New member, no ID yet + household_id=HouseholdID(10), + user_id=HouseholdUserID(2), + role=HouseholdRole("participant"), + joined_at=None, + invited_by_user_id=HouseholdUserID(1), + invited_at=now, + ) + + # Act + model = HouseholdMemberMapper.to_model(dto) + + # Assert + assert model.id is None # Will be assigned by database + assert model.household_id == 10 + assert model.user_id == 2 + + def test_to_model_with_none_invited_by_user_id(self): + """Test converting DTO with None invited_by_user_id""" + # Arrange + now = datetime.now(UTC) + dto = HouseholdMemberDTO( + member_id=HouseholdMemberID(1), + household_id=HouseholdID(10), + user_id=HouseholdUserID(2), + role=HouseholdRole("participant"), + joined_at=now, + invited_by_user_id=None, + invited_at=now, + ) + + # Act + model = HouseholdMemberMapper.to_model(dto) + + # Assert + assert model.invited_by_user_id is None + + def test_roundtrip_conversion(self): + """Test converting model to DTO and back to model""" + # Arrange + now = datetime.now(UTC) + original_model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, + invited_by_user_id=1, + invited_at=now, + ) + + # Act - convert to DTO and back + dto = HouseholdMemberMapper.to_dto(original_model) + final_model = HouseholdMemberMapper.to_model(dto) + + # Assert - values should be preserved + assert final_model.id == original_model.id + assert final_model.household_id == original_model.household_id + assert final_model.user_id == original_model.user_id + assert final_model.role == original_model.role + assert final_model.joined_at == original_model.joined_at + assert final_model.invited_by_user_id == original_model.invited_by_user_id + assert final_model.invited_at == original_model.invited_at + + def test_to_dto_or_fail_with_valid_model(self): + """Test to_dto_or_fail with valid model""" + # Arrange + now = datetime.now(UTC) + model = HouseholdMemberModel( + id=1, + household_id=10, + user_id=2, + role="participant", + joined_at=now, + invited_by_user_id=1, + invited_at=now, + ) + + # Act + dto = HouseholdMemberMapper.to_dto_or_fail(model) + + # Assert + assert dto is not None + assert isinstance(dto, HouseholdMemberDTO) diff --git a/tests/unit/context/user_account/__init__.py b/tests/unit/context/user_account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/user_account/application/__init__.py b/tests/unit/context/user_account/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/user_account/application/create_account_handler_test.py b/tests/unit/context/user_account/application/create_account_handler_test.py new file mode 100644 index 0000000..c7bcb58 --- /dev/null +++ b/tests/unit/context/user_account/application/create_account_handler_test.py @@ -0,0 +1,164 @@ +"""Unit tests for CreateAccountHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.user_account.application.commands import CreateAccountCommand +from app.context.user_account.application.dto import CreateAccountErrorCode +from app.context.user_account.application.handlers.create_account_handler import ( + CreateAccountHandler, +) +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateAccountHandler: + """Tests for CreateAccountHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service""" + return CreateAccountHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_create_account_success(self, handler, mock_service): + """Test successful account creation""" + # Arrange + command = CreateAccountCommand(user_id=1, name="My Account", currency="USD", balance=100.50) + + account_dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + account_id=UserAccountID(10), + ) + + mock_service.create_account = AsyncMock(return_value=account_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.account_id == 10 + assert result.account_name == "My Account" + assert result.account_balance == 100.50 + mock_service.create_account.assert_called_once() + + @pytest.mark.asyncio + async def test_create_account_without_id_returns_error(self, handler, mock_service): + """Test that missing account_id in result returns error""" + # Arrange + command = CreateAccountCommand(user_id=1, name="My Account", currency="USD", balance=100.00) + + account_dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + account_id=None, # Missing ID + ) + + mock_service.create_account = AsyncMock(return_value=account_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateAccountErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Error creating account" + assert result.account_id is None + + @pytest.mark.asyncio + async def test_create_account_duplicate_name_error(self, handler, mock_service): + """Test handling of duplicate name exception""" + # Arrange + command = CreateAccountCommand(user_id=1, name="Duplicate", currency="USD", balance=100.00) + + mock_service.create_account = AsyncMock(side_effect=UserAccountNameAlreadyExistError("Duplicate name")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateAccountErrorCode.NAME_ALREADY_EXISTS + assert result.error_message == "Account name already exist" + assert result.account_id is None + + @pytest.mark.asyncio + async def test_create_account_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + command = CreateAccountCommand(user_id=1, name="Test", currency="USD", balance=100.00) + + mock_service.create_account = AsyncMock(side_effect=UserAccountMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateAccountErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping model to dto" + assert result.account_id is None + + @pytest.mark.asyncio + async def test_create_account_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + command = CreateAccountCommand(user_id=1, name="Test", currency="USD", balance=100.00) + + mock_service.create_account = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == CreateAccountErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" + assert result.account_id is None + + @pytest.mark.asyncio + async def test_create_account_converts_primitives_to_value_objects(self, handler, mock_service): + """Test that handler converts command primitives to value objects""" + # Arrange + command = CreateAccountCommand(user_id=1, name="Test", currency="USD", balance=100.50) + + account_dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("Test"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + account_id=UserAccountID(10), + ) + + mock_service.create_account = AsyncMock(return_value=account_dto) + + # Act + await handler.handle(command) + + # Assert - verify service was called with value objects + call_args = mock_service.create_account.call_args + assert isinstance(call_args.kwargs["user_id"], UserAccountUserID) + assert isinstance(call_args.kwargs["name"], AccountName) + assert isinstance(call_args.kwargs["currency"], UserAccountCurrency) + assert isinstance(call_args.kwargs["balance"], UserAccountBalance) diff --git a/tests/unit/context/user_account/application/delete_account_handler_test.py b/tests/unit/context/user_account/application/delete_account_handler_test.py new file mode 100644 index 0000000..dc88d69 --- /dev/null +++ b/tests/unit/context/user_account/application/delete_account_handler_test.py @@ -0,0 +1,72 @@ +"""Unit tests for DeleteAccountHandler""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.user_account.application.commands import DeleteAccountCommand +from app.context.user_account.application.dto import DeleteAccountErrorCode +from app.context.user_account.application.handlers.delete_account_handler import ( + DeleteAccountHandler, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestDeleteAccountHandler: + """Tests for DeleteAccountHandler""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_repository, mock_logger): + """Create handler with mocked repository""" + return DeleteAccountHandler(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_delete_account_success(self, handler, mock_repository): + """Test successful account deletion""" + # Arrange + command = DeleteAccountCommand(account_id=10, user_id=1) + mock_repository.delete_account = AsyncMock(return_value=True) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.success is True + mock_repository.delete_account.assert_called_once() + + @pytest.mark.asyncio + async def test_delete_account_not_found(self, handler, mock_repository): + """Test deleting non-existent account""" + # Arrange + command = DeleteAccountCommand(account_id=999, user_id=1) + mock_repository.delete_account = AsyncMock(return_value=False) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == DeleteAccountErrorCode.NOT_FOUND + assert result.error_message == "Account not found" + # When there's an error, success field should not be set (None by default) + assert hasattr(result, "success") # Field exists but should be None in error case + + @pytest.mark.asyncio + async def test_delete_account_unexpected_error(self, handler, mock_repository): + """Test handling of unexpected exception""" + # Arrange + command = DeleteAccountCommand(account_id=10, user_id=1) + mock_repository.delete_account = AsyncMock(side_effect=Exception("DB error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == DeleteAccountErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" diff --git a/tests/unit/context/user_account/application/find_account_by_id_handler_test.py b/tests/unit/context/user_account/application/find_account_by_id_handler_test.py new file mode 100644 index 0000000..1be6585 --- /dev/null +++ b/tests/unit/context/user_account/application/find_account_by_id_handler_test.py @@ -0,0 +1,121 @@ +"""Unit tests for FindAccountByIdHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.user_account.application.dto.find_single_account_result import ( + FindSingleAccountErrorCode, +) +from app.context.user_account.application.handlers.find_account_by_id_handler import ( + FindAccountByIdHandler, +) +from app.context.user_account.application.queries import FindAccountByIdQuery +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestFindAccountByIdHandler: + """Tests for FindAccountByIdHandler""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_repository, mock_logger): + """Create handler with mocked repository""" + return FindAccountByIdHandler(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_find_account_by_id_success(self, handler, mock_repository): + """Test finding account by ID successfully""" + # Arrange + query = FindAccountByIdQuery(account_id=10, user_id=1) + + account_dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + account_id=UserAccountID(10), + ) + + # Handler expects a list from the repository + mock_repository.find_user_accounts = AsyncMock(return_value=[account_dto]) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.error_code is None + assert result.error_message is None + assert result.account is not None + assert result.account.account_id == 10 + assert result.account.user_id == 1 + assert result.account.name == "My Account" + assert result.account.currency == "USD" + assert result.account.balance == Decimal("100.00") + + @pytest.mark.asyncio + async def test_find_account_by_id_not_found(self, handler, mock_repository): + """Test finding non-existent account returns error result""" + # Arrange + query = FindAccountByIdQuery(account_id=999, user_id=1) + # Repository returns None when account not found + mock_repository.find_user_accounts = AsyncMock(return_value=None) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.error_code == FindSingleAccountErrorCode.NOT_FOUND + assert result.error_message == "No account found" + assert result.account is None + + @pytest.mark.asyncio + async def test_find_account_by_id_calls_repository_with_correct_params(self, handler, mock_repository): + """Test that handler calls repository with correct parameters""" + # Arrange + query = FindAccountByIdQuery(account_id=10, user_id=1) + # Return empty list to test repository call without affecting test focus + mock_repository.find_user_accounts = AsyncMock(return_value=[]) + + # Act + await handler.handle(query) + + # Assert + call_args = mock_repository.find_user_accounts.call_args + assert isinstance(call_args.kwargs["account_id"], UserAccountID) + assert call_args.kwargs["account_id"].value == 10 + assert isinstance(call_args.kwargs["user_id"], UserAccountUserID) + assert call_args.kwargs["user_id"].value == 1 + + @pytest.mark.asyncio + async def test_find_account_by_id_empty_list_returns_not_found(self, handler, mock_repository): + """Test finding account when repository returns empty list""" + # Arrange + query = FindAccountByIdQuery(account_id=999, user_id=1) + # Repository returns empty list when account not found + mock_repository.find_user_accounts = AsyncMock(return_value=[]) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.error_code == FindSingleAccountErrorCode.NOT_FOUND + assert result.error_message == "No account found" + assert result.account is None diff --git a/tests/unit/context/user_account/application/find_accounts_by_user_handler_test.py b/tests/unit/context/user_account/application/find_accounts_by_user_handler_test.py new file mode 100644 index 0000000..960cb49 --- /dev/null +++ b/tests/unit/context/user_account/application/find_accounts_by_user_handler_test.py @@ -0,0 +1,125 @@ +"""Unit tests for FindAccountsByUserHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.user_account.application.dto import FindMultipleAccountsErrorCode +from app.context.user_account.application.handlers.find_accounts_by_user_handler import ( + FindAccountsByUserHandler, +) +from app.context.user_account.application.queries import FindAccountsByUserQuery +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestFindAccountsByUserHandler: + """Tests for FindAccountsByUserHandler""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_repository, mock_logger): + """Create handler with mocked repository""" + return FindAccountsByUserHandler(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_find_accounts_by_user_success(self, handler, mock_repository): + """Test finding all accounts for a user""" + # Arrange + query = FindAccountsByUserQuery(user_id=1) + + accounts = [ + UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("Account 1"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + account_id=UserAccountID(10), + ), + UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("Account 2"), + currency=UserAccountCurrency("EUR"), + balance=UserAccountBalance(Decimal("200.00")), + account_id=UserAccountID(11), + ), + ] + + mock_repository.find_user_accounts = AsyncMock(return_value=accounts) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.error_code is None + assert result.error_message is None + assert result.accounts is not None + assert len(result.accounts) == 2 + assert result.accounts[0].account_id == 10 + assert result.accounts[0].name == "Account 1" + assert result.accounts[1].account_id == 11 + assert result.accounts[1].name == "Account 2" + + @pytest.mark.asyncio + async def test_find_accounts_by_user_empty_list(self, handler, mock_repository): + """Test finding accounts when user has none""" + # Arrange + query = FindAccountsByUserQuery(user_id=1) + mock_repository.find_user_accounts = AsyncMock(return_value=[]) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.error_code is None + assert result.error_message is None + assert result.accounts is not None + assert result.accounts == [] + + @pytest.mark.asyncio + async def test_find_accounts_by_user_none_returns_empty_list(self, handler, mock_repository): + """Test that None from repository returns empty list""" + # Arrange + query = FindAccountsByUserQuery(user_id=1) + mock_repository.find_user_accounts = AsyncMock(return_value=None) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.error_code is None + assert result.error_message is None + assert result.accounts is not None + assert result.accounts == [] + + @pytest.mark.asyncio + async def test_find_accounts_by_user_exception_returns_error(self, handler, mock_repository): + """Test that exceptions are caught and returned as error result""" + # Arrange + query = FindAccountsByUserQuery(user_id=1) + mock_repository.find_user_accounts = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.error_code == FindMultipleAccountsErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error while finding accounts" + assert result.accounts is None diff --git a/tests/unit/context/user_account/application/update_account_handler_test.py b/tests/unit/context/user_account/application/update_account_handler_test.py new file mode 100644 index 0000000..85f4872 --- /dev/null +++ b/tests/unit/context/user_account/application/update_account_handler_test.py @@ -0,0 +1,126 @@ +"""Unit tests for UpdateAccountHandler""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.user_account.application.commands import UpdateAccountCommand +from app.context.user_account.application.dto import UpdateAccountErrorCode +from app.context.user_account.application.handlers.update_account_handler import ( + UpdateAccountHandler, +) +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestUpdateAccountHandler: + """Tests for UpdateAccountHandler""" + + @pytest.fixture + def mock_service(self): + """Create a mock service""" + return MagicMock() + + @pytest.fixture + def handler(self, mock_service, mock_logger): + """Create handler with mocked service""" + return UpdateAccountHandler(mock_service, mock_logger) + + @pytest.mark.asyncio + async def test_update_account_success(self, handler, mock_service): + """Test successful account update""" + # Arrange + command = UpdateAccountCommand(account_id=10, user_id=1, name="Updated", currency="EUR", balance=200.00) + + updated_dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("Updated"), + currency=UserAccountCurrency("EUR"), + balance=UserAccountBalance(Decimal("200.00")), + account_id=UserAccountID(10), + ) + + mock_service.update_account = AsyncMock(return_value=updated_dto) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code is None + assert result.account_id == 10 + assert result.account_name == "Updated" + assert result.account_balance == 200.00 + + @pytest.mark.asyncio + async def test_update_account_not_found(self, handler, mock_service): + """Test handling of account not found exception""" + # Arrange + command = UpdateAccountCommand(account_id=999, user_id=1, name="Test", currency="USD", balance=100.00) + + mock_service.update_account = AsyncMock(side_effect=UserAccountNotFoundError("Not found")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateAccountErrorCode.NOT_FOUND + assert result.error_message == "Account not found" + + @pytest.mark.asyncio + async def test_update_account_duplicate_name(self, handler, mock_service): + """Test handling of duplicate name exception""" + # Arrange + command = UpdateAccountCommand(account_id=10, user_id=1, name="Duplicate", currency="USD", balance=100.00) + + mock_service.update_account = AsyncMock(side_effect=UserAccountNameAlreadyExistError("Duplicate")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateAccountErrorCode.NAME_ALREADY_EXISTS + assert result.error_message == "Account name already exist" + + @pytest.mark.asyncio + async def test_update_account_mapper_error(self, handler, mock_service): + """Test handling of mapper exception""" + # Arrange + command = UpdateAccountCommand(account_id=10, user_id=1, name="Test", currency="USD", balance=100.00) + + mock_service.update_account = AsyncMock(side_effect=UserAccountMapperError("Mapping failed")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateAccountErrorCode.MAPPER_ERROR + assert result.error_message == "Error mapping model to dto" + + @pytest.mark.asyncio + async def test_update_account_unexpected_error(self, handler, mock_service): + """Test handling of unexpected exception""" + # Arrange + command = UpdateAccountCommand(account_id=10, user_id=1, name="Test", currency="USD", balance=100.00) + + mock_service.update_account = AsyncMock(side_effect=Exception("Database error")) + + # Act + result = await handler.handle(command) + + # Assert + assert result.error_code == UpdateAccountErrorCode.UNEXPECTED_ERROR + assert result.error_message == "Unexpected error" diff --git a/tests/unit/context/user_account/domain/__init__.py b/tests/unit/context/user_account/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/user_account/domain/account_name_test.py b/tests/unit/context/user_account/domain/account_name_test.py new file mode 100644 index 0000000..645f8f4 --- /dev/null +++ b/tests/unit/context/user_account/domain/account_name_test.py @@ -0,0 +1,60 @@ +"""Unit tests for AccountName value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.user_account.domain.value_objects import AccountName + + +@pytest.mark.unit +class TestAccountName: + """Tests for AccountName value object""" + + def test_valid_name_creation(self): + """Test creating valid account names""" + name = AccountName("My Account") + assert name.value == "My Account" + + def test_single_character_name(self): + """Test minimum length name""" + name = AccountName("A") + assert name.value == "A" + + def test_max_length_name(self): + """Test maximum length name (100 characters)""" + long_name = "A" * 100 + name = AccountName(long_name) + assert name.value == long_name + + def test_empty_string_raises_error(self): + """Test that empty string raises ValueError""" + with pytest.raises(ValueError, match="AccountName cannot be empty or whitespace"): + AccountName("") + + def test_whitespace_only_raises_error(self): + """Test that whitespace-only string raises ValueError""" + with pytest.raises(ValueError, match="AccountName cannot be empty or whitespace"): + AccountName(" ") + + def test_exceeds_max_length_raises_error(self): + """Test that names over 100 characters raise ValueError""" + with pytest.raises(ValueError, match="AccountName cannot exceed 100 characters"): + AccountName("A" * 101) + + def test_invalid_type_raises_error(self): + """Test that non-string types raise ValueError""" + with pytest.raises(ValueError, match="AccountName must be a string"): + AccountName(123) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # Should work even with empty string + name = AccountName.from_trusted_source("") + assert name.value == "" + + def test_immutability(self): + """Test that value object is immutable""" + name = AccountName("Test") + with pytest.raises(FrozenInstanceError): + name.value = "Changed" diff --git a/tests/unit/context/user_account/domain/create_account_service_test.py b/tests/unit/context/user_account/domain/create_account_service_test.py new file mode 100644 index 0000000..4bc230b --- /dev/null +++ b/tests/unit/context/user_account/domain/create_account_service_test.py @@ -0,0 +1,108 @@ +"""Unit tests for CreateAccountService""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.services.create_account_service import ( + CreateAccountService, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateAccountService: + """Tests for CreateAccountService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository""" + return CreateAccountService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_create_account_success(self, service, mock_repository): + """Test successful account creation""" + # Arrange + user_id = UserAccountUserID(1) + name = AccountName("My Account") + currency = UserAccountCurrency("USD") + balance = UserAccountBalance(Decimal("100.00")) + + expected_dto = UserAccountDTO( + user_id=user_id, + name=name, + currency=currency, + balance=balance, + account_id=UserAccountID(10), + ) + + mock_repository.save_account = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_account(user_id=user_id, name=name, currency=currency, balance=balance) + + # Assert + assert result == expected_dto + mock_repository.save_account.assert_called_once() + # Verify the DTO passed to save_account has correct values + call_args = mock_repository.save_account.call_args[0][0] + assert call_args.user_id == user_id + assert call_args.name == name + assert call_args.currency == currency + assert call_args.balance == balance + assert call_args.account_id is None # New account, no ID yet + + @pytest.mark.asyncio + async def test_create_account_with_zero_balance(self, service, mock_repository): + """Test creating account with zero balance""" + # Arrange + user_id = UserAccountUserID(1) + name = AccountName("Zero Balance Account") + currency = UserAccountCurrency("EUR") + balance = UserAccountBalance(Decimal("0.00")) + + expected_dto = UserAccountDTO( + user_id=user_id, + name=name, + currency=currency, + balance=balance, + account_id=UserAccountID(20), + ) + + mock_repository.save_account = AsyncMock(return_value=expected_dto) + + # Act + result = await service.create_account(user_id=user_id, name=name, currency=currency, balance=balance) + + # Assert + assert result.balance.value == Decimal("0.00") + mock_repository.save_account.assert_called_once() + + @pytest.mark.asyncio + async def test_create_account_propagates_repository_exceptions(self, service, mock_repository): + """Test that repository exceptions are propagated""" + # Arrange + mock_repository.save_account = AsyncMock(side_effect=Exception("Database error")) + + # Act & Assert + with pytest.raises(Exception, match="Database error"): + await service.create_account( + user_id=UserAccountUserID(1), + name=AccountName("Test"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) diff --git a/tests/unit/context/user_account/domain/update_account_service_test.py b/tests/unit/context/user_account/domain/update_account_service_test.py new file mode 100644 index 0000000..95c78e8 --- /dev/null +++ b/tests/unit/context/user_account/domain/update_account_service_test.py @@ -0,0 +1,280 @@ +"""Unit tests for UpdateAccountService""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.exceptions import ( + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) +from app.context.user_account.domain.services.update_account_service import ( + UpdateAccountService, +) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestUpdateAccountService: + """Tests for UpdateAccountService""" + + @pytest.fixture + def mock_repository(self): + """Create a mock repository""" + return MagicMock() + + @pytest.fixture + def service(self, mock_repository, mock_logger): + """Create service with mocked repository""" + return UpdateAccountService(mock_repository, mock_logger) + + @pytest.mark.asyncio + async def test_update_account_success(self, service, mock_repository): + """Test successful account update""" + # Arrange + account_id = UserAccountID(10) + user_id = UserAccountUserID(1) + name = AccountName("Updated Account") + currency = UserAccountCurrency("USD") + balance = UserAccountBalance(Decimal("200.00")) + + existing_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=AccountName("Old Name"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + updated_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=name, + currency=currency, + balance=balance, + ) + + mock_repository.find_user_account_by_id = AsyncMock(return_value=existing_dto) + mock_repository.find_user_accounts = AsyncMock(return_value=[]) + mock_repository.update_account = AsyncMock(return_value=updated_dto) + + # Act + result = await service.update_account( + account_id=account_id, + user_id=user_id, + name=name, + currency=currency, + balance=balance, + ) + + # Assert + assert result == updated_dto + mock_repository.find_user_account_by_id.assert_called_once_with(user_id=user_id, account_id=account_id) + mock_repository.update_account.assert_called_once() + + @pytest.mark.asyncio + async def test_update_account_not_found(self, service, mock_repository): + """Test updating non-existent account raises error""" + # Arrange + account_id = UserAccountID(999) + user_id = UserAccountUserID(1) + + mock_repository.find_user_account_by_id = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(UserAccountNotFoundError, match="Account with ID 999 not found"): + await service.update_account( + account_id=account_id, + user_id=user_id, + name=AccountName("Test"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + mock_repository.find_user_account_by_id.assert_called_once() + mock_repository.update_account.assert_not_called() + + @pytest.mark.asyncio + async def test_update_account_name_unchanged(self, service, mock_repository): + """Test updating account when name doesn't change""" + # Arrange + account_id = UserAccountID(10) + user_id = UserAccountUserID(1) + name = AccountName("Same Name") # Same name + currency = UserAccountCurrency("EUR") + balance = UserAccountBalance(Decimal("200.00")) + + existing_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=AccountName("Same Name"), # Same name + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + updated_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=name, + currency=currency, + balance=balance, + ) + + mock_repository.find_user_account_by_id = AsyncMock(return_value=existing_dto) + mock_repository.update_account = AsyncMock(return_value=updated_dto) + + # Act + result = await service.update_account( + account_id=account_id, + user_id=user_id, + name=name, + currency=currency, + balance=balance, + ) + + # Assert - should not check for duplicates when name unchanged + assert result == updated_dto + mock_repository.find_user_accounts.assert_not_called() + mock_repository.update_account.assert_called_once() + + @pytest.mark.asyncio + async def test_update_account_duplicate_name(self, service, mock_repository): + """Test updating account with duplicate name raises error""" + # Arrange + account_id = UserAccountID(10) + user_id = UserAccountUserID(1) + new_name = AccountName("Duplicate Name") + + existing_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=AccountName("Old Name"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + # Another account with the same name already exists + duplicate_dto = UserAccountDTO( + account_id=UserAccountID(20), # Different account ID + user_id=user_id, + name=new_name, + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("50.00")), + ) + + mock_repository.find_user_account_by_id = AsyncMock(return_value=existing_dto) + mock_repository.find_user_accounts = AsyncMock(return_value=[duplicate_dto]) + + # Act & Assert + with pytest.raises( + UserAccountNameAlreadyExistError, + match="Account with name 'Duplicate Name' already exists", + ): + await service.update_account( + account_id=account_id, + user_id=user_id, + name=new_name, + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + mock_repository.update_account.assert_not_called() + + @pytest.mark.asyncio + async def test_update_account_checks_inactive_accounts_for_duplicates(self, service, mock_repository): + """Test that duplicate name check includes inactive accounts""" + # Arrange + account_id = UserAccountID(10) + user_id = UserAccountUserID(1) + new_name = AccountName("New Name") + + existing_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=AccountName("Old Name"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + updated_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=new_name, + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + mock_repository.find_user_account_by_id = AsyncMock(return_value=existing_dto) + mock_repository.find_user_accounts = AsyncMock(return_value=[]) + mock_repository.update_account = AsyncMock(return_value=updated_dto) + + # Act + await service.update_account( + account_id=account_id, + user_id=user_id, + name=new_name, + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + # Assert - should check both active and inactive accounts + mock_repository.find_user_accounts.assert_called_once_with(user_id=user_id, name=new_name, only_active=False) + + @pytest.mark.asyncio + async def test_update_account_allows_same_account_name(self, service, mock_repository): + """Test that updating account can keep same name (not duplicate)""" + # Arrange + account_id = UserAccountID(10) + user_id = UserAccountUserID(1) + new_name = AccountName("Updated Name") + + existing_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=AccountName("Old Name"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + # Find returns the same account (not a duplicate, it's the same one) + same_account_dto = UserAccountDTO( + account_id=account_id, # Same account ID + user_id=user_id, + name=new_name, + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + ) + + updated_dto = UserAccountDTO( + account_id=account_id, + user_id=user_id, + name=new_name, + currency=UserAccountCurrency("EUR"), + balance=UserAccountBalance(Decimal("200.00")), + ) + + mock_repository.find_user_account_by_id = AsyncMock(return_value=existing_dto) + mock_repository.find_user_accounts = AsyncMock(return_value=[same_account_dto]) + mock_repository.update_account = AsyncMock(return_value=updated_dto) + + # Act - should succeed because the found account is the same one being updated + result = await service.update_account( + account_id=account_id, + user_id=user_id, + name=new_name, + currency=UserAccountCurrency("EUR"), + balance=UserAccountBalance(Decimal("200.00")), + ) + + # Assert + assert result == updated_dto + mock_repository.update_account.assert_called_once() diff --git a/tests/unit/context/user_account/domain/user_account_balance_test.py b/tests/unit/context/user_account/domain/user_account_balance_test.py new file mode 100644 index 0000000..0f02b3e --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_balance_test.py @@ -0,0 +1,54 @@ +"""Unit tests for UserAccountBalance value object""" + +from dataclasses import FrozenInstanceError +from decimal import Decimal + +import pytest + +from app.context.user_account.domain.value_objects import UserAccountBalance + + +@pytest.mark.unit +class TestUserAccountBalance: + """Tests for UserAccountBalance value object""" + + def test_valid_balance_creation(self): + """Test creating valid balances""" + balance = UserAccountBalance(Decimal("100.50")) + assert balance.value == Decimal("100.50") + + def test_from_float_factory_method(self): + """Test creating balance from float""" + balance = UserAccountBalance.from_float(100.50) + assert balance.value == Decimal("100.50") + + def test_zero_balance(self): + """Test that zero balance is valid""" + balance = UserAccountBalance(Decimal("0.00")) + assert balance.value == Decimal("0.00") + + def test_negative_balance(self): + """Test that negative balance is valid (for credit cards, overdrafts)""" + balance = UserAccountBalance(Decimal("-50.00")) + assert balance.value == Decimal("-50.00") + + def test_max_two_decimal_places(self): + """Test that balance accepts max 2 decimal places""" + balance = UserAccountBalance(Decimal("100.99")) + assert balance.value == Decimal("100.99") + + def test_more_than_two_decimals_raises_error(self): + """Test that more than 2 decimal places raise ValueError""" + with pytest.raises(ValueError, match="Balance cannot have more than 2 decimal places"): + UserAccountBalance(Decimal("100.999")) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + balance = UserAccountBalance.from_trusted_source(Decimal("100.9999")) + assert balance.value == Decimal("100.9999") + + def test_immutability(self): + """Test that value object is immutable""" + balance = UserAccountBalance(Decimal("100.00")) + with pytest.raises(FrozenInstanceError): + balance.value = Decimal("200.00") diff --git a/tests/unit/context/user_account/domain/user_account_currency_test.py b/tests/unit/context/user_account/domain/user_account_currency_test.py new file mode 100644 index 0000000..5a95750 --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_currency_test.py @@ -0,0 +1,48 @@ +"""Unit tests for UserAccountCurrency value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.user_account.domain.value_objects import UserAccountCurrency + + +@pytest.mark.unit +class TestUserAccountCurrency: + """Tests for UserAccountCurrency value object""" + + def test_valid_currency_creation(self): + """Test creating valid currencies""" + currency = UserAccountCurrency("USD") + assert currency.value == "USD" + + def test_three_letter_uppercase_required(self): + """Test that currency must be 3 uppercase letters""" + valid_currencies = ["USD", "EUR", "GBP", "JPY"] + for curr in valid_currencies: + currency = UserAccountCurrency(curr) + assert currency.value == curr + + def test_lowercase_raises_error(self): + """Test that lowercase currency codes raise ValueError""" + with pytest.raises(ValueError, match="Currency code must be uppercase"): + UserAccountCurrency("usd") + + def test_wrong_length_raises_error(self): + """Test that non-3-character codes raise ValueError""" + with pytest.raises(ValueError, match="Currency code must be exactly 3 characters"): + UserAccountCurrency("US") + + with pytest.raises(ValueError, match="Currency code must be exactly 3 characters"): + UserAccountCurrency("USDD") + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + currency = UserAccountCurrency.from_trusted_source("invalid") + assert currency.value == "invalid" + + def test_immutability(self): + """Test that value object is immutable""" + currency = UserAccountCurrency("USD") + with pytest.raises(FrozenInstanceError): + currency.value = "EUR" diff --git a/tests/unit/context/user_account/domain/user_account_deleted_at_test.py b/tests/unit/context/user_account/domain/user_account_deleted_at_test.py new file mode 100644 index 0000000..4145505 --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_deleted_at_test.py @@ -0,0 +1,46 @@ +"""Unit tests for UserAccountDeletedAt value object""" + +from dataclasses import FrozenInstanceError +from datetime import UTC, datetime + +import pytest + +from app.context.user_account.domain.value_objects import UserAccountDeletedAt + + +@pytest.mark.unit +class TestUserAccountDeletedAt: + """Tests for UserAccountDeletedAt value object""" + + def test_now_class_method(self): + """Test the now() class method creates current timestamp""" + deleted_at = UserAccountDeletedAt.now() + assert isinstance(deleted_at.value, datetime) + # Should be very recent (within 1 second) + time_diff = datetime.now(UTC) - deleted_at.value + assert time_diff.total_seconds() < 1 + + def test_from_optional_with_value(self): + """Test from_optional with non-None value""" + now = datetime.now(UTC) + deleted_at = UserAccountDeletedAt.from_optional(now) + assert deleted_at is not None + assert deleted_at.value == now + + def test_from_optional_with_none(self): + """Test from_optional with None value returns None""" + deleted_at = UserAccountDeletedAt.from_optional(None) + assert deleted_at is None + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + future_date = datetime.now(UTC).replace(year=2099) + # Should work even with future date when using from_trusted_source + deleted_at = UserAccountDeletedAt.from_trusted_source(future_date) + assert deleted_at.value == future_date + + def test_immutability(self): + """Test that value object is immutable""" + deleted_at = UserAccountDeletedAt.now() + with pytest.raises(FrozenInstanceError): + deleted_at.value = datetime.now() diff --git a/tests/unit/context/user_account/domain/user_account_dto_test.py b/tests/unit/context/user_account/domain/user_account_dto_test.py new file mode 100644 index 0000000..04aabff --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_dto_test.py @@ -0,0 +1,170 @@ +"""Unit tests for user_account domain DTOs""" + +from dataclasses import FrozenInstanceError +from datetime import datetime +from decimal import Decimal + +import pytest + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountDeletedAt, + UserAccountID, + UserAccountUserID, +) + + +@pytest.mark.unit +class TestUserAccountDTO: + """Tests for UserAccountDTO""" + + def test_create_dto_with_all_fields(self): + """Test creating DTO with all fields populated""" + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + account_id=UserAccountID(10), + deleted_at=UserAccountDeletedAt.now(), + ) + + assert dto.user_id.value == 1 + assert dto.name.value == "My Account" + assert dto.currency.value == "USD" + assert dto.balance.value == Decimal("100.50") + assert dto.account_id is not None + assert dto.account_id.value == 10 + assert dto.deleted_at is not None + assert isinstance(dto.deleted_at.value, datetime) + + def test_create_dto_without_optional_fields(self): + """Test creating DTO without optional fields""" + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + ) + + assert dto.user_id.value == 1 + assert dto.name.value == "My Account" + assert dto.currency.value == "USD" + assert dto.balance.value == Decimal("100.50") + assert dto.account_id is None + assert dto.deleted_at is None + + def test_is_deleted_property_when_deleted(self): + """Test is_deleted property returns True when deleted_at is set""" + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + deleted_at=UserAccountDeletedAt.now(), + ) + + assert dto.is_deleted is True + + def test_is_deleted_property_when_not_deleted(self): + """Test is_deleted property returns False when deleted_at is None""" + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + deleted_at=None, + ) + + assert dto.is_deleted is False + + def test_is_deleted_property_with_none_deleted_at(self): + """Test is_deleted property when deleted_at is None (using from_optional)""" + # from_optional(None) returns None, not a DeletedAt object + deleted_at_none = UserAccountDeletedAt.from_optional(None) + + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + deleted_at=deleted_at_none, # This is None + ) + + # deleted_at is None, so is_deleted should be False + assert dto.is_deleted is False + + def test_immutability(self): + """Test that DTO is immutable""" + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + ) + + with pytest.raises(FrozenInstanceError): + dto.user_id = UserAccountUserID(2) + + def test_dto_with_negative_balance(self): + """Test DTO with negative balance (overdraft)""" + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("Overdraft Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("-50.00")), + ) + + assert dto.balance.value == Decimal("-50.00") + + def test_dto_with_zero_balance(self): + """Test DTO with zero balance""" + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("Empty Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("0.00")), + ) + + assert dto.balance.value == Decimal("0.00") + + def test_dto_equality(self): + """Test that two DTOs with same values are equal""" + dto1 = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + account_id=UserAccountID(10), + ) + + dto2 = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + account_id=UserAccountID(10), + ) + + assert dto1 == dto2 + + def test_dto_inequality(self): + """Test that DTOs with different values are not equal""" + dto1 = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + ) + + dto2 = UserAccountDTO( + user_id=UserAccountUserID(2), # Different user_id + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + ) + + assert dto1 != dto2 diff --git a/tests/unit/context/user_account/domain/user_account_id_test.py b/tests/unit/context/user_account/domain/user_account_id_test.py new file mode 100644 index 0000000..89519d9 --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_id_test.py @@ -0,0 +1,44 @@ +"""Unit tests for UserAccountID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.user_account.domain.value_objects import UserAccountID + + +@pytest.mark.unit +class TestUserAccountID: + """Tests for UserAccountID value object""" + + def test_valid_id_creation(self): + """Test creating valid account IDs""" + account_id = UserAccountID(1) + assert account_id.value == 1 + + def test_invalid_type_raises_error(self): + """Test that invalid types raise ValueError""" + with pytest.raises(ValueError, match="AccountID must be an integer"): + UserAccountID("not_an_int") + + def test_negative_id_raises_error(self): + """Test that negative IDs raise ValueError""" + with pytest.raises(ValueError, match="AccountID must be positive"): + UserAccountID(-1) + + def test_zero_id_raises_error(self): + """Test that zero ID raises ValueError""" + with pytest.raises(ValueError, match="AccountID must be positive"): + UserAccountID(0) + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + # This should work even with invalid data + account_id = UserAccountID.from_trusted_source(-999) + assert account_id.value == -999 + + def test_immutability(self): + """Test that value object is immutable""" + account_id = UserAccountID(1) + with pytest.raises(FrozenInstanceError): + account_id.value = 2 diff --git a/tests/unit/context/user_account/domain/user_account_user_id_test.py b/tests/unit/context/user_account/domain/user_account_user_id_test.py new file mode 100644 index 0000000..77857eb --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_user_id_test.py @@ -0,0 +1,28 @@ +"""Unit tests for UserAccountUserID value object""" + +from dataclasses import FrozenInstanceError + +import pytest + +from app.context.user_account.domain.value_objects import UserAccountUserID + + +@pytest.mark.unit +class TestUserAccountUserID: + """Tests for UserAccountUserID value object""" + + def test_valid_user_id_creation(self): + """Test creating valid user IDs""" + user_id = UserAccountUserID(1) + assert user_id.value == 1 + + def test_from_trusted_source_skips_validation(self): + """Test that from_trusted_source bypasses validation""" + user_id = UserAccountUserID.from_trusted_source(-999) + assert user_id.value == -999 + + def test_immutability(self): + """Test that value object is immutable""" + user_id = UserAccountUserID(1) + with pytest.raises(FrozenInstanceError): + user_id.value = 2 diff --git a/tests/unit/context/user_account/infrastructure/__init__.py b/tests/unit/context/user_account/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py b/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py new file mode 100644 index 0000000..0ad8160 --- /dev/null +++ b/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py @@ -0,0 +1,254 @@ +"""Unit tests for user_account infrastructure mapper""" + +from datetime import datetime +from decimal import Decimal + +import pytest + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.exceptions import UserAccountMapperError +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountDeletedAt, + UserAccountID, + UserAccountUserID, +) +from app.context.user_account.infrastructure.mappers.user_account_mapper import ( + UserAccountMapper, +) +from app.context.user_account.infrastructure.models.user_account_model import ( + UserAccountModel, +) + + +@pytest.mark.unit +class TestUserAccountMapper: + """Tests for UserAccountMapper""" + + def test_to_dto_converts_model_to_dto(self): + """Test converting database model to domain DTO""" + # Arrange + model = UserAccountModel( + id=10, + user_id=1, + name="My Account", + currency="USD", + balance=Decimal("100.50"), + deleted_at=None, + ) + + # Act + dto = UserAccountMapper.to_dto(model) + + # Assert + assert dto is not None + assert isinstance(dto, UserAccountDTO) + assert dto.account_id.value == 10 + assert dto.user_id.value == 1 + assert dto.name.value == "My Account" + assert dto.currency.value == "USD" + assert dto.balance.value == Decimal("100.50") + assert dto.deleted_at is None # from_optional(None) returns None + + def test_to_dto_with_deleted_at(self): + """Test converting model with deleted_at timestamp""" + # Arrange + now = datetime.now() + model = UserAccountModel( + id=10, + user_id=1, + name="Deleted Account", + currency="USD", + balance=Decimal("0.00"), + deleted_at=now, + ) + + # Act + dto = UserAccountMapper.to_dto(model) + + # Assert + assert dto is not None + assert dto.deleted_at.value == now + assert dto.is_deleted is True + + def test_to_dto_with_none_model_returns_none(self): + """Test that None model returns None DTO""" + # Act + dto = UserAccountMapper.to_dto(None) + + # Assert + assert dto is None + + def test_to_dto_uses_trusted_source(self): + """Test that to_dto uses from_trusted_source for performance""" + # Arrange - create model with data that would fail validation + # (empty name would normally fail AccountName validation) + model = UserAccountModel( + id=10, + user_id=1, + name="", # Would fail validation if not using from_trusted_source + currency="USD", + balance=Decimal("100.00"), + deleted_at=None, + ) + + # Act - should not raise because using from_trusted_source + dto = UserAccountMapper.to_dto(model) + + # Assert + assert dto is not None + assert dto.name.value == "" # Empty name preserved + + def test_to_dto_or_fail_with_valid_model(self): + """Test to_dto_or_fail with valid model""" + # Arrange + model = UserAccountModel( + id=10, + user_id=1, + name="My Account", + currency="USD", + balance=Decimal("100.00"), + deleted_at=None, + ) + + # Act + dto = UserAccountMapper.to_dto_or_fail(model) + + # Assert + assert dto is not None + assert isinstance(dto, UserAccountDTO) + assert dto.account_id.value == 10 + + def test_to_dto_or_fail_with_none_raises_error(self): + """Test to_dto_or_fail raises error when DTO conversion results in None""" + # When to_dto(None) is called, it returns None + # to_dto_or_fail should raise UserAccountMapperError + with pytest.raises(UserAccountMapperError, match="dto cannot be null"): + UserAccountMapper.to_dto_or_fail(None) + + def test_to_model_converts_dto_to_model(self): + """Test converting domain DTO to database model""" + # Arrange + dto = UserAccountDTO( + account_id=UserAccountID(10), + user_id=UserAccountUserID(1), + name=AccountName("My Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.50")), + deleted_at=None, + ) + + # Act + model = UserAccountMapper.to_model(dto) + + # Assert + assert isinstance(model, UserAccountModel) + assert model.id == 10 + assert model.user_id == 1 + assert model.name == "My Account" + assert model.currency == "USD" + assert model.balance == Decimal("100.50") + assert model.deleted_at is None + + def test_to_model_with_none_account_id(self): + """Test converting DTO without account_id (new entity)""" + # Arrange + dto = UserAccountDTO( + user_id=UserAccountUserID(1), + name=AccountName("New Account"), + currency=UserAccountCurrency("EUR"), + balance=UserAccountBalance(Decimal("200.00")), + account_id=None, # New account, no ID yet + ) + + # Act + model = UserAccountMapper.to_model(dto) + + # Assert + assert model.id is None # Will be assigned by database + assert model.user_id == 1 + assert model.name == "New Account" + assert model.currency == "EUR" + + def test_to_model_with_deleted_at(self): + """Test converting DTO with deleted_at timestamp""" + # Arrange + from datetime import UTC + + now = datetime.now(UTC) + dto = UserAccountDTO( + account_id=UserAccountID(10), + user_id=UserAccountUserID(1), + name=AccountName("Deleted Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("0.00")), + deleted_at=UserAccountDeletedAt.from_trusted_source(now), + ) + + # Act + model = UserAccountMapper.to_model(dto) + + # Assert + assert model.deleted_at == now + + def test_to_model_with_none_deleted_at(self): + """Test converting DTO with None deleted_at""" + # Arrange + dto = UserAccountDTO( + account_id=UserAccountID(10), + user_id=UserAccountUserID(1), + name=AccountName("Active Account"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("100.00")), + deleted_at=None, + ) + + # Act + model = UserAccountMapper.to_model(dto) + + # Assert + assert model.deleted_at is None + + def test_roundtrip_conversion(self): + """Test converting model to DTO and back to model""" + # Arrange + original_model = UserAccountModel( + id=10, + user_id=1, + name="Test Account", + currency="GBP", + balance=Decimal("500.25"), + deleted_at=None, + ) + + # Act - convert to DTO and back + dto = UserAccountMapper.to_dto(original_model) + final_model = UserAccountMapper.to_model(dto) + + # Assert - values should be preserved + assert final_model.id == original_model.id + assert final_model.user_id == original_model.user_id + assert final_model.name == original_model.name + assert final_model.currency == original_model.currency + assert final_model.balance == original_model.balance + assert final_model.deleted_at == original_model.deleted_at + + def test_to_model_preserves_decimal_precision(self): + """Test that decimal precision is preserved during conversion""" + # Arrange + dto = UserAccountDTO( + account_id=UserAccountID(10), + user_id=UserAccountUserID(1), + name=AccountName("Precision Test"), + currency=UserAccountCurrency("USD"), + balance=UserAccountBalance(Decimal("99.99")), + ) + + # Act + model = UserAccountMapper.to_model(dto) + + # Assert + assert model.balance == Decimal("99.99") + assert isinstance(model.balance, Decimal) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0ee0b04 --- /dev/null +++ b/uv.lock @@ -0,0 +1,878 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "alembic" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.125.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/71/2df15009fb4bdd522a069d2fbca6007c6c5487fce5cb965be00fc335f1d1/fastapi-0.125.0.tar.gz", hash = "sha256:16b532691a33e2c5dee1dac32feb31dc6eb41a3dd4ff29a95f9487cb21c054c0", size = 370550, upload-time = "2025-12-17T21:41:44.15Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/2f/ff2fcc98f500713368d8b650e1bbc4a0b3ebcdd3e050dcdaad5f5a13fd7e/fastapi-0.125.0-py3-none-any.whl", hash = "sha256:2570ec4f3aecf5cca8f0428aed2398b774fcdfee6c2116f86e80513f2f86a7a1", size = 112888, upload-time = "2025-12-17T21:41:41.286Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "homecomp-api" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "psycopg2-binary" }, + { name = "pydantic", extra = ["email"] }, + { name = "sqlalchemy" }, + { name = "structlog" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "python-logging-loki" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.17.2" }, + { name = "argon2-cffi", specifier = ">=25.1.0" }, + { name = "asyncpg", specifier = ">=0.31.0" }, + { name = "fastapi", specifier = ">=0.125.0" }, + { name = "greenlet", specifier = ">=3.3.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.12.5" }, + { name = "sqlalchemy", specifier = ">=2.0.45" }, + { name = "structlog", specifier = ">=25.5.0" }, + { name = "uvicorn", specifier = ">=0.38.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.28.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "python-logging-loki", specifier = ">=0.3.1" }, + { name = "ruff", specifier = ">=0.14.10" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-logging-loki" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, + { name = "rfc3339" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/e2/b1ca91524530e5c7d73e70c4a2df952e5b7f1519977c7c51b3966b0332a8/python-logging-loki-0.3.1.tar.gz", hash = "sha256:b83610c8a3adc99fbab072493b91dfb25ced69be4874fefe3ab457b391adbf60", size = 5214, upload-time = "2019-11-28T22:34:38.273Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/ca/1f5660fbda815ed04839d657cab77605337e32c224c03c780cd383c7bcd6/python_logging_loki-0.3.1-py3-none-any.whl", hash = "sha256:8a9131db037fbea3d390089c4c32dbe7ed233944905079615a9fb6f669b0f4e6", size = 7004, upload-time = "2019-11-28T22:34:36.635Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rfc3339" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/fb/2835a62f2de226796fce76411daec6b9831eaf6d2fd04994ac1de055dc13/rfc3339-6.2.tar.gz", hash = "sha256:d53c3b5eefaef892b7240ba2a91fef012e86faa4d0a0ca782359c490e00ad4d0", size = 4144, upload-time = "2019-09-24T16:46:18.176Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2e/48d6cf57dec789c90a7b1cb59a21c3cad509f0ec1284632152f33bb1d88d/rfc3339-6.2-py3-none-any.whl", hash = "sha256:f44316b21b21db90a625cde04ebb0d46268f153e6093021fa5893e92a96f58a3", size = 5515, upload-time = "2019-09-24T16:46:16.63Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.38.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" }, +]