From 34f16c4889580fb8d6e1c6611786f53355bfad7a Mon Sep 17 00:00:00 2001 From: polivera Date: Sun, 14 Dec 2025 14:14:44 +0100 Subject: [PATCH 01/58] Adding some boilerplate --- .gitignore | 50 +++++++++++++++++++ app/__init__.py | 0 app/context/auth/__init__.py | 0 app/context/auth/interface/rest/__init__.py | 1 + .../interface/rest/controllers/__init__.py | 1 + .../rest/controllers/login_rest_controller.py | 5 ++ app/context/auth/interface/rest/routes.py | 11 ++++ .../auth/interface/rest/schemas/__init__.py | 1 + .../rest/schemas/login_rest_schema.py | 14 ++++++ app/main.py | 11 ++++ requirements.txt | 40 +++++++++++++++ 11 files changed, 134 insertions(+) create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/context/auth/__init__.py create mode 100644 app/context/auth/interface/rest/__init__.py create mode 100644 app/context/auth/interface/rest/controllers/__init__.py create mode 100644 app/context/auth/interface/rest/controllers/login_rest_controller.py create mode 100644 app/context/auth/interface/rest/routes.py create mode 100644 app/context/auth/interface/rest/schemas/__init__.py create mode 100644 app/context/auth/interface/rest/schemas/login_rest_schema.py create mode 100644 app/main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db6d0ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# 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 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/interface/rest/__init__.py b/app/context/auth/interface/rest/__init__.py new file mode 100644 index 0000000..af14aba --- /dev/null +++ b/app/context/auth/interface/rest/__init__.py @@ -0,0 +1 @@ +from .routes import 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..903e1e9 --- /dev/null +++ b/app/context/auth/interface/rest/controllers/__init__.py @@ -0,0 +1 @@ +from .login_rest_controller import loginAction 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..b0489c6 --- /dev/null +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -0,0 +1,5 @@ +from app.context.auth.interface.rest.schemas import LoginRequest + + +async def loginAction(request: LoginRequest): + return {"leemail": request.email, "lepass": request.password} diff --git a/app/context/auth/interface/rest/routes.py b/app/context/auth/interface/rest/routes.py new file mode 100644 index 0000000..fafff72 --- /dev/null +++ b/app/context/auth/interface/rest/routes.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + +from app.context.auth.interface.rest.controllers import loginAction +from app.context.auth.interface.rest.schemas import LoginRequest + +auth_routes = APIRouter(prefix="/api/auth", tags=["auth"]) + + +@auth_routes.post("/login") +async def login(request: LoginRequest): + return await loginAction(request) 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..2ba0a8c --- /dev/null +++ b/app/context/auth/interface/rest/schemas/__init__.py @@ -0,0 +1 @@ +from .login_rest_schema import LoginRequest 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..739a18b --- /dev/null +++ b/app/context/auth/interface/rest/schemas/login_rest_schema.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from pydantic import BaseModel, EmailStr, ConfigDict + + +class LoginRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + email: EmailStr + password: str + + +@dataclass(frozen=True) +class LoginResponse: + email: str diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..dd0918f --- /dev/null +++ b/app/main.py @@ -0,0 +1,11 @@ +from fastapi import FastAPI +from .context.auth.interface.rest import auth_routes + +app = FastAPI(title="Homecomp API", description="API for Homecomp", version="0.1.0") + +app.include_router(auth_routes) + + +@app.get("/") +async def root(): + return {"message": "Welcome to Homecomp API"} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7c83cca --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.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 +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 +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 From 48b7b05965ff23c50f75640643896b6ca48f3e61 Mon Sep 17 00:00:00 2001 From: polivera Date: Sun, 14 Dec 2025 18:38:55 +0100 Subject: [PATCH 02/58] How to DDD on pythong (needs modifications) --- app/context/auth/application/__init__.py | 0 .../auth/application/services/__init__.py | 3 + .../auth/application/services/user_service.py | 86 +++++++++++++++++++ app/context/auth/domain/__init__.py | 0 app/context/auth/domain/models/__init__.py | 3 + app/context/auth/domain/models/user.py | 14 +++ app/context/auth/infrastructure/__init__.py | 0 .../infrastructure/repositories/__init__.py | 3 + .../repositories/user_repository.py | 47 ++++++++++ .../interface/rest/controllers/__init__.py | 3 + .../rest/controllers/user_rest_controller.py | 80 +++++++++++++++++ .../auth/interface/rest/dependencies.py | 30 +++++++ app/context/auth/interface/rest/routes.py | 11 ++- .../auth/interface/rest/schemas/__init__.py | 3 + .../rest/schemas/user_rest_schema.py | 33 +++++++ app/infrastructure/database.py | 37 ++++++++ app/main.py | 22 ++++- requirements.txt | 3 + 18 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 app/context/auth/application/__init__.py create mode 100644 app/context/auth/application/services/__init__.py create mode 100644 app/context/auth/application/services/user_service.py create mode 100644 app/context/auth/domain/__init__.py create mode 100644 app/context/auth/domain/models/__init__.py create mode 100644 app/context/auth/domain/models/user.py create mode 100644 app/context/auth/infrastructure/__init__.py create mode 100644 app/context/auth/infrastructure/repositories/__init__.py create mode 100644 app/context/auth/infrastructure/repositories/user_repository.py create mode 100644 app/context/auth/interface/rest/controllers/user_rest_controller.py create mode 100644 app/context/auth/interface/rest/dependencies.py create mode 100644 app/context/auth/interface/rest/schemas/user_rest_schema.py create mode 100644 app/infrastructure/database.py 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/services/__init__.py b/app/context/auth/application/services/__init__.py new file mode 100644 index 0000000..3b69507 --- /dev/null +++ b/app/context/auth/application/services/__init__.py @@ -0,0 +1,3 @@ +from .user_service import UserService + +__all__ = ["UserService"] diff --git a/app/context/auth/application/services/user_service.py b/app/context/auth/application/services/user_service.py new file mode 100644 index 0000000..ea81030 --- /dev/null +++ b/app/context/auth/application/services/user_service.py @@ -0,0 +1,86 @@ +from app.context.auth.infrastructure.repositories.user_repository import UserRepository +from app.context.auth.domain.models.user import User +from typing import Optional, List +from fastapi import HTTPException + + +class UserService: + def __init__(self, user_repo: UserRepository): + self.user_repo = user_repo + + async def get_user_by_id(self, user_id: int) -> Optional[User]: + """Get user by ID""" + return await self.user_repo.find_by_id(user_id) + + async def get_user_by_email(self, email: str) -> Optional[User]: + """Get user by email""" + return await self.user_repo.find_by_email(email) + + async def get_user_by_username(self, username: str) -> Optional[User]: + """Get user by username""" + return await self.user_repo.find_by_username(username) + + async def create_user( + self, email: str, username: str, password: str + ) -> User: + """ + Create a new user + + Note: In production, you should hash the password using a library like bcrypt or passlib + """ + # Check if user already exists + existing_user = await self.user_repo.find_by_email(email) + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + + existing_username = await self.user_repo.find_by_username(username) + if existing_username: + raise HTTPException(status_code=400, detail="Username already taken") + + # TODO: Hash password before storing + # from passlib.context import CryptContext + # pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + # hashed_password = pwd_context.hash(password) + + user = User( + email=email, + username=username, + hashed_password=password, # WARNING: Should be hashed in production! + ) + return await self.user_repo.create(user) + + async def list_users(self, skip: int = 0, limit: int = 100) -> List[User]: + """List all users with pagination""" + return await self.user_repo.list_all(skip, limit) + + async def update_user( + self, + user_id: int, + email: Optional[str] = None, + username: Optional[str] = None, + ) -> User: + """Update user information""" + user = await self.user_repo.find_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if email: + existing = await self.user_repo.find_by_email(email) + if existing and existing.id != user_id: + raise HTTPException(status_code=400, detail="Email already in use") + user.email = email + + if username: + existing = await self.user_repo.find_by_username(username) + if existing and existing.id != user_id: + raise HTTPException(status_code=400, detail="Username already taken") + user.username = username + + return await self.user_repo.update(user) + + async def delete_user(self, user_id: int) -> None: + """Delete a user""" + user = await self.user_repo.find_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + await self.user_repo.delete(user) 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/models/__init__.py b/app/context/auth/domain/models/__init__.py new file mode 100644 index 0000000..35b01f3 --- /dev/null +++ b/app/context/auth/domain/models/__init__.py @@ -0,0 +1,3 @@ +from .user import User + +__all__ = ["User"] diff --git a/app/context/auth/domain/models/user.py b/app/context/auth/domain/models/user.py new file mode 100644 index 0000000..2cf7d22 --- /dev/null +++ b/app/context/auth/domain/models/user.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, Integer, String, DateTime +from sqlalchemy.sql import func +from app.infrastructure.database import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String, unique=True, index=True, nullable=False) + username = Column(String, unique=True, index=True, nullable=False) + hashed_password = Column(String, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) 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/repositories/__init__.py b/app/context/auth/infrastructure/repositories/__init__.py new file mode 100644 index 0000000..15c7389 --- /dev/null +++ b/app/context/auth/infrastructure/repositories/__init__.py @@ -0,0 +1,3 @@ +from .user_repository import UserRepository + +__all__ = ["UserRepository"] diff --git a/app/context/auth/infrastructure/repositories/user_repository.py b/app/context/auth/infrastructure/repositories/user_repository.py new file mode 100644 index 0000000..b5abb2c --- /dev/null +++ b/app/context/auth/infrastructure/repositories/user_repository.py @@ -0,0 +1,47 @@ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.context.auth.domain.models.user import User +from typing import Optional, List + + +class UserRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def find_by_id(self, user_id: int) -> Optional[User]: + """Find user by ID""" + result = await self.db.execute(select(User).where(User.id == user_id)) + return result.scalar_one_or_none() + + async def find_by_email(self, email: str) -> Optional[User]: + """Find user by email""" + result = await self.db.execute(select(User).where(User.email == email)) + return result.scalar_one_or_none() + + async def find_by_username(self, username: str) -> Optional[User]: + """Find user by username""" + result = await self.db.execute(select(User).where(User.username == username)) + return result.scalar_one_or_none() + + async def create(self, user: User) -> User: + """Create a new user""" + self.db.add(user) + await self.db.commit() + await self.db.refresh(user) + return user + + async def list_all(self, skip: int = 0, limit: int = 100) -> List[User]: + """List all users with pagination""" + result = await self.db.execute(select(User).offset(skip).limit(limit)) + return list(result.scalars().all()) + + async def update(self, user: User) -> User: + """Update an existing user""" + await self.db.commit() + await self.db.refresh(user) + return user + + async def delete(self, user: User) -> None: + """Delete a user""" + await self.db.delete(user) + await self.db.commit() diff --git a/app/context/auth/interface/rest/controllers/__init__.py b/app/context/auth/interface/rest/controllers/__init__.py index 903e1e9..d6b4c24 100644 --- a/app/context/auth/interface/rest/controllers/__init__.py +++ b/app/context/auth/interface/rest/controllers/__init__.py @@ -1 +1,4 @@ from .login_rest_controller import loginAction +from .user_rest_controller import router as user_router + +__all__ = ["loginAction", "user_router"] diff --git a/app/context/auth/interface/rest/controllers/user_rest_controller.py b/app/context/auth/interface/rest/controllers/user_rest_controller.py new file mode 100644 index 0000000..423860a --- /dev/null +++ b/app/context/auth/interface/rest/controllers/user_rest_controller.py @@ -0,0 +1,80 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from app.context.auth.application.services.user_service import UserService +from app.context.auth.interface.rest.dependencies import get_user_service +from app.context.auth.interface.rest.schemas import ( + UserCreate, + UserResponse, + UserUpdate, +) +from typing import List + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: int, + user_service: UserService = Depends(get_user_service), +): + """ + Get user by ID. + + Notice how clean this is! We inject UserService directly, + and FastAPI handles the entire dependency chain: + get_db() → get_user_repository() → get_user_service() + """ + user = await user_service.get_user_by_id(user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + user_data: UserCreate, + user_service: UserService = Depends(get_user_service), +): + """ + Create a new user. + + The same dependency injection chain happens here automatically. + """ + return await user_service.create_user( + email=user_data.email, + username=user_data.username, + password=user_data.password, + ) + + +@router.get("/", response_model=List[UserResponse]) +async def list_users( + skip: int = 0, + limit: int = 100, + user_service: UserService = Depends(get_user_service), +): + """List all users with pagination""" + return await user_service.list_users(skip, limit) + + +@router.patch("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: int, + user_data: UserUpdate, + user_service: UserService = Depends(get_user_service), +): + """Update user information""" + return await user_service.update_user( + user_id=user_id, + email=user_data.email, + username=user_data.username, + ) + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + user_service: UserService = Depends(get_user_service), +): + """Delete a user""" + await user_service.delete_user(user_id) + return None diff --git a/app/context/auth/interface/rest/dependencies.py b/app/context/auth/interface/rest/dependencies.py new file mode 100644 index 0000000..a85e87c --- /dev/null +++ b/app/context/auth/interface/rest/dependencies.py @@ -0,0 +1,30 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.infrastructure.database import get_db +from app.context.auth.infrastructure.repositories.user_repository import UserRepository +from app.context.auth.application.services.user_service import UserService + + +def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepository: + """ + Dependency to get UserRepository instance. + + This function is called by FastAPI's dependency injection system. + It receives the database session from get_db() and creates a UserRepository. + """ + return UserRepository(db) + + +def get_user_service( + user_repo: UserRepository = Depends(get_user_repository), +) -> UserService: + """ + Dependency to get UserService instance. + + This function is called by FastAPI's dependency injection system. + It receives the UserRepository from get_user_repository() and creates a UserService. + + Dependency chain: + get_db() → get_user_repository() → get_user_service() + """ + return UserService(user_repo) diff --git a/app/context/auth/interface/rest/routes.py b/app/context/auth/interface/rest/routes.py index fafff72..b744566 100644 --- a/app/context/auth/interface/rest/routes.py +++ b/app/context/auth/interface/rest/routes.py @@ -1,11 +1,16 @@ from fastapi import APIRouter - -from app.context.auth.interface.rest.controllers import loginAction +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.infrastructure.database import get_db +from app.context.auth.interface.rest.controllers import loginAction, user_router from app.context.auth.interface.rest.schemas import LoginRequest auth_routes = APIRouter(prefix="/api/auth", tags=["auth"]) +# Include user router (with dependency injection chain) +auth_routes.include_router(user_router) + @auth_routes.post("/login") -async def login(request: LoginRequest): +async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): return await loginAction(request) diff --git a/app/context/auth/interface/rest/schemas/__init__.py b/app/context/auth/interface/rest/schemas/__init__.py index 2ba0a8c..850b521 100644 --- a/app/context/auth/interface/rest/schemas/__init__.py +++ b/app/context/auth/interface/rest/schemas/__init__.py @@ -1 +1,4 @@ from .login_rest_schema import LoginRequest +from .user_rest_schema import UserCreate, UserResponse, UserUpdate + +__all__ = ["LoginRequest", "UserCreate", "UserResponse", "UserUpdate"] diff --git a/app/context/auth/interface/rest/schemas/user_rest_schema.py b/app/context/auth/interface/rest/schemas/user_rest_schema.py new file mode 100644 index 0000000..d243852 --- /dev/null +++ b/app/context/auth/interface/rest/schemas/user_rest_schema.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, EmailStr, ConfigDict +from datetime import datetime +from typing import Optional + + +class UserBase(BaseModel): + """Base user schema with common fields""" + + email: EmailStr + username: str + + +class UserCreate(UserBase): + """Schema for creating a new user""" + + password: str + + +class UserUpdate(BaseModel): + """Schema for updating user information""" + + email: Optional[EmailStr] = None + username: Optional[str] = None + + +class UserResponse(UserBase): + """Schema for user response (excludes sensitive data)""" + + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) diff --git a/app/infrastructure/database.py b/app/infrastructure/database.py new file mode 100644 index 0000000..0b654d9 --- /dev/null +++ b/app/infrastructure/database.py @@ -0,0 +1,37 @@ +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base + + +DB_HOST = os.getenv("DB_HOST") +DB_PORT = os.getenv("DB_PORT") +DB_USER = os.getenv("DB_USER") +DB_PASS = os.getenv("DB_PASS") +DB_NAME = os.getenv("DB_NAME") + +DATABASE_URL = os.getenv( + "DATABASE_URL", + f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", +) + +echo_queries = os.getenv("APP_ENV", "prod") == "dev" + +async_engine = create_async_engine( + DATABASE_URL, + echo=echo_queries, # 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 diff --git a/app/main.py b/app/main.py index dd0918f..4a08671 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,27 @@ from fastapi import FastAPI +from contextlib import asynccontextmanager +from .infrastructure.database import async_engine from .context.auth.interface.rest import auth_routes -app = FastAPI(title="Homecomp API", description="API for Homecomp", version="0.1.0") + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup: Create tables (or use Alembic for migrations) + # async with async_engine.begin() as conn: + # await conn.run_sync(Base.metadata.create_all) + + yield # Application runs + + # Shutdown: Close connection pool + await async_engine.dispose() + + +app = FastAPI( + title="Homecomp API", + description="API for Homecomp", + version="0.1.0", + lifespan=lifespan, +) app.include_router(auth_routes) diff --git a/requirements.txt b/requirements.txt index 7c83cca..f56b0a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ 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 @@ -9,6 +10,7 @@ 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 @@ -29,6 +31,7 @@ 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 From f2151a85bddee4f4bd17a21981779ef84ba78d18 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 15 Dec 2025 00:26:03 +0100 Subject: [PATCH 03/58] Working on DDD and dependency injection (kind of) --- .../user/application/contract/__init__.py | 1 + .../find_user_query_handler_contract.py | 10 +++++++ .../user/application/dto/UserContextDTO.py | 7 +++++ app/context/user/application/dto/__init__.py | 1 + .../user/application/handler/__init__.py | 1 + .../application/handler/find_user_handler.py | 18 ++++++++++++ .../user/application/query/__init__.py | 1 + .../user/application/query/find_user_query.py | 9 ++++++ .../contract/infrastrutcure/__init__.py | 1 + .../user_repository_contract.py | 11 ++++++++ app/context/user/domain/dto/__init__.py | 1 + app/context/user/domain/dto/user_dto.py | 7 +++++ app/context/user/infrastructure/dependency.py | 28 +++++++++++++++++++ .../infrastructure/repository/__init__.py | 1 + .../repository/user_repository.py | 18 ++++++++++++ .../interface/console/find_user_console.py | 27 ++++++++++++++++++ app/shared/domain/valueobject/__init__.py | 1 + app/shared/domain/valueobject/email.py | 15 ++++++++++ docker-compose.yml | 23 +++++++++++++++ 19 files changed, 181 insertions(+) create mode 100644 app/context/user/application/contract/__init__.py create mode 100644 app/context/user/application/contract/find_user_query_handler_contract.py create mode 100644 app/context/user/application/dto/UserContextDTO.py create mode 100644 app/context/user/application/dto/__init__.py create mode 100644 app/context/user/application/handler/__init__.py create mode 100644 app/context/user/application/handler/find_user_handler.py create mode 100644 app/context/user/application/query/__init__.py create mode 100644 app/context/user/application/query/find_user_query.py create mode 100644 app/context/user/domain/contract/infrastrutcure/__init__.py create mode 100644 app/context/user/domain/contract/infrastrutcure/user_repository_contract.py create mode 100644 app/context/user/domain/dto/__init__.py create mode 100644 app/context/user/domain/dto/user_dto.py create mode 100644 app/context/user/infrastructure/dependency.py create mode 100644 app/context/user/infrastructure/repository/__init__.py create mode 100644 app/context/user/infrastructure/repository/user_repository.py create mode 100644 app/context/user/interface/console/find_user_console.py create mode 100644 app/shared/domain/valueobject/__init__.py create mode 100644 app/shared/domain/valueobject/email.py create mode 100644 docker-compose.yml diff --git a/app/context/user/application/contract/__init__.py b/app/context/user/application/contract/__init__.py new file mode 100644 index 0000000..ee566d6 --- /dev/null +++ b/app/context/user/application/contract/__init__.py @@ -0,0 +1 @@ +from .find_user_query_handler_contract import FindUserHandlerContract diff --git a/app/context/user/application/contract/find_user_query_handler_contract.py b/app/context/user/application/contract/find_user_query_handler_contract.py new file mode 100644 index 0000000..c3bef44 --- /dev/null +++ b/app/context/user/application/contract/find_user_query_handler_contract.py @@ -0,0 +1,10 @@ +from abc import ABC, abstractmethod +from typing import Optional +from app.context.user.application.query import FindUserQuery +from app.context.user.application.dto import UserContextDTO + + +class FindUserHandlerContract(ABC): + @abstractmethod + async def handle(self, query: FindUserQuery) -> Optional[UserContextDTO]: + pass diff --git a/app/context/user/application/dto/UserContextDTO.py b/app/context/user/application/dto/UserContextDTO.py new file mode 100644 index 0000000..c929ceb --- /dev/null +++ b/app/context/user/application/dto/UserContextDTO.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserContextDTO: + id: int + email: str diff --git a/app/context/user/application/dto/__init__.py b/app/context/user/application/dto/__init__.py new file mode 100644 index 0000000..106bfa9 --- /dev/null +++ b/app/context/user/application/dto/__init__.py @@ -0,0 +1 @@ +from .UserContextDTO import UserContextDTO diff --git a/app/context/user/application/handler/__init__.py b/app/context/user/application/handler/__init__.py new file mode 100644 index 0000000..130dd41 --- /dev/null +++ b/app/context/user/application/handler/__init__.py @@ -0,0 +1 @@ +from .find_user_handler import FindUserHandler diff --git a/app/context/user/application/handler/find_user_handler.py b/app/context/user/application/handler/find_user_handler.py new file mode 100644 index 0000000..a34d6cc --- /dev/null +++ b/app/context/user/application/handler/find_user_handler.py @@ -0,0 +1,18 @@ +from typing import Optional +from app.context.user.application.contract import FindUserHandlerContract +from app.context.user.application.dto import UserContextDTO +from app.context.user.application.query import FindUserQuery +from app.context.user.domain.contract.infrastrutcure import ( + UserRepositoryContract, +) +from app.shared.domain.valueobject import Email + + +class FindUserHandler(FindUserHandlerContract): + def __init__(self, user_repo: UserRepositoryContract): + self.user_repo = user_repo + + async def handle(self, query: FindUserQuery) -> Optional[UserContextDTO]: + email = Email(value=query.email) if query.email is not None else None + res = await self.user_repo.find_user(email) + return UserContextDTO(id=1, email=res.email.value) if res is not None else None diff --git a/app/context/user/application/query/__init__.py b/app/context/user/application/query/__init__.py new file mode 100644 index 0000000..2965ba3 --- /dev/null +++ b/app/context/user/application/query/__init__.py @@ -0,0 +1 @@ +from .find_user_query import FindUserQuery diff --git a/app/context/user/application/query/find_user_query.py b/app/context/user/application/query/find_user_query.py new file mode 100644 index 0000000..feec240 --- /dev/null +++ b/app/context/user/application/query/find_user_query.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class FindUserQuery: + id: Optional[int] + email: Optional[str] + password: Optional[str] diff --git a/app/context/user/domain/contract/infrastrutcure/__init__.py b/app/context/user/domain/contract/infrastrutcure/__init__.py new file mode 100644 index 0000000..b952a4a --- /dev/null +++ b/app/context/user/domain/contract/infrastrutcure/__init__.py @@ -0,0 +1 @@ +from .user_repository_contract import UserRepositoryContract diff --git a/app/context/user/domain/contract/infrastrutcure/user_repository_contract.py b/app/context/user/domain/contract/infrastrutcure/user_repository_contract.py new file mode 100644 index 0000000..b889800 --- /dev/null +++ b/app/context/user/domain/contract/infrastrutcure/user_repository_contract.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from app.context.user.domain.dto import UserDTO +from app.shared.domain.valueobject import Email + + +class UserRepositoryContract(ABC): + @abstractmethod + async def find_user(self, email: Optional[Email]) -> Optional[UserDTO]: + 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..4b35baf --- /dev/null +++ b/app/context/user/domain/dto/__init__.py @@ -0,0 +1 @@ +from .user_dto import 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..c17f5fc --- /dev/null +++ b/app/context/user/domain/dto/user_dto.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass +from app.shared.domain.valueobject import Email + + +@dataclass(frozen=True) +class UserDTO: + email: Email diff --git a/app/context/user/infrastructure/dependency.py b/app/context/user/infrastructure/dependency.py new file mode 100644 index 0000000..1e15819 --- /dev/null +++ b/app/context/user/infrastructure/dependency.py @@ -0,0 +1,28 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user.application.contract.find_user_query_handler_contract import ( + FindUserHandlerContract, +) +from app.context.user.application.handler.find_user_handler import FindUserHandler +from app.context.user.domain.contract.infrastrutcure import ( + UserRepositoryContract, +) +from app.context.user.infrastructure.repository.user_repository import UserRepository +from app.infrastructure.database import get_db + + +def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepositoryContract: + """ + Initialize user repository + """ + return UserRepository(db) + + +def get_find_user_query_handler( + user_repo: UserRepositoryContract = Depends(get_user_repository), +) -> FindUserHandlerContract: + """ + Initialize FindUserHandler + """ + return FindUserHandler(user_repo) diff --git a/app/context/user/infrastructure/repository/__init__.py b/app/context/user/infrastructure/repository/__init__.py new file mode 100644 index 0000000..73ba011 --- /dev/null +++ b/app/context/user/infrastructure/repository/__init__.py @@ -0,0 +1 @@ +from .user_repository import UserRepository diff --git a/app/context/user/infrastructure/repository/user_repository.py b/app/context/user/infrastructure/repository/user_repository.py new file mode 100644 index 0000000..4e65db9 --- /dev/null +++ b/app/context/user/infrastructure/repository/user_repository.py @@ -0,0 +1,18 @@ +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession +from app.context.user.domain.contract.infrastrutcure import ( + UserRepositoryContract, +) +from app.context.user.domain.dto import UserDTO +from app.shared.domain.valueobject import Email + + +class UserRepository(UserRepositoryContract): + + def __init__(self, db: AsyncSession): + self.db = db + + async def find_user(self, email: Optional[Email]) -> Optional[UserDTO]: + print(email) + return UserDTO(email=Email(value="test@test.com")) 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..ced5a47 --- /dev/null +++ b/app/context/user/interface/console/find_user_console.py @@ -0,0 +1,27 @@ +import asyncio + +from app.context.user.application.query.find_user_query import FindUserQuery +from app.context.user.infrastructure.repository.user_repository import UserRepository +from app.context.user.application.handler.find_user_handler import FindUserHandler +from app.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(id=None, email="test@example.com", password=None) + result = await handler.handle(query) + print(f"Result: {result}") + + # Example 2: Find by id + # query = FindUserQuery(id=1, email=None, password=None) + # result = await handler.handle(query) + # print(f"Result: {result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/shared/domain/valueobject/__init__.py b/app/shared/domain/valueobject/__init__.py new file mode 100644 index 0000000..2ebe67e --- /dev/null +++ b/app/shared/domain/valueobject/__init__.py @@ -0,0 +1 @@ +from .email import Email diff --git a/app/shared/domain/valueobject/email.py b/app/shared/domain/valueobject/email.py new file mode 100644 index 0000000..6696250 --- /dev/null +++ b/app/shared/domain/valueobject/email.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass +import re + + +@dataclass(frozen=True) +class Email: + value: str + + def __post_init__(self): + 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}") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fd605a6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + 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 + +volumes: + postgres_data: From 7af9a1196313dddf1b3453171aee069ff3006b68 Mon Sep 17 00:00:00 2001 From: polivera Date: Sun, 21 Dec 2025 03:20:14 +0100 Subject: [PATCH 04/58] Start working on login --- .gitignore | 15 - alembic.ini | 147 +++++ .../auth/application/commands/__init__.py | 1 + .../application/commands/login_command.py | 7 + .../auth/application/contracts/__init__.py | 1 + .../contracts/login_handler_contract.py | 11 + app/context/auth/application/dto/__init__.py | 1 + .../dto/login_handler_result_dto.py | 7 + .../auth/application/handlers/__init__.py | 1 + .../application/handlers/login_handler.py | 11 + .../auth/application/services/__init__.py | 3 - .../auth/application/services/user_service.py | 86 --- app/context/auth/domain/models/__init__.py | 3 - app/context/auth/domain/models/user.py | 14 - .../repositories/user_repository.py | 47 -- .../interface/rest/controllers/__init__.py | 5 +- .../rest/controllers/login_rest_controller.py | 17 +- .../rest/controllers/user_rest_controller.py | 80 --- .../auth/interface/rest/dependencies.py | 31 +- app/context/auth/interface/rest/routes.py | 16 +- .../rest/schemas/user_rest_schema.py | 33 -- .../{contract => contracts}/__init__.py | 0 .../find_user_query_handler_contract.py | 3 +- app/context/user/application/dto/__init__.py | 2 +- ...{UserContextDTO.py => user_context_dto.py} | 1 - .../{handler => handlers}/__init__.py | 0 .../find_user_handler.py | 11 +- .../{query => queries}/__init__.py | 0 .../{query => queries}/find_user_query.py | 0 .../infrastrutcure/__init__.py | 0 .../user_repository_contract.py | 2 +- app/context/user/domain/dto/user_dto.py | 3 +- .../user/domain/value_objects/__init__.py | 1 + .../user/domain/value_objects}/email.py | 2 +- .../user/domain/value_objects/password.py | 30 + app/context/user/infrastructure/dependency.py | 14 +- .../user/infrastructure/models/__init__.py | 1 + .../user/infrastructure/models/user_model.py | 15 + .../{repository => repositories}/__init__.py | 0 .../user_repository.py | 8 +- .../interface/console/find_user_console.py | 8 +- app/main.py | 8 +- app/shared/domain/valueobject/__init__.py | 1 - app/{ => shared}/infrastructure/database.py | 20 +- app/shared/infrastructure/models/__init__.py | 1 + .../infrastructure/models/base_model.py | 5 + docker-compose.yml | 2 - justfile | 11 + main.py | 6 + migrations/README | 1 + migrations/env.py | 88 +++ migrations/script.py.mako | 28 + .../f8333e5b2bac_create_user_table.py | 34 ++ pyproject.toml | 17 + scripts/seed.py | 27 + uv.lock | 558 ++++++++++++++++++ 56 files changed, 1075 insertions(+), 370 deletions(-) create mode 100644 alembic.ini create mode 100644 app/context/auth/application/commands/__init__.py create mode 100644 app/context/auth/application/commands/login_command.py create mode 100644 app/context/auth/application/contracts/__init__.py create mode 100644 app/context/auth/application/contracts/login_handler_contract.py create mode 100644 app/context/auth/application/dto/__init__.py create mode 100644 app/context/auth/application/dto/login_handler_result_dto.py create mode 100644 app/context/auth/application/handlers/__init__.py create mode 100644 app/context/auth/application/handlers/login_handler.py delete mode 100644 app/context/auth/application/services/__init__.py delete mode 100644 app/context/auth/application/services/user_service.py delete mode 100644 app/context/auth/domain/models/__init__.py delete mode 100644 app/context/auth/domain/models/user.py delete mode 100644 app/context/auth/infrastructure/repositories/user_repository.py delete mode 100644 app/context/auth/interface/rest/controllers/user_rest_controller.py delete mode 100644 app/context/auth/interface/rest/schemas/user_rest_schema.py rename app/context/user/application/{contract => contracts}/__init__.py (100%) rename app/context/user/application/{contract => contracts}/find_user_query_handler_contract.py (81%) rename app/context/user/application/dto/{UserContextDTO.py => user_context_dto.py} (99%) rename app/context/user/application/{handler => handlers}/__init__.py (100%) rename app/context/user/application/{handler => handlers}/find_user_handler.py (65%) rename app/context/user/application/{query => queries}/__init__.py (100%) rename app/context/user/application/{query => queries}/find_user_query.py (100%) rename app/context/user/domain/{contract => contracts}/infrastrutcure/__init__.py (100%) rename app/context/user/domain/{contract => contracts}/infrastrutcure/user_repository_contract.py (82%) create mode 100644 app/context/user/domain/value_objects/__init__.py rename app/{shared/domain/valueobject => context/user/domain/value_objects}/email.py (100%) create mode 100644 app/context/user/domain/value_objects/password.py create mode 100644 app/context/user/infrastructure/models/__init__.py create mode 100644 app/context/user/infrastructure/models/user_model.py rename app/context/user/infrastructure/{repository => repositories}/__init__.py (100%) rename app/context/user/infrastructure/{repository => repositories}/user_repository.py (73%) delete mode 100644 app/shared/domain/valueobject/__init__.py rename app/{ => shared}/infrastructure/database.py (68%) create mode 100644 app/shared/infrastructure/models/__init__.py create mode 100644 app/shared/infrastructure/models/base_model.py create mode 100644 justfile create mode 100644 main.py create mode 100644 migrations/README create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/f8333e5b2bac_create_user_table.py create mode 100644 pyproject.toml create mode 100644 scripts/seed.py create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index db6d0ff..acfc802 100644 --- a/.gitignore +++ b/.gitignore @@ -4,21 +4,6 @@ __pycache__/ *$py.class *.so .Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg # Virtual Environment venv/ 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/context/auth/application/commands/__init__.py b/app/context/auth/application/commands/__init__.py new file mode 100644 index 0000000..eb44419 --- /dev/null +++ b/app/context/auth/application/commands/__init__.py @@ -0,0 +1 @@ +from .login_command import 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..b9c99ac --- /dev/null +++ b/app/context/auth/application/contracts/__init__.py @@ -0,0 +1 @@ +from .login_handler_contract import LoginHandlerContract 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..7353e6c --- /dev/null +++ b/app/context/auth/application/contracts/login_handler_contract.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod +from typing import Optional + +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) -> Optional[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..2f6b86b --- /dev/null +++ b/app/context/auth/application/dto/__init__.py @@ -0,0 +1 @@ +from .login_handler_result_dto import LoginHandlerResultDTO 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..27456d0 --- /dev/null +++ b/app/context/auth/application/dto/login_handler_result_dto.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass +class LoginHandlerResultDTO: + token: str + error: str diff --git a/app/context/auth/application/handlers/__init__.py b/app/context/auth/application/handlers/__init__.py new file mode 100644 index 0000000..d7ed4de --- /dev/null +++ b/app/context/auth/application/handlers/__init__.py @@ -0,0 +1 @@ +from .login_handler import LoginHandler 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..18adde4 --- /dev/null +++ b/app/context/auth/application/handlers/login_handler.py @@ -0,0 +1,11 @@ +from typing import Optional + +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.contracts import LoginHandlerContract +from app.context.auth.application.dto import LoginHandlerResultDTO + + +class LoginHandler(LoginHandlerContract): + async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO]: + print("This is the actual handler") + print(command) diff --git a/app/context/auth/application/services/__init__.py b/app/context/auth/application/services/__init__.py deleted file mode 100644 index 3b69507..0000000 --- a/app/context/auth/application/services/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .user_service import UserService - -__all__ = ["UserService"] diff --git a/app/context/auth/application/services/user_service.py b/app/context/auth/application/services/user_service.py deleted file mode 100644 index ea81030..0000000 --- a/app/context/auth/application/services/user_service.py +++ /dev/null @@ -1,86 +0,0 @@ -from app.context.auth.infrastructure.repositories.user_repository import UserRepository -from app.context.auth.domain.models.user import User -from typing import Optional, List -from fastapi import HTTPException - - -class UserService: - def __init__(self, user_repo: UserRepository): - self.user_repo = user_repo - - async def get_user_by_id(self, user_id: int) -> Optional[User]: - """Get user by ID""" - return await self.user_repo.find_by_id(user_id) - - async def get_user_by_email(self, email: str) -> Optional[User]: - """Get user by email""" - return await self.user_repo.find_by_email(email) - - async def get_user_by_username(self, username: str) -> Optional[User]: - """Get user by username""" - return await self.user_repo.find_by_username(username) - - async def create_user( - self, email: str, username: str, password: str - ) -> User: - """ - Create a new user - - Note: In production, you should hash the password using a library like bcrypt or passlib - """ - # Check if user already exists - existing_user = await self.user_repo.find_by_email(email) - if existing_user: - raise HTTPException(status_code=400, detail="Email already registered") - - existing_username = await self.user_repo.find_by_username(username) - if existing_username: - raise HTTPException(status_code=400, detail="Username already taken") - - # TODO: Hash password before storing - # from passlib.context import CryptContext - # pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - # hashed_password = pwd_context.hash(password) - - user = User( - email=email, - username=username, - hashed_password=password, # WARNING: Should be hashed in production! - ) - return await self.user_repo.create(user) - - async def list_users(self, skip: int = 0, limit: int = 100) -> List[User]: - """List all users with pagination""" - return await self.user_repo.list_all(skip, limit) - - async def update_user( - self, - user_id: int, - email: Optional[str] = None, - username: Optional[str] = None, - ) -> User: - """Update user information""" - user = await self.user_repo.find_by_id(user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - - if email: - existing = await self.user_repo.find_by_email(email) - if existing and existing.id != user_id: - raise HTTPException(status_code=400, detail="Email already in use") - user.email = email - - if username: - existing = await self.user_repo.find_by_username(username) - if existing and existing.id != user_id: - raise HTTPException(status_code=400, detail="Username already taken") - user.username = username - - return await self.user_repo.update(user) - - async def delete_user(self, user_id: int) -> None: - """Delete a user""" - user = await self.user_repo.find_by_id(user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - await self.user_repo.delete(user) diff --git a/app/context/auth/domain/models/__init__.py b/app/context/auth/domain/models/__init__.py deleted file mode 100644 index 35b01f3..0000000 --- a/app/context/auth/domain/models/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .user import User - -__all__ = ["User"] diff --git a/app/context/auth/domain/models/user.py b/app/context/auth/domain/models/user.py deleted file mode 100644 index 2cf7d22..0000000 --- a/app/context/auth/domain/models/user.py +++ /dev/null @@ -1,14 +0,0 @@ -from sqlalchemy import Column, Integer, String, DateTime -from sqlalchemy.sql import func -from app.infrastructure.database import Base - - -class User(Base): - __tablename__ = "users" - - id = Column(Integer, primary_key=True, index=True) - email = Column(String, unique=True, index=True, nullable=False) - username = Column(String, unique=True, index=True, nullable=False) - hashed_password = Column(String, nullable=False) - created_at = Column(DateTime(timezone=True), server_default=func.now()) - updated_at = Column(DateTime(timezone=True), onupdate=func.now()) diff --git a/app/context/auth/infrastructure/repositories/user_repository.py b/app/context/auth/infrastructure/repositories/user_repository.py deleted file mode 100644 index b5abb2c..0000000 --- a/app/context/auth/infrastructure/repositories/user_repository.py +++ /dev/null @@ -1,47 +0,0 @@ -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select -from app.context.auth.domain.models.user import User -from typing import Optional, List - - -class UserRepository: - def __init__(self, db: AsyncSession): - self.db = db - - async def find_by_id(self, user_id: int) -> Optional[User]: - """Find user by ID""" - result = await self.db.execute(select(User).where(User.id == user_id)) - return result.scalar_one_or_none() - - async def find_by_email(self, email: str) -> Optional[User]: - """Find user by email""" - result = await self.db.execute(select(User).where(User.email == email)) - return result.scalar_one_or_none() - - async def find_by_username(self, username: str) -> Optional[User]: - """Find user by username""" - result = await self.db.execute(select(User).where(User.username == username)) - return result.scalar_one_or_none() - - async def create(self, user: User) -> User: - """Create a new user""" - self.db.add(user) - await self.db.commit() - await self.db.refresh(user) - return user - - async def list_all(self, skip: int = 0, limit: int = 100) -> List[User]: - """List all users with pagination""" - result = await self.db.execute(select(User).offset(skip).limit(limit)) - return list(result.scalars().all()) - - async def update(self, user: User) -> User: - """Update an existing user""" - await self.db.commit() - await self.db.refresh(user) - return user - - async def delete(self, user: User) -> None: - """Delete a user""" - await self.db.delete(user) - await self.db.commit() diff --git a/app/context/auth/interface/rest/controllers/__init__.py b/app/context/auth/interface/rest/controllers/__init__.py index d6b4c24..528e57b 100644 --- a/app/context/auth/interface/rest/controllers/__init__.py +++ b/app/context/auth/interface/rest/controllers/__init__.py @@ -1,4 +1,3 @@ -from .login_rest_controller import loginAction -from .user_rest_controller import router as user_router +from .login_rest_controller import router as login_router -__all__ = ["loginAction", "user_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 index b0489c6..234a7b6 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -1,5 +1,18 @@ +from fastapi import APIRouter, Depends + +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.contracts import LoginHandlerContract +from app.context.auth.interface.rest.dependencies import get_login_handler from app.context.auth.interface.rest.schemas import LoginRequest +router = APIRouter(prefix="/login", tags=["login"]) + -async def loginAction(request: LoginRequest): - return {"leemail": request.email, "lepass": request.password} +@router.post("") +async def login( + request: LoginRequest, handler: LoginHandlerContract = Depends(get_login_handler) +): + """User login endpoint""" + return await handler.handle( + LoginCommand(email=request.email, password=request.password) + ) diff --git a/app/context/auth/interface/rest/controllers/user_rest_controller.py b/app/context/auth/interface/rest/controllers/user_rest_controller.py deleted file mode 100644 index 423860a..0000000 --- a/app/context/auth/interface/rest/controllers/user_rest_controller.py +++ /dev/null @@ -1,80 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException, status -from app.context.auth.application.services.user_service import UserService -from app.context.auth.interface.rest.dependencies import get_user_service -from app.context.auth.interface.rest.schemas import ( - UserCreate, - UserResponse, - UserUpdate, -) -from typing import List - -router = APIRouter(prefix="/users", tags=["users"]) - - -@router.get("/{user_id}", response_model=UserResponse) -async def get_user( - user_id: int, - user_service: UserService = Depends(get_user_service), -): - """ - Get user by ID. - - Notice how clean this is! We inject UserService directly, - and FastAPI handles the entire dependency chain: - get_db() → get_user_repository() → get_user_service() - """ - user = await user_service.get_user_by_id(user_id) - if not user: - raise HTTPException(status_code=404, detail="User not found") - return user - - -@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) -async def create_user( - user_data: UserCreate, - user_service: UserService = Depends(get_user_service), -): - """ - Create a new user. - - The same dependency injection chain happens here automatically. - """ - return await user_service.create_user( - email=user_data.email, - username=user_data.username, - password=user_data.password, - ) - - -@router.get("/", response_model=List[UserResponse]) -async def list_users( - skip: int = 0, - limit: int = 100, - user_service: UserService = Depends(get_user_service), -): - """List all users with pagination""" - return await user_service.list_users(skip, limit) - - -@router.patch("/{user_id}", response_model=UserResponse) -async def update_user( - user_id: int, - user_data: UserUpdate, - user_service: UserService = Depends(get_user_service), -): - """Update user information""" - return await user_service.update_user( - user_id=user_id, - email=user_data.email, - username=user_data.username, - ) - - -@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) -async def delete_user( - user_id: int, - user_service: UserService = Depends(get_user_service), -): - """Delete a user""" - await user_service.delete_user(user_id) - return None diff --git a/app/context/auth/interface/rest/dependencies.py b/app/context/auth/interface/rest/dependencies.py index a85e87c..77e3ab6 100644 --- a/app/context/auth/interface/rest/dependencies.py +++ b/app/context/auth/interface/rest/dependencies.py @@ -1,30 +1,9 @@ -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from app.infrastructure.database import get_db -from app.context.auth.infrastructure.repositories.user_repository import UserRepository -from app.context.auth.application.services.user_service import UserService +from app.context.auth.application.contracts import LoginHandlerContract +from app.context.auth.application.handlers import LoginHandler -def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepository: +def get_login_handler() -> LoginHandlerContract: """ - Dependency to get UserRepository instance. - - This function is called by FastAPI's dependency injection system. - It receives the database session from get_db() and creates a UserRepository. - """ - return UserRepository(db) - - -def get_user_service( - user_repo: UserRepository = Depends(get_user_repository), -) -> UserService: - """ - Dependency to get UserService instance. - - This function is called by FastAPI's dependency injection system. - It receives the UserRepository from get_user_repository() and creates a UserService. - - Dependency chain: - get_db() → get_user_repository() → get_user_service() + Login handler dependency """ - return UserService(user_repo) + return LoginHandler() diff --git a/app/context/auth/interface/rest/routes.py b/app/context/auth/interface/rest/routes.py index b744566..0dac66d 100644 --- a/app/context/auth/interface/rest/routes.py +++ b/app/context/auth/interface/rest/routes.py @@ -1,16 +1,8 @@ from fastapi import APIRouter -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from app.infrastructure.database import get_db -from app.context.auth.interface.rest.controllers import loginAction, user_router -from app.context.auth.interface.rest.schemas import LoginRequest -auth_routes = APIRouter(prefix="/api/auth", tags=["auth"]) - -# Include user router (with dependency injection chain) -auth_routes.include_router(user_router) +from app.context.auth.interface.rest.controllers import login_router +auth_routes = APIRouter(prefix="/api/auth", tags=["auth"]) -@auth_routes.post("/login") -async def login(request: LoginRequest, db: AsyncSession = Depends(get_db)): - return await loginAction(request) +# Include all controller routers +auth_routes.include_router(login_router) diff --git a/app/context/auth/interface/rest/schemas/user_rest_schema.py b/app/context/auth/interface/rest/schemas/user_rest_schema.py deleted file mode 100644 index d243852..0000000 --- a/app/context/auth/interface/rest/schemas/user_rest_schema.py +++ /dev/null @@ -1,33 +0,0 @@ -from pydantic import BaseModel, EmailStr, ConfigDict -from datetime import datetime -from typing import Optional - - -class UserBase(BaseModel): - """Base user schema with common fields""" - - email: EmailStr - username: str - - -class UserCreate(UserBase): - """Schema for creating a new user""" - - password: str - - -class UserUpdate(BaseModel): - """Schema for updating user information""" - - email: Optional[EmailStr] = None - username: Optional[str] = None - - -class UserResponse(UserBase): - """Schema for user response (excludes sensitive data)""" - - id: int - created_at: datetime - updated_at: Optional[datetime] = None - - model_config = ConfigDict(from_attributes=True) diff --git a/app/context/user/application/contract/__init__.py b/app/context/user/application/contracts/__init__.py similarity index 100% rename from app/context/user/application/contract/__init__.py rename to app/context/user/application/contracts/__init__.py diff --git a/app/context/user/application/contract/find_user_query_handler_contract.py b/app/context/user/application/contracts/find_user_query_handler_contract.py similarity index 81% rename from app/context/user/application/contract/find_user_query_handler_contract.py rename to app/context/user/application/contracts/find_user_query_handler_contract.py index c3bef44..ba62b46 100644 --- a/app/context/user/application/contract/find_user_query_handler_contract.py +++ b/app/context/user/application/contracts/find_user_query_handler_contract.py @@ -1,7 +1,8 @@ from abc import ABC, abstractmethod from typing import Optional -from app.context.user.application.query import FindUserQuery + from app.context.user.application.dto import UserContextDTO +from app.context.user.application.queries import FindUserQuery class FindUserHandlerContract(ABC): diff --git a/app/context/user/application/dto/__init__.py b/app/context/user/application/dto/__init__.py index 106bfa9..8a3f0c7 100644 --- a/app/context/user/application/dto/__init__.py +++ b/app/context/user/application/dto/__init__.py @@ -1 +1 @@ -from .UserContextDTO import UserContextDTO +from .user_context_dto import UserContextDTO diff --git a/app/context/user/application/dto/UserContextDTO.py b/app/context/user/application/dto/user_context_dto.py similarity index 99% rename from app/context/user/application/dto/UserContextDTO.py rename to app/context/user/application/dto/user_context_dto.py index c929ceb..c3862ab 100644 --- a/app/context/user/application/dto/UserContextDTO.py +++ b/app/context/user/application/dto/user_context_dto.py @@ -1,6 +1,5 @@ from dataclasses import dataclass - @dataclass(frozen=True) class UserContextDTO: id: int diff --git a/app/context/user/application/handler/__init__.py b/app/context/user/application/handlers/__init__.py similarity index 100% rename from app/context/user/application/handler/__init__.py rename to app/context/user/application/handlers/__init__.py diff --git a/app/context/user/application/handler/find_user_handler.py b/app/context/user/application/handlers/find_user_handler.py similarity index 65% rename from app/context/user/application/handler/find_user_handler.py rename to app/context/user/application/handlers/find_user_handler.py index a34d6cc..e9c1d1b 100644 --- a/app/context/user/application/handler/find_user_handler.py +++ b/app/context/user/application/handlers/find_user_handler.py @@ -1,11 +1,10 @@ from typing import Optional -from app.context.user.application.contract import FindUserHandlerContract + +from app.context.user.application.contracts import FindUserHandlerContract from app.context.user.application.dto import UserContextDTO -from app.context.user.application.query import FindUserQuery -from app.context.user.domain.contract.infrastrutcure import ( - UserRepositoryContract, -) -from app.shared.domain.valueobject import Email +from app.context.user.application.queries import FindUserQuery +from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract +from app.context.user.domain.value_objects import Email class FindUserHandler(FindUserHandlerContract): diff --git a/app/context/user/application/query/__init__.py b/app/context/user/application/queries/__init__.py similarity index 100% rename from app/context/user/application/query/__init__.py rename to app/context/user/application/queries/__init__.py diff --git a/app/context/user/application/query/find_user_query.py b/app/context/user/application/queries/find_user_query.py similarity index 100% rename from app/context/user/application/query/find_user_query.py rename to app/context/user/application/queries/find_user_query.py diff --git a/app/context/user/domain/contract/infrastrutcure/__init__.py b/app/context/user/domain/contracts/infrastrutcure/__init__.py similarity index 100% rename from app/context/user/domain/contract/infrastrutcure/__init__.py rename to app/context/user/domain/contracts/infrastrutcure/__init__.py diff --git a/app/context/user/domain/contract/infrastrutcure/user_repository_contract.py b/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py similarity index 82% rename from app/context/user/domain/contract/infrastrutcure/user_repository_contract.py rename to app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py index b889800..dedb337 100644 --- a/app/context/user/domain/contract/infrastrutcure/user_repository_contract.py +++ b/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py @@ -2,7 +2,7 @@ from typing import Optional from app.context.user.domain.dto import UserDTO -from app.shared.domain.valueobject import Email +from app.context.user.domain.value_objects import Email class UserRepositoryContract(ABC): diff --git a/app/context/user/domain/dto/user_dto.py b/app/context/user/domain/dto/user_dto.py index c17f5fc..c1347ee 100644 --- a/app/context/user/domain/dto/user_dto.py +++ b/app/context/user/domain/dto/user_dto.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from app.shared.domain.valueobject import Email + +from app.context.user.domain.value_objects import Email @dataclass(frozen=True) 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..efb61b4 --- /dev/null +++ b/app/context/user/domain/value_objects/__init__.py @@ -0,0 +1 @@ +from .email import Email as Email diff --git a/app/shared/domain/valueobject/email.py b/app/context/user/domain/value_objects/email.py similarity index 100% rename from app/shared/domain/valueobject/email.py rename to app/context/user/domain/value_objects/email.py index 6696250..a42c3c0 100644 --- a/app/shared/domain/valueobject/email.py +++ b/app/context/user/domain/value_objects/email.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass import re +from dataclasses import dataclass @dataclass(frozen=True) 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..337095d --- /dev/null +++ b/app/context/user/domain/value_objects/password.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass + +from argon2 import PasswordHasher +from argon2.low_level import VerifyMismatchError + + +@dataclass(frozen=True) +class Password: + """ + Password value object. It will always represent hashed password in the domain + """ + + value: str + + @classmethod + def from_plain_text(cls, plainPassword: str) -> "Password": + ph = PasswordHasher() + return cls(value=ph.hash(plainPassword)) + + def verify(self, plainPassword: str) -> bool: + ph = PasswordHasher() + try: + return ph.verify(self.value, plainPassword) + except VerifyMismatchError: + # TODO: Logger here + return False + + @staticmethod + def validate(plainPassword: str) -> bool: + return len(plainPassword) >= 8 diff --git a/app/context/user/infrastructure/dependency.py b/app/context/user/infrastructure/dependency.py index 1e15819..bd773a8 100644 --- a/app/context/user/infrastructure/dependency.py +++ b/app/context/user/infrastructure/dependency.py @@ -1,15 +1,11 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from app.context.user.application.contract.find_user_query_handler_contract import ( - FindUserHandlerContract, -) -from app.context.user.application.handler.find_user_handler import FindUserHandler -from app.context.user.domain.contract.infrastrutcure import ( - UserRepositoryContract, -) -from app.context.user.infrastructure.repository.user_repository import UserRepository -from app.infrastructure.database import get_db +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.handlers import FindUserHandler +from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract +from app.context.user.infrastructure.repositories import UserRepository +from app.shared.infrastructure.database import get_db def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepositoryContract: diff --git a/app/context/user/infrastructure/models/__init__.py b/app/context/user/infrastructure/models/__init__.py new file mode 100644 index 0000000..0ad873f --- /dev/null +++ b/app/context/user/infrastructure/models/__init__.py @@ -0,0 +1 @@ +from .user_model import UserModel as 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..f35be5e --- /dev/null +++ b/app/context/user/infrastructure/models/user_model.py @@ -0,0 +1,15 @@ +from typing import Optional + +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.infrastructure.models import BaseDBModel + + +class UserModel(BaseDBModel): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(100)) + password: Mapped[str] = mapped_column(String(150)) + username: Mapped[Optional[str]] diff --git a/app/context/user/infrastructure/repository/__init__.py b/app/context/user/infrastructure/repositories/__init__.py similarity index 100% rename from app/context/user/infrastructure/repository/__init__.py rename to app/context/user/infrastructure/repositories/__init__.py diff --git a/app/context/user/infrastructure/repository/user_repository.py b/app/context/user/infrastructure/repositories/user_repository.py similarity index 73% rename from app/context/user/infrastructure/repository/user_repository.py rename to app/context/user/infrastructure/repositories/user_repository.py index 4e65db9..125580b 100644 --- a/app/context/user/infrastructure/repository/user_repository.py +++ b/app/context/user/infrastructure/repositories/user_repository.py @@ -1,15 +1,13 @@ from typing import Optional from sqlalchemy.ext.asyncio import AsyncSession -from app.context.user.domain.contract.infrastrutcure import ( - UserRepositoryContract, -) + +from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract from app.context.user.domain.dto import UserDTO -from app.shared.domain.valueobject import Email +from app.context.user.domain.value_objects import Email class UserRepository(UserRepositoryContract): - def __init__(self, db: AsyncSession): self.db = db diff --git a/app/context/user/interface/console/find_user_console.py b/app/context/user/interface/console/find_user_console.py index ced5a47..13fe856 100644 --- a/app/context/user/interface/console/find_user_console.py +++ b/app/context/user/interface/console/find_user_console.py @@ -1,9 +1,9 @@ import asyncio -from app.context.user.application.query.find_user_query import FindUserQuery -from app.context.user.infrastructure.repository.user_repository import UserRepository -from app.context.user.application.handler.find_user_handler import FindUserHandler -from app.infrastructure.database import AsyncSessionLocal +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(): diff --git a/app/main.py b/app/main.py index 4a08671..b4111ed 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,9 @@ -from fastapi import FastAPI from contextlib import asynccontextmanager -from .infrastructure.database import async_engine -from .context.auth.interface.rest import auth_routes + +from fastapi import FastAPI + +from app.context.auth.interface.rest import auth_routes +from app.shared.infrastructure.database import async_engine @asynccontextmanager diff --git a/app/shared/domain/valueobject/__init__.py b/app/shared/domain/valueobject/__init__.py deleted file mode 100644 index 2ebe67e..0000000 --- a/app/shared/domain/valueobject/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .email import Email diff --git a/app/infrastructure/database.py b/app/shared/infrastructure/database.py similarity index 68% rename from app/infrastructure/database.py rename to app/shared/infrastructure/database.py index 0b654d9..02019a5 100644 --- a/app/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -1,20 +1,20 @@ -import os -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker -from sqlalchemy.orm import declarative_base +from os import getenv +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import declarative_base -DB_HOST = os.getenv("DB_HOST") -DB_PORT = os.getenv("DB_PORT") -DB_USER = os.getenv("DB_USER") -DB_PASS = os.getenv("DB_PASS") -DB_NAME = os.getenv("DB_NAME") +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") -DATABASE_URL = os.getenv( +DATABASE_URL = getenv( "DATABASE_URL", f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", ) -echo_queries = os.getenv("APP_ENV", "prod") == "dev" +echo_queries = getenv("APP_ENV", "prod") == "dev" async_engine = create_async_engine( DATABASE_URL, diff --git a/app/shared/infrastructure/models/__init__.py b/app/shared/infrastructure/models/__init__.py new file mode 100644 index 0000000..987e1a4 --- /dev/null +++ b/app/shared/infrastructure/models/__init__.py @@ -0,0 +1 @@ +from .base_model import BaseDBModel as 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 index fd605a6..a583cfa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: postgres: image: postgres:16-alpine diff --git a/justfile b/justfile new file mode 100644 index 0000000..18bd2b9 --- /dev/null +++ b/justfile @@ -0,0 +1,11 @@ +run: + uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 + +migration-generate comment: + alembic revision -m "{{comment}}" + +migrate: + alembic upgrade head + +pgcli: + pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:$DB_PORT/$DB_NAME diff --git a/main.py b/main.py new file mode 100644 index 0000000..f4b12ca --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from homecomp-api!") + + +if __name__ == "__main__": + main() 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..3725889 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,88 @@ +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") +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/f8333e5b2bac_create_user_table.py b/migrations/versions/f8333e5b2bac_create_user_table.py new file mode 100644 index 0000000..b9c157b --- /dev/null +++ b/migrations/versions/f8333e5b2bac_create_user_table.py @@ -0,0 +1,34 @@ +"""Create user table + +Revision ID: f8333e5b2bac +Revises: +Create Date: 2025-12-21 01:00:30.079504 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "f8333e5b2bac" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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), unique=True), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("users") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7b8cd5a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[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", + "uvicorn>=0.38.0", +] diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..9d62651 --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,27 @@ +import asyncio +import sys +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from app.context.user.domain.value_objects.password import Password +from app.context.user.infrastructure.models import UserModel +from app.shared.infrastructure.database import AsyncSessionLocal + + +async def seed(): + passwd = Password.from_plain_text("testonga") + async with AsyncSessionLocal() as session: + # Add your seed data + print("Seeding users") + users = [ + UserModel(email="user1@test.com", password=passwd.value), + UserModel(email="user2@test.com", password=passwd.value), + ] + session.add_all(users) + await session.commit() + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8a813f1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,558 @@ +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 = "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 = "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 = "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 = "uvicorn" }, +] + +[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 = "uvicorn", specifier = ">=0.38.0" }, +] + +[[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 = "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 = "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 = "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 = "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 = "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" }, +] From 9747c02b422161f6fee04e3af418ccd935ab0d8d Mon Sep 17 00:00:00 2001 From: polivera Date: Sun, 21 Dec 2025 19:47:15 +0100 Subject: [PATCH 05/58] Working on auth throttling --- app/context/auth/domain/contracts/__init__.py | 9 + .../login_attempts_service_contract.py | 9 + .../contracts/login_service_contract.py | 9 + .../contracts/session_repository_contract.py | 16 + app/context/auth/domain/services/__init__.py | 3 + .../domain/services/login_attempts_service.py | 39 ++ .../auth/domain/services/login_service.py | 0 .../auth/domain/value_objects/__init__.py | 4 + .../value_objects/failed_login_attempts.py | 10 + .../domain/value_objects/throttle_time.py | 13 + .../rest => infrastructure}/dependencies.py | 0 .../infrastructure/repositories/__init__.py | 4 +- .../repositories/session_repository.py | 8 + .../rest/controllers/login_rest_controller.py | 2 +- .../auth/interface/rest/schemas/__init__.py | 3 +- .../user/domain/value_objects/__init__.py | 5 +- docs/login-throttle-implementation.md | 533 ++++++++++++++++++ 17 files changed, 661 insertions(+), 6 deletions(-) create mode 100644 app/context/auth/domain/contracts/__init__.py create mode 100644 app/context/auth/domain/contracts/login_attempts_service_contract.py create mode 100644 app/context/auth/domain/contracts/login_service_contract.py create mode 100644 app/context/auth/domain/contracts/session_repository_contract.py create mode 100644 app/context/auth/domain/services/__init__.py create mode 100644 app/context/auth/domain/services/login_attempts_service.py create mode 100644 app/context/auth/domain/services/login_service.py create mode 100644 app/context/auth/domain/value_objects/__init__.py create mode 100644 app/context/auth/domain/value_objects/failed_login_attempts.py create mode 100644 app/context/auth/domain/value_objects/throttle_time.py rename app/context/auth/{interface/rest => infrastructure}/dependencies.py (100%) create mode 100644 app/context/auth/infrastructure/repositories/session_repository.py create mode 100644 docs/login-throttle-implementation.md 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..ca0a3fe --- /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 Email + + +class LoginAttemptsServiceContract(ABC): + @abstractmethod + async def handle(self, email: Email): + 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..c31bab8 --- /dev/null +++ b/app/context/auth/domain/contracts/login_service_contract.py @@ -0,0 +1,9 @@ +from abc import ABC + +from app.context.user.domain.value_objects import Email, Password + + +class LoginServiceContract(ABC): + # TODO: create return dto + async def handle(self, email: Email, password: Password): + 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..224a0d1 --- /dev/null +++ b/app/context/auth/domain/contracts/session_repository_contract.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod + +from app.context.auth.domain.value_objects import FailedLoginAttempts +from app.context.user.domain.value_objects import Email + + +class SessionRepositoryContract(ABC): + @abstractmethod + async def getLoginAttepmts(self, email: Email) -> FailedLoginAttempts: + pass + + async def updateAttempts(self, email: Email, attempts: FailedLoginAttempts) -> None: + pass + + async def clearAttempts(self, email: Email) -> None: + pass diff --git a/app/context/auth/domain/services/__init__.py b/app/context/auth/domain/services/__init__.py new file mode 100644 index 0000000..1567e81 --- /dev/null +++ b/app/context/auth/domain/services/__init__.py @@ -0,0 +1,3 @@ +from .login_attempts_service import LoginAttemptsService + +__all__ = ["LoginAttemptsService"] diff --git a/app/context/auth/domain/services/login_attempts_service.py b/app/context/auth/domain/services/login_attempts_service.py new file mode 100644 index 0000000..24e7a4e --- /dev/null +++ b/app/context/auth/domain/services/login_attempts_service.py @@ -0,0 +1,39 @@ +import asyncio + +from pip._vendor.rich import print + +from app.context.auth.domain.contracts import ( + LoginAttemptsServiceContract, + SessionRepositoryContract, +) +from app.context.auth.domain.value_objects import FailedLoginAttempts, ThrottleTime +from app.context.user.domain.value_objects import Email + + +class LoginAttemptsService(LoginAttemptsServiceContract): + _sessionRepository: SessionRepositoryContract + + def __init__(self, sessionRepo: SessionRepositoryContract): + self._sessionRepository = sessionRepo + pass + + async def handle(self, email: Email): + attempts = await self._sessionRepository.getLoginAttepmts(email) + + if attempts.hasReachMaxAttempts(): + # User has reached max attempts - timeout handling will be done later + print("User blocked - max attempts reached") + # TODO: Handle timeout logic + pass + else: + # User has remaining attempts + # Store failed attempt by incrementing the count + new_attempts = FailedLoginAttempts(value=attempts.value + 1) + await self._sessionRepository.updateAttempts(email, new_attempts) + + # Calculate throttle time and delay the response + throttle_time = ThrottleTime.fromAttempts(new_attempts) + print(f"Failed attempt {new_attempts.value}. Delaying response by {throttle_time.value}s") + + # Sleep to delay the response (throttle) + await asyncio.sleep(throttle_time.value) 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..e69de29 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..e281e9f --- /dev/null +++ b/app/context/auth/domain/value_objects/__init__.py @@ -0,0 +1,4 @@ +from .failed_login_attempts import FailedLoginAttempts +from .throttle_time import ThrottleTime + +__all__ = ["FailedLoginAttempts", "ThrottleTime"] 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..d01af24 --- /dev/null +++ b/app/context/auth/domain/value_objects/failed_login_attempts.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FailedLoginAttempts: + value: int + _max_attempts: int = 4 + + def hasReachMaxAttempts(self) -> bool: + return self.value >= self._max_attempts 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..55ac835 --- /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/interface/rest/dependencies.py b/app/context/auth/infrastructure/dependencies.py similarity index 100% rename from app/context/auth/interface/rest/dependencies.py rename to app/context/auth/infrastructure/dependencies.py diff --git a/app/context/auth/infrastructure/repositories/__init__.py b/app/context/auth/infrastructure/repositories/__init__.py index 15c7389..ee3420e 100644 --- a/app/context/auth/infrastructure/repositories/__init__.py +++ b/app/context/auth/infrastructure/repositories/__init__.py @@ -1,3 +1,3 @@ -from .user_repository import UserRepository +from .session_repository import SessionRepository -__all__ = ["UserRepository"] +__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..1f3e15f --- /dev/null +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -0,0 +1,8 @@ +from app.context.auth.domain.contracts import SessionRepositoryContract +from app.context.auth.domain.value_objects import FailedLoginAttempts +from app.context.user.domain.value_objects import Email + + +class SessionRepository(SessionRepositoryContract): + async def getLoginAttepmps(self, email: Email) -> FailedLoginAttempts: + pass diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 234a7b6..4eabf21 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -2,7 +2,7 @@ from app.context.auth.application.commands import LoginCommand from app.context.auth.application.contracts import LoginHandlerContract -from app.context.auth.interface.rest.dependencies import get_login_handler +from app.context.auth.infrastructure.dependencies import get_login_handler from app.context.auth.interface.rest.schemas import LoginRequest router = APIRouter(prefix="/login", tags=["login"]) diff --git a/app/context/auth/interface/rest/schemas/__init__.py b/app/context/auth/interface/rest/schemas/__init__.py index 850b521..6c6bc9e 100644 --- a/app/context/auth/interface/rest/schemas/__init__.py +++ b/app/context/auth/interface/rest/schemas/__init__.py @@ -1,4 +1,3 @@ from .login_rest_schema import LoginRequest -from .user_rest_schema import UserCreate, UserResponse, UserUpdate -__all__ = ["LoginRequest", "UserCreate", "UserResponse", "UserUpdate"] +__all__ = ["LoginRequest"] diff --git a/app/context/user/domain/value_objects/__init__.py b/app/context/user/domain/value_objects/__init__.py index efb61b4..1a7fa3f 100644 --- a/app/context/user/domain/value_objects/__init__.py +++ b/app/context/user/domain/value_objects/__init__.py @@ -1 +1,4 @@ -from .email import Email as Email +from .email import Email +from .password import Password + +__all__ = ["Email", "Password"] diff --git a/docs/login-throttle-implementation.md b/docs/login-throttle-implementation.md new file mode 100644 index 0000000..4e78b89 --- /dev/null +++ b/docs/login-throttle-implementation.md @@ -0,0 +1,533 @@ +# Login Throttle Implementation Guide + +This document provides implementation options for adding login throttling to the FastAPI application. + +## Overview + +Login throttling prevents brute-force attacks by limiting the number of login attempts within a specific time window. This can be implemented in several ways depending on your requirements. + +## Option 1: Using SlowAPI (Simple & Quick) + +Best for: Small to medium applications, quick implementation + +### Installation + +```bash +pip install slowapi +``` + +### Implementation + +#### 1. Create rate limiter instance + +```python +# app/shared/infrastructure/rate_limiter.py +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) +``` + +#### 2. Update login controller + +```python +# app/context/auth/interface/rest/controllers/login_rest_controller.py +from fastapi import APIRouter, Depends +from app.shared.infrastructure.rate_limiter import limiter +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.contracts import LoginHandlerContract +from app.context.auth.interface.rest.dependencies import get_login_handler +from app.context.auth.interface.rest.schemas import LoginRequest + +router = APIRouter(prefix="/login", tags=["login"]) + +@router.post("") +@limiter.limit("5/minute") # 5 attempts per minute per IP +async def login( + request: LoginRequest, + handler: LoginHandlerContract = Depends(get_login_handler) +): + """User login endpoint with rate limiting""" + return await handler.handle( + LoginCommand(email=request.email, password=request.password) + ) +``` + +#### 3. Register in main application + +```python +# main.py +from fastapi import FastAPI +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from app.shared.infrastructure.rate_limiter import limiter + +app = FastAPI() +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +``` + +### Pros +- Quick to implement +- Minimal code changes +- Works out of the box + +### Cons +- Throttles by IP only (can't throttle by username/email) +- In-memory only (doesn't work across multiple instances without Redis backend) + +--- + +## Option 2: Redis-Based Throttle Service (Production Ready) + +Best for: Production applications, distributed systems, throttling by username/email + +### Installation + +```bash +pip install redis +``` + +### Implementation + +#### 1. Create throttle service + +```python +# app/shared/infrastructure/services/throttle_service.py +from datetime import datetime, timedelta +from typing import Optional +import redis.asyncio as redis + + +class LoginThrottleService: + """ + Redis-based login throttle service. + + Tracks login attempts per identifier (email/username or IP) and enforces + rate limits to prevent brute-force attacks. + """ + + def __init__(self, redis_client: redis.Redis): + self.redis = redis_client + self.max_attempts = 5 + self.window_seconds = 300 # 5 minutes + self.lockout_seconds = 900 # 15 minutes after max attempts + + async def is_throttled(self, identifier: str) -> tuple[bool, Optional[int]]: + """ + Check if login attempts are throttled for the given identifier. + + Args: + identifier: Email, username, or IP address to check + + Returns: + Tuple of (is_throttled, retry_after_seconds) + """ + key = f"login_throttle:{identifier}" + attempts = await self.redis.get(key) + + if attempts and int(attempts) >= self.max_attempts: + ttl = await self.redis.ttl(key) + return True, ttl + + return False, None + + async def record_attempt(self, identifier: str): + """ + Record a failed login attempt. + + Args: + identifier: Email, username, or IP address + """ + key = f"login_throttle:{identifier}" + current = await self.redis.get(key) + + if current is None: + # First attempt - set with normal window + await self.redis.setex(key, self.window_seconds, 1) + else: + # Increment attempts + await self.redis.incr(key) + + # If we've hit the max, extend the lockout period + if int(current) + 1 >= self.max_attempts: + await self.redis.expire(key, self.lockout_seconds) + + async def clear_attempts(self, identifier: str): + """ + Clear all attempts for an identifier (call after successful login). + + Args: + identifier: Email, username, or IP address + """ + key = f"login_throttle:{identifier}" + await self.redis.delete(key) +``` + +#### 2. Create Redis connection + +```python +# app/shared/infrastructure/redis_client.py +import redis.asyncio as redis +from typing import AsyncGenerator + + +class RedisClient: + def __init__(self, url: str = "redis://localhost:6379/0"): + self.url = url + self._pool = None + + async def get_pool(self) -> redis.Redis: + if self._pool is None: + self._pool = redis.from_url( + self.url, + encoding="utf-8", + decode_responses=True + ) + return self._pool + + async def close(self): + if self._pool: + await self._pool.close() + + +# Global instance +redis_client = RedisClient() + + +async def get_redis() -> AsyncGenerator[redis.Redis, None]: + pool = await redis_client.get_pool() + yield pool +``` + +#### 3. Create dependency for throttle check + +```python +# app/context/auth/interface/rest/dependencies.py +from fastapi import HTTPException, Request, status, Depends +import redis.asyncio as redis + +from app.context.auth.interface.rest.schemas import LoginRequest +from app.shared.infrastructure.services.throttle_service import LoginThrottleService +from app.shared.infrastructure.redis_client import get_redis + + +async def get_throttle_service( + redis_conn: redis.Redis = Depends(get_redis) +) -> LoginThrottleService: + return LoginThrottleService(redis_conn) + + +async def check_login_throttle( + request: LoginRequest, + throttle_service: LoginThrottleService = Depends(get_throttle_service) +): + """ + Dependency that checks if login attempts are throttled. + Raises HTTPException if throttled. + """ + # Throttle by email/username + is_throttled, retry_after = await throttle_service.is_throttled(request.email) + + if is_throttled: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Too many login attempts. Try again in {retry_after} seconds.", + headers={"Retry-After": str(retry_after)} + ) +``` + +#### 4. Update login controller + +```python +# app/context/auth/interface/rest/controllers/login_rest_controller.py +from fastapi import APIRouter, Depends, HTTPException, status + +from app.context.auth.application.commands import LoginCommand +from app.context.auth.application.contracts import LoginHandlerContract +from app.context.auth.interface.rest.dependencies import ( + get_login_handler, + check_login_throttle, + get_throttle_service +) +from app.context.auth.interface.rest.schemas import LoginRequest +from app.shared.infrastructure.services.throttle_service import LoginThrottleService + +router = APIRouter(prefix="/login", tags=["login"]) + + +@router.post("") +async def login( + request: LoginRequest, + handler: LoginHandlerContract = Depends(get_login_handler), + throttle_service: LoginThrottleService = Depends(get_throttle_service), + _: None = Depends(check_login_throttle) +): + """User login endpoint with throttling""" + try: + result = await handler.handle( + LoginCommand(email=request.email, password=request.password) + ) + + # Clear throttle on successful login + await throttle_service.clear_attempts(request.email) + + return result + + except Exception as e: + # Record failed attempt + await throttle_service.record_attempt(request.email) + raise +``` + +#### 5. Update main.py for Redis lifecycle + +```python +# main.py +from contextlib import asynccontextmanager +from fastapi import FastAPI +from app.shared.infrastructure.redis_client import redis_client + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + await redis_client.get_pool() + yield + # Shutdown + await redis_client.close() + + +app = FastAPI(lifespan=lifespan) +``` + +### Pros +- Works across multiple instances +- Can throttle by username/email +- Production-ready +- Configurable limits +- Persistent across restarts + +### Cons +- Requires Redis infrastructure +- More complex setup + +--- + +## Option 3: In-Memory Throttle Service (Development) + +Best for: Development, single-instance deployments, no external dependencies + +### Implementation + +```python +# app/shared/infrastructure/services/in_memory_throttle_service.py +from collections import defaultdict +from datetime import datetime, timedelta +from typing import Dict, List + + +class InMemoryThrottleService: + """ + In-memory login throttle service. + + WARNING: This implementation does not work across multiple instances. + Use Redis-based implementation for production. + """ + + def __init__(self): + self.attempts: Dict[str, List[datetime]] = defaultdict(list) + self.max_attempts = 5 + self.window_minutes = 5 + + def is_throttled(self, identifier: str) -> tuple[bool, int]: + """ + Check if login attempts are throttled. + + Returns: + Tuple of (is_throttled, retry_after_seconds) + """ + now = datetime.now() + cutoff = now - timedelta(minutes=self.window_minutes) + + # Clean old attempts + self.attempts[identifier] = [ + attempt for attempt in self.attempts[identifier] + if attempt > cutoff + ] + + if len(self.attempts[identifier]) >= self.max_attempts: + oldest = self.attempts[identifier][0] + retry_after = int( + (oldest + timedelta(minutes=self.window_minutes) - now).total_seconds() + ) + return True, max(0, retry_after) + + return False, 0 + + def record_attempt(self, identifier: str): + """Record a failed login attempt.""" + self.attempts[identifier].append(datetime.now()) + + def clear_attempts(self, identifier: str): + """Clear attempts after successful login.""" + self.attempts.pop(identifier, None) + + +# Global instance +throttle_service = InMemoryThrottleService() + + +def get_throttle_service() -> InMemoryThrottleService: + return throttle_service +``` + +### Usage in controller + +```python +# app/context/auth/interface/rest/controllers/login_rest_controller.py +from fastapi import APIRouter, Depends, HTTPException, status +from app.shared.infrastructure.services.in_memory_throttle_service import ( + get_throttle_service, + InMemoryThrottleService +) + +router = APIRouter(prefix="/login", tags=["login"]) + + +@router.post("") +async def login( + request: LoginRequest, + handler: LoginHandlerContract = Depends(get_login_handler), + throttle_service: InMemoryThrottleService = Depends(get_throttle_service) +): + """User login endpoint with in-memory throttling""" + # Check throttle + is_throttled, retry_after = throttle_service.is_throttled(request.email) + if is_throttled: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Too many login attempts. Try again in {retry_after} seconds.", + headers={"Retry-After": str(retry_after)} + ) + + try: + result = await handler.handle( + LoginCommand(email=request.email, password=request.password) + ) + + # Clear on success + throttle_service.clear_attempts(request.email) + return result + + except Exception as e: + # Record failed attempt + throttle_service.record_attempt(request.email) + raise +``` + +### Pros +- No external dependencies +- Simple implementation +- Good for development + +### Cons +- Doesn't work across multiple instances +- Lost on restart +- Not suitable for production + +--- + +## Configuration Recommendations + +### Rate Limit Settings + +| Environment | Max Attempts | Window | Lockout | +|-------------|--------------|--------|---------| +| Development | 10 | 5 min | 5 min | +| Staging | 5 | 5 min | 15 min | +| Production | 5 | 5 min | 30 min | + +### What to Throttle By + +1. **Email/Username** (recommended) - Prevents brute-force on specific accounts +2. **IP Address** - Prevents distributed attacks but can affect shared IPs +3. **Both** - Most secure, implement separate limits for each + +Example for dual throttling: + +```python +async def check_login_throttle( + request: Request, + login_request: LoginRequest, + throttle_service: LoginThrottleService = Depends(get_throttle_service) +): + # Check by email + is_throttled, retry_after = await throttle_service.is_throttled( + f"email:{login_request.email}" + ) + if is_throttled: + raise HTTPException(...) + + # Check by IP + client_ip = request.client.host + is_throttled, retry_after = await throttle_service.is_throttled( + f"ip:{client_ip}" + ) + if is_throttled: + raise HTTPException(...) +``` + +## Security Best Practices + +1. **Log throttled attempts** - Monitor for attack patterns +2. **Use HTTPS** - Prevent credential interception +3. **Return generic errors** - Don't reveal if user exists +4. **Consider CAPTCHA** - After X failed attempts +5. **Monitor from infrastructure** - Use WAF/rate limiting at load balancer level +6. **Alert on patterns** - Set up monitoring for unusual login attempt patterns + +## Testing + +Example test for throttle service: + +```python +import pytest +from app.shared.infrastructure.services.throttle_service import LoginThrottleService + + +@pytest.mark.asyncio +async def test_throttle_after_max_attempts(redis_client): + service = LoginThrottleService(redis_client) + identifier = "test@example.com" + + # Make max attempts + for _ in range(5): + await service.record_attempt(identifier) + + # Should be throttled + is_throttled, retry_after = await service.is_throttled(identifier) + assert is_throttled is True + assert retry_after > 0 + + +@pytest.mark.asyncio +async def test_clear_attempts_on_success(redis_client): + service = LoginThrottleService(redis_client) + identifier = "test@example.com" + + await service.record_attempt(identifier) + await service.clear_attempts(identifier) + + is_throttled, _ = await service.is_throttled(identifier) + assert is_throttled is False +``` + +## Recommendation + +For your DDD-based FastAPI application: + +**Development**: Start with Option 3 (In-Memory) +**Production**: Use Option 2 (Redis-based) + +The Redis implementation fits well with your DDD architecture and provides the robustness needed for production use. From c257305dcd28e971f3bd13c4ed501c9e126b3a88 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 22 Dec 2025 00:04:22 +0100 Subject: [PATCH 06/58] Building login with DDD --- .../application/handlers/login_handler.py | 16 +++++++++++-- .../contracts/login_service_contract.py | 2 +- app/context/auth/domain/dto/__init__.py | 3 +++ .../auth/domain/dto/login_attempt_result.py | 10 ++++++++ app/context/auth/domain/services/__init__.py | 3 ++- .../domain/services/login_attempts_service.py | 6 ++--- .../auth/domain/services/login_service.py | 24 +++++++++++++++++++ .../domain/value_objects/throttle_time.py | 2 +- .../auth/infrastructure/dependencies.py | 17 +++++++++++-- .../repositories/session_repository.py | 6 +++++ app/context/auth/interface/__init__.py | 0 .../rest/controllers/login_rest_controller.py | 2 +- .../user/application/dto/user_context_dto.py | 4 +++- .../application/handlers/find_user_handler.py | 15 +++++++++--- .../application/queries/find_user_query.py | 5 ++-- .../user_repository_contract.py | 6 +++-- app/context/user/domain/dto/user_dto.py | 4 +++- .../user/domain/value_objects/__init__.py | 3 ++- .../user/domain/value_objects/password.py | 20 +++++++++++----- .../user/domain/value_objects/user_id.py | 6 +++++ .../repositories/user_repository.py | 20 ++++++++++++---- .../interface/console/find_user_console.py | 2 +- app/main.py | 16 +------------ scripts/seed.py | 6 ++--- 24 files changed, 147 insertions(+), 51 deletions(-) create mode 100644 app/context/auth/domain/dto/__init__.py create mode 100644 app/context/auth/domain/dto/login_attempt_result.py create mode 100644 app/context/auth/interface/__init__.py create mode 100644 app/context/user/domain/value_objects/user_id.py diff --git a/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py index 18adde4..4cba73b 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -3,9 +3,21 @@ from app.context.auth.application.commands import LoginCommand from app.context.auth.application.contracts import LoginHandlerContract from app.context.auth.application.dto import LoginHandlerResultDTO +from app.context.auth.domain.contracts import LoginServiceContract +from app.context.user.domain.value_objects import Email, Password class LoginHandler(LoginHandlerContract): + _login_service: LoginServiceContract + + def __init__(self, login_service: LoginServiceContract): + self._login_service = login_service + pass + async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO]: - print("This is the actual handler") - print(command) + email = Email(command.email) + password = Password.keep_plain(command.password) + + result = await self._login_service.handle(email, password) + print(result) + print("---end---") diff --git a/app/context/auth/domain/contracts/login_service_contract.py b/app/context/auth/domain/contracts/login_service_contract.py index c31bab8..6d8c6fa 100644 --- a/app/context/auth/domain/contracts/login_service_contract.py +++ b/app/context/auth/domain/contracts/login_service_contract.py @@ -5,5 +5,5 @@ class LoginServiceContract(ABC): # TODO: create return dto - async def handle(self, email: Email, password: Password): + async def handle(self, email: Email, plain_password: Password): 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..ae26f3a --- /dev/null +++ b/app/context/auth/domain/dto/__init__.py @@ -0,0 +1,3 @@ +from .login_attempt_result import LoginAttemptResult + +__all__ = ["LoginAttemptResult"] diff --git a/app/context/auth/domain/dto/login_attempt_result.py b/app/context/auth/domain/dto/login_attempt_result.py new file mode 100644 index 0000000..82148b7 --- /dev/null +++ b/app/context/auth/domain/dto/login_attempt_result.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass(frozen=True) +class LoginAttemptResult: + is_blocked: bool + throttle_applied_seconds: int + attempts_remaining: int + unblock_at: datetime | None = None diff --git a/app/context/auth/domain/services/__init__.py b/app/context/auth/domain/services/__init__.py index 1567e81..c7baad2 100644 --- a/app/context/auth/domain/services/__init__.py +++ b/app/context/auth/domain/services/__init__.py @@ -1,3 +1,4 @@ from .login_attempts_service import LoginAttemptsService +from .login_service import LoginService -__all__ = ["LoginAttemptsService"] +__all__ = ["LoginAttemptsService", "LoginService"] diff --git a/app/context/auth/domain/services/login_attempts_service.py b/app/context/auth/domain/services/login_attempts_service.py index 24e7a4e..fd83e48 100644 --- a/app/context/auth/domain/services/login_attempts_service.py +++ b/app/context/auth/domain/services/login_attempts_service.py @@ -1,7 +1,5 @@ import asyncio -from pip._vendor.rich import print - from app.context.auth.domain.contracts import ( LoginAttemptsServiceContract, SessionRepositoryContract, @@ -33,7 +31,9 @@ async def handle(self, email: Email): # Calculate throttle time and delay the response throttle_time = ThrottleTime.fromAttempts(new_attempts) - print(f"Failed attempt {new_attempts.value}. Delaying response by {throttle_time.value}s") + print( + f"Failed attempt {new_attempts.value}. Delaying response by {throttle_time.value}s" + ) # Sleep to delay the response (throttle) await asyncio.sleep(throttle_time.value) diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index e69de29..5c0e29b 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -0,0 +1,24 @@ +from app.context.auth.domain.contracts import LoginServiceContract +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.queries import FindUserQuery +from app.context.user.domain.value_objects import Email, Password + + +class LoginService(LoginServiceContract): + _user_service: FindUserHandlerContract + + def __init__(self, user_service: FindUserHandlerContract): + self._user_service = user_service + + async def handle(self, email: Email, plain_password: Password): + user = await self._user_service.handle(FindUserQuery(email=email.value)) + + if user is not None and Password.from_hash(user.password).verify( + plain_password.value + ): + print("login success") + + else: + print("User does not exist") + + pass diff --git a/app/context/auth/domain/value_objects/throttle_time.py b/app/context/auth/domain/value_objects/throttle_time.py index 55ac835..d7123d4 100644 --- a/app/context/auth/domain/value_objects/throttle_time.py +++ b/app/context/auth/domain/value_objects/throttle_time.py @@ -6,7 +6,7 @@ @dataclass(frozen=True) class ThrottleTime: value: int - _throttleTimeSeconds = tuple[int, ...] = (0, 2, 4, 8) + _throttleTimeSeconds: tuple[int, ...] = (0, 2, 4, 8) @classmethod def fromAttempts(cls, attempts: FailedLoginAttempts) -> "ThrottleTime": diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index 77e3ab6..aaf9edb 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -1,9 +1,22 @@ +from fastapi import Depends + from app.context.auth.application.contracts import LoginHandlerContract from app.context.auth.application.handlers import LoginHandler +from app.context.auth.domain.contracts import LoginServiceContract +from app.context.auth.domain.services import LoginService +from app.context.user.infrastructure.dependency import get_find_user_query_handler + + +def get_login_service( + userQueryHandler=Depends(get_find_user_query_handler), +) -> LoginServiceContract: + return LoginService(userQueryHandler) -def get_login_handler() -> LoginHandlerContract: +def get_login_handler( + loginService: LoginServiceContract = Depends(get_login_service), +) -> LoginHandlerContract: """ Login handler dependency """ - return LoginHandler() + return LoginHandler(loginService) diff --git a/app/context/auth/infrastructure/repositories/session_repository.py b/app/context/auth/infrastructure/repositories/session_repository.py index 1f3e15f..a8d765d 100644 --- a/app/context/auth/infrastructure/repositories/session_repository.py +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -1,8 +1,14 @@ from app.context.auth.domain.contracts import SessionRepositoryContract from app.context.auth.domain.value_objects import FailedLoginAttempts from app.context.user.domain.value_objects import Email +from app.shared.infrastructure.database import AsyncSessionLocal class SessionRepository(SessionRepositoryContract): + _db: AsyncSessionLocal + + def __init__(self, db: AsyncSessionLocal): + self.db = db + async def getLoginAttepmps(self, email: Email) -> FailedLoginAttempts: pass 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/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 4eabf21..58dfbae 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -14,5 +14,5 @@ async def login( ): """User login endpoint""" return await handler.handle( - LoginCommand(email=request.email, password=request.password) + LoginCommand(email=str(request.email), password=request.password) ) diff --git a/app/context/user/application/dto/user_context_dto.py b/app/context/user/application/dto/user_context_dto.py index c3862ab..e8fd982 100644 --- a/app/context/user/application/dto/user_context_dto.py +++ b/app/context/user/application/dto/user_context_dto.py @@ -1,6 +1,8 @@ from dataclasses import dataclass + @dataclass(frozen=True) class UserContextDTO: - id: int + user_id: int email: str + password: str diff --git a/app/context/user/application/handlers/find_user_handler.py b/app/context/user/application/handlers/find_user_handler.py index e9c1d1b..cb1c1ea 100644 --- a/app/context/user/application/handlers/find_user_handler.py +++ b/app/context/user/application/handlers/find_user_handler.py @@ -4,7 +4,7 @@ from app.context.user.application.dto import UserContextDTO from app.context.user.application.queries import FindUserQuery from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract -from app.context.user.domain.value_objects import Email +from app.context.user.domain.value_objects import Email, UserID class FindUserHandler(FindUserHandlerContract): @@ -13,5 +13,14 @@ def __init__(self, user_repo: UserRepositoryContract): async def handle(self, query: FindUserQuery) -> Optional[UserContextDTO]: email = Email(value=query.email) if query.email is not None else None - res = await self.user_repo.find_user(email) - return UserContextDTO(id=1, email=res.email.value) if res is not None else None + user_id = UserID(value=query.user_id) if query.user_id is not None else None + res = await self.user_repo.find_user(user_id=user_id, email=email) + return ( + UserContextDTO( + user_id=res.user_id.value, + email=res.email.value, + password=res.password.value, + ) + if res is not None + else None + ) diff --git a/app/context/user/application/queries/find_user_query.py b/app/context/user/application/queries/find_user_query.py index feec240..f3f881e 100644 --- a/app/context/user/application/queries/find_user_query.py +++ b/app/context/user/application/queries/find_user_query.py @@ -4,6 +4,5 @@ @dataclass(frozen=True) class FindUserQuery: - id: Optional[int] - email: Optional[str] - password: Optional[str] + user_id: Optional[int] = None + email: Optional[str] = None diff --git a/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py b/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py index dedb337..9739bc4 100644 --- a/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py +++ b/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py @@ -2,10 +2,12 @@ from typing import Optional from app.context.user.domain.dto import UserDTO -from app.context.user.domain.value_objects import Email +from app.context.user.domain.value_objects import Email, UserID class UserRepositoryContract(ABC): @abstractmethod - async def find_user(self, email: Optional[Email]) -> Optional[UserDTO]: + async def find_user( + self, user_id: Optional[UserID] = None, email: Optional[Email] = None + ) -> Optional[UserDTO]: pass diff --git a/app/context/user/domain/dto/user_dto.py b/app/context/user/domain/dto/user_dto.py index c1347ee..de37b49 100644 --- a/app/context/user/domain/dto/user_dto.py +++ b/app/context/user/domain/dto/user_dto.py @@ -1,8 +1,10 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects import Email +from app.context.user.domain.value_objects import Email, Password, UserID @dataclass(frozen=True) class UserDTO: + user_id: UserID email: Email + password: Password diff --git a/app/context/user/domain/value_objects/__init__.py b/app/context/user/domain/value_objects/__init__.py index 1a7fa3f..2050067 100644 --- a/app/context/user/domain/value_objects/__init__.py +++ b/app/context/user/domain/value_objects/__init__.py @@ -1,4 +1,5 @@ from .email import Email from .password import Password +from .user_id import UserID -__all__ = ["Email", "Password"] +__all__ = ["Email", "Password", "UserID"] diff --git a/app/context/user/domain/value_objects/password.py b/app/context/user/domain/value_objects/password.py index 337095d..dfce699 100644 --- a/app/context/user/domain/value_objects/password.py +++ b/app/context/user/domain/value_objects/password.py @@ -13,18 +13,26 @@ class Password: value: str @classmethod - def from_plain_text(cls, plainPassword: str) -> "Password": + def from_plain_text(cls, plain_password: str) -> "Password": ph = PasswordHasher() - return cls(value=ph.hash(plainPassword)) + return cls(value=ph.hash(plain_password)) - def verify(self, plainPassword: str) -> bool: + @classmethod + def from_hash(cls, hashed_password: str) -> "Password": + return cls(value=hashed_password) + + @classmethod + def keep_plain(cls, plain_password: str) -> "Password": + return Password(value=plain_password) + + def verify(self, plain_password: str) -> bool: ph = PasswordHasher() try: - return ph.verify(self.value, plainPassword) + return ph.verify(self.value, plain_password) except VerifyMismatchError: # TODO: Logger here return False @staticmethod - def validate(plainPassword: str) -> bool: - return len(plainPassword) >= 8 + def validate(plain_password: str) -> bool: + return len(plain_password) >= 8 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..8b8353e --- /dev/null +++ b/app/context/user/domain/value_objects/user_id.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UserID: + value: int diff --git a/app/context/user/infrastructure/repositories/user_repository.py b/app/context/user/infrastructure/repositories/user_repository.py index 125580b..eb69972 100644 --- a/app/context/user/infrastructure/repositories/user_repository.py +++ b/app/context/user/infrastructure/repositories/user_repository.py @@ -4,13 +4,25 @@ from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract from app.context.user.domain.dto import UserDTO -from app.context.user.domain.value_objects import Email +from app.context.user.domain.value_objects import Email, Password, UserID class UserRepository(UserRepositoryContract): def __init__(self, db: AsyncSession): self.db = db - async def find_user(self, email: Optional[Email]) -> Optional[UserDTO]: - print(email) - return UserDTO(email=Email(value="test@test.com")) + async def find_user( + self, user_id: Optional[UserID] = None, email: Optional[Email] = None + ) -> Optional[UserDTO]: + print("----------------------------------------------------") + print("Entering in the repository") + print(user_id.value if user_id is not None else "no id sent") + print(email.value if email is not None else "no email sent") + print("----------------------------------------------------") + return UserDTO( + user_id=UserID(1), + email=Email("test@test.com"), + password=Password.from_hash( + "$argon2id$v=19$m=65536,t=3,p=4$BtkhPdaq4kwywhCm5+SDRw$gqds7qYNWVrWd48HS/eGbe+FZrUu9ndZp98pK2zPMgU" + ), + ) diff --git a/app/context/user/interface/console/find_user_console.py b/app/context/user/interface/console/find_user_console.py index 13fe856..abef7e0 100644 --- a/app/context/user/interface/console/find_user_console.py +++ b/app/context/user/interface/console/find_user_console.py @@ -13,7 +13,7 @@ async def main(): handler = FindUserHandler(user_repo) # Example 1: Find by email - query = FindUserQuery(id=None, email="test@example.com", password=None) + query = FindUserQuery(user_id=None, email="test@example.com") result = await handler.handle(query) print(f"Result: {result}") diff --git a/app/main.py b/app/main.py index b4111ed..6820be5 100644 --- a/app/main.py +++ b/app/main.py @@ -3,26 +3,12 @@ from fastapi import FastAPI from app.context.auth.interface.rest import auth_routes -from app.shared.infrastructure.database import async_engine - - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup: Create tables (or use Alembic for migrations) - # async with async_engine.begin() as conn: - # await conn.run_sync(Base.metadata.create_all) - - yield # Application runs - - # Shutdown: Close connection pool - await async_engine.dispose() - app = FastAPI( title="Homecomp API", description="API for Homecomp", version="0.1.0", - lifespan=lifespan, + # lifespan=lifespan, ) app.include_router(auth_routes) diff --git a/scripts/seed.py b/scripts/seed.py index 9d62651..b3a4293 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -2,13 +2,13 @@ import sys from pathlib import Path -# Add project root to path -sys.path.insert(0, str(Path(__file__).parent.parent)) - from app.context.user.domain.value_objects.password import Password from app.context.user.infrastructure.models import UserModel from app.shared.infrastructure.database import AsyncSessionLocal +# Add project root to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + async def seed(): passwd = Password.from_plain_text("testonga") From 50f320b743aa2e202a72c99bac4f92e2fddf625b Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 22 Dec 2025 14:55:44 +0100 Subject: [PATCH 07/58] Working on login --- .../contracts/get_session_handler_contract.py | 9 +++++ .../handlers/get_session_handler.py | 0 .../application/handlers/login_handler.py | 20 +++++----- .../auth/application/query/__init__.py | 1 + .../application/query/get_session_query.py | 8 ++++ .../contracts/login_service_contract.py | 8 ++-- .../contracts/session_repository_contract.py | 18 ++++----- app/context/auth/domain/dto/__init__.py | 3 +- app/context/auth/domain/dto/session_dto.py | 17 ++++++++ .../auth/domain/services/login_service.py | 6 +-- .../auth/domain/value_objects/__init__.py | 13 ++++++- .../auth/domain/value_objects/auth_email.py | 8 ++++ .../domain/value_objects/auth_password.py | 8 ++++ .../auth/domain/value_objects/auth_user_id.py | 6 +++ .../domain/value_objects/session_token.py | 23 +++++++++++ .../auth/infrastructure/dependencies.py | 8 ++-- .../auth/infrastructure/mappers/__init__.py | 3 ++ .../infrastructure/mappers/session_mapper.py | 34 ++++++++++++++++ .../auth/infrastructure/models/__init__.py | 3 ++ .../infrastructure/models/session_model.py | 18 +++++++++ .../repositories/session_repository.py | 17 +++++--- .../user/infrastructure/mappers/__init__.py | 3 ++ .../infrastructure/mappers/user_mapper.py | 20 ++++++++++ .../user/infrastructure/models/__init__.py | 4 +- .../repositories/user_repository.py | 32 ++++++++------- app/shared/domain/value_objects/__init__.py | 4 ++ .../domain/value_objects/shared_email.py | 15 +++++++ .../domain/value_objects/shared_password.py | 39 +++++++++++++++++++ .../b8067948709f_create_session_table.py | 33 ++++++++++++++++ 29 files changed, 329 insertions(+), 52 deletions(-) create mode 100644 app/context/auth/application/contracts/get_session_handler_contract.py create mode 100644 app/context/auth/application/handlers/get_session_handler.py create mode 100644 app/context/auth/application/query/__init__.py create mode 100644 app/context/auth/application/query/get_session_query.py create mode 100644 app/context/auth/domain/dto/session_dto.py create mode 100644 app/context/auth/domain/value_objects/auth_email.py create mode 100644 app/context/auth/domain/value_objects/auth_password.py create mode 100644 app/context/auth/domain/value_objects/auth_user_id.py create mode 100644 app/context/auth/domain/value_objects/session_token.py create mode 100644 app/context/auth/infrastructure/mappers/__init__.py create mode 100644 app/context/auth/infrastructure/mappers/session_mapper.py create mode 100644 app/context/auth/infrastructure/models/__init__.py create mode 100644 app/context/auth/infrastructure/models/session_model.py create mode 100644 app/context/user/infrastructure/mappers/__init__.py create mode 100644 app/context/user/infrastructure/mappers/user_mapper.py create mode 100644 app/shared/domain/value_objects/__init__.py create mode 100644 app/shared/domain/value_objects/shared_email.py create mode 100644 app/shared/domain/value_objects/shared_password.py create mode 100644 migrations/versions/b8067948709f_create_session_table.py 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..3c5d7fb --- /dev/null +++ b/app/context/auth/application/contracts/get_session_handler_contract.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + +from app.context.auth.application.query import GetSessionQuery + + +class GetSessionHandlerContract(ABC): + @abstractmethod + async def handle(self, query: GetSessionQuery): + pass 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..e69de29 diff --git a/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py index 4cba73b..95e1027 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -3,21 +3,21 @@ from app.context.auth.application.commands import LoginCommand from app.context.auth.application.contracts import LoginHandlerContract from app.context.auth.application.dto import LoginHandlerResultDTO -from app.context.auth.domain.contracts import LoginServiceContract -from app.context.user.domain.value_objects import Email, Password +from app.context.user.application.contracts.find_user_query_handler_contract import ( + FindUserHandlerContract, +) +from app.context.user.application.queries.find_user_query import FindUserQuery class LoginHandler(LoginHandlerContract): - _login_service: LoginServiceContract + _user_service: FindUserHandlerContract - def __init__(self, login_service: LoginServiceContract): - self._login_service = login_service + def __init__(self, user_service: FindUserHandlerContract): + self._user_service = user_service pass async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO]: - email = Email(command.email) - password = Password.keep_plain(command.password) - - result = await self._login_service.handle(email, password) - print(result) + user = await self._user_service.handle(FindUserQuery(email=command.email)) + print(command) + print(user) print("---end---") diff --git a/app/context/auth/application/query/__init__.py b/app/context/auth/application/query/__init__.py new file mode 100644 index 0000000..2215ef6 --- /dev/null +++ b/app/context/auth/application/query/__init__.py @@ -0,0 +1 @@ +from .get_session_query import GetSessionQuery diff --git a/app/context/auth/application/query/get_session_query.py b/app/context/auth/application/query/get_session_query.py new file mode 100644 index 0000000..ce803d5 --- /dev/null +++ b/app/context/auth/application/query/get_session_query.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class GetSessionQuery: + user_id: Optional[int] = None + token: Optional[str] = None diff --git a/app/context/auth/domain/contracts/login_service_contract.py b/app/context/auth/domain/contracts/login_service_contract.py index 6d8c6fa..8c120b0 100644 --- a/app/context/auth/domain/contracts/login_service_contract.py +++ b/app/context/auth/domain/contracts/login_service_contract.py @@ -1,9 +1,9 @@ -from abc import ABC +from abc import ABC, abstractmethod -from app.context.user.domain.value_objects import Email, Password +from app.context.auth.domain.value_objects import AuthEmail, AuthPassword class LoginServiceContract(ABC): - # TODO: create return dto - async def handle(self, email: Email, plain_password: Password): + @abstractmethod + async def handle(self, email: AuthEmail, plain_password: AuthPassword): pass diff --git a/app/context/auth/domain/contracts/session_repository_contract.py b/app/context/auth/domain/contracts/session_repository_contract.py index 224a0d1..7230fe4 100644 --- a/app/context/auth/domain/contracts/session_repository_contract.py +++ b/app/context/auth/domain/contracts/session_repository_contract.py @@ -1,16 +1,16 @@ from abc import ABC, abstractmethod +from typing import Optional -from app.context.auth.domain.value_objects import FailedLoginAttempts -from app.context.user.domain.value_objects import Email +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 getLoginAttepmts(self, email: Email) -> FailedLoginAttempts: - pass - - async def updateAttempts(self, email: Email, attempts: FailedLoginAttempts) -> None: - pass - - async def clearAttempts(self, email: Email) -> None: + async def getSession( + self, user_id: Optional[AuthUserID] = None, token: Optional[SessionToken] = None + ) -> Optional[SessionDTO]: pass diff --git a/app/context/auth/domain/dto/__init__.py b/app/context/auth/domain/dto/__init__.py index ae26f3a..f8727ae 100644 --- a/app/context/auth/domain/dto/__init__.py +++ b/app/context/auth/domain/dto/__init__.py @@ -1,3 +1,4 @@ from .login_attempt_result import LoginAttemptResult +from .session_dto import SessionDTO -__all__ = ["LoginAttemptResult"] +__all__ = ["LoginAttemptResult", "SessionDTO"] 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..068b7b0 --- /dev/null +++ b/app/context/auth/domain/dto/session_dto.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from app.context.auth.domain.value_objects import ( + AuthUserID, + FailedLoginAttempts, + SessionToken, +) + + +@dataclass(frozen=True) +class SessionDTO: + user_id: AuthUserID + token: Optional[SessionToken] + failed_attempts: FailedLoginAttempts + blocked_until: Optional[datetime] diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index 5c0e29b..26c0861 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -1,7 +1,7 @@ from app.context.auth.domain.contracts import LoginServiceContract +from app.context.auth.domain.value_objects import AuthEmail, AuthPassword from app.context.user.application.contracts import FindUserHandlerContract from app.context.user.application.queries import FindUserQuery -from app.context.user.domain.value_objects import Email, Password class LoginService(LoginServiceContract): @@ -10,10 +10,10 @@ class LoginService(LoginServiceContract): def __init__(self, user_service: FindUserHandlerContract): self._user_service = user_service - async def handle(self, email: Email, plain_password: Password): + async def handle(self, email: AuthEmail, plain_password: AuthPassword): user = await self._user_service.handle(FindUserQuery(email=email.value)) - if user is not None and Password.from_hash(user.password).verify( + if user is not None and AuthPassword.from_hash(user.password).verify( plain_password.value ): print("login success") diff --git a/app/context/auth/domain/value_objects/__init__.py b/app/context/auth/domain/value_objects/__init__.py index e281e9f..5c63288 100644 --- a/app/context/auth/domain/value_objects/__init__.py +++ b/app/context/auth/domain/value_objects/__init__.py @@ -1,4 +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"] +__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/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/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index aaf9edb..535c5ec 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -4,7 +4,9 @@ from app.context.auth.application.handlers import LoginHandler from app.context.auth.domain.contracts import LoginServiceContract from app.context.auth.domain.services import LoginService -from app.context.user.infrastructure.dependency import get_find_user_query_handler +from app.context.user.infrastructure.dependency import ( + get_find_user_query_handler, +) def get_login_service( @@ -14,9 +16,9 @@ def get_login_service( def get_login_handler( - loginService: LoginServiceContract = Depends(get_login_service), + user_query_handler=Depends(get_find_user_query_handler), ) -> LoginHandlerContract: """ Login handler dependency """ - return LoginHandler(loginService) + return LoginHandler(user_query_handler) 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..e6f0dff --- /dev/null +++ b/app/context/auth/infrastructure/mappers/session_mapper.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from typing import Optional + +from app.context.auth.domain.dto import SessionDTO +from app.context.auth.domain.value_objects import ( + AuthUserID, + FailedLoginAttempts, + SessionToken, +) +from app.context.auth.infrastructure.models import SessionModel + + +@dataclass(frozen=True) +class SessionMapper: + @staticmethod + def toDTO(model: Optional[SessionModel]) -> Optional[SessionDTO]: + 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=model.blocked_until, + ) + + @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..3dffd36 --- /dev/null +++ b/app/context/auth/infrastructure/models/session_model.py @@ -0,0 +1,18 @@ +from datetime import datetime +from typing import Optional + +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[Optional[str]] = mapped_column(String(100), unique=True, index=True) + failed_attempts: Mapped[int] = mapped_column(Integer, default=0) + blocked_until: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) diff --git a/app/context/auth/infrastructure/repositories/session_repository.py b/app/context/auth/infrastructure/repositories/session_repository.py index a8d765d..c7dfc18 100644 --- a/app/context/auth/infrastructure/repositories/session_repository.py +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -1,14 +1,19 @@ +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + from app.context.auth.domain.contracts import SessionRepositoryContract -from app.context.auth.domain.value_objects import FailedLoginAttempts -from app.context.user.domain.value_objects import Email -from app.shared.infrastructure.database import AsyncSessionLocal +from app.context.auth.domain.dto.session_dto import SessionDTO +from app.context.auth.domain.value_objects import AuthUserID, SessionToken class SessionRepository(SessionRepositoryContract): - _db: AsyncSessionLocal + _db: AsyncSession - def __init__(self, db: AsyncSessionLocal): + def __init__(self, db: AsyncSession): self.db = db - async def getLoginAttepmps(self, email: Email) -> FailedLoginAttempts: + async def getSession( + self, user_id: Optional[AuthUserID] = None, token: Optional[SessionToken] = None + ) -> Optional[SessionDTO]: pass 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..cf62b79 --- /dev/null +++ b/app/context/user/infrastructure/mappers/user_mapper.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from typing import Optional + +from app.context.user.domain.dto.user_dto import UserDTO +from app.context.user.domain.value_objects import Email, Password, UserID +from app.context.user.infrastructure.models.user_model import UserModel + + +@dataclass(frozen=True) +class UserMapper: + @staticmethod + def toDTO(model: Optional[UserModel]) -> Optional[UserDTO]: + if model is None: + return None + + return UserDTO( + user_id=UserID(model.id), + email=Email(model.email), + password=Password.from_hash(model.password), + ) diff --git a/app/context/user/infrastructure/models/__init__.py b/app/context/user/infrastructure/models/__init__.py index 0ad873f..4fc3466 100644 --- a/app/context/user/infrastructure/models/__init__.py +++ b/app/context/user/infrastructure/models/__init__.py @@ -1 +1,3 @@ -from .user_model import UserModel as UserModel +from .user_model import UserModel + +__all__ = ["UserModel"] diff --git a/app/context/user/infrastructure/repositories/user_repository.py b/app/context/user/infrastructure/repositories/user_repository.py index eb69972..8fa3605 100644 --- a/app/context/user/infrastructure/repositories/user_repository.py +++ b/app/context/user/infrastructure/repositories/user_repository.py @@ -1,28 +1,32 @@ from typing import Optional +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract from app.context.user.domain.dto import UserDTO -from app.context.user.domain.value_objects import Email, Password, UserID +from app.context.user.domain.value_objects import Email, UserID +from app.context.user.infrastructure.mappers import UserMapper +from app.context.user.infrastructure.models import UserModel class UserRepository(UserRepositoryContract): + _db: AsyncSession + def __init__(self, db: AsyncSession): - self.db = db + self._db = db async def find_user( self, user_id: Optional[UserID] = None, email: Optional[Email] = None ) -> Optional[UserDTO]: - print("----------------------------------------------------") - print("Entering in the repository") - print(user_id.value if user_id is not None else "no id sent") - print(email.value if email is not None else "no email sent") - print("----------------------------------------------------") - return UserDTO( - user_id=UserID(1), - email=Email("test@test.com"), - password=Password.from_hash( - "$argon2id$v=19$m=65536,t=3,p=4$BtkhPdaq4kwywhCm5+SDRw$gqds7qYNWVrWd48HS/eGbe+FZrUu9ndZp98pK2zPMgU" - ), - ) + stmt = select(UserModel) + + if user_id is not None: + stmt = stmt.where(UserModel.id == user_id.value) + else: + if email is not None: + stmt = stmt.where(UserModel.email == email.value) + + res = await self._db.execute(stmt) + + return UserMapper.toDTO(res.scalar_one_or_none()) diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py new file mode 100644 index 0000000..24fd07f --- /dev/null +++ b/app/shared/domain/value_objects/__init__.py @@ -0,0 +1,4 @@ +from .shared_email import SharedEmail +from .shared_password import SharedPassword + +__all__ = ["SharedEmail", "SharedPassword"] 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..0e38c4f --- /dev/null +++ b/app/shared/domain/value_objects/shared_email.py @@ -0,0 +1,15 @@ +import re +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SharedEmail: + value: str + + def __post_init__(self): + 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}") 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/migrations/versions/b8067948709f_create_session_table.py b/migrations/versions/b8067948709f_create_session_table.py new file mode 100644 index 0000000..52408c5 --- /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 typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b8067948709f" +down_revision: Union[str, Sequence[str], None] = "f8333e5b2bac" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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") From c1c98424f83445c8ed7b77be8c4aa4f691f934ec Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 22 Dec 2025 20:15:51 +0100 Subject: [PATCH 08/58] Still working on login, coming to understand DDD flow within python --- .../auth/application/contracts/__init__.py | 3 ++ .../contracts/get_session_handler_contract.py | 4 ++- .../application/dto/get_session_result_dto.py | 10 ++++++ .../dto/login_handler_result_dto.py | 2 +- .../auth/application/handlers/__init__.py | 3 ++ .../handlers/get_session_handler.py | 32 +++++++++++++++++ .../application/handlers/login_handler.py | 26 +++++++++++--- .../auth/application/query/__init__.py | 2 ++ app/context/auth/domain/dto/session_dto.py | 4 +-- .../auth/domain/services/login_service.py | 2 -- .../auth/domain/value_objects/blocked_time.py | 10 ++++++ .../auth/infrastructure/dependencies.py | 36 ++++++++++++++++--- .../infrastructure/mappers/session_mapper.py | 5 ++- .../repositories/session_repository.py | 14 ++++++-- 14 files changed, 136 insertions(+), 17 deletions(-) create mode 100644 app/context/auth/application/dto/get_session_result_dto.py create mode 100644 app/context/auth/domain/value_objects/blocked_time.py diff --git a/app/context/auth/application/contracts/__init__.py b/app/context/auth/application/contracts/__init__.py index b9c99ac..32dd255 100644 --- a/app/context/auth/application/contracts/__init__.py +++ b/app/context/auth/application/contracts/__init__.py @@ -1 +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 index 3c5d7fb..19d2e04 100644 --- a/app/context/auth/application/contracts/get_session_handler_contract.py +++ b/app/context/auth/application/contracts/get_session_handler_contract.py @@ -1,9 +1,11 @@ from abc import ABC, abstractmethod +from typing import Optional +from app.context.auth.application.dto.get_session_result_dto import GetSessionResultDTO from app.context.auth.application.query import GetSessionQuery class GetSessionHandlerContract(ABC): @abstractmethod - async def handle(self, query: GetSessionQuery): + async def handle(self, query: GetSessionQuery) -> Optional[GetSessionResultDTO]: pass 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..9df8fc0 --- /dev/null +++ b/app/context/auth/application/dto/get_session_result_dto.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class GetSessionResultDTO: + user_id: int + token: Optional[str] + failed_attempts: int + blocked_until: Optional[str] diff --git a/app/context/auth/application/dto/login_handler_result_dto.py b/app/context/auth/application/dto/login_handler_result_dto.py index 27456d0..e42dab3 100644 --- a/app/context/auth/application/dto/login_handler_result_dto.py +++ b/app/context/auth/application/dto/login_handler_result_dto.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -@dataclass +@dataclass(frozen=True) class LoginHandlerResultDTO: token: str error: str diff --git a/app/context/auth/application/handlers/__init__.py b/app/context/auth/application/handlers/__init__.py index d7ed4de..b7118eb 100644 --- a/app/context/auth/application/handlers/__init__.py +++ b/app/context/auth/application/handlers/__init__.py @@ -1 +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 index e69de29..d68b460 100644 --- a/app/context/auth/application/handlers/get_session_handler.py +++ b/app/context/auth/application/handlers/get_session_handler.py @@ -0,0 +1,32 @@ +from typing import Optional + +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.query 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) -> Optional[GetSessionResultDTO]: + 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 index 95e1027..da1efe3 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -1,8 +1,12 @@ from typing import Optional from app.context.auth.application.commands import LoginCommand -from app.context.auth.application.contracts import LoginHandlerContract +from app.context.auth.application.contracts import ( + GetSessionHandlerContract, + LoginHandlerContract, +) from app.context.auth.application.dto import LoginHandlerResultDTO +from app.context.auth.application.query import GetSessionQuery from app.context.user.application.contracts.find_user_query_handler_contract import ( FindUserHandlerContract, ) @@ -11,13 +15,27 @@ class LoginHandler(LoginHandlerContract): _user_service: FindUserHandlerContract + _session_service: GetSessionHandlerContract - def __init__(self, user_service: FindUserHandlerContract): + def __init__( + self, + user_service: FindUserHandlerContract, + session_service: GetSessionHandlerContract, + ): self._user_service = user_service + self._session_service = session_service pass async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO]: user = await self._user_service.handle(FindUserQuery(email=command.email)) - print(command) - print(user) + + if user is None: + print("Bolocks") + return + + session = await self._session_service.handle( + GetSessionQuery(user_id=user.user_id) + ) + print(session) + print("---end---") diff --git a/app/context/auth/application/query/__init__.py b/app/context/auth/application/query/__init__.py index 2215ef6..677fdf5 100644 --- a/app/context/auth/application/query/__init__.py +++ b/app/context/auth/application/query/__init__.py @@ -1 +1,3 @@ from .get_session_query import GetSessionQuery + +__all__ = ["GetSessionQuery"] diff --git a/app/context/auth/domain/dto/session_dto.py b/app/context/auth/domain/dto/session_dto.py index 068b7b0..27f097f 100644 --- a/app/context/auth/domain/dto/session_dto.py +++ b/app/context/auth/domain/dto/session_dto.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from datetime import datetime from typing import Optional from app.context.auth.domain.value_objects import ( @@ -7,6 +6,7 @@ FailedLoginAttempts, SessionToken, ) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime @dataclass(frozen=True) @@ -14,4 +14,4 @@ class SessionDTO: user_id: AuthUserID token: Optional[SessionToken] failed_attempts: FailedLoginAttempts - blocked_until: Optional[datetime] + blocked_until: Optional[BlockedTime] diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index 26c0861..593ec60 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -5,8 +5,6 @@ class LoginService(LoginServiceContract): - _user_service: FindUserHandlerContract - def __init__(self, user_service: FindUserHandlerContract): self._user_service = user_service 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..ff7e54b --- /dev/null +++ b/app/context/auth/domain/value_objects/blocked_time.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from datetime import datetime + + +@dataclass +class BlockedTime: + value: datetime + + def toString(self) -> str: + return self.value.isoformat() diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index 535c5ec..7942c7a 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -1,12 +1,27 @@ from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession -from app.context.auth.application.contracts import LoginHandlerContract +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.domain.contracts import LoginServiceContract +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.find_user_query_handler_contract import ( + FindUserHandlerContract, +) from app.context.user.infrastructure.dependency import ( get_find_user_query_handler, ) +from app.shared.infrastructure.database import get_db def get_login_service( @@ -15,10 +30,23 @@ def get_login_service( return LoginService(userQueryHandler) +def get_session_repository( + db: AsyncSession = Depends(get_db), +) -> SessionRepositoryContract: + return SessionRepository(db) + + +def get_session_handler( + session_repo: SessionRepository = Depends(get_session_repository), +) -> GetSessionHandlerContract: + return GetSessionHandler(session_repo) + + def get_login_handler( - user_query_handler=Depends(get_find_user_query_handler), + user_query_handler: FindUserHandlerContract = Depends(get_find_user_query_handler), + get_session_query_handler: GetSessionHandlerContract = Depends(get_session_handler), ) -> LoginHandlerContract: """ Login handler dependency """ - return LoginHandler(user_query_handler) + return LoginHandler(user_query_handler, get_session_query_handler) diff --git a/app/context/auth/infrastructure/mappers/session_mapper.py b/app/context/auth/infrastructure/mappers/session_mapper.py index e6f0dff..4b61617 100644 --- a/app/context/auth/infrastructure/mappers/session_mapper.py +++ b/app/context/auth/infrastructure/mappers/session_mapper.py @@ -7,6 +7,7 @@ FailedLoginAttempts, SessionToken, ) +from app.context.auth.domain.value_objects.blocked_time import BlockedTime from app.context.auth.infrastructure.models import SessionModel @@ -21,7 +22,9 @@ def toDTO(model: Optional[SessionModel]) -> Optional[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=model.blocked_until, + blocked_until=BlockedTime(model.blocked_until) + if model.blocked_until is not None + else None, ) @staticmethod diff --git a/app/context/auth/infrastructure/repositories/session_repository.py b/app/context/auth/infrastructure/repositories/session_repository.py index c7dfc18..30707aa 100644 --- a/app/context/auth/infrastructure/repositories/session_repository.py +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -1,19 +1,29 @@ from typing import Optional +from sqlalchemy import select 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 + self._db = db async def getSession( self, user_id: Optional[AuthUserID] = None, token: Optional[SessionToken] = None ) -> Optional[SessionDTO]: - pass + 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()) From 86021edb920aea262c443a65924e613126873d92 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 22 Dec 2025 21:10:02 +0100 Subject: [PATCH 09/58] Working on session --- .../application/handlers/login_handler.py | 40 +++++++++--------- .../contracts/login_service_contract.py | 5 ++- app/context/auth/domain/dto/__init__.py | 4 +- app/context/auth/domain/dto/auth_user_dto.py | 10 +++++ .../auth/domain/dto/login_attempt_result.py | 10 ----- .../auth/domain/services/login_service.py | 42 ++++++++++++------- .../auth/domain/value_objects/blocked_time.py | 3 ++ .../auth/infrastructure/dependencies.py | 21 ++++++---- 8 files changed, 79 insertions(+), 56 deletions(-) create mode 100644 app/context/auth/domain/dto/auth_user_dto.py delete mode 100644 app/context/auth/domain/dto/login_attempt_result.py diff --git a/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py index da1efe3..6b89b1f 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -2,40 +2,42 @@ from app.context.auth.application.commands import LoginCommand from app.context.auth.application.contracts import ( - GetSessionHandlerContract, LoginHandlerContract, ) from app.context.auth.application.dto import LoginHandlerResultDTO -from app.context.auth.application.query import GetSessionQuery -from app.context.user.application.contracts.find_user_query_handler_contract import ( - FindUserHandlerContract, -) -from app.context.user.application.queries.find_user_query import FindUserQuery +from app.context.auth.domain.contracts import LoginServiceContract +from app.context.auth.domain.dto import AuthUserDTO +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 class LoginHandler(LoginHandlerContract): - _user_service: FindUserHandlerContract - _session_service: GetSessionHandlerContract + _user_handler: FindUserHandlerContract + _login_service: LoginServiceContract def __init__( - self, - user_service: FindUserHandlerContract, - session_service: GetSessionHandlerContract, + self, user_handler: FindUserHandlerContract, login_service: LoginServiceContract ): - self._user_service = user_service - self._session_service = session_service + self._user_handler = user_handler + self._login_service = login_service pass async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO]: - user = await self._user_service.handle(FindUserQuery(email=command.email)) - + user = await self._user_handler.handle(FindUserQuery(email=command.email)) if user is None: - print("Bolocks") + # Error invalid login attempt return - session = await self._session_service.handle( - GetSessionQuery(user_id=user.user_id) + res = 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), + ), ) - print(session) + + print(res) print("---end---") diff --git a/app/context/auth/domain/contracts/login_service_contract.py b/app/context/auth/domain/contracts/login_service_contract.py index 8c120b0..11cebbb 100644 --- a/app/context/auth/domain/contracts/login_service_contract.py +++ b/app/context/auth/domain/contracts/login_service_contract.py @@ -1,9 +1,10 @@ from abc import ABC, abstractmethod -from app.context.auth.domain.value_objects import AuthEmail, AuthPassword +from app.context.auth.domain.dto import AuthUserDTO +from app.context.auth.domain.value_objects import AuthPassword class LoginServiceContract(ABC): @abstractmethod - async def handle(self, email: AuthEmail, plain_password: AuthPassword): + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO): pass diff --git a/app/context/auth/domain/dto/__init__.py b/app/context/auth/domain/dto/__init__.py index f8727ae..b4a3b18 100644 --- a/app/context/auth/domain/dto/__init__.py +++ b/app/context/auth/domain/dto/__init__.py @@ -1,4 +1,4 @@ -from .login_attempt_result import LoginAttemptResult +from .auth_user_dto import AuthUserDTO from .session_dto import SessionDTO -__all__ = ["LoginAttemptResult", "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/login_attempt_result.py b/app/context/auth/domain/dto/login_attempt_result.py deleted file mode 100644 index 82148b7..0000000 --- a/app/context/auth/domain/dto/login_attempt_result.py +++ /dev/null @@ -1,10 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime - - -@dataclass(frozen=True) -class LoginAttemptResult: - is_blocked: bool - throttle_applied_seconds: int - attempts_remaining: int - unblock_at: datetime | None = None diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index 593ec60..b8dc3aa 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -1,22 +1,36 @@ -from app.context.auth.domain.contracts import LoginServiceContract -from app.context.auth.domain.value_objects import AuthEmail, AuthPassword -from app.context.user.application.contracts import FindUserHandlerContract -from app.context.user.application.queries import FindUserQuery +from app.context.auth.domain.contracts import ( + LoginServiceContract, + SessionRepositoryContract, +) +from app.context.auth.domain.dto import AuthUserDTO +from app.context.auth.domain.value_objects import AuthPassword class LoginService(LoginServiceContract): - def __init__(self, user_service: FindUserHandlerContract): - self._user_service = user_service + _session_repo: SessionRepositoryContract - async def handle(self, email: AuthEmail, plain_password: AuthPassword): - user = await self._user_service.handle(FindUserQuery(email=email.value)) + def __init__(self, session_repo: SessionRepositoryContract): + self._session_repo = session_repo - if user is not None and AuthPassword.from_hash(user.password).verify( - plain_password.value - ): - print("login success") + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO): + session = await self._session_repo.getSession(user_id=db_user.user_id) - else: - print("User does not exist") + if session is None: + # create session, remove the return + return + + if session.blocked_until is not None and not session.blocked_until.isOver(): + # return you can't log in + # clear session (don't save yet) + return + + if not db_user.password.verify(user_password.value): + # update session attempt or add blocked_until if the attempts pass thet threshold + # Invalid username or password + return + + # Create token + # reset session failed attempts and store the token + # Return Token pass diff --git a/app/context/auth/domain/value_objects/blocked_time.py b/app/context/auth/domain/value_objects/blocked_time.py index ff7e54b..8146809 100644 --- a/app/context/auth/domain/value_objects/blocked_time.py +++ b/app/context/auth/domain/value_objects/blocked_time.py @@ -8,3 +8,6 @@ class BlockedTime: def toString(self) -> str: return self.value.isoformat() + + def isOver(self) -> bool: + return self.value > datetime.now() diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index 7942c7a..f2598b4 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -24,18 +24,21 @@ from app.shared.infrastructure.database import get_db -def get_login_service( - userQueryHandler=Depends(get_find_user_query_handler), -) -> LoginServiceContract: - return LoginService(userQueryHandler) - - def get_session_repository( db: AsyncSession = Depends(get_db), ) -> SessionRepositoryContract: return SessionRepository(db) +def get_login_service( + session_repo: SessionRepositoryContract = Depends(get_session_repository), +) -> LoginServiceContract: + """ + LoginService dependency injection + """ + return LoginService(session_repo) + + def get_session_handler( session_repo: SessionRepository = Depends(get_session_repository), ) -> GetSessionHandlerContract: @@ -44,9 +47,9 @@ def get_session_handler( def get_login_handler( user_query_handler: FindUserHandlerContract = Depends(get_find_user_query_handler), - get_session_query_handler: GetSessionHandlerContract = Depends(get_session_handler), + login_service: LoginServiceContract = Depends(get_login_service), ) -> LoginHandlerContract: """ - Login handler dependency + LoginHandler dependency injection """ - return LoginHandler(user_query_handler, get_session_query_handler) + return LoginHandler(user_query_handler, login_service) From 48ccc9ebe0dd0fc3d82b8abd74ca8d3091ab01d5 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 23 Dec 2025 02:04:25 +0100 Subject: [PATCH 10/58] Almost finish with login with throttle --- .../application/handlers/login_handler.py | 37 ++++++--- .../contracts/login_service_contract.py | 20 ++++- .../contracts/session_repository_contract.py | 10 +++ app/context/auth/domain/exceptions.py | 21 +++++ .../auth/domain/services/login_service.py | 76 +++++++++++++++---- .../auth/domain/value_objects/blocked_time.py | 4 +- .../value_objects/failed_login_attempts.py | 10 ++- .../repositories/session_repository.py | 39 +++++++++- 8 files changed, 187 insertions(+), 30 deletions(-) create mode 100644 app/context/auth/domain/exceptions.py diff --git a/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py index 6b89b1f..633e0c1 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -7,6 +7,10 @@ from app.context.auth.application.dto import LoginHandlerResultDTO 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 @@ -29,15 +33,28 @@ async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO] # Error invalid login attempt return - res = 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), - ), - ) - - print(res) + try: + res = 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), + ), + ) + + print(res) + except AccountBlockedException: + print("------------------------") + print("Account is blocked") + print("------------------------") + except InvalidCredentialsException: + print("------------------------") + print("Invalid username or password") + print("------------------------") + except Exception: + print("------------------------") + print("Unhandled exception") + print("------------------------") print("---end---") diff --git a/app/context/auth/domain/contracts/login_service_contract.py b/app/context/auth/domain/contracts/login_service_contract.py index 11cebbb..da9b482 100644 --- a/app/context/auth/domain/contracts/login_service_contract.py +++ b/app/context/auth/domain/contracts/login_service_contract.py @@ -1,10 +1,26 @@ from abc import ABC, abstractmethod from app.context.auth.domain.dto import AuthUserDTO -from app.context.auth.domain.value_objects import AuthPassword +from app.context.auth.domain.value_objects import AuthPassword, SessionToken class LoginServiceContract(ABC): @abstractmethod - async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO): + 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 index 7230fe4..1565769 100644 --- a/app/context/auth/domain/contracts/session_repository_contract.py +++ b/app/context/auth/domain/contracts/session_repository_contract.py @@ -14,3 +14,13 @@ async def getSession( self, user_id: Optional[AuthUserID] = None, token: Optional[SessionToken] = None ) -> Optional[SessionDTO]: 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/exceptions.py b/app/context/auth/domain/exceptions.py new file mode 100644 index 0000000..05c4677 --- /dev/null +++ b/app/context/auth/domain/exceptions.py @@ -0,0 +1,21 @@ +"""Domain exceptions for Auth context.""" + + +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: str): + self.blocked_until = blocked_until + super().__init__(f"Account is blocked until {blocked_until}") diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index b8dc3aa..a2bb8d5 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -1,9 +1,21 @@ +import asyncio +from datetime import datetime, timedelta + from app.context.auth.domain.contracts import ( LoginServiceContract, SessionRepositoryContract, ) -from app.context.auth.domain.dto import AuthUserDTO -from app.context.auth.domain.value_objects import AuthPassword +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 class LoginService(LoginServiceContract): @@ -12,25 +24,59 @@ class LoginService(LoginServiceContract): def __init__(self, session_repo: SessionRepositoryContract): self._session_repo = session_repo - async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO): + async def handle( + self, user_password: AuthPassword, db_user: AuthUserDTO + ) -> SessionToken: session = await self._session_repo.getSession(user_id=db_user.user_id) if session is None: - # create session, remove the return - return + # 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(): - # return you can't log in - # clear session (don't save yet) - return + # Account is blocked + raise AccountBlockedException(session.blocked_until.toString()) if not db_user.password.verify(user_password.value): - # update session attempt or add blocked_until if the attempts pass thet threshold - # Invalid username or password - return + # Increment failed attempts + new_attempts = FailedLoginAttempts(session.failed_attempts.value + 1) + + # Block account if max attempts reached + blocked_until = None + if new_attempts.hasReachMaxAttempts(): + blocked_until = BlockedTime(datetime.now() + timedelta(minutes=15)) + + await self._session_repo.updateSession( + SessionDTO( + user_id=db_user.user_id, + token=None, + failed_attempts=new_attempts, + blocked_until=blocked_until, + ) + ) + + # Seep to avoid brute force attempts + await asyncio.sleep(new_attempts.getAttemptDelay()) + + raise InvalidCredentialsException() + + # Create token and reset failed attempts + new_token = SessionToken.generate() - # Create token - # reset session failed attempts and store the token - # Return Token + await self._session_repo.updateSession( + SessionDTO( + user_id=db_user.user_id, + token=new_token, + failed_attempts=FailedLoginAttempts(0), + blocked_until=None, + ) + ) - pass + return new_token diff --git a/app/context/auth/domain/value_objects/blocked_time.py b/app/context/auth/domain/value_objects/blocked_time.py index 8146809..4e49b7a 100644 --- a/app/context/auth/domain/value_objects/blocked_time.py +++ b/app/context/auth/domain/value_objects/blocked_time.py @@ -10,4 +10,6 @@ def toString(self) -> str: return self.value.isoformat() def isOver(self) -> bool: - return self.value > datetime.now() + print(self.value.isoformat()) + print(datetime.now().isoformat()) + return self.value < datetime.now() diff --git a/app/context/auth/domain/value_objects/failed_login_attempts.py b/app/context/auth/domain/value_objects/failed_login_attempts.py index d01af24..8bcbd62 100644 --- a/app/context/auth/domain/value_objects/failed_login_attempts.py +++ b/app/context/auth/domain/value_objects/failed_login_attempts.py @@ -1,10 +1,18 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field @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 + ) diff --git a/app/context/auth/infrastructure/repositories/session_repository.py b/app/context/auth/infrastructure/repositories/session_repository.py index 30707aa..9485e20 100644 --- a/app/context/auth/infrastructure/repositories/session_repository.py +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -1,6 +1,6 @@ from typing import Optional -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.context.auth.domain.contracts import SessionRepositoryContract @@ -27,3 +27,40 @@ async def getSession( 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 From 970d3ce3575343a48f0949a0a960b08f517ab805 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 23 Dec 2025 02:30:31 +0100 Subject: [PATCH 11/58] Start adding tests --- pyproject.toml | 6 + tests/__init__.py | 0 tests/conftest.py | 34 +++ tests/unit/__init__.py | 0 tests/unit/context/__init__.py | 0 tests/unit/context/auth/__init__.py | 0 .../unit/context/auth/application/__init__.py | 0 .../application/test_get_session_handler.py | 211 ++++++++++++++++++ uv.lock | 76 +++++++ 9 files changed, 327 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/context/__init__.py create mode 100644 tests/unit/context/auth/__init__.py create mode 100644 tests/unit/context/auth/application/__init__.py create mode 100644 tests/unit/context/auth/application/test_get_session_handler.py diff --git a/pyproject.toml b/pyproject.toml index 7b8cd5a..3f7d468 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,3 +15,9 @@ dependencies = [ "sqlalchemy>=2.0.45", "uvicorn>=0.38.0", ] + +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", +] 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..70c43ed --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,34 @@ +from typing import Optional +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: Optional[AuthUserID] = None, token: Optional[SessionToken] = None + ) -> Optional[SessionDTO]: + 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/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/test_get_session_handler.py b/tests/unit/context/auth/application/test_get_session_handler.py new file mode 100644 index 0000000..fd8e5c8 --- /dev/null +++ b/tests/unit/context/auth/application/test_get_session_handler.py @@ -0,0 +1,211 @@ +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.query.get_session_query 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.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/uv.lock b/uv.lock index 8a813f1..e11efc9 100644 --- a/uv.lock +++ b/uv.lock @@ -284,6 +284,12 @@ dependencies = [ { name = "uvicorn" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.17.2" }, @@ -297,6 +303,12 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.38.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, +] + [[package]] name = "idna" version = "3.11" @@ -306,6 +318,15 @@ 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" @@ -370,6 +391,24 @@ wheels = [ { 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" @@ -482,6 +521,43 @@ wheels = [ { 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 = "sqlalchemy" version = "2.0.45" From 3b53b19d65c8d15c8bd114e3d48ee620a35fd15f Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 23 Dec 2025 11:19:55 +0100 Subject: [PATCH 12/58] Almost finish with login --- .../contracts/login_handler_contract.py | 3 +- app/context/auth/application/dto/__init__.py | 4 +- .../dto/login_handler_result_dto.py | 17 ++++++- .../application/handlers/login_handler.py | 49 +++++++++++-------- app/context/auth/domain/exceptions.py | 6 ++- app/context/auth/domain/services/__init__.py | 3 +- .../domain/services/login_attempts_service.py | 39 --------------- .../auth/domain/services/login_service.py | 7 ++- .../auth/domain/value_objects/blocked_time.py | 11 +++-- .../value_objects/failed_login_attempts.py | 5 ++ .../rest/controllers/login_rest_controller.py | 29 +++++++++-- .../auth/interface/rest/schemas/__init__.py | 4 +- .../rest/schemas/login_rest_schema.py | 2 +- .../interface/console/find_user_console.py | 5 -- main.py | 6 --- 15 files changed, 96 insertions(+), 94 deletions(-) delete mode 100644 app/context/auth/domain/services/login_attempts_service.py delete mode 100644 main.py diff --git a/app/context/auth/application/contracts/login_handler_contract.py b/app/context/auth/application/contracts/login_handler_contract.py index 7353e6c..d6c71f0 100644 --- a/app/context/auth/application/contracts/login_handler_contract.py +++ b/app/context/auth/application/contracts/login_handler_contract.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.auth.application.commands import LoginCommand from app.context.auth.application.dto import LoginHandlerResultDTO @@ -7,5 +6,5 @@ class LoginHandlerContract(ABC): @abstractmethod - async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO]: + 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 index 2f6b86b..b2b403b 100644 --- a/app/context/auth/application/dto/__init__.py +++ b/app/context/auth/application/dto/__init__.py @@ -1 +1,3 @@ -from .login_handler_result_dto import LoginHandlerResultDTO +from .login_handler_result_dto import LoginHandlerResultDTO, LoginHandlerResultStatus + +__all__ = ["LoginHandlerResultDTO", "LoginHandlerResultStatus"] diff --git a/app/context/auth/application/dto/login_handler_result_dto.py b/app/context/auth/application/dto/login_handler_result_dto.py index e42dab3..fbc245a 100644 --- a/app/context/auth/application/dto/login_handler_result_dto.py +++ b/app/context/auth/application/dto/login_handler_result_dto.py @@ -1,7 +1,20 @@ from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Optional + + +class LoginHandlerResultStatus(Enum): + SUCCESS = "success" + INVALID_CREDENTIALS = "invalid" + ACCOUNT_BLOCKED = "blocked" + UNEXPECTED_ERROR = "unexpected_error" @dataclass(frozen=True) class LoginHandlerResultDTO: - token: str - error: str + status: LoginHandlerResultStatus + token: Optional[str] = None + user_id: Optional[int] = None + error_msg: Optional[str] = None + retry_after: Optional[datetime] = None diff --git a/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py index 633e0c1..f732504 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -1,10 +1,11 @@ -from typing import Optional - from app.context.auth.application.commands import LoginCommand from app.context.auth.application.contracts import ( LoginHandlerContract, ) -from app.context.auth.application.dto import LoginHandlerResultDTO +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 ( @@ -27,14 +28,16 @@ def __init__( self._login_service = login_service pass - async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO]: + async def handle(self, command: LoginCommand) -> LoginHandlerResultDTO: user = await self._user_handler.handle(FindUserQuery(email=command.email)) if user is None: - # Error invalid login attempt - return + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.INVALID_CREDENTIALS, + error_msg="Invalid username or password", + ) try: - res = await self._login_service.handle( + user_token = await self._login_service.handle( user_password=AuthPassword(command.password), db_user=AuthUserDTO( user_id=AuthUserID(user.user_id), @@ -43,18 +46,24 @@ async def handle(self, command: LoginCommand) -> Optional[LoginHandlerResultDTO] ), ) - print(res) - except AccountBlockedException: - print("------------------------") - print("Account is blocked") - print("------------------------") + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.SUCCESS, + token=user_token.value, + user_id=user.user_id, + ) + except AccountBlockedException as abe: + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.ACCOUNT_BLOCKED, + retry_after=abe.blocked_until, + error_msg="Account is blocked, try again later", + ) except InvalidCredentialsException: - print("------------------------") - print("Invalid username or password") - print("------------------------") + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.INVALID_CREDENTIALS, + error_msg="Invalid username or password", + ) except Exception: - print("------------------------") - print("Unhandled exception") - print("------------------------") - - print("---end---") + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.UNEXPECTED_ERROR, + error_msg="Unexpected error", + ) diff --git a/app/context/auth/domain/exceptions.py b/app/context/auth/domain/exceptions.py index 05c4677..db0da7d 100644 --- a/app/context/auth/domain/exceptions.py +++ b/app/context/auth/domain/exceptions.py @@ -1,5 +1,7 @@ """Domain exceptions for Auth context.""" +from datetime import datetime + class AuthDomainException(Exception): """Base exception for Auth domain errors.""" @@ -16,6 +18,6 @@ class InvalidCredentialsException(AuthDomainException): class AccountBlockedException(AuthDomainException): """Raised when account is temporarily blocked due to failed login attempts.""" - def __init__(self, blocked_until: str): + def __init__(self, blocked_until: datetime): self.blocked_until = blocked_until - super().__init__(f"Account is 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 index c7baad2..4e8173c 100644 --- a/app/context/auth/domain/services/__init__.py +++ b/app/context/auth/domain/services/__init__.py @@ -1,4 +1,3 @@ -from .login_attempts_service import LoginAttemptsService from .login_service import LoginService -__all__ = ["LoginAttemptsService", "LoginService"] +__all__ = ["LoginService"] diff --git a/app/context/auth/domain/services/login_attempts_service.py b/app/context/auth/domain/services/login_attempts_service.py deleted file mode 100644 index fd83e48..0000000 --- a/app/context/auth/domain/services/login_attempts_service.py +++ /dev/null @@ -1,39 +0,0 @@ -import asyncio - -from app.context.auth.domain.contracts import ( - LoginAttemptsServiceContract, - SessionRepositoryContract, -) -from app.context.auth.domain.value_objects import FailedLoginAttempts, ThrottleTime -from app.context.user.domain.value_objects import Email - - -class LoginAttemptsService(LoginAttemptsServiceContract): - _sessionRepository: SessionRepositoryContract - - def __init__(self, sessionRepo: SessionRepositoryContract): - self._sessionRepository = sessionRepo - pass - - async def handle(self, email: Email): - attempts = await self._sessionRepository.getLoginAttepmts(email) - - if attempts.hasReachMaxAttempts(): - # User has reached max attempts - timeout handling will be done later - print("User blocked - max attempts reached") - # TODO: Handle timeout logic - pass - else: - # User has remaining attempts - # Store failed attempt by incrementing the count - new_attempts = FailedLoginAttempts(value=attempts.value + 1) - await self._sessionRepository.updateAttempts(email, new_attempts) - - # Calculate throttle time and delay the response - throttle_time = ThrottleTime.fromAttempts(new_attempts) - print( - f"Failed attempt {new_attempts.value}. Delaying response by {throttle_time.value}s" - ) - - # Sleep to delay the response (throttle) - await asyncio.sleep(throttle_time.value) diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index a2bb8d5..2f6fef8 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -1,5 +1,4 @@ import asyncio -from datetime import datetime, timedelta from app.context.auth.domain.contracts import ( LoginServiceContract, @@ -42,7 +41,7 @@ async def handle( if session.blocked_until is not None and not session.blocked_until.isOver(): # Account is blocked - raise AccountBlockedException(session.blocked_until.toString()) + raise AccountBlockedException(session.blocked_until.value) if not db_user.password.verify(user_password.value): # Increment failed attempts @@ -51,7 +50,7 @@ async def handle( # Block account if max attempts reached blocked_until = None if new_attempts.hasReachMaxAttempts(): - blocked_until = BlockedTime(datetime.now() + timedelta(minutes=15)) + blocked_until = BlockedTime.setBlocked() await self._session_repo.updateSession( SessionDTO( @@ -74,7 +73,7 @@ async def handle( SessionDTO( user_id=db_user.user_id, token=new_token, - failed_attempts=FailedLoginAttempts(0), + failed_attempts=FailedLoginAttempts.reset(), blocked_until=None, ) ) diff --git a/app/context/auth/domain/value_objects/blocked_time.py b/app/context/auth/domain/value_objects/blocked_time.py index 4e49b7a..973b83d 100644 --- a/app/context/auth/domain/value_objects/blocked_time.py +++ b/app/context/auth/domain/value_objects/blocked_time.py @@ -1,15 +1,20 @@ from dataclasses import dataclass -from datetime import datetime +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: - print(self.value.isoformat()) - print(datetime.now().isoformat()) 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 index 8bcbd62..4ff4f27 100644 --- a/app/context/auth/domain/value_objects/failed_login_attempts.py +++ b/app/context/auth/domain/value_objects/failed_login_attempts.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from typing import Self @dataclass(frozen=True) @@ -16,3 +17,7 @@ def getAttemptDelay(self) -> float: if self.value < len(self._wait_attempts) else 4 ) + + @classmethod + def reset(cls) -> Self: + return cls(0) diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 58dfbae..746cd85 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -1,18 +1,37 @@ -from fastapi import APIRouter, Depends +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 +from app.context.auth.interface.rest.schemas import LoginRequest, LoginResponse router = APIRouter(prefix="/login", tags=["login"]) -@router.post("") +@router.post("", response_model=LoginResponse) async def login( - request: LoginRequest, handler: LoginHandlerContract = Depends(get_login_handler) + response: Response, + request: LoginRequest, + handler: LoginHandlerContract = Depends(get_login_handler), ): """User login endpoint""" - return await handler.handle( + 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: + raise HTTPException(status_code=500, detail="Token generation failed") + return LoginResponse(token=login_result.token) + + if login_result.status == LoginHandlerResultStatus.INVALID_CREDENTIALS: + raise HTTPException(status_code=401, detail=login_result.error_msg) + + if login_result.status == LoginHandlerResultStatus.ACCOUNT_BLOCKED: + if login_result.retry_after: + response.headers["Retry-After"] = login_result.retry_after.isoformat() + raise HTTPException(status_code=429, detail=login_result.error_msg) + + # UNEXPECTED_ERROR or any other status + raise HTTPException(status_code=500, detail=login_result.error_msg) diff --git a/app/context/auth/interface/rest/schemas/__init__.py b/app/context/auth/interface/rest/schemas/__init__.py index 6c6bc9e..fe9dc67 100644 --- a/app/context/auth/interface/rest/schemas/__init__.py +++ b/app/context/auth/interface/rest/schemas/__init__.py @@ -1,3 +1,3 @@ -from .login_rest_schema import LoginRequest +from .login_rest_schema import LoginRequest, LoginResponse -__all__ = ["LoginRequest"] +__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 index 739a18b..d54929b 100644 --- a/app/context/auth/interface/rest/schemas/login_rest_schema.py +++ b/app/context/auth/interface/rest/schemas/login_rest_schema.py @@ -11,4 +11,4 @@ class LoginRequest(BaseModel): @dataclass(frozen=True) class LoginResponse: - email: str + token: str diff --git a/app/context/user/interface/console/find_user_console.py b/app/context/user/interface/console/find_user_console.py index abef7e0..682a42b 100644 --- a/app/context/user/interface/console/find_user_console.py +++ b/app/context/user/interface/console/find_user_console.py @@ -17,11 +17,6 @@ async def main(): result = await handler.handle(query) print(f"Result: {result}") - # Example 2: Find by id - # query = FindUserQuery(id=1, email=None, password=None) - # result = await handler.handle(query) - # print(f"Result: {result}") - if __name__ == "__main__": asyncio.run(main()) diff --git a/main.py b/main.py deleted file mode 100644 index f4b12ca..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from homecomp-api!") - - -if __name__ == "__main__": - main() From a4dc2c8f929f73290f0cc9d030a4602d9a58453a Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 23 Dec 2025 12:21:19 +0100 Subject: [PATCH 13/58] Adding tests on login_handler --- .env.template | 6 + .../rest/controllers/login_rest_controller.py | 22 +- .../rest/schemas/login_rest_schema.py | 5 +- app/shared/infrastructure/database.py | 2 +- tests/conftest.py | 45 ++- .../auth/application/test_login_handler.py | 304 ++++++++++++++++++ 6 files changed, 376 insertions(+), 8 deletions(-) create mode 100644 .env.template create mode 100644 tests/unit/context/auth/application/test_login_handler.py diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..02a8e4f --- /dev/null +++ b/.env.template @@ -0,0 +1,6 @@ +APP_ENV=dev +DB_HOST=localhost +DB_PORT=5432 +DB_USER=uhomecomp +DB_PASS=homecomppass +DB_NAME=homecomp diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 746cd85..58e12e3 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -1,3 +1,5 @@ +from os import getenv + from fastapi import APIRouter, Depends, HTTPException, Response from app.context.auth.application.commands import LoginCommand @@ -23,15 +25,29 @@ async def login( if login_result.status == LoginHandlerResultStatus.SUCCESS: if login_result.token is None: raise HTTPException(status_code=500, detail="Token generation failed") - return LoginResponse(token=login_result.token) + + # Set JWT token as HTTP-only secure cookie + response.set_cookie( + key="access_token", + value=login_result.token, + httponly=True, # Prevents JavaScript access (XSS protection) + secure=(getenv("APP_ENV", "dev") == "prod"), # Only send over HTTPS + samesite="lax", # CSRF protection + max_age=3600, # 1 hour expiration (adjust as needed) + ) + + return LoginResponse(message="Login successful") if login_result.status == LoginHandlerResultStatus.INVALID_CREDENTIALS: raise HTTPException(status_code=401, detail=login_result.error_msg) if login_result.status == LoginHandlerResultStatus.ACCOUNT_BLOCKED: + headers = {} if login_result.retry_after: - response.headers["Retry-After"] = login_result.retry_after.isoformat() - raise HTTPException(status_code=429, detail=login_result.error_msg) + 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 raise HTTPException(status_code=500, detail=login_result.error_msg) diff --git a/app/context/auth/interface/rest/schemas/login_rest_schema.py b/app/context/auth/interface/rest/schemas/login_rest_schema.py index d54929b..26a2b70 100644 --- a/app/context/auth/interface/rest/schemas/login_rest_schema.py +++ b/app/context/auth/interface/rest/schemas/login_rest_schema.py @@ -1,5 +1,6 @@ from dataclasses import dataclass -from pydantic import BaseModel, EmailStr, ConfigDict + +from pydantic import BaseModel, ConfigDict, EmailStr class LoginRequest(BaseModel): @@ -11,4 +12,4 @@ class LoginRequest(BaseModel): @dataclass(frozen=True) class LoginResponse: - token: str + message: str diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index 02019a5..59a802e 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -14,7 +14,7 @@ f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", ) -echo_queries = getenv("APP_ENV", "prod") == "dev" +echo_queries = getenv("APP_ENV", "prod") == "debug" async_engine = create_async_engine( DATABASE_URL, diff --git a/tests/conftest.py b/tests/conftest.py index 70c43ed..dc55c1b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,9 +3,16 @@ import pytest -from app.context.auth.domain.contracts import SessionRepositoryContract +from app.context.auth.domain.contracts import ( + LoginServiceContract, + SessionRepositoryContract, +) +from app.context.auth.domain.dto import AuthUserDTO from app.context.auth.domain.dto.session_dto import SessionDTO -from app.context.auth.domain.value_objects import AuthUserID, SessionToken +from app.context.auth.domain.value_objects import AuthPassword, AuthUserID, SessionToken +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.dto import UserContextDTO +from app.context.user.application.queries import FindUserQuery class MockSessionRepository(SessionRepositoryContract): @@ -32,3 +39,37 @@ async def updateSession(self, session: SessionDTO) -> SessionDTO: def mock_session_repository(): """Fixture providing a mock session repository.""" return MockSessionRepository() + + +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) -> Optional[UserContextDTO]: + return await self.handle_mock(query) + + +@pytest.fixture +def mock_find_user_handler(): + """Fixture providing a mock find user handler.""" + return MockFindUserHandler() + + +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/unit/context/auth/application/test_login_handler.py b/tests/unit/context/auth/application/test_login_handler.py new file mode 100644 index 0000000..51ef16a --- /dev/null +++ b/tests/unit/context/auth/application/test_login_handler.py @@ -0,0 +1,304 @@ +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 UserContextDTO + + +@pytest.mark.asyncio +class TestLoginHandler: + """Unit tests for LoginHandler.""" + + async def test_handle_successful_login( + self, mock_find_user_handler, mock_login_service + ): + """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 = UserContextDTO( + 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) + 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 + ): + """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) + 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 + ): + """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 = UserContextDTO( + 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) + 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 + ): + """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 = UserContextDTO( + 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) + 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 + ): + """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 = UserContextDTO( + 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) + 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 + ): + """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 = UserContextDTO( + 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) + 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 + ): + """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 = UserContextDTO( + 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) + 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 + ): + """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 = UserContextDTO( + 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) + 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}" From 71bb69d192feb5b48369672c59bdee6937fe77d0 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 23 Dec 2025 12:32:45 +0100 Subject: [PATCH 14/58] Splitting test fixtures into separate folders --- .../contracts/get_session_handler_contract.py | 2 +- .../handlers/get_session_handler.py | 2 +- .../{query => queries}/__init__.py | 0 .../{query => queries}/get_session_query.py | 0 tests/conftest.py | 99 +++++-------------- tests/fixtures/__init__.py | 1 + tests/fixtures/auth/__init__.py | 11 +++ tests/fixtures/auth/repositories.py | 36 +++++++ tests/fixtures/auth/services.py | 27 +++++ tests/fixtures/user/__init__.py | 8 ++ tests/fixtures/user/handlers.py | 26 +++++ .../application/test_get_session_handler.py | 2 +- 12 files changed, 138 insertions(+), 76 deletions(-) rename app/context/auth/application/{query => queries}/__init__.py (100%) rename app/context/auth/application/{query => queries}/get_session_query.py (100%) create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/auth/__init__.py create mode 100644 tests/fixtures/auth/repositories.py create mode 100644 tests/fixtures/auth/services.py create mode 100644 tests/fixtures/user/__init__.py create mode 100644 tests/fixtures/user/handlers.py diff --git a/app/context/auth/application/contracts/get_session_handler_contract.py b/app/context/auth/application/contracts/get_session_handler_contract.py index 19d2e04..49b24e6 100644 --- a/app/context/auth/application/contracts/get_session_handler_contract.py +++ b/app/context/auth/application/contracts/get_session_handler_contract.py @@ -2,7 +2,7 @@ from typing import Optional from app.context.auth.application.dto.get_session_result_dto import GetSessionResultDTO -from app.context.auth.application.query import GetSessionQuery +from app.context.auth.application.queries import GetSessionQuery class GetSessionHandlerContract(ABC): diff --git a/app/context/auth/application/handlers/get_session_handler.py b/app/context/auth/application/handlers/get_session_handler.py index d68b460..db9db02 100644 --- a/app/context/auth/application/handlers/get_session_handler.py +++ b/app/context/auth/application/handlers/get_session_handler.py @@ -2,7 +2,7 @@ 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.query import GetSessionQuery +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 diff --git a/app/context/auth/application/query/__init__.py b/app/context/auth/application/queries/__init__.py similarity index 100% rename from app/context/auth/application/query/__init__.py rename to app/context/auth/application/queries/__init__.py diff --git a/app/context/auth/application/query/get_session_query.py b/app/context/auth/application/queries/get_session_query.py similarity index 100% rename from app/context/auth/application/query/get_session_query.py rename to app/context/auth/application/queries/get_session_query.py diff --git a/tests/conftest.py b/tests/conftest.py index dc55c1b..32161e3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,75 +1,28 @@ -from typing import Optional -from unittest.mock import AsyncMock - -import pytest - -from app.context.auth.domain.contracts import ( - LoginServiceContract, - SessionRepositoryContract, +"""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 auth context fixtures +from tests.fixtures.auth import ( + MockLoginService, + MockSessionRepository, + mock_login_service, + mock_session_repository, ) -from app.context.auth.domain.dto import AuthUserDTO -from app.context.auth.domain.dto.session_dto import SessionDTO -from app.context.auth.domain.value_objects import AuthPassword, AuthUserID, SessionToken -from app.context.user.application.contracts import FindUserHandlerContract -from app.context.user.application.dto import UserContextDTO -from app.context.user.application.queries import FindUserQuery - - -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: Optional[AuthUserID] = None, token: Optional[SessionToken] = None - ) -> Optional[SessionDTO]: - 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() - - -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) -> Optional[UserContextDTO]: - return await self.handle_mock(query) - - -@pytest.fixture -def mock_find_user_handler(): - """Fixture providing a mock find user handler.""" - return MockFindUserHandler() - - -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() +# Import user context fixtures +from tests.fixtures.user import MockFindUserHandler, mock_find_user_handler + +# Make fixtures available to pytest +__all__ = [ + # Auth fixtures + "MockSessionRepository", + "mock_session_repository", + "MockLoginService", + "mock_login_service", + # User fixtures + "MockFindUserHandler", + "mock_find_user_handler", +] 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..f571160 --- /dev/null +++ b/tests/fixtures/auth/repositories.py @@ -0,0 +1,36 @@ +"""Mock repositories for auth context testing.""" + +from typing import Optional +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: Optional[AuthUserID] = None, token: Optional[SessionToken] = None + ) -> Optional[SessionDTO]: + 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..b8cde1e --- /dev/null +++ b/tests/fixtures/auth/services.py @@ -0,0 +1,27 @@ +"""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/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..1f3514f --- /dev/null +++ b/tests/fixtures/user/handlers.py @@ -0,0 +1,26 @@ +"""Mock handlers for user context testing.""" + +from typing import Optional +from unittest.mock import AsyncMock + +import pytest + +from app.context.user.application.contracts import FindUserHandlerContract +from app.context.user.application.dto import UserContextDTO +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) -> Optional[UserContextDTO]: + 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/unit/context/auth/application/test_get_session_handler.py b/tests/unit/context/auth/application/test_get_session_handler.py index fd8e5c8..08ba759 100644 --- a/tests/unit/context/auth/application/test_get_session_handler.py +++ b/tests/unit/context/auth/application/test_get_session_handler.py @@ -4,7 +4,7 @@ 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.query.get_session_query import GetSessionQuery +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, From 09921ac57242c06501f478e25f8d992de3b2fcb2 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 23 Dec 2025 12:50:22 +0100 Subject: [PATCH 15/58] Adding login service tests (needs recheck) --- .../context/auth/domain/test_login_service.py | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 tests/unit/context/auth/domain/test_login_service.py diff --git a/tests/unit/context/auth/domain/test_login_service.py b/tests/unit/context/auth/domain/test_login_service.py new file mode 100644 index 0000000..9d467e0 --- /dev/null +++ b/tests/unit/context/auth/domain/test_login_service.py @@ -0,0 +1,503 @@ +"""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.asyncio +class TestLoginService: + """Unit tests for LoginService domain service.""" + + async def test_successful_login_no_existing_session(self, mock_session_repository): + """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) + + # 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 + ): + """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) + + # 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 + ): + """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) + + # 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): + """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) + + # 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 + ): + """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) + + # 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 + ): + """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) + + # 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): + """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) + + # 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 + ): + """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) + + # 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 + ): + """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) + + # 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): + """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) + + # 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): + """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) + + # 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 From ad41e25411e5b20dc09d44b1d74a3e32f9406f06 Mon Sep 17 00:00:00 2001 From: polivera Date: Thu, 25 Dec 2025 19:08:21 +0100 Subject: [PATCH 16/58] Adding integration tests --- .claude/CLAUDE.local.md | 132 +++++ .claude/rules/code-style.md | 330 +++++++++++++ .claude/rules/database.md | 338 +++++++++++++ .claude/rules/testing.md | 181 +++++++ .claude/settings.local.json | 8 + CLAUDE.md | 457 ++++++++++++++++++ .../commands/create_entry_command.py | 14 + .../schemas/create_account_schema.py | 8 + docker-compose.yml | 19 + justfile | 8 +- ...d756346442c3_create_user_accounts_table.py | 36 ++ .../f8333e5b2bac_create_user_table.py | 1 + pyproject.toml | 11 + tests/integration/__init__.py | 0 tests/integration/conftest.py | 32 ++ tests/integration/fixtures/__init__.py | 28 ++ tests/integration/fixtures/client.py | 32 ++ tests/integration/fixtures/database.py | 48 ++ tests/integration/fixtures/users.py | 72 +++ tests/integration/test_login_flow.py | 262 ++++++++++ .../application/test_get_session_handler.py | 1 + .../auth/application/test_login_handler.py | 1 + .../context/auth/domain/test_login_service.py | 1 + uv.lock | 39 ++ 24 files changed, 2058 insertions(+), 1 deletion(-) create mode 100644 .claude/CLAUDE.local.md create mode 100644 .claude/rules/code-style.md create mode 100644 .claude/rules/database.md create mode 100644 .claude/rules/testing.md create mode 100644 .claude/settings.local.json create mode 100644 CLAUDE.md create mode 100644 app/context/entry/application/commands/create_entry_command.py create mode 100644 app/context/user_account/interface/schemas/create_account_schema.py create mode 100644 migrations/versions/d756346442c3_create_user_accounts_table.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/fixtures/__init__.py create mode 100644 tests/integration/fixtures/client.py create mode 100644 tests/integration/fixtures/database.py create mode 100644 tests/integration/fixtures/users.py create mode 100644 tests/integration/test_login_flow.py 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..9313032 --- /dev/null +++ b/.claude/rules/code-style.md @@ -0,0 +1,330 @@ +--- +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) + ) +``` + +### 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 + +### Value Objects + +Raise `ValueError` for validation failures: + +```python +def __post_init__(self): + if len(self.value) < 8: + raise ValueError("Password must be at least 8 characters") +``` + +### Domain Services + +Create domain-specific exceptions: + +```python +class AuthenticationError(Exception): + pass + +class LoginService: + async def login(...) -> LoginResult: + if not user: + raise AuthenticationError("Invalid credentials") +``` + +### Controllers + +Convert to HTTP exceptions: + +```python +from fastapi import HTTPException + +@router.post("/login") +async def login(request: LoginRequest): + try: + result = await handler.handle(...) + except AuthenticationError: + raise HTTPException(status_code=401, detail="Invalid credentials") +``` + +## 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 +``` + +## 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) +``` + +## 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..176e578 --- /dev/null +++ b/.claude/rules/database.md @@ -0,0 +1,338 @@ +--- +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. + +### 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 + +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=datetime.utcnow) +``` + +### 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 +created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) + +# Relationships +addresses: Mapped[list["AddressModel"]] = relationship(back_populates="user") +``` + +### 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"` + +## 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/testing.md b/.claude/rules/testing.md new file mode 100644 index 0000000..4f2b58f --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,181 @@ +--- +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 +``` + +## 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 + +## Unit Test Patterns + +### 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 with coverage +pytest --cov=app --cov-report=html + +# Run specific test file +pytest tests/unit/context/auth/domain/test_login_service.py + +# Run tests matching pattern +pytest -k "login" +``` diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4b0dc47 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(cat:*)", + "Bash(PGPASSWORD=homecomppass psql:*)" + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9a7ed11 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,457 @@ +# 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}") +``` + +## 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/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..f0fd659 --- /dev/null +++ b/app/context/entry/application/commands/create_entry_command.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@dataclass(frozen=True) +class CreateEntryCommand: + expense_type: str + expense_date: datetime + account_id: int + category_id: int + household_id: Optional[int] + amount: float + description: str 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..2f1b3eb --- /dev/null +++ b/app/context/user_account/interface/schemas/create_account_schema.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, ConfigDict + + +class CreateAccountRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + currency: str + balance: float diff --git a/docker-compose.yml b/docker-compose.yml index a583cfa..a70ad66 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,5 +17,24 @@ services: timeout: 5s retries: 5 + 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 + volumes: postgres_data: + postgres_test_data: diff --git a/justfile b/justfile index 18bd2b9..0c4745c 100644 --- a/justfile +++ b/justfile @@ -5,7 +5,13 @@ migration-generate comment: alembic revision -m "{{comment}}" migrate: - alembic upgrade head + uv run alembic upgrade head + +migrate-test: + DB_PORT=5433 DB_NAME=homecomp_test uv run alembic upgrade head pgcli: pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:$DB_PORT/$DB_NAME + +pgcli-test: + pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:5433/homecomp_test 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..f01815c --- /dev/null +++ b/migrations/versions/d756346442c3_create_user_accounts_table.py @@ -0,0 +1,36 @@ +"""Create user accounts table + +Revision ID: d756346442c3 +Revises: b8067948709f +Create Date: 2025-12-25 18:44:45.911971 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d756346442c3" +down_revision: Union[str, Sequence[str], None] = "b8067948709f" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.UniqueConstraint("user_id", "name", name="uq_user_accounts_user_id_name"), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_table("user_accounts") diff --git a/migrations/versions/f8333e5b2bac_create_user_table.py b/migrations/versions/f8333e5b2bac_create_user_table.py index b9c157b..f774873 100644 --- a/migrations/versions/f8333e5b2bac_create_user_table.py +++ b/migrations/versions/f8333e5b2bac_create_user_table.py @@ -26,6 +26,7 @@ def upgrade() -> None: sa.Column("email", sa.String(100), unique=True, nullable=False), sa.Column("password", sa.String(150), nullable=False), sa.Column("username", sa.String(100), unique=True), + sa.Column("deleted_at", sa.DateTime, nullable=True), ) diff --git a/pyproject.toml b/pyproject.toml index 3f7d468..15693bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,17 @@ dependencies = [ [dependency-groups] dev = [ + "httpx>=0.28.1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", ] + +[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"] +python_classes = ["Test*"] +python_functions = ["test_*"] 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..abfb496 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,32 @@ +"""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 +from tests.integration.fixtures.database import ( + test_db_session, + test_engine, +) + +# Import client fixtures +from tests.integration.fixtures.client import test_client + +# 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..80fb0d0 --- /dev/null +++ b/tests/integration/fixtures/__init__.py @@ -0,0 +1,28 @@ +"""Integration test fixtures organized by category.""" + +# Import database fixtures +from tests.integration.fixtures.database import ( + test_db_session, + test_engine, +) + +# Import client fixtures +from tests.integration.fixtures.client import test_client + +# 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..6b46425 --- /dev/null +++ b/tests/integration/fixtures/client.py @@ -0,0 +1,32 @@ +"""HTTP client fixtures for integration testing. + +Provides fixtures for FastAPI test client with database dependency overrides. +""" + +from typing 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, None]: + """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..32c49fc --- /dev/null +++ b/tests/integration/fixtures/database.py @@ -0,0 +1,48 @@ +"""Database fixtures for integration testing. + +Provides fixtures for database engine and session management. +""" + +import os +from typing 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 URL - using a separate test database on port 5433 +TEST_DB_URL = os.getenv( + "TEST_DATABASE_URL", + "postgresql+asyncpg://uhomecomp:homecomppass@localhost:5433/homecomp_test", +) + + +@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, None]: + """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..aced3eb --- /dev/null +++ b/tests/integration/test_login_flow.py @@ -0,0 +1,262 @@ +"""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.shared.domain.value_objects import SharedPassword + from app.context.user.infrastructure.models import UserModel + + 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/context/auth/application/test_get_session_handler.py b/tests/unit/context/auth/application/test_get_session_handler.py index 08ba759..e93b5ad 100644 --- a/tests/unit/context/auth/application/test_get_session_handler.py +++ b/tests/unit/context/auth/application/test_get_session_handler.py @@ -14,6 +14,7 @@ from app.context.auth.domain.value_objects.blocked_time import BlockedTime +@pytest.mark.unit @pytest.mark.asyncio class TestGetSessionHandler: """Unit tests for GetSessionHandler.""" diff --git a/tests/unit/context/auth/application/test_login_handler.py b/tests/unit/context/auth/application/test_login_handler.py index 51ef16a..cf8a5e0 100644 --- a/tests/unit/context/auth/application/test_login_handler.py +++ b/tests/unit/context/auth/application/test_login_handler.py @@ -16,6 +16,7 @@ from app.context.user.application.dto import UserContextDTO +@pytest.mark.unit @pytest.mark.asyncio class TestLoginHandler: """Unit tests for LoginHandler.""" diff --git a/tests/unit/context/auth/domain/test_login_service.py b/tests/unit/context/auth/domain/test_login_service.py index 9d467e0..ac1936d 100644 --- a/tests/unit/context/auth/domain/test_login_service.py +++ b/tests/unit/context/auth/domain/test_login_service.py @@ -21,6 +21,7 @@ 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.""" diff --git a/uv.lock b/uv.lock index e11efc9..97b5717 100644 --- a/uv.lock +++ b/uv.lock @@ -125,6 +125,15 @@ wheels = [ { 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" @@ -286,6 +295,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, ] @@ -305,10 +315,39 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = ">=0.28.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, ] +[[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" From 7d369b6211851fcc4109b3bc376c94c8b5abbe3d Mon Sep 17 00:00:00 2001 From: polivera Date: Thu, 25 Dec 2025 21:07:12 +0100 Subject: [PATCH 17/58] Adding user account creation endpoint --- app/context/user_account/__init__.py | 0 .../user_account/application/__init__.py | 0 .../application/commands/__init__.py | 0 .../commands/create_account_command.py | 16 ++++ .../application/contracts/__init__.py | 0 .../create_account_handler_contract.py | 24 ++++++ .../user_account/application/dto/__init__.py | 0 .../application/dto/create_account_result.py | 11 +++ .../application/handlers/__init__.py | 0 .../handlers/create_account_handler.py | 27 +++++++ app/context/user_account/domain/__init__.py | 0 .../user_account/domain/contracts/__init__.py | 0 .../contracts/infrastructure/__init__.py | 0 .../user_account_repository_contract.py | 47 ++++++++++++ .../domain/contracts/services/__init__.py | 0 .../create_account_service_contract.py | 32 ++++++++ .../user_account/domain/dto/__init__.py | 0 .../domain/dto/user_account_dto.py | 19 +++++ .../user_account/domain/services/__init__.py | 0 .../domain/services/create_account_service.py | 45 ++++++++++++ .../domain/value_objects/__init__.py | 0 .../domain/value_objects/account_id.py | 20 +++++ .../domain/value_objects/account_name.py | 22 ++++++ .../domain/value_objects/balance.py | 30 ++++++++ .../domain/value_objects/currency.py | 25 +++++++ .../user_account/infrastructure/__init__.py | 0 .../infrastructure/dependencies.py | 41 +++++++++++ .../infrastructure/mappers/__init__.py | 0 .../mappers/user_account_mapper.py | 35 +++++++++ .../infrastructure/models/__init__.py | 0 .../models/user_account_model.py | 18 +++++ .../infrastructure/repositories/__init__.py | 0 .../repositories/user_account_repository.py | 73 +++++++++++++++++++ .../user_account/interface/__init__.py | 0 .../user_account/interface/rest/__init__.py | 1 + .../interface/rest/controllers/__init__.py | 3 + .../controllers/create_account_controller.py | 60 +++++++++++++++ .../user_account/interface/rest/routes.py | 8 ++ .../interface/schemas/__init__.py | 0 .../schemas/create_account_response.py | 10 +++ .../schemas/create_account_schema.py | 15 +++- app/main.py | 2 + justfile | 5 +- 43 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 app/context/user_account/__init__.py create mode 100644 app/context/user_account/application/__init__.py create mode 100644 app/context/user_account/application/commands/__init__.py create mode 100644 app/context/user_account/application/commands/create_account_command.py create mode 100644 app/context/user_account/application/contracts/__init__.py create mode 100644 app/context/user_account/application/contracts/create_account_handler_contract.py create mode 100644 app/context/user_account/application/dto/__init__.py create mode 100644 app/context/user_account/application/dto/create_account_result.py create mode 100644 app/context/user_account/application/handlers/__init__.py create mode 100644 app/context/user_account/application/handlers/create_account_handler.py create mode 100644 app/context/user_account/domain/__init__.py create mode 100644 app/context/user_account/domain/contracts/__init__.py create mode 100644 app/context/user_account/domain/contracts/infrastructure/__init__.py create mode 100644 app/context/user_account/domain/contracts/infrastructure/user_account_repository_contract.py create mode 100644 app/context/user_account/domain/contracts/services/__init__.py create mode 100644 app/context/user_account/domain/contracts/services/create_account_service_contract.py create mode 100644 app/context/user_account/domain/dto/__init__.py create mode 100644 app/context/user_account/domain/dto/user_account_dto.py create mode 100644 app/context/user_account/domain/services/__init__.py create mode 100644 app/context/user_account/domain/services/create_account_service.py create mode 100644 app/context/user_account/domain/value_objects/__init__.py create mode 100644 app/context/user_account/domain/value_objects/account_id.py create mode 100644 app/context/user_account/domain/value_objects/account_name.py create mode 100644 app/context/user_account/domain/value_objects/balance.py create mode 100644 app/context/user_account/domain/value_objects/currency.py create mode 100644 app/context/user_account/infrastructure/__init__.py create mode 100644 app/context/user_account/infrastructure/dependencies.py create mode 100644 app/context/user_account/infrastructure/mappers/__init__.py create mode 100644 app/context/user_account/infrastructure/mappers/user_account_mapper.py create mode 100644 app/context/user_account/infrastructure/models/__init__.py create mode 100644 app/context/user_account/infrastructure/models/user_account_model.py create mode 100644 app/context/user_account/infrastructure/repositories/__init__.py create mode 100644 app/context/user_account/infrastructure/repositories/user_account_repository.py create mode 100644 app/context/user_account/interface/__init__.py create mode 100644 app/context/user_account/interface/rest/__init__.py create mode 100644 app/context/user_account/interface/rest/controllers/__init__.py create mode 100644 app/context/user_account/interface/rest/controllers/create_account_controller.py create mode 100644 app/context/user_account/interface/rest/routes.py create mode 100644 app/context/user_account/interface/schemas/__init__.py create mode 100644 app/context/user_account/interface/schemas/create_account_response.py diff --git a/app/context/user_account/__init__.py b/app/context/user_account/__init__.py new file mode 100644 index 0000000..e69de29 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..e69de29 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..213b025 --- /dev/null +++ b/app/context/user_account/application/commands/create_account_command.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency + + +@dataclass(frozen=True) +class CreateAccountCommand: + """Command to create a new user account""" + + user_id: UserID + name: AccountName + currency: Currency + balance: Balance 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..e69de29 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/dto/__init__.py b/app/context/user_account/application/dto/__init__.py new file mode 100644 index 0000000..e69de29 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..439804d --- /dev/null +++ b/app/context/user_account/application/dto/create_account_result.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + +from app.context.user_account.domain.value_objects.account_id import AccountID + + +@dataclass(frozen=True) +class CreateAccountResult: + """Result of account creation operation""" + + account_id: AccountID + message: str = "Account created successfully" 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..e69de29 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..8011520 --- /dev/null +++ b/app/context/user_account/application/handlers/create_account_handler.py @@ -0,0 +1,27 @@ +from app.context.user_account.application.commands.create_account_command import CreateAccountCommand +from app.context.user_account.application.contracts.create_account_handler_contract import ( + CreateAccountHandlerContract, +) +from app.context.user_account.application.dto.create_account_result import CreateAccountResult +from app.context.user_account.domain.contracts.services.create_account_service_contract import ( + CreateAccountServiceContract, +) + + +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""" + + account_id = await self._service.create_account( + user_id=command.user_id, + name=command.name, + currency=command.currency, + balance=command.balance, + ) + + return CreateAccountResult(account_id=account_id) 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..e69de29 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..8b87006 --- /dev/null +++ b/app/context/user_account/domain/contracts/infrastructure/user_account_repository_contract.py @@ -0,0 +1,47 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects.account_id import AccountID +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: + Exception if account with same user_id and name already exists + """ + pass + + @abstractmethod + async def find_account( + self, + account_id: Optional[AccountID] = None, + user_id: Optional[UserID] = None, + name: Optional[AccountName] = None, + ) -> Optional[UserAccountDTO]: + """ + 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 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..e69de29 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..4b0c8ff --- /dev/null +++ b/app/context/user_account/domain/contracts/services/create_account_service_contract.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency + + +class CreateAccountServiceContract(ABC): + """Contract for create account service""" + + @abstractmethod + async def create_account( + self, user_id: UserID, name: AccountName, currency: Currency, balance: Balance + ) -> AccountID: + """ + 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: + ValueError if account with same name already exists for user + """ + 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..e69de29 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..e7388f4 --- /dev/null +++ b/app/context/user_account/domain/dto/user_account_dto.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from typing import Optional + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency + + +@dataclass(frozen=True) +class UserAccountDTO: + """Domain DTO for user account entity""" + + user_id: UserID + name: AccountName + currency: Currency + balance: Balance + account_id: Optional[AccountID] = None 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..e69de29 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..851a900 --- /dev/null +++ b/app/context/user_account/domain/services/create_account_service.py @@ -0,0 +1,45 @@ +from app.context.user.domain.value_objects.user_id import UserID +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.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency + + +class CreateAccountService(CreateAccountServiceContract): + """Service for creating user accounts""" + + def __init__(self, account_repository: UserAccountRepositoryContract): + self._account_repository = account_repository + + async def create_account( + self, user_id: UserID, name: AccountName, currency: Currency, balance: Balance + ) -> AccountID: + """Create a new user account with validation""" + + # Check if account with same name already exists for this user + existing_account = await self._account_repository.find_account( + user_id=user_id, name=name + ) + + if existing_account: + raise ValueError( + f"Account with name '{name.value}' already exists for this user" + ) + + # 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 + return await self._account_repository.save_account(account_dto) 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..e69de29 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..872fd8e --- /dev/null +++ b/app/context/user_account/domain/value_objects/account_id.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class AccountID: + """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) -> "AccountID": + """Create AccountID from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) 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/balance.py b/app/context/user_account/domain/value_objects/balance.py new file mode 100644 index 0000000..3688a72 --- /dev/null +++ b/app/context/user_account/domain/value_objects/balance.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field +from decimal import Decimal + + +@dataclass(frozen=True) +class Balance: + """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) -> "Balance": + """Create Balance from float value""" + return cls(Decimal(str(value))) + + @classmethod + def from_trusted_source(cls, value: Decimal) -> "Balance": + """Create Balance from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) diff --git a/app/context/user_account/domain/value_objects/currency.py b/app/context/user_account/domain/value_objects/currency.py new file mode 100644 index 0000000..4cbb9b1 --- /dev/null +++ b/app/context/user_account/domain/value_objects/currency.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class Currency: + """Value object for currency code (ISO 4217)""" + + 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) -> "Currency": + """Create Currency from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) 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..7c28857 --- /dev/null +++ b/app/context/user_account/infrastructure/dependencies.py @@ -0,0 +1,41 @@ +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.handlers.create_account_handler import ( + CreateAccountHandler, +) +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.services.create_account_service import CreateAccountService +from app.context.user_account.infrastructure.repositories.user_account_repository import ( + UserAccountRepository, +) +from app.shared.infrastructure.database import get_db + + +def get_user_account_repository( + db: AsyncSession = Depends(get_db), +) -> UserAccountRepositoryContract: + """UserAccountRepository dependency injection""" + return UserAccountRepository(db) + + +def get_create_account_service( + account_repository: UserAccountRepositoryContract = Depends(get_user_account_repository), +) -> CreateAccountServiceContract: + """CreateAccountService dependency injection""" + return CreateAccountService(account_repository) + + +def get_create_account_handler( + service: CreateAccountServiceContract = Depends(get_create_account_service), +) -> CreateAccountHandlerContract: + """CreateAccountHandler dependency injection""" + return CreateAccountHandler(service) 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..e69de29 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..a9551b3 --- /dev/null +++ b/app/context/user_account/infrastructure/mappers/user_account_mapper.py @@ -0,0 +1,35 @@ +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency +from app.context.user_account.infrastructure.models.user_account_model import ( + UserAccountModel, +) + + +class UserAccountMapper: + """Mapper for converting between UserAccountModel and UserAccountDTO""" + + @staticmethod + def toDTO(model: UserAccountModel) -> UserAccountDTO: + """Convert database model to domain DTO""" + return UserAccountDTO( + account_id=AccountID.from_trusted_source(model.id), + user_id=UserID(model.user_id), + name=AccountName.from_trusted_source(model.name), + currency=Currency.from_trusted_source(model.currency), + balance=Balance.from_trusted_source(model.balance), + ) + + @staticmethod + def toModel(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, + ) 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..e69de29 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..2159e9b --- /dev/null +++ b/app/context/user_account/infrastructure/models/user_account_model.py @@ -0,0 +1,18 @@ +from decimal import Decimal + +from sqlalchemy import DECIMAL, 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) 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..e69de29 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..b2b59e5 --- /dev/null +++ b/app/context/user_account/infrastructure/repositories/user_account_repository.py @@ -0,0 +1,73 @@ +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.infrastructure.mappers.user_account_mapper import ( + UserAccountMapper, +) +from app.context.user_account.infrastructure.models.user_account_model 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""" + # Convert DTO to model (without ID for new records) + model = UserAccountModel( + user_id=account.user_id.value, + name=account.name.value, + currency=account.currency.value, + balance=account.balance.value, + ) + + self._db.add(model) + + try: + await self._db.commit() + await self._db.refresh(model) + except IntegrityError as e: + await self._db.rollback() + raise ValueError( + f"Account with name '{account.name.value}' already exists for this user" + ) from e + + return UserAccountMapper.toDTO(model) + + async def find_account( + self, + account_id: Optional[AccountID] = None, + user_id: Optional[UserID] = None, + name: Optional[AccountName] = None, + ) -> Optional[UserAccountDTO]: + """Find an account by ID or by user_id and name""" + stmt = select(UserAccountModel) + + 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.toDTO(model) if model else None 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..b886e76 --- /dev/null +++ b/app/context/user_account/interface/rest/__init__.py @@ -0,0 +1 @@ +from .routes import 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..2f80e87 --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/create_account_controller.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.application.commands.create_account_command import ( + CreateAccountCommand, +) +from app.context.user_account.application.contracts.create_account_handler_contract import ( + CreateAccountHandlerContract, +) +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency +from app.context.user_account.infrastructure.dependencies import ( + get_create_account_handler, +) +from app.context.user_account.interface.schemas.create_account_response import ( + CreateAccountResponse, +) +from app.context.user_account.interface.schemas.create_account_schema import ( + CreateAccountRequest, +) + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.post("", response_model=CreateAccountResponse, status_code=201) +async def create_account( + request: CreateAccountRequest, + handler: CreateAccountHandlerContract = Depends(get_create_account_handler), +): + """Create a new user account""" + + try: + # Convert request to command with value objects + # TODO: user_id from cookie header + command = CreateAccountCommand( + user_id=UserID(1), + name=AccountName(request.name), + currency=Currency(request.currency), + balance=Balance.from_float(request.balance), + ) + + # Handle the command + result = await handler.handle(command) + + # Return response + return CreateAccountResponse( + account_id=result.account_id.value, + account_name=request.name, + message=result.message, + ) + + except ValueError as e: + # Business logic errors (duplicate account, validation errors, etc.) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Unexpected errors + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) 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..42937f0 --- /dev/null +++ b/app/context/user_account/interface/rest/routes.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.context.user_account.interface.rest.controllers import create_account_router + +user_account_routes = APIRouter(prefix="/api/user-accounts", tags=["user-accounts"]) + +# Include all controller routers +user_account_routes.include_router(create_account_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..e69de29 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..d3d1535 --- /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 + account_name: str + message: str diff --git a/app/context/user_account/interface/schemas/create_account_schema.py b/app/context/user_account/interface/schemas/create_account_schema.py index 2f1b3eb..7f70fe7 100644 --- a/app/context/user_account/interface/schemas/create_account_schema.py +++ b/app/context/user_account/interface/schemas/create_account_schema.py @@ -1,8 +1,17 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, field_validator class CreateAccountRequest(BaseModel): model_config = ConfigDict(frozen=True) - currency: str - balance: float + 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/main.py b/app/main.py index 6820be5..73dc7f4 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI from app.context.auth.interface.rest import auth_routes +from app.context.user_account.interface.rest import user_account_routes app = FastAPI( title="Homecomp API", @@ -12,6 +13,7 @@ ) app.include_router(auth_routes) +app.include_router(user_account_routes) @app.get("/") diff --git a/justfile b/justfile index 0c4745c..9194619 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,10 @@ migrate: uv run alembic upgrade head migrate-test: - DB_PORT=5433 DB_NAME=homecomp_test uv run alembic upgrade head + DB_PORT=5434 DB_NAME=homecomp_test uv run alembic upgrade head + +seed: + uv run python -m scripts.seed pgcli: pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:$DB_PORT/$DB_NAME From 3b668230eb2c8a33820f5fe96829e2daa89c47b4 Mon Sep 17 00:00:00 2001 From: polivera Date: Fri, 26 Dec 2025 13:00:46 +0100 Subject: [PATCH 18/58] User accounts functionality --- .../application/commands/__init__.py | 5 + .../commands/delete_account_command.py | 10 ++ .../commands/update_account_command.py | 16 +++ .../application/contracts/__init__.py | 13 +++ .../delete_account_handler_contract.py | 11 +++ .../find_account_by_id_handler_contract.py | 17 ++++ .../find_accounts_by_user_handler_contract.py | 14 +++ .../update_account_handler_contract.py | 14 +++ .../user_account/application/dto/__init__.py | 5 + .../application/dto/account_response_dto.py | 25 +++++ .../application/dto/create_account_result.py | 9 +- .../application/dto/update_account_result.py | 9 ++ .../application/handlers/__init__.py | 13 +++ .../handlers/create_account_handler.py | 15 ++- .../handlers/delete_account_handler.py | 20 ++++ .../handlers/find_account_by_id_handler.py | 29 ++++++ .../handlers/find_accounts_by_user_handler.py | 21 ++++ .../handlers/update_account_handler.py | 30 ++++++ .../application/queries/__init__.py | 4 + .../queries/find_account_by_id_query.py | 10 ++ .../queries/find_accounts_by_user_query.py | 8 ++ .../user_account_repository_contract.py | 43 ++++++++ .../domain/contracts/services/__init__.py | 4 + .../create_account_service_contract.py | 4 +- .../update_account_service_contract.py | 21 ++++ .../user_account/domain/services/__init__.py | 4 + .../domain/services/create_account_service.py | 4 +- .../domain/services/update_account_service.py | 48 +++++++++ .../infrastructure/dependencies.py | 99 +++++++++++++++++-- .../models/user_account_model.py | 5 +- .../repositories/user_account_repository.py | 63 +++++++++++- .../controllers/create_account_controller.py | 8 +- .../controllers/delete_account_controller.py | 32 ++++++ .../controllers/find_account_controller.py | 66 +++++++++++++ .../controllers/update_account_controller.py | 55 +++++++++++ .../user_account/interface/rest/routes.py | 18 +++- .../interface/schemas/__init__.py | 13 +++ .../interface/schemas/account_response.py | 10 ++ .../schemas/create_account_response.py | 7 +- .../schemas/update_account_response.py | 7 ++ .../schemas/update_account_schema.py | 14 +++ ...d756346442c3_create_user_accounts_table.py | 3 + 42 files changed, 797 insertions(+), 29 deletions(-) create mode 100644 app/context/user_account/application/commands/delete_account_command.py create mode 100644 app/context/user_account/application/commands/update_account_command.py create mode 100644 app/context/user_account/application/contracts/delete_account_handler_contract.py create mode 100644 app/context/user_account/application/contracts/find_account_by_id_handler_contract.py create mode 100644 app/context/user_account/application/contracts/find_accounts_by_user_handler_contract.py create mode 100644 app/context/user_account/application/contracts/update_account_handler_contract.py create mode 100644 app/context/user_account/application/dto/account_response_dto.py create mode 100644 app/context/user_account/application/dto/update_account_result.py create mode 100644 app/context/user_account/application/handlers/delete_account_handler.py create mode 100644 app/context/user_account/application/handlers/find_account_by_id_handler.py create mode 100644 app/context/user_account/application/handlers/find_accounts_by_user_handler.py create mode 100644 app/context/user_account/application/handlers/update_account_handler.py create mode 100644 app/context/user_account/application/queries/__init__.py create mode 100644 app/context/user_account/application/queries/find_account_by_id_query.py create mode 100644 app/context/user_account/application/queries/find_accounts_by_user_query.py create mode 100644 app/context/user_account/domain/contracts/services/update_account_service_contract.py create mode 100644 app/context/user_account/domain/services/update_account_service.py create mode 100644 app/context/user_account/interface/rest/controllers/delete_account_controller.py create mode 100644 app/context/user_account/interface/rest/controllers/find_account_controller.py create mode 100644 app/context/user_account/interface/rest/controllers/update_account_controller.py create mode 100644 app/context/user_account/interface/schemas/account_response.py create mode 100644 app/context/user_account/interface/schemas/update_account_response.py create mode 100644 app/context/user_account/interface/schemas/update_account_schema.py diff --git a/app/context/user_account/application/commands/__init__.py b/app/context/user_account/application/commands/__init__.py index e69de29..d55f843 100644 --- a/app/context/user_account/application/commands/__init__.py +++ 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/delete_account_command.py b/app/context/user_account/application/commands/delete_account_command.py new file mode 100644 index 0000000..285e667 --- /dev/null +++ b/app/context/user_account/application/commands/delete_account_command.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.value_objects.account_id import AccountID + + +@dataclass(frozen=True) +class DeleteAccountCommand: + account_id: AccountID + user_id: UserID 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..d20e66c --- /dev/null +++ b/app/context/user_account/application/commands/update_account_command.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency + + +@dataclass(frozen=True) +class UpdateAccountCommand: + account_id: AccountID + user_id: UserID + name: AccountName + currency: Currency + balance: Balance diff --git a/app/context/user_account/application/contracts/__init__.py b/app/context/user_account/application/contracts/__init__.py index e69de29..d32eb37 100644 --- a/app/context/user_account/application/contracts/__init__.py +++ 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/delete_account_handler_contract.py b/app/context/user_account/application/contracts/delete_account_handler_contract.py new file mode 100644 index 0000000..5bf2722 --- /dev/null +++ b/app/context/user_account/application/contracts/delete_account_handler_contract.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + +from app.context.user_account.application.commands.delete_account_command import ( + DeleteAccountCommand, +) + + +class DeleteAccountHandlerContract(ABC): + @abstractmethod + async def handle(self, command: DeleteAccountCommand) -> bool: + 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..bcc5036 --- /dev/null +++ b/app/context/user_account/application/contracts/find_account_by_id_handler_contract.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from app.context.user_account.application.dto.account_response_dto import ( + AccountResponseDTO, +) +from app.context.user_account.application.queries.find_account_by_id_query import ( + FindAccountByIdQuery, +) + + +class FindAccountByIdHandlerContract(ABC): + @abstractmethod + async def handle( + self, query: FindAccountByIdQuery + ) -> Optional[AccountResponseDTO]: + 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..8e26f79 --- /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.account_response_dto import ( + AccountResponseDTO, +) +from app.context.user_account.application.queries.find_accounts_by_user_query import ( + FindAccountsByUserQuery, +) + + +class FindAccountsByUserHandlerContract(ABC): + @abstractmethod + async def handle(self, query: FindAccountsByUserQuery) -> list[AccountResponseDTO]: + 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 index e69de29..dbc4cfd 100644 --- a/app/context/user_account/application/dto/__init__.py +++ b/app/context/user_account/application/dto/__init__.py @@ -0,0 +1,5 @@ +from .account_response_dto import AccountResponseDTO +from .create_account_result import CreateAccountResult +from .update_account_result import UpdateAccountResult + +__all__ = ["CreateAccountResult", "AccountResponseDTO", "UpdateAccountResult"] 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..f4444de --- /dev/null +++ b/app/context/user_account/application/dto/account_response_dto.py @@ -0,0 +1,25 @@ +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( + 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 index 439804d..43e31f1 100644 --- a/app/context/user_account/application/dto/create_account_result.py +++ b/app/context/user_account/application/dto/create_account_result.py @@ -1,11 +1,12 @@ from dataclasses import dataclass - -from app.context.user_account.domain.value_objects.account_id import AccountID +from typing import Optional @dataclass(frozen=True) class CreateAccountResult: """Result of account creation operation""" - account_id: AccountID - message: str = "Account created successfully" + account_id: Optional[int] = None + account_name: Optional[str] = None + account_balance: Optional[float] = None + error: Optional[str] = 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..78692f5 --- /dev/null +++ b/app/context/user_account/application/dto/update_account_result.py @@ -0,0 +1,9 @@ +from dataclasses import dataclass + +from app.context.user_account.domain.value_objects.account_id import AccountID + + +@dataclass(frozen=True) +class UpdateAccountResult: + account_id: AccountID + message: str diff --git a/app/context/user_account/application/handlers/__init__.py b/app/context/user_account/application/handlers/__init__.py index e69de29..4549876 100644 --- a/app/context/user_account/application/handlers/__init__.py +++ 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 index 8011520..0fb3636 100644 --- a/app/context/user_account/application/handlers/create_account_handler.py +++ b/app/context/user_account/application/handlers/create_account_handler.py @@ -1,8 +1,12 @@ -from app.context.user_account.application.commands.create_account_command import CreateAccountCommand +from app.context.user_account.application.commands.create_account_command import ( + CreateAccountCommand, +) from app.context.user_account.application.contracts.create_account_handler_contract import ( CreateAccountHandlerContract, ) -from app.context.user_account.application.dto.create_account_result import CreateAccountResult +from app.context.user_account.application.dto.create_account_result import ( + CreateAccountResult, +) from app.context.user_account.domain.contracts.services.create_account_service_contract import ( CreateAccountServiceContract, ) @@ -17,11 +21,14 @@ def __init__(self, service: CreateAccountServiceContract): async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: """Execute the create account command""" - account_id = await self._service.create_account( + account_dto = await self._service.create_account( user_id=command.user_id, name=command.name, currency=command.currency, balance=command.balance, ) - return CreateAccountResult(account_id=account_id) + if account_dto.account_id is None: + return CreateAccountResult(error="Error creating account") + + return CreateAccountResult(account_id=account_dto.account_id.value) 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..2d75c63 --- /dev/null +++ b/app/context/user_account/application/handlers/delete_account_handler.py @@ -0,0 +1,20 @@ +from app.context.user_account.application.commands.delete_account_command import ( + DeleteAccountCommand, +) +from app.context.user_account.application.contracts.delete_account_handler_contract import ( + DeleteAccountHandlerContract, +) +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) + + +class DeleteAccountHandler(DeleteAccountHandlerContract): + def __init__(self, repository: UserAccountRepositoryContract): + self._repository = repository + + async def handle(self, command: DeleteAccountCommand) -> bool: + # Call repository directly - no complex business logic needed + return await self._repository.delete_account( + account_id=command.account_id, user_id=command.user_id + ) 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..7d67544 --- /dev/null +++ b/app/context/user_account/application/handlers/find_account_by_id_handler.py @@ -0,0 +1,29 @@ +from typing import Optional + +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.queries.find_account_by_id_query import ( + FindAccountByIdQuery, +) +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) + + +class FindAccountByIdHandler(FindAccountByIdHandlerContract): + def __init__(self, repository: UserAccountRepositoryContract): + self._repository = repository + + async def handle(self, query: FindAccountByIdQuery) -> Optional[AccountResponseDTO]: + # Call repository directly (CQRS - queries bypass domain) + account = await self._repository.find_account(account_id=query.account_id) + + # Authorization: verify user owns the account + if account and account.user_id.value != query.user_id.value: + return None # Return 404, not 403 + + return AccountResponseDTO.from_domain_dto(account) if account else None 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..34b3fc1 --- /dev/null +++ b/app/context/user_account/application/handlers/find_accounts_by_user_handler.py @@ -0,0 +1,21 @@ +from app.context.user_account.application.contracts.find_accounts_by_user_handler_contract import ( + FindAccountsByUserHandlerContract, +) +from app.context.user_account.application.dto.account_response_dto import ( + AccountResponseDTO, +) +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, +) + + +class FindAccountsByUserHandler(FindAccountsByUserHandlerContract): + def __init__(self, repository: UserAccountRepositoryContract): + self._repository = repository + + async def handle(self, query: FindAccountsByUserQuery) -> list[AccountResponseDTO]: + accounts = await self._repository.find_accounts_by_user(user_id=query.user_id) + return [AccountResponseDTO.from_domain_dto(acc) for acc in 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..96bf197 --- /dev/null +++ b/app/context/user_account/application/handlers/update_account_handler.py @@ -0,0 +1,30 @@ +from app.context.user_account.application.commands.update_account_command import ( + UpdateAccountCommand, +) +from app.context.user_account.application.contracts.update_account_handler_contract import ( + UpdateAccountHandlerContract, +) +from app.context.user_account.application.dto.update_account_result import ( + UpdateAccountResult, +) +from app.context.user_account.domain.contracts.services.update_account_service_contract import ( + UpdateAccountServiceContract, +) + + +class UpdateAccountHandler(UpdateAccountHandlerContract): + def __init__(self, service: UpdateAccountServiceContract): + self._service = service + + async def handle(self, command: UpdateAccountCommand) -> UpdateAccountResult: + updated = await self._service.update_account( + account_id=command.account_id, + user_id=command.user_id, + name=command.name, + currency=command.currency, + balance=command.balance, + ) + + return UpdateAccountResult( + account_id=updated.account_id, message="Account updated successfully" + ) 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..d6ac14f --- /dev/null +++ b/app/context/user_account/application/queries/find_account_by_id_query.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.value_objects.account_id import AccountID + + +@dataclass(frozen=True) +class FindAccountByIdQuery: + account_id: AccountID + user_id: UserID 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..30b9c8f --- /dev/null +++ b/app/context/user_account/application/queries/find_accounts_by_user_query.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects.user_id import UserID + + +@dataclass(frozen=True) +class FindAccountsByUserQuery: + user_id: UserID 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 index 8b87006..47061c9 100644 --- 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 @@ -45,3 +45,46 @@ async def find_account( UserAccountDTO if found, None otherwise """ pass + + @abstractmethod + async def find_accounts_by_user(self, user_id: UserID) -> list[UserAccountDTO]: + """ + Find all non-deleted accounts for a user + + Args: + user_id: User ID to search for + + Returns: + List of UserAccountDTO objects + """ + 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: AccountID, user_id: UserID) -> 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 index e69de29..f69f536 100644 --- a/app/context/user_account/domain/contracts/services/__init__.py +++ 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 index 4b0c8ff..4e3f170 100644 --- 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 @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO from app.context.user_account.domain.value_objects.account_name import AccountName from app.context.user_account.domain.value_objects.balance import Balance from app.context.user_account.domain.value_objects.currency import Currency @@ -13,7 +13,7 @@ class CreateAccountServiceContract(ABC): @abstractmethod async def create_account( self, user_id: UserID, name: AccountName, currency: Currency, balance: Balance - ) -> AccountID: + ) -> UserAccountDTO: """ Create a new user account 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..53a0e2c --- /dev/null +++ b/app/context/user_account/domain/contracts/services/update_account_service_contract.py @@ -0,0 +1,21 @@ +from abc import ABC, abstractmethod + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency + + +class UpdateAccountServiceContract(ABC): + @abstractmethod + async def update_account( + self, + account_id: AccountID, + user_id: UserID, + name: AccountName, + currency: Currency, + balance: Balance, + ) -> UserAccountDTO: + pass diff --git a/app/context/user_account/domain/services/__init__.py b/app/context/user_account/domain/services/__init__.py index e69de29..646e6d6 100644 --- a/app/context/user_account/domain/services/__init__.py +++ 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 index 851a900..7be2d6b 100644 --- a/app/context/user_account/domain/services/create_account_service.py +++ b/app/context/user_account/domain/services/create_account_service.py @@ -6,7 +6,6 @@ CreateAccountServiceContract, ) from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO -from app.context.user_account.domain.value_objects.account_id import AccountID from app.context.user_account.domain.value_objects.account_name import AccountName from app.context.user_account.domain.value_objects.balance import Balance from app.context.user_account.domain.value_objects.currency import Currency @@ -20,10 +19,11 @@ def __init__(self, account_repository: UserAccountRepositoryContract): async def create_account( self, user_id: UserID, name: AccountName, currency: Currency, balance: Balance - ) -> AccountID: + ) -> UserAccountDTO: """Create a new user account with validation""" # Check if account with same name already exists for this user + # TODO: Why should I select first to create the account or not existing_account = await self._account_repository.find_account( user_id=user_id, name=name ) 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..1da0b7a --- /dev/null +++ b/app/context/user_account/domain/services/update_account_service.py @@ -0,0 +1,48 @@ +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( + UserAccountRepositoryContract, +) +from app.context.user_account.domain.contracts.services.update_account_service_contract import ( + UpdateAccountServiceContract, +) +from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency + + +class UpdateAccountService(UpdateAccountServiceContract): + def __init__(self, repository: UserAccountRepositoryContract): + self._repository = repository + + async def update_account( + self, + account_id: AccountID, + user_id: UserID, + name: AccountName, + currency: Currency, + balance: Balance, + ) -> UserAccountDTO: + # 1. Check account exists and user owns it + existing = await self._repository.find_account(account_id=account_id) + if not existing: + raise ValueError("Account not found") + if existing.user_id.value != user_id.value: + raise ValueError("Account not found") # Don't reveal it exists + + # 2. If name changed, check for duplicates + if existing.name.value != name.value: + duplicate = await self._repository.find_account(user_id=user_id, name=name) + if duplicate and duplicate.account_id.value != account_id.value: + raise ValueError(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, + ) + return await self._repository.update_account(updated_dto) diff --git a/app/context/user_account/infrastructure/dependencies.py b/app/context/user_account/infrastructure/dependencies.py index 7c28857..e00fc26 100644 --- a/app/context/user_account/infrastructure/dependencies.py +++ b/app/context/user_account/infrastructure/dependencies.py @@ -4,8 +4,17 @@ from app.context.user_account.application.contracts.create_account_handler_contract import ( CreateAccountHandlerContract, ) -from app.context.user_account.application.handlers.create_account_handler import ( - CreateAccountHandler, +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, @@ -13,9 +22,8 @@ from app.context.user_account.domain.contracts.services.create_account_service_contract import ( CreateAccountServiceContract, ) -from app.context.user_account.domain.services.create_account_service import CreateAccountService -from app.context.user_account.infrastructure.repositories.user_account_repository import ( - UserAccountRepository, +from app.context.user_account.domain.contracts.services.update_account_service_contract import ( + UpdateAccountServiceContract, ) from app.shared.infrastructure.database import get_db @@ -24,13 +32,28 @@ def get_user_account_repository( db: 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: UserAccountRepositoryContract = Depends(get_user_account_repository), + account_repository: UserAccountRepositoryContract = Depends( + get_user_account_repository + ), ) -> CreateAccountServiceContract: """CreateAccountService dependency injection""" + from app.context.user_account.domain.services.create_account_service import ( + CreateAccountService, + ) + return CreateAccountService(account_repository) @@ -38,4 +61,68 @@ def get_create_account_handler( service: CreateAccountServiceContract = Depends(get_create_account_service), ) -> CreateAccountHandlerContract: """CreateAccountHandler dependency injection""" + from app.context.user_account.application.handlers.create_account_handler import ( + CreateAccountHandler, + ) + return CreateAccountHandler(service) + + +def get_update_account_service( + repository: UserAccountRepositoryContract = Depends(get_user_account_repository), +) -> UpdateAccountServiceContract: + """UpdateAccountService dependency injection""" + from app.context.user_account.domain.services.update_account_service import ( + UpdateAccountService, + ) + + return UpdateAccountService(repository) + + +def get_update_account_handler( + service: UpdateAccountServiceContract = Depends(get_update_account_service), +) -> UpdateAccountHandlerContract: + """UpdateAccountHandler dependency injection""" + from app.context.user_account.application.handlers.update_account_handler import ( + UpdateAccountHandler, + ) + + return UpdateAccountHandler(service) + + +def get_delete_account_handler( + repository: UserAccountRepositoryContract = Depends(get_user_account_repository), +) -> DeleteAccountHandlerContract: + """DeleteAccountHandler dependency injection""" + from app.context.user_account.application.handlers.delete_account_handler import ( + DeleteAccountHandler, + ) + + return DeleteAccountHandler(repository) + + +# ───────────────────────────────────────────────────────────────── +# QUERY HANDLERS (Read operations) +# ───────────────────────────────────────────────────────────────── + + +def get_find_account_by_id_handler( + repository: UserAccountRepositoryContract = Depends(get_user_account_repository), +) -> FindAccountByIdHandlerContract: + """FindAccountByIdHandler dependency injection""" + from app.context.user_account.application.handlers.find_account_by_id_handler import ( + FindAccountByIdHandler, + ) + + return FindAccountByIdHandler(repository) + + +def get_find_accounts_by_user_handler( + repository: UserAccountRepositoryContract = Depends(get_user_account_repository), +) -> FindAccountsByUserHandlerContract: + """FindAccountsByUserHandler dependency injection""" + from app.context.user_account.application.handlers.find_accounts_by_user_handler import ( + FindAccountsByUserHandler, + ) + + return FindAccountsByUserHandler(repository) diff --git a/app/context/user_account/infrastructure/models/user_account_model.py b/app/context/user_account/infrastructure/models/user_account_model.py index 2159e9b..4745dab 100644 --- a/app/context/user_account/infrastructure/models/user_account_model.py +++ b/app/context/user_account/infrastructure/models/user_account_model.py @@ -1,6 +1,8 @@ +from datetime import datetime from decimal import Decimal +from typing import Optional -from sqlalchemy import DECIMAL, ForeignKey, Integer, String +from sqlalchemy import DECIMAL, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.shared.infrastructure.models import BaseDBModel @@ -16,3 +18,4 @@ class UserAccountModel(BaseDBModel): 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[Optional[datetime]] = mapped_column(DateTime, nullable=True, default=None) diff --git a/app/context/user_account/infrastructure/repositories/user_account_repository.py b/app/context/user_account/infrastructure/repositories/user_account_repository.py index b2b59e5..f52d550 100644 --- a/app/context/user_account/infrastructure/repositories/user_account_repository.py +++ b/app/context/user_account/infrastructure/repositories/user_account_repository.py @@ -1,6 +1,7 @@ +from datetime import datetime from typing import Optional -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -55,7 +56,7 @@ async def find_account( name: Optional[AccountName] = None, ) -> Optional[UserAccountDTO]: """Find an account by ID or by user_id and name""" - stmt = select(UserAccountModel) + stmt = select(UserAccountModel).where(UserAccountModel.deleted_at.is_(None)) if account_id is not None: stmt = stmt.where(UserAccountModel.id == account_id.value) @@ -71,3 +72,61 @@ async def find_account( model = result.scalar_one_or_none() return UserAccountMapper.toDTO(model) if model else None + + async def find_accounts_by_user(self, user_id: UserID) -> list[UserAccountDTO]: + """Find all non-deleted accounts for a user""" + stmt = select(UserAccountModel).where( + UserAccountModel.user_id == user_id.value, + UserAccountModel.deleted_at.is_(None) + ) + result = await self._db.execute(stmt) + models = result.scalars().all() + return [UserAccountMapper.toDTO(model) for model in models] + + async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: + """Update an existing account""" + 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 = await self._db.execute(stmt) + if result.rowcount == 0: + raise ValueError("Account not found or already deleted") + + await self._db.commit() + + # Fetch updated record + updated = await self.find_account(account_id=account.account_id) + return updated + + async def delete_account(self, account_id: AccountID, user_id: UserID) -> 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=datetime.utcnow()) + ) + + result = await self._db.execute(stmt) + await self._db.commit() + + return result.rowcount > 0 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 index 2f80e87..ec7fc5b 100644 --- a/app/context/user_account/interface/rest/controllers/create_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/create_account_controller.py @@ -42,12 +42,14 @@ async def create_account( # Handle the command result = await handler.handle(command) + if result.error is not None: + raise HTTPException(status_code=400, detail=result.error) # Return response return CreateAccountResponse( - account_id=result.account_id.value, - account_name=request.name, - message=result.message, + account_id=result.account_id, + account_name=result.account_name, + account_balance=result.account_balance, ) except ValueError as e: 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..30bff78 --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/delete_account_controller.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.application.commands.delete_account_command import ( + DeleteAccountCommand, +) +from app.context.user_account.application.contracts.delete_account_handler_contract import ( + DeleteAccountHandlerContract, +) +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.infrastructure.dependencies import ( + get_delete_account_handler, +) + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.delete("/{account_id}", status_code=204) +async def delete_account( + account_id: int, + handler: DeleteAccountHandlerContract = Depends(get_delete_account_handler), +): + """Delete a user account (soft delete)""" + command = DeleteAccountCommand( + account_id=AccountID(account_id), user_id=UserID(1) # TODO: from cookie header + ) + + success = await handler.handle(command) + if not success: + raise HTTPException(status_code=404, detail="Account not found") + + return # 204 No Content 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..c94ac33 --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -0,0 +1,66 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user.domain.value_objects.user_id import UserID +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.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.domain.value_objects.account_id import AccountID +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 + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.get("/{account_id}", response_model=AccountResponse) +async def get_account( + account_id: int, + handler: FindAccountByIdHandlerContract = Depends(get_find_account_by_id_handler), +): + """Get a specific user account by ID""" + query = FindAccountByIdQuery( + account_id=AccountID(account_id), user_id=UserID(1) # TODO: from cookie header + ) + + result = await handler.handle(query) + if not result: + raise HTTPException(status_code=404, detail="Account not found") + + return AccountResponse( + account_id=result.account_id, + name=result.name, + currency=result.currency, + balance=result.balance, + ) + + +@router.get("", response_model=list[AccountResponse]) +async def get_all_accounts( + handler: FindAccountsByUserHandlerContract = Depends( + get_find_accounts_by_user_handler + ), +): + """Get all accounts for the authenticated user""" + query = FindAccountsByUserQuery(user_id=UserID(1)) # TODO: from cookie + results = await handler.handle(query) + + return [ + AccountResponse( + account_id=r.account_id, + name=r.name, + currency=r.currency, + balance=r.balance, + ) + for r in results + ] 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..5dd6038 --- /dev/null +++ b/app/context/user_account/interface/rest/controllers/update_account_controller.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.application.commands.update_account_command import ( + UpdateAccountCommand, +) +from app.context.user_account.application.contracts.update_account_handler_contract import ( + UpdateAccountHandlerContract, +) +from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_name import AccountName +from app.context.user_account.domain.value_objects.balance import Balance +from app.context.user_account.domain.value_objects.currency import Currency +from app.context.user_account.infrastructure.dependencies import ( + get_update_account_handler, +) +from app.context.user_account.interface.schemas.update_account_response import ( + UpdateAccountResponse, +) +from app.context.user_account.interface.schemas.update_account_schema import ( + UpdateAccountRequest, +) + +router = APIRouter(prefix="/accounts", tags=["accounts"]) + + +@router.put("/{account_id}", response_model=UpdateAccountResponse) +async def update_account( + account_id: int, + request: UpdateAccountRequest, + handler: UpdateAccountHandlerContract = Depends(get_update_account_handler), +): + """Update a user account (full update - all fields required)""" + try: + command = UpdateAccountCommand( + account_id=AccountID(account_id), + user_id=UserID(1), # TODO: from cookie header + name=AccountName(request.name), + currency=Currency(request.currency), + balance=Balance.from_float(request.balance), + ) + + result = await handler.handle(command) + + return UpdateAccountResponse( + account_id=result.account_id.value, message=result.message + ) + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) diff --git a/app/context/user_account/interface/rest/routes.py b/app/context/user_account/interface/rest/routes.py index 42937f0..f81a33b 100644 --- a/app/context/user_account/interface/rest/routes.py +++ b/app/context/user_account/interface/rest/routes.py @@ -1,8 +1,22 @@ from fastapi import APIRouter -from app.context.user_account.interface.rest.controllers import create_account_router +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_account_router) +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 index e69de29..eb3b890 100644 --- a/app/context/user_account/interface/schemas/__init__.py +++ 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 index d3d1535..42ccd2f 100644 --- a/app/context/user_account/interface/schemas/create_account_response.py +++ b/app/context/user_account/interface/schemas/create_account_response.py @@ -1,10 +1,11 @@ from dataclasses import dataclass +from typing import Optional @dataclass(frozen=True) class CreateAccountResponse: """Response schema for account creation""" - account_id: int - account_name: str - message: str + account_id: Optional[int] + account_name: Optional[str] + account_balance: Optional[float] 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..386834f --- /dev/null +++ b/app/context/user_account/interface/schemas/update_account_response.py @@ -0,0 +1,7 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UpdateAccountResponse: + account_id: int + message: str 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/migrations/versions/d756346442c3_create_user_accounts_table.py b/migrations/versions/d756346442c3_create_user_accounts_table.py index f01815c..8cd397e 100644 --- a/migrations/versions/d756346442c3_create_user_accounts_table.py +++ b/migrations/versions/d756346442c3_create_user_accounts_table.py @@ -26,11 +26,14 @@ def upgrade() -> None: 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(), 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") From 3ded16fb69339a42d818f91b6f025e90bfb65550 Mon Sep 17 00:00:00 2001 From: polivera Date: Sat, 27 Dec 2025 10:34:25 +0100 Subject: [PATCH 19/58] Fixing AI slop --- .claude/rules/code-style.md | 224 +++- .claude/settings.local.json | 8 - .gitignore | 3 + .../auth/infrastructure/dependencies.py | 4 + .../rest/controllers/login_rest_controller.py | 2 +- app/context/credit_card/__init__.py | 0 .../credit_card/application/__init__.py | 0 .../application/commands/__init__.py | 9 + .../commands/create_credit_card_command.py | 12 + .../commands/delete_credit_card_command.py | 9 + .../commands/update_credit_card_command.py | 13 + .../application/contracts/__init__.py | 13 + .../create_credit_card_handler_contract.py | 19 + .../delete_credit_card_handler_contract.py | 14 + ...find_credit_card_by_id_handler_contract.py | 20 + ...d_credit_cards_by_user_handler_contract.py | 19 + .../update_credit_card_handler_contract.py | 19 + .../credit_card/application/dto/__init__.py | 9 + .../dto/create_credit_card_result.py | 10 + .../dto/credit_card_response_dto.py | 30 + .../dto/update_credit_card_result.py | 10 + .../application/handlers/__init__.py | 13 + .../handlers/create_credit_card_handler.py | 42 + .../handlers/delete_credit_card_handler.py | 24 + .../find_credit_card_by_id_handler.py | 39 + .../find_credit_cards_by_user_handler.py | 30 + .../handlers/update_credit_card_handler.py | 38 + .../application/queries/__init__.py | 7 + .../queries/find_credit_card_by_id_query.py | 12 + .../find_credit_cards_by_user_query.py | 10 + app/context/credit_card/domain/__init__.py | 0 .../credit_card/domain/contracts/__init__.py | 0 .../contracts/infrastructure/__init__.py | 3 + .../credit_card_repository_contract.py | 45 + .../domain/contracts/services/__init__.py | 7 + .../create_credit_card_service_contract.py | 28 + .../update_credit_card_service_contract.py | 27 + .../credit_card/domain/dto/__init__.py | 3 + .../credit_card/domain/dto/credit_card_dto.py | 27 + .../credit_card/domain/exceptions/__init__.py | 47 + .../domain/exceptions/exceptions.py | 88 ++ .../credit_card/domain/services/__init__.py | 7 + .../services/create_credit_card_service.py | 48 + .../services/update_credit_card_service.py | 90 ++ .../domain/value_objects/__init__.py | 17 + .../domain/value_objects/card_limit.py | 49 + .../domain/value_objects/card_used.py | 49 + .../value_objects/credit_card_account_id.py | 8 + .../value_objects/credit_card_currency.py | 8 + .../domain/value_objects/credit_card_id.py | 29 + .../domain/value_objects/credit_card_name.py | 34 + .../value_objects/credit_card_user_id.py | 8 + .../credit_card/infrastructure/__init__.py | 0 .../infrastructure/dependencies.py | 131 +++ .../infrastructure/mappers/__init__.py | 3 + .../mappers/credit_card_mapper.py | 44 + .../infrastructure/models/__init__.py | 3 + .../models/credit_card_model.py | 27 + .../infrastructure/repositories/__init__.py | 3 + .../repositories/credit_card_repository.py | 154 +++ app/context/credit_card/interface/__init__.py | 0 .../credit_card/interface/rest/__init__.py | 3 + .../interface/rest/controllers/__init__.py | 0 .../create_credit_card_controller.py | 60 ++ .../delete_credit_card_controller.py | 54 + .../find_credit_card_controller.py | 111 ++ .../update_credit_card_controller.py | 78 ++ .../credit_card/interface/rest/routes.py | 22 + .../credit_card/interface/schemas/__init__.py | 13 + .../schemas/create_credit_card_response.py | 9 + .../schemas/create_credit_card_schema.py | 18 + .../interface/schemas/credit_card_response.py | 13 + .../schemas/update_credit_card_response.py | 7 + .../schemas/update_credit_card_schema.py | 13 + app/context/user_account/__init__.py | 1 + .../commands/create_account_command.py | 13 +- .../commands/delete_account_command.py | 7 +- .../commands/update_account_command.py | 16 +- .../application/dto/update_account_result.py | 4 +- .../handlers/create_account_handler.py | 47 +- .../handlers/delete_account_handler.py | 7 +- .../handlers/find_account_by_id_handler.py | 14 +- .../handlers/find_accounts_by_user_handler.py | 11 +- .../handlers/update_account_handler.py | 17 +- .../queries/find_account_by_id_query.py | 7 +- .../queries/find_accounts_by_user_query.py | 4 +- .../contracts/infrastructure/__init__.py | 3 + .../user_account_repository_contract.py | 41 +- .../create_account_service_contract.py | 19 +- .../update_account_service_contract.py | 22 +- .../user_account/domain/dto/__init__.py | 3 + .../domain/dto/user_account_dto.py | 22 +- .../domain/exceptions/__init__.py | 3 + .../domain/exceptions/exceptions.py | 6 + .../domain/services/create_account_service.py | 25 +- .../domain/services/update_account_service.py | 47 +- .../domain/value_objects/__init__.py | 15 + .../domain/value_objects/account_balance.py | 8 + .../domain/value_objects/account_currency.py | 8 + .../domain/value_objects/account_id.py | 22 +- .../domain/value_objects/account_user_id.py | 8 + .../domain/value_objects/deleted_at.py | 8 + .../infrastructure/mappers/__init__.py | 3 + .../mappers/user_account_mapper.py | 40 +- .../infrastructure/models/__init__.py | 3 + .../infrastructure/repositories/__init__.py | 3 + .../repositories/user_account_repository.py | 115 +- .../controllers/create_account_controller.py | 29 +- .../controllers/delete_account_controller.py | 5 +- .../controllers/find_account_controller.py | 5 +- .../controllers/update_account_controller.py | 17 +- app/main.py | 2 + app/shared/domain/value_objects/__init__.py | 15 +- .../domain/value_objects/shared_account_id.py | 21 + .../domain/value_objects/shared_balance.py} | 7 +- .../domain/value_objects/shared_currency.py} | 15 +- .../domain/value_objects/shared_deleted_at.py | 50 + .../domain/value_objects/shared_user_id.py | 13 + .../infrastructure/middleware/__init__.py | 3 + .../middleware/session_auth_dependency.py | 136 +++ docs/ddd-patterns.md | 992 ++++++++++++++++++ .../e19a954402db_create_credit_cards_table.py | 44 + requests/base.sh | 4 + requests/login.sh | 7 + requests/user_account_create.sh | 8 + requests/user_account_list.sh | 5 + 126 files changed, 3738 insertions(+), 262 deletions(-) delete mode 100644 .claude/settings.local.json create mode 100644 app/context/credit_card/__init__.py create mode 100644 app/context/credit_card/application/__init__.py create mode 100644 app/context/credit_card/application/commands/__init__.py create mode 100644 app/context/credit_card/application/commands/create_credit_card_command.py create mode 100644 app/context/credit_card/application/commands/delete_credit_card_command.py create mode 100644 app/context/credit_card/application/commands/update_credit_card_command.py create mode 100644 app/context/credit_card/application/contracts/__init__.py create mode 100644 app/context/credit_card/application/contracts/create_credit_card_handler_contract.py create mode 100644 app/context/credit_card/application/contracts/delete_credit_card_handler_contract.py create mode 100644 app/context/credit_card/application/contracts/find_credit_card_by_id_handler_contract.py create mode 100644 app/context/credit_card/application/contracts/find_credit_cards_by_user_handler_contract.py create mode 100644 app/context/credit_card/application/contracts/update_credit_card_handler_contract.py create mode 100644 app/context/credit_card/application/dto/__init__.py create mode 100644 app/context/credit_card/application/dto/create_credit_card_result.py create mode 100644 app/context/credit_card/application/dto/credit_card_response_dto.py create mode 100644 app/context/credit_card/application/dto/update_credit_card_result.py create mode 100644 app/context/credit_card/application/handlers/__init__.py create mode 100644 app/context/credit_card/application/handlers/create_credit_card_handler.py create mode 100644 app/context/credit_card/application/handlers/delete_credit_card_handler.py create mode 100644 app/context/credit_card/application/handlers/find_credit_card_by_id_handler.py create mode 100644 app/context/credit_card/application/handlers/find_credit_cards_by_user_handler.py create mode 100644 app/context/credit_card/application/handlers/update_credit_card_handler.py create mode 100644 app/context/credit_card/application/queries/__init__.py create mode 100644 app/context/credit_card/application/queries/find_credit_card_by_id_query.py create mode 100644 app/context/credit_card/application/queries/find_credit_cards_by_user_query.py create mode 100644 app/context/credit_card/domain/__init__.py create mode 100644 app/context/credit_card/domain/contracts/__init__.py create mode 100644 app/context/credit_card/domain/contracts/infrastructure/__init__.py create mode 100644 app/context/credit_card/domain/contracts/infrastructure/credit_card_repository_contract.py create mode 100644 app/context/credit_card/domain/contracts/services/__init__.py create mode 100644 app/context/credit_card/domain/contracts/services/create_credit_card_service_contract.py create mode 100644 app/context/credit_card/domain/contracts/services/update_credit_card_service_contract.py create mode 100644 app/context/credit_card/domain/dto/__init__.py create mode 100644 app/context/credit_card/domain/dto/credit_card_dto.py create mode 100644 app/context/credit_card/domain/exceptions/__init__.py create mode 100644 app/context/credit_card/domain/exceptions/exceptions.py create mode 100644 app/context/credit_card/domain/services/__init__.py create mode 100644 app/context/credit_card/domain/services/create_credit_card_service.py create mode 100644 app/context/credit_card/domain/services/update_credit_card_service.py create mode 100644 app/context/credit_card/domain/value_objects/__init__.py create mode 100644 app/context/credit_card/domain/value_objects/card_limit.py create mode 100644 app/context/credit_card/domain/value_objects/card_used.py create mode 100644 app/context/credit_card/domain/value_objects/credit_card_account_id.py create mode 100644 app/context/credit_card/domain/value_objects/credit_card_currency.py create mode 100644 app/context/credit_card/domain/value_objects/credit_card_id.py create mode 100644 app/context/credit_card/domain/value_objects/credit_card_name.py create mode 100644 app/context/credit_card/domain/value_objects/credit_card_user_id.py create mode 100644 app/context/credit_card/infrastructure/__init__.py create mode 100644 app/context/credit_card/infrastructure/dependencies.py create mode 100644 app/context/credit_card/infrastructure/mappers/__init__.py create mode 100644 app/context/credit_card/infrastructure/mappers/credit_card_mapper.py create mode 100644 app/context/credit_card/infrastructure/models/__init__.py create mode 100644 app/context/credit_card/infrastructure/models/credit_card_model.py create mode 100644 app/context/credit_card/infrastructure/repositories/__init__.py create mode 100644 app/context/credit_card/infrastructure/repositories/credit_card_repository.py create mode 100644 app/context/credit_card/interface/__init__.py create mode 100644 app/context/credit_card/interface/rest/__init__.py create mode 100644 app/context/credit_card/interface/rest/controllers/__init__.py create mode 100644 app/context/credit_card/interface/rest/controllers/create_credit_card_controller.py create mode 100644 app/context/credit_card/interface/rest/controllers/delete_credit_card_controller.py create mode 100644 app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py create mode 100644 app/context/credit_card/interface/rest/controllers/update_credit_card_controller.py create mode 100644 app/context/credit_card/interface/rest/routes.py create mode 100644 app/context/credit_card/interface/schemas/__init__.py create mode 100644 app/context/credit_card/interface/schemas/create_credit_card_response.py create mode 100644 app/context/credit_card/interface/schemas/create_credit_card_schema.py create mode 100644 app/context/credit_card/interface/schemas/credit_card_response.py create mode 100644 app/context/credit_card/interface/schemas/update_credit_card_response.py create mode 100644 app/context/credit_card/interface/schemas/update_credit_card_schema.py create mode 100644 app/context/user_account/domain/exceptions/__init__.py create mode 100644 app/context/user_account/domain/exceptions/exceptions.py create mode 100644 app/context/user_account/domain/value_objects/account_balance.py create mode 100644 app/context/user_account/domain/value_objects/account_currency.py create mode 100644 app/context/user_account/domain/value_objects/account_user_id.py create mode 100644 app/context/user_account/domain/value_objects/deleted_at.py create mode 100644 app/shared/domain/value_objects/shared_account_id.py rename app/{context/user_account/domain/value_objects/balance.py => shared/domain/value_objects/shared_balance.py} (86%) rename app/{context/user_account/domain/value_objects/currency.py => shared/domain/value_objects/shared_currency.py} (65%) create mode 100644 app/shared/domain/value_objects/shared_deleted_at.py create mode 100644 app/shared/domain/value_objects/shared_user_id.py create mode 100644 app/shared/infrastructure/middleware/__init__.py create mode 100644 app/shared/infrastructure/middleware/session_auth_dependency.py create mode 100644 docs/ddd-patterns.md create mode 100644 migrations/versions/e19a954402db_create_credit_cards_table.py create mode 100644 requests/base.sh create mode 100755 requests/login.sh create mode 100755 requests/user_account_create.sh create mode 100755 requests/user_account_list.sh diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 9313032..781c72f 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -150,43 +150,140 @@ class LoginRequest(BaseModel): ## 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 -Raise `ValueError` for validation failures: +Create specific validation exceptions: ```python -def __post_init__(self): - if len(self.value) < 8: - raise ValueError("Password must be at least 8 characters") +# 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 -Create domain-specific exceptions: +Raise specific domain exceptions: ```python -class AuthenticationError(Exception): +# 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 AuthenticationError("Invalid credentials") + raise InvalidCredentialsError("Invalid email or password") + + if user.is_locked: + raise AccountLockedError(f"Account locked until {user.locked_until}") ``` ### Controllers -Convert to HTTP exceptions: +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(...) - except AuthenticationError: - raise HTTPException(status_code=401, detail="Invalid credentials") + 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 @@ -212,6 +309,113 @@ 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) +``` + ## Dependency Injection ### Define Contract-Based Factories diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 4b0dc47..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cat:*)", - "Bash(PGPASSWORD=homecomppass psql:*)" - ] - } -} diff --git a/.gitignore b/.gitignore index acfc802..5070896 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ ENV/ # OS .DS_Store Thumbs.db + +.claude/settings.local.json + diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index f2598b4..229db84 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -15,6 +15,10 @@ ) from app.context.auth.domain.services import LoginService from app.context.auth.infrastructure.repositories import SessionRepository +from app.shared.infrastructure.middleware.session_auth_dependency import ( + get_current_user_id, + get_current_user_id_optional, +) from app.context.user.application.contracts.find_user_query_handler_contract import ( FindUserHandlerContract, ) diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 58e12e3..09f1f2d 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -26,7 +26,7 @@ async def login( if login_result.token is None: raise HTTPException(status_code=500, detail="Token generation failed") - # Set JWT token as HTTP-only secure cookie + # Set session token as HTTP-only secure cookie response.set_cookie( key="access_token", value=login_result.token, 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..25f5130 --- /dev/null +++ b/app/context/credit_card/application/commands/update_credit_card_command.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class UpdateCreditCardCommand: + """Command to update an existing credit card""" + + credit_card_id: int + user_id: int + name: Optional[str] = None + limit: Optional[float] = None + used: Optional[float] = 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..dc1b48c --- /dev/null +++ b/app/context/credit_card/application/contracts/create_credit_card_handler_contract.py @@ -0,0 +1,19 @@ +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..109cdfe --- /dev/null +++ b/app/context/credit_card/application/contracts/delete_credit_card_handler_contract.py @@ -0,0 +1,14 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.application.commands.delete_credit_card_command import ( + DeleteCreditCardCommand, +) + + +class DeleteCreditCardHandlerContract(ABC): + """Contract for delete credit card command handler""" + + @abstractmethod + async def handle(self, command: DeleteCreditCardCommand) -> bool: + """Handle the delete credit card command. Returns True if deleted, False otherwise""" + 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..1ee779d --- /dev/null +++ b/app/context/credit_card/application/contracts/find_credit_card_by_id_handler_contract.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from app.context.credit_card.application.queries.find_credit_card_by_id_query import ( + FindCreditCardByIdQuery, +) +from app.context.credit_card.application.dto.credit_card_response_dto import ( + CreditCardResponseDTO, +) + + +class FindCreditCardByIdHandlerContract(ABC): + """Contract for find credit card by ID query handler""" + + @abstractmethod + async def handle( + self, query: FindCreditCardByIdQuery + ) -> Optional[CreditCardResponseDTO]: + """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..ee38778 --- /dev/null +++ b/app/context/credit_card/application/contracts/find_credit_cards_by_user_handler_contract.py @@ -0,0 +1,19 @@ +from abc import ABC, abstractmethod + +from app.context.credit_card.application.queries.find_credit_cards_by_user_query import ( + FindCreditCardsByUserQuery, +) +from app.context.credit_card.application.dto.credit_card_response_dto import ( + CreditCardResponseDTO, +) + + +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..ee9559d --- /dev/null +++ b/app/context/credit_card/application/contracts/update_credit_card_handler_contract.py @@ -0,0 +1,19 @@ +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..fcfc8a0 --- /dev/null +++ b/app/context/credit_card/application/dto/__init__.py @@ -0,0 +1,9 @@ +from .create_credit_card_result import CreateCreditCardResult +from .credit_card_response_dto import CreditCardResponseDTO +from .update_credit_card_result import UpdateCreditCardResult + +__all__ = [ + "CreateCreditCardResult", + "CreditCardResponseDTO", + "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..9a42210 --- /dev/null +++ b/app/context/credit_card/application/dto/create_credit_card_result.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class CreateCreditCardResult: + """Result of credit card creation operation""" + + credit_card_id: Optional[int] = None + error: Optional[str] = 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/update_credit_card_result.py b/app/context/credit_card/application/dto/update_credit_card_result.py new file mode 100644 index 0000000..cdf1d1d --- /dev/null +++ b/app/context/credit_card/application/dto/update_credit_card_result.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class UpdateCreditCardResult: + """Result of credit card update operation""" + + success: bool = False + error: Optional[str] = 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..c128bec --- /dev/null +++ b/app/context/credit_card/application/handlers/create_credit_card_handler.py @@ -0,0 +1,42 @@ +from app.context.credit_card.application.commands.create_credit_card_command import ( + CreateCreditCardCommand, +) +from app.context.credit_card.application.contracts.create_credit_card_handler_contract import ( + CreateCreditCardHandlerContract, +) +from app.context.credit_card.application.dto.create_credit_card_result import ( + CreateCreditCardResult, +) +from app.context.credit_card.domain.contracts.services.create_credit_card_service_contract import ( + CreateCreditCardServiceContract, +) +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CreditCardAccountID, + CreditCardCurrency, + CreditCardName, + CreditCardUserID, +) + + +class CreateCreditCardHandler(CreateCreditCardHandlerContract): + """Handler for create credit card command""" + + def __init__(self, service: CreateCreditCardServiceContract): + self._service = service + + async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResult: + """Execute the create credit card command""" + + 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), + ) + + if card_dto.credit_card_id is None: + return CreateCreditCardResult(error="Error creating credit card") + + return CreateCreditCardResult(credit_card_id=card_dto.credit_card_id.value) 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..aa64efc --- /dev/null +++ b/app/context/credit_card/application/handlers/delete_credit_card_handler.py @@ -0,0 +1,24 @@ +from app.context.credit_card.application.commands.delete_credit_card_command import ( + DeleteCreditCardCommand, +) +from app.context.credit_card.application.contracts.delete_credit_card_handler_contract import ( + DeleteCreditCardHandlerContract, +) +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) + + +class DeleteCreditCardHandler(DeleteCreditCardHandlerContract): + """Handler for delete credit card command""" + + def __init__(self, repository: CreditCardRepositoryContract): + self._repository = repository + + async def handle(self, command: DeleteCreditCardCommand) -> bool: + """Execute the delete credit card command""" + + return await self._repository.delete_credit_card( + card_id=command.credit_card_id, + user_id=command.user_id, + ) 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..5f543e7 --- /dev/null +++ b/app/context/credit_card/application/handlers/find_credit_card_by_id_handler.py @@ -0,0 +1,39 @@ +from typing import Optional + +from app.context.credit_card.application.contracts.find_credit_card_by_id_handler_contract import ( + FindCreditCardByIdHandlerContract, +) +from app.context.credit_card.application.queries.find_credit_card_by_id_query import ( + FindCreditCardByIdQuery, +) +from app.context.credit_card.application.dto.credit_card_response_dto import ( + CreditCardResponseDTO, +) +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) + + +class FindCreditCardByIdHandler(FindCreditCardByIdHandlerContract): + """Handler for find credit card by ID query""" + + def __init__(self, repository: CreditCardRepositoryContract): + self._repository = repository + + async def handle( + self, query: FindCreditCardByIdQuery + ) -> Optional[CreditCardResponseDTO]: + """Execute the find credit card by ID query""" + + card_dto = await self._repository.find_credit_card( + card_id=query.credit_card_id + ) + + if not card_dto: + return None + + # Verify ownership + if card_dto.user_id.value != query.user_id.value: + 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..578bc84 --- /dev/null +++ b/app/context/credit_card/application/handlers/find_credit_cards_by_user_handler.py @@ -0,0 +1,30 @@ +from app.context.credit_card.application.contracts.find_credit_cards_by_user_handler_contract import ( + FindCreditCardsByUserHandlerContract, +) +from app.context.credit_card.application.queries.find_credit_cards_by_user_query import ( + FindCreditCardsByUserQuery, +) +from app.context.credit_card.application.dto.credit_card_response_dto import ( + CreditCardResponseDTO, +) +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) + + +class FindCreditCardsByUserHandler(FindCreditCardsByUserHandlerContract): + """Handler for find credit cards by user query""" + + def __init__(self, repository: CreditCardRepositoryContract): + self._repository = repository + + async def handle( + self, query: FindCreditCardsByUserQuery + ) -> list[CreditCardResponseDTO]: + """Execute the find credit cards by user query""" + + card_dtos = await self._repository.find_credit_cards_by_user( + user_id=query.user_id + ) + + return [CreditCardResponseDTO.from_domain_dto(dto) for dto in card_dtos] 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..610a441 --- /dev/null +++ b/app/context/credit_card/application/handlers/update_credit_card_handler.py @@ -0,0 +1,38 @@ +from app.context.credit_card.application.commands.update_credit_card_command import ( + UpdateCreditCardCommand, +) +from app.context.credit_card.application.contracts.update_credit_card_handler_contract import ( + UpdateCreditCardHandlerContract, +) +from app.context.credit_card.application.dto.update_credit_card_result import ( + UpdateCreditCardResult, +) +from app.context.credit_card.domain.contracts.services.update_credit_card_service_contract import ( + UpdateCreditCardServiceContract, +) + + +class UpdateCreditCardHandler(UpdateCreditCardHandlerContract): + """Handler for update credit card command""" + + def __init__(self, service: UpdateCreditCardServiceContract): + self._service = service + + async def handle( + self, command: UpdateCreditCardCommand + ) -> UpdateCreditCardResult: + """Execute the update credit card command""" + + try: + await self._service.update_credit_card( + credit_card_id=command.credit_card_id, + user_id=command.user_id, + name=command.name, + limit=command.limit, + used=command.used, + ) + + return UpdateCreditCardResult(success=True) + + except ValueError as e: + return UpdateCreditCardResult(success=False, error=str(e)) 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..def7e89 --- /dev/null +++ b/app/context/credit_card/application/queries/find_credit_card_by_id_query.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID + + +@dataclass(frozen=True) +class FindCreditCardByIdQuery: + """Query to find a credit card by ID""" + + credit_card_id: CreditCardID + user_id: UserID 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..6884d9e --- /dev/null +++ b/app/context/credit_card/application/queries/find_credit_cards_by_user_query.py @@ -0,0 +1,10 @@ +from dataclasses import dataclass + +from app.context.user.domain.value_objects.user_id import UserID + + +@dataclass(frozen=True) +class FindCreditCardsByUserQuery: + """Query to find all credit cards for a user""" + + user_id: UserID 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..1786967 --- /dev/null +++ b/app/context/credit_card/domain/contracts/infrastructure/credit_card_repository_contract.py @@ -0,0 +1,45 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +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""" + pass + + @abstractmethod + async def find_credit_card( + self, + card_id: Optional[CreditCardID] = None, + user_id: Optional[UserID] = None, + name: Optional[CreditCardName] = None, + ) -> Optional[CreditCardDTO]: + """Find a credit card by ID or by user_id and name""" + pass + + @abstractmethod + async def find_credit_cards_by_user(self, user_id: UserID) -> list[CreditCardDTO]: + """Find all non-deleted credit cards for a user""" + pass + + @abstractmethod + async def update_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: + """Update an existing credit card""" + pass + + @abstractmethod + async def delete_credit_card( + self, card_id: CreditCardID, user_id: UserID + ) -> bool: + """Soft delete a credit card. Returns True if deleted, False if not found/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..e1bf0e3 --- /dev/null +++ b/app/context/credit_card/domain/contracts/services/update_credit_card_service_contract.py @@ -0,0 +1,27 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +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: UserID, + name: Optional[CreditCardName] = None, + limit: Optional[CardLimit] = None, + used: Optional[CardUsed] = 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..ccd3a95 --- /dev/null +++ b/app/context/credit_card/domain/dto/credit_card_dto.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass +from typing import Optional + +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.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 + credit_card_id: Optional[CreditCardID] = 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..077869b --- /dev/null +++ b/app/context/credit_card/domain/exceptions/__init__.py @@ -0,0 +1,47 @@ +from .exceptions import ( + CreditCardCreationError, + CreditCardMapperError, + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, + CreditCardRepositoryInvalidParametersError, + CreditCardUnauthorizedAccessError, + CreditCardUpdateError, + CreditCardUpdateWithoutIdError, + CreditCardUsedExceedsLimitError, + InvalidCardLimitFormatError, + InvalidCardLimitPrecisionError, + InvalidCardLimitTypeError, + InvalidCardLimitValueError, + InvalidCardUsedFormatError, + InvalidCardUsedPrecisionError, + InvalidCardUsedTypeError, + InvalidCardUsedValueError, + InvalidCreditCardIdTypeError, + InvalidCreditCardIdValueError, + InvalidCreditCardNameLengthError, + InvalidCreditCardNameTypeError, +) + +__all__ = [ + "CreditCardCreationError", + "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..a46038f --- /dev/null +++ b/app/context/credit_card/domain/exceptions/exceptions.py @@ -0,0 +1,88 @@ +# 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 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..158c5a9 --- /dev/null +++ b/app/context/credit_card/domain/services/create_credit_card_service.py @@ -0,0 +1,48 @@ +from decimal import Decimal + +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.card_used import CardUsed +from app.context.credit_card.domain.value_objects.credit_card_name import ( + CreditCardName, +) + + +class CreateCreditCardService(CreateCreditCardServiceContract): + """Service for creating credit cards""" + + def __init__(self, card_repository: CreditCardRepositoryContract): + self._card_repository = card_repository + + 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""" + + card_dto = CreditCardDTO( + user_id=user_id, + account_id=account_id, + name=name, + currency=currency, + limit=limit, + used=CardUsed(Decimal("0.00")), + ) + + # 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..796df4b --- /dev/null +++ b/app/context/credit_card/domain/services/update_credit_card_service.py @@ -0,0 +1,90 @@ +from typing import Optional + +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.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.context.user.domain.value_objects.user_id import UserID + + +class UpdateCreditCardService(UpdateCreditCardServiceContract): + """Service for updating credit cards""" + + def __init__(self, repository: CreditCardRepositoryContract): + self._repository = repository + + async def update_credit_card( + self, + credit_card_id: CreditCardID, + user_id: UserID, + name: Optional[CreditCardName] = None, + limit: Optional[CardLimit] = None, + used: Optional[CardUsed] = None, + ) -> CreditCardDTO: + """Update an existing credit card with validation""" + + # Find the existing card + existing_card = await self._repository.find_credit_card(card_id=credit_card_id) + + if not existing_card: + raise CreditCardNotFoundError( + f"Credit card with ID {credit_card_id.value} not found" + ) + + # Verify ownership + if existing_card.user_id.value != user_id.value: + raise CreditCardUnauthorizedAccessError( + f"User {user_id.value} is not authorized to update credit card " + f"{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: + 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: + raise CreditCardUsedExceedsLimitError( + f"Used amount ({updated_used.value}) cannot exceed limit " + f"({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..26ce325 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/__init__.py @@ -0,0 +1,17 @@ +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_id import CreditCardID +from .credit_card_name import CreditCardName +from .credit_card_user_id import CreditCardUserID + +__all__ = [ + "CardLimit", + "CardUsed", + "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..11e2d69 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/card_limit.py @@ -0,0 +1,49 @@ +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..6cf1fe2 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/card_used.py @@ -0,0 +1,49 @@ +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_id.py b/app/context/credit_card/domain/value_objects/credit_card_id.py new file mode 100644 index 0000000..62b3cef --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_id.py @@ -0,0 +1,29 @@ +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..195b426 --- /dev/null +++ b/app/context/credit_card/domain/value_objects/credit_card_name.py @@ -0,0 +1,34 @@ +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..e46c943 --- /dev/null +++ b/app/context/credit_card/infrastructure/dependencies.py @@ -0,0 +1,131 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.infrastructure.database import get_db +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.context.credit_card.application.contracts.create_credit_card_handler_contract import ( + CreateCreditCardHandlerContract, +) +from app.context.credit_card.application.contracts.update_credit_card_handler_contract import ( + UpdateCreditCardHandlerContract, +) +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, +) + + +# ───────────────────────────────────────────────────────────────── +# REPOSITORY +# ───────────────────────────────────────────────────────────────── + + +def get_credit_card_repository( + db: 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: CreditCardRepositoryContract = Depends(get_credit_card_repository), +) -> CreateCreditCardServiceContract: + """CreateCreditCardService dependency injection""" + from app.context.credit_card.domain.services.create_credit_card_service import ( + CreateCreditCardService, + ) + + return CreateCreditCardService(card_repository) + + +def get_create_credit_card_handler( + service: CreateCreditCardServiceContract = Depends(get_create_credit_card_service), +) -> CreateCreditCardHandlerContract: + """CreateCreditCardHandler dependency injection""" + from app.context.credit_card.application.handlers.create_credit_card_handler import ( + CreateCreditCardHandler, + ) + + return CreateCreditCardHandler(service) + + +def get_update_credit_card_service( + repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), +) -> UpdateCreditCardServiceContract: + """UpdateCreditCardService dependency injection""" + from app.context.credit_card.domain.services.update_credit_card_service import ( + UpdateCreditCardService, + ) + + return UpdateCreditCardService(repository) + + +def get_update_credit_card_handler( + service: UpdateCreditCardServiceContract = Depends(get_update_credit_card_service), +) -> UpdateCreditCardHandlerContract: + """UpdateCreditCardHandler dependency injection""" + from app.context.credit_card.application.handlers.update_credit_card_handler import ( + UpdateCreditCardHandler, + ) + + return UpdateCreditCardHandler(service) + + +def get_delete_credit_card_handler( + repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), +) -> DeleteCreditCardHandlerContract: + """DeleteCreditCardHandler dependency injection""" + from app.context.credit_card.application.handlers.delete_credit_card_handler import ( + DeleteCreditCardHandler, + ) + + return DeleteCreditCardHandler(repository) + + +# ───────────────────────────────────────────────────────────────── +# QUERY HANDLERS (Read operations) +# ───────────────────────────────────────────────────────────────── + + +def get_find_credit_card_by_id_handler( + repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), +) -> FindCreditCardByIdHandlerContract: + """FindCreditCardByIdHandler dependency injection""" + from app.context.credit_card.application.handlers.find_credit_card_by_id_handler import ( + FindCreditCardByIdHandler, + ) + + return FindCreditCardByIdHandler(repository) + + +def get_find_credit_cards_by_user_handler( + repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), +) -> FindCreditCardsByUserHandlerContract: + """FindCreditCardsByUserHandler dependency injection""" + from app.context.credit_card.application.handlers.find_credit_cards_by_user_handler import ( + FindCreditCardsByUserHandler, + ) + + return FindCreditCardsByUserHandler(repository) 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..1920084 --- /dev/null +++ b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py @@ -0,0 +1,44 @@ +from app.context.user_account.domain.value_objects.currency import Currency + +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +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.context.credit_card.infrastructure.models.credit_card_model import ( + CreditCardModel, +) +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user_account.domain.value_objects.account_id import UserAccountID + + +class CreditCardMapper: + """Mapper for converting between CreditCardModel and CreditCardDTO""" + + @staticmethod + def toDTO(model: CreditCardModel) -> CreditCardDTO: + """Convert database model to domain DTO""" + return CreditCardDTO( + credit_card_id=CreditCardID.from_trusted_source(model.id), + user_id=UserID(model.user_id), + account_id=UserAccountID.from_trusted_source(model.account_id), + name=CreditCardName.from_trusted_source(model.name), + currency=Currency.from_trusted_source(model.currency), + limit=CardLimit.from_trusted_source(model.limit), + used=CardUsed.from_trusted_source(model.used), + ) + + @staticmethod + def toModel(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, + ) 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..3d70140 --- /dev/null +++ b/app/context/credit_card/infrastructure/models/credit_card_model.py @@ -0,0 +1,27 @@ +from datetime import datetime +from decimal import Decimal +from typing import Optional + +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[Optional[datetime]] = mapped_column( + DateTime, 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..755a1b8 --- /dev/null +++ b/app/context/credit_card/infrastructure/repositories/credit_card_repository.py @@ -0,0 +1,154 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( + CreditCardRepositoryContract, +) +from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +from app.context.credit_card.domain.exceptions import ( + CreditCardCreationError, + CreditCardNameAlreadyExistError, + CreditCardNotFoundError, + CreditCardRepositoryInvalidParametersError, + CreditCardUpdateError, + CreditCardUpdateWithoutIdError, +) +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.context.credit_card.infrastructure.mappers.credit_card_mapper import ( + CreditCardMapper, +) +from app.context.credit_card.infrastructure.models.credit_card_model import ( + CreditCardModel, +) +from app.context.user.domain.value_objects.user_id import UserID + + +class CreditCardRepository(CreditCardRepositoryContract): + """Repository for credit card persistence operations""" + + def __init__(self, db: AsyncSession): + self._db = db + + async def save_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: + """Create a new credit card in the database""" + model = CreditCardMapper.toModel(card) + + self._db.add(model) + + try: + await self._db.commit() + await self._db.refresh(model) + except IntegrityError as e: + await self._db.rollback() + if "uq_credit_cards_user_id_name" in str(e.orig): + raise CreditCardNameAlreadyExistError( + f"Credit card with name '{card.name.value}' already exists for this user" + ) from e + raise CreditCardCreationError(f"Failed to create credit card: {str(e)}") from e + + return CreditCardMapper.toDTO(model) + + async def find_credit_card( + self, + card_id: Optional[CreditCardID] = None, + user_id: Optional[UserID] = None, + name: Optional[CreditCardName] = None, + ) -> Optional[CreditCardDTO]: + """Find a credit card by ID or by user_id and name""" + stmt = select(CreditCardModel).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 CreditCardRepositoryInvalidParametersError( + "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.toDTO(model) if model else None + + async def find_credit_cards_by_user(self, user_id: UserID) -> list[CreditCardDTO]: + """Find all non-deleted credit cards for a user""" + stmt = select(CreditCardModel).where( + CreditCardModel.user_id == user_id.value, + CreditCardModel.deleted_at.is_(None), + ) + + result = await self._db.execute(stmt) + models = result.scalars().all() + + return [CreditCardMapper.toDTO(model) for model in models] + + async def update_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: + """Update an existing credit card""" + if card.credit_card_id is None: + raise CreditCardUpdateWithoutIdError("Cannot update credit card without an ID") + + 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, + used=card.used.value, + ) + ) + + try: + result = await self._db.execute(stmt) + await self._db.commit() + + if result.rowcount == 0: + raise CreditCardNotFoundError( + f"Credit card with ID {card.credit_card_id.value} not found" + ) + + # Fetch and return updated card + return await self.find_credit_card(card_id=card.credit_card_id) + + except IntegrityError as e: + await self._db.rollback() + if "uq_credit_cards_user_id_name" in str(e.orig): + raise CreditCardNameAlreadyExistError( + f"Credit card with name '{card.name.value}' already exists for this user" + ) from e + raise CreditCardUpdateError(f"Failed to update credit card: {str(e)}") from e + + async def delete_credit_card(self, card_id: CreditCardID, user_id: UserID) -> bool: + """Soft delete a credit card""" + # Verify the card exists and belongs to the user + card = await self.find_credit_card(card_id=card_id) + if not card or card.user_id.value != user_id.value: + return False + + stmt = ( + update(CreditCardModel) + .where( + CreditCardModel.id == card_id.value, + CreditCardModel.user_id == user_id.value, + CreditCardModel.deleted_at.is_(None), + ) + .values(deleted_at=datetime.utcnow()) + ) + + result = await self._db.execute(stmt) + await self._db.commit() + + return result.rowcount > 0 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..e69de29 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..c1af427 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/create_credit_card_controller.py @@ -0,0 +1,60 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.context.credit_card.application.commands.create_credit_card_command import ( + CreateCreditCardCommand, +) +from app.context.credit_card.application.contracts.create_credit_card_handler_contract import ( + CreateCreditCardHandlerContract, +) +from app.context.credit_card.domain.value_objects.card_limit 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, +) + +router = APIRouter(prefix="/cards", tags=["credit-cards"]) + + +@router.post("", response_model=CreateCreditCardResponse, status_code=201) +async def create_credit_card( + request: CreateCreditCardRequest, + handler: CreateCreditCardHandlerContract = Depends(get_create_credit_card_handler), +): + """Create a new credit card""" + + try: + # Convert request to command with value objects + # TODO: user_id from cookie header + command = CreateCreditCardCommand( + user_id=1, + account_id=request.account_id, + name=request.name, + currency=request.currency, + limit=request.limit, + ) + + # Handle the command + result = await handler.handle(command) + if result.error is not None: + raise HTTPException(status_code=400, detail=result.error) + + # Fetch the created card details to return complete response + return CreateCreditCardResponse( + credit_card_id=result.credit_card_id.value, + name=request.name, + limit=CardLimit.from_float(request.limit).value, + ) + + except ValueError as e: + # Business logic errors (duplicate card, validation errors, etc.) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Unexpected errors + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) 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..8a0098d --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/delete_credit_card_controller.py @@ -0,0 +1,54 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.credit_card.application.commands.delete_credit_card_command import ( + DeleteCreditCardCommand, +) +from app.context.credit_card.application.contracts.delete_credit_card_handler_contract import ( + DeleteCreditCardHandlerContract, +) +from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID +from app.context.credit_card.infrastructure.dependencies import ( + get_delete_credit_card_handler, +) + +router = APIRouter(prefix="/cards", tags=["credit-cards"]) + + +@router.delete("/{credit_card_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_credit_card( + credit_card_id: int, + handler: DeleteCreditCardHandlerContract = Depends(get_delete_credit_card_handler), +): + """Delete a credit card (soft delete)""" + + try: + # Convert to command + # TODO: user_id from cookie header + command = DeleteCreditCardCommand( + credit_card_id=CreditCardID(credit_card_id), + user_id=UserID(1), + ) + + # Handle the command + deleted = await handler.handle(command) + + if not deleted: + raise HTTPException( + status_code=404, + detail=f"Credit card with ID {credit_card_id} not found or not owned by user", + ) + + return None + + except ValueError as e: + # Validation errors + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + # Unexpected errors + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) diff --git a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py new file mode 100644 index 0000000..4f3b74c --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user.domain.value_objects.user_id import UserID +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.queries.find_credit_card_by_id_query import ( + FindCreditCardByIdQuery, +) +from app.context.credit_card.application.queries.find_credit_cards_by_user_query import ( + FindCreditCardsByUserQuery, +) +from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID +from app.context.credit_card.infrastructure.dependencies import ( + get_find_credit_card_by_id_handler, + get_find_credit_cards_by_user_handler, +) +from app.context.credit_card.interface.schemas.credit_card_response import ( + CreditCardResponse, +) + +router = APIRouter(prefix="/cards", tags=["credit-cards"]) + + +@router.get("/{credit_card_id}", response_model=CreditCardResponse) +async def get_credit_card( + credit_card_id: int, + handler: FindCreditCardByIdHandlerContract = Depends( + get_find_credit_card_by_id_handler + ), +): + """Get a credit card by ID""" + + try: + # TODO: user_id from cookie header + query = FindCreditCardByIdQuery( + credit_card_id=CreditCardID(credit_card_id), + user_id=UserID(1), + ) + + # Handle the query + result = await handler.handle(query) + + if not result: + raise HTTPException( + status_code=404, + detail=f"Credit card with ID {credit_card_id} not found", + ) + + 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, + ) + + except ValueError as e: + # Validation errors + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + # Re-raise HTTP exceptions + raise + except Exception as e: + # Unexpected errors + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) + + +@router.get("", response_model=list[CreditCardResponse]) +async def get_credit_cards( + handler: FindCreditCardsByUserHandlerContract = Depends( + get_find_credit_cards_by_user_handler + ), +): + """Get all credit cards for the current user""" + + try: + # TODO: user_id from cookie header + query = FindCreditCardsByUserQuery(user_id=UserID(1)) + + # Handle the query + results = await handler.handle(query) + + 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 + ] + + except ValueError as e: + # Validation errors + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Unexpected errors + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) 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..da66b64 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/update_credit_card_controller.py @@ -0,0 +1,78 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException + +from app.context.user.domain.value_objects.user_id import UserID +from app.context.credit_card.application.commands.update_credit_card_command import ( + UpdateCreditCardCommand, +) +from app.context.credit_card.application.contracts.update_credit_card_handler_contract import ( + UpdateCreditCardHandlerContract, +) +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.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, +) + +router = APIRouter(prefix="/cards", tags=["credit-cards"]) + + +@router.put("/{credit_card_id}", response_model=UpdateCreditCardResponse) +async def update_credit_card( + credit_card_id: int, + request: UpdateCreditCardRequest, + handler: UpdateCreditCardHandlerContract = Depends(get_update_credit_card_handler), +): + """Update an existing credit card""" + + try: + # Build optional value objects + name: Optional[CreditCardName] = ( + CreditCardName(request.name) if request.name else None + ) + limit: Optional[CardLimit] = ( + CardLimit.from_float(request.limit) if request.limit is not None else None + ) + used: Optional[CardUsed] = ( + CardUsed.from_float(request.used) if request.used is not None else None + ) + + # Convert request to command + # TODO: user_id from cookie header + command = UpdateCreditCardCommand( + credit_card_id=CreditCardID(credit_card_id), + user_id=UserID(1), + name=name, + limit=limit, + used=used, + ) + + # Handle the command + result = await handler.handle(command) + + if not result.success: + raise HTTPException(status_code=400, detail=result.error) + + return UpdateCreditCardResponse( + success=True, message="Credit card updated successfully" + ) + + except ValueError as e: + # Business logic errors (validation errors, etc.) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # Unexpected errors + raise HTTPException( + status_code=500, detail=f"An unexpected error occurred: {str(e)}" + ) 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..83a596a --- /dev/null +++ b/app/context/credit_card/interface/rest/routes.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter + +from app.context.credit_card.interface.rest.controllers.create_credit_card_controller import ( + router as create_router, +) +from app.context.credit_card.interface.rest.controllers.delete_credit_card_controller import ( + router as delete_router, +) +from app.context.credit_card.interface.rest.controllers.find_credit_card_controller import ( + router as find_router, +) +from app.context.credit_card.interface.rest.controllers.update_credit_card_controller import ( + router as 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_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..9db1288 --- /dev/null +++ b/app/context/credit_card/interface/schemas/create_credit_card_schema.py @@ -0,0 +1,18 @@ +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..cd2d888 --- /dev/null +++ b/app/context/credit_card/interface/schemas/update_credit_card_schema.py @@ -0,0 +1,13 @@ +from typing import Optional + +from pydantic import BaseModel, ConfigDict, Field + + +class UpdateCreditCardRequest(BaseModel): + model_config = ConfigDict(frozen=True) + + name: Optional[str] = Field( + None, min_length=3, max_length=100, description="Credit card name" + ) + limit: Optional[float] = Field(None, gt=0, description="Credit card limit") + used: Optional[float] = Field(None, ge=0, description="Credit card used amount") diff --git a/app/context/user_account/__init__.py b/app/context/user_account/__init__.py index e69de29..8b13789 100644 --- a/app/context/user_account/__init__.py +++ b/app/context/user_account/__init__.py @@ -0,0 +1 @@ + diff --git a/app/context/user_account/application/commands/create_account_command.py b/app/context/user_account/application/commands/create_account_command.py index 213b025..16b062c 100644 --- a/app/context/user_account/application/commands/create_account_command.py +++ b/app/context/user_account/application/commands/create_account_command.py @@ -1,16 +1,11 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency - @dataclass(frozen=True) class CreateAccountCommand: """Command to create a new user account""" - user_id: UserID - name: AccountName - currency: Currency - balance: Balance + 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 index 285e667..b8c897e 100644 --- a/app/context/user_account/application/commands/delete_account_command.py +++ b/app/context/user_account/application/commands/delete_account_command.py @@ -1,10 +1,7 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.value_objects.account_id import AccountID - @dataclass(frozen=True) class DeleteAccountCommand: - account_id: AccountID - user_id: UserID + 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 index d20e66c..f002894 100644 --- a/app/context/user_account/application/commands/update_account_command.py +++ b/app/context/user_account/application/commands/update_account_command.py @@ -1,16 +1,10 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.value_objects.account_id import AccountID -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency - @dataclass(frozen=True) class UpdateAccountCommand: - account_id: AccountID - user_id: UserID - name: AccountName - currency: Currency - balance: Balance + account_id: int + user_id: int + name: str + currency: str + balance: float diff --git a/app/context/user_account/application/dto/update_account_result.py b/app/context/user_account/application/dto/update_account_result.py index 78692f5..7ede4e7 100644 --- a/app/context/user_account/application/dto/update_account_result.py +++ b/app/context/user_account/application/dto/update_account_result.py @@ -1,9 +1,7 @@ from dataclasses import dataclass -from app.context.user_account.domain.value_objects.account_id import AccountID - @dataclass(frozen=True) class UpdateAccountResult: - account_id: AccountID + account_id: int message: str diff --git a/app/context/user_account/application/handlers/create_account_handler.py b/app/context/user_account/application/handlers/create_account_handler.py index 0fb3636..ac323a0 100644 --- a/app/context/user_account/application/handlers/create_account_handler.py +++ b/app/context/user_account/application/handlers/create_account_handler.py @@ -1,15 +1,25 @@ -from app.context.user_account.application.commands.create_account_command import ( +from app.context.user_account.application.commands import ( CreateAccountCommand, ) -from app.context.user_account.application.contracts.create_account_handler_contract import ( +from app.context.user_account.application.contracts import ( CreateAccountHandlerContract, ) -from app.context.user_account.application.dto.create_account_result import ( +from app.context.user_account.application.dto import ( CreateAccountResult, ) -from app.context.user_account.domain.contracts.services.create_account_service_contract import ( +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, +) class CreateAccountHandler(CreateAccountHandlerContract): @@ -21,14 +31,25 @@ def __init__(self, service: CreateAccountServiceContract): async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: """Execute the create account command""" - account_dto = await self._service.create_account( - user_id=command.user_id, - name=command.name, - currency=command.currency, - balance=command.balance, - ) + 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: - return CreateAccountResult(error="Error creating account") + if account_dto.account_id is None: + return CreateAccountResult(error="Error creating account") - return CreateAccountResult(account_id=account_dto.account_id.value) + 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="Account name already exist") + except UserAccountMapperError: + return CreateAccountResult(error="Error mapping model to dto") + except Exception: + return CreateAccountResult(error="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 index 2d75c63..a5e20b8 100644 --- a/app/context/user_account/application/handlers/delete_account_handler.py +++ b/app/context/user_account/application/handlers/delete_account_handler.py @@ -7,6 +7,10 @@ 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, +) class DeleteAccountHandler(DeleteAccountHandlerContract): @@ -16,5 +20,6 @@ def __init__(self, repository: UserAccountRepositoryContract): async def handle(self, command: DeleteAccountCommand) -> bool: # Call repository directly - no complex business logic needed return await self._repository.delete_account( - account_id=command.account_id, user_id=command.user_id + account_id=UserAccountID(command.account_id), + user_id=UserAccountUserID(command.user_id), ) 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 index 7d67544..52925a2 100644 --- 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 @@ -12,6 +12,10 @@ 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, +) class FindAccountByIdHandler(FindAccountByIdHandlerContract): @@ -19,11 +23,9 @@ def __init__(self, repository: UserAccountRepositoryContract): self._repository = repository async def handle(self, query: FindAccountByIdQuery) -> Optional[AccountResponseDTO]: - # Call repository directly (CQRS - queries bypass domain) - account = await self._repository.find_account(account_id=query.account_id) - - # Authorization: verify user owns the account - if account and account.user_id.value != query.user_id.value: - return None # Return 404, not 403 + account = await self._repository.find_user_accounts( + account_id=UserAccountID(query.account_id), + user_id=UserAccountUserID(query.user_id), + ) return AccountResponseDTO.from_domain_dto(account) if account else None 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 index 34b3fc1..922cb2c 100644 --- 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 @@ -10,6 +10,7 @@ from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( UserAccountRepositoryContract, ) +from app.context.user_account.domain.value_objects import UserAccountUserID class FindAccountsByUserHandler(FindAccountsByUserHandlerContract): @@ -17,5 +18,11 @@ def __init__(self, repository: UserAccountRepositoryContract): self._repository = repository async def handle(self, query: FindAccountsByUserQuery) -> list[AccountResponseDTO]: - accounts = await self._repository.find_accounts_by_user(user_id=query.user_id) - return [AccountResponseDTO.from_domain_dto(acc) for acc in accounts] + accounts = await self._repository.find_user_accounts( + user_id=UserAccountUserID(query.user_id) + ) + return ( + [AccountResponseDTO.from_domain_dto(acc) for acc in accounts] + if accounts is not None + else [] + ) diff --git a/app/context/user_account/application/handlers/update_account_handler.py b/app/context/user_account/application/handlers/update_account_handler.py index 96bf197..be6e03f 100644 --- a/app/context/user_account/application/handlers/update_account_handler.py +++ b/app/context/user_account/application/handlers/update_account_handler.py @@ -10,6 +10,13 @@ from app.context.user_account.domain.contracts.services.update_account_service_contract import ( UpdateAccountServiceContract, ) +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) class UpdateAccountHandler(UpdateAccountHandlerContract): @@ -18,11 +25,11 @@ def __init__(self, service: UpdateAccountServiceContract): async def handle(self, command: UpdateAccountCommand) -> UpdateAccountResult: updated = await self._service.update_account( - account_id=command.account_id, - user_id=command.user_id, - name=command.name, - currency=command.currency, - balance=command.balance, + 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), ) return UpdateAccountResult( 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 index d6ac14f..7af23e7 100644 --- 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 @@ -1,10 +1,7 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.value_objects.account_id import AccountID - @dataclass(frozen=True) class FindAccountByIdQuery: - account_id: AccountID - user_id: UserID + 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 index 30b9c8f..e077ac1 100644 --- 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 @@ -1,8 +1,6 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects.user_id import UserID - @dataclass(frozen=True) class FindAccountsByUserQuery: - user_id: UserID + user_id: int diff --git a/app/context/user_account/domain/contracts/infrastructure/__init__.py b/app/context/user_account/domain/contracts/infrastructure/__init__.py index e69de29..e9896ce 100644 --- a/app/context/user_account/domain/contracts/infrastructure/__init__.py +++ 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 index 47061c9..6996a51 100644 --- 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 @@ -1,9 +1,9 @@ from abc import ABC, abstractmethod from typing import Optional -from app.context.user.domain.value_objects.user_id import UserID from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO -from app.context.user_account.domain.value_objects.account_id import AccountID +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 @@ -22,15 +22,16 @@ async def save_account(self, account: UserAccountDTO) -> UserAccountDTO: UserAccountDTO of the created account Raises: - Exception if account with same user_id and name already exists + UserAccountMapperError if cannot map model to dto + UserAccountNameAlreadyExistError if account name already exist """ pass @abstractmethod async def find_account( self, - account_id: Optional[AccountID] = None, - user_id: Optional[UserID] = None, + account_id: Optional[UserAccountID] = None, + user_id: Optional[UserAccountUserID] = None, name: Optional[AccountName] = None, ) -> Optional[UserAccountDTO]: """ @@ -47,18 +48,36 @@ async def find_account( pass @abstractmethod - async def find_accounts_by_user(self, user_id: UserID) -> list[UserAccountDTO]: + async def find_user_accounts( + self, + user_id: UserAccountUserID, + account_id: Optional[UserAccountID] = None, + name: Optional[AccountName] = None, + only_active: Optional[bool] = True, + ) -> Optional[list[UserAccountDTO]]: """ - Find all non-deleted accounts for a user + Find user account always filtering by user_id (for user-scoped queries) Args: - user_id: User ID to search for + 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: - List of UserAccountDTO objects + UserAccountDTO if found, None otherwise """ pass + @abstractmethod + async def find_user_account_by_id( + self, + user_id: UserAccountUserID, + account_id: UserAccountID, + only_active: Optional[bool] = True, + ) -> Optional[UserAccountDTO]: + pass + @abstractmethod async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: """ @@ -76,7 +95,9 @@ async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: pass @abstractmethod - async def delete_account(self, account_id: AccountID, user_id: UserID) -> bool: + 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 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 index 4e3f170..bbf4e75 100644 --- 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 @@ -1,10 +1,12 @@ from abc import ABC, abstractmethod -from app.context.user.domain.value_objects.user_id import UserID from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountUserID, +) class CreateAccountServiceContract(ABC): @@ -12,7 +14,11 @@ class CreateAccountServiceContract(ABC): @abstractmethod async def create_account( - self, user_id: UserID, name: AccountName, currency: Currency, balance: Balance + self, + user_id: UserAccountUserID, + name: AccountName, + currency: UserAccountCurrency, + balance: UserAccountBalance, ) -> UserAccountDTO: """ Create a new user account @@ -27,6 +33,7 @@ async def create_account( AccountID of the created account Raises: - ValueError if account with same name already exists for user + 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 index 53a0e2c..a96597c 100644 --- 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 @@ -1,21 +1,23 @@ from abc import ABC, abstractmethod -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO -from app.context.user_account.domain.value_objects.account_id import AccountID -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency +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: AccountID, - user_id: UserID, + account_id: UserAccountID, + user_id: UserAccountUserID, name: AccountName, - currency: Currency, - balance: Balance, + 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 index e69de29..375a12e 100644 --- a/app/context/user_account/domain/dto/__init__.py +++ 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 index e7388f4..68e536c 100644 --- a/app/context/user_account/domain/dto/user_account_dto.py +++ b/app/context/user_account/domain/dto/user_account_dto.py @@ -1,19 +1,23 @@ from dataclasses import dataclass from typing import Optional -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.value_objects.account_id import AccountID -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency +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: UserID + user_id: UserAccountUserID name: AccountName - currency: Currency - balance: Balance - account_id: Optional[AccountID] = None + currency: UserAccountCurrency + balance: UserAccountBalance + account_id: Optional[UserAccountID] = None + deleted_at: Optional[UserAccountDeletedAt] = 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..d4cacaa --- /dev/null +++ b/app/context/user_account/domain/exceptions/__init__.py @@ -0,0 +1,3 @@ +from .exceptions import UserAccountMapperError, UserAccountNameAlreadyExistError + +__all__ = ["UserAccountMapperError", "UserAccountNameAlreadyExistError"] 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..3fc3fc5 --- /dev/null +++ b/app/context/user_account/domain/exceptions/exceptions.py @@ -0,0 +1,6 @@ +class UserAccountMapperError(Exception): + pass + + +class UserAccountNameAlreadyExistError(Exception): + pass diff --git a/app/context/user_account/domain/services/create_account_service.py b/app/context/user_account/domain/services/create_account_service.py index 7be2d6b..f42fc5d 100644 --- a/app/context/user_account/domain/services/create_account_service.py +++ b/app/context/user_account/domain/services/create_account_service.py @@ -1,4 +1,3 @@ -from app.context.user.domain.value_objects.user_id import UserID from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( UserAccountRepositoryContract, ) @@ -6,9 +5,12 @@ 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.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency class CreateAccountService(CreateAccountServiceContract): @@ -18,21 +20,14 @@ def __init__(self, account_repository: UserAccountRepositoryContract): self._account_repository = account_repository async def create_account( - self, user_id: UserID, name: AccountName, currency: Currency, balance: Balance + self, + user_id: UserAccountUserID, + name: AccountName, + currency: UserAccountCurrency, + balance: UserAccountBalance, ) -> UserAccountDTO: """Create a new user account with validation""" - # Check if account with same name already exists for this user - # TODO: Why should I select first to create the account or not - existing_account = await self._account_repository.find_account( - user_id=user_id, name=name - ) - - if existing_account: - raise ValueError( - f"Account with name '{name.value}' already exists for this user" - ) - # Create new account DTO (without ID, will be assigned by database) account_dto = UserAccountDTO( user_id=user_id, diff --git a/app/context/user_account/domain/services/update_account_service.py b/app/context/user_account/domain/services/update_account_service.py index 1da0b7a..90540d4 100644 --- a/app/context/user_account/domain/services/update_account_service.py +++ b/app/context/user_account/domain/services/update_account_service.py @@ -1,15 +1,17 @@ -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( +from app.context.user_account.domain.contracts.infrastructure import ( UserAccountRepositoryContract, ) -from app.context.user_account.domain.contracts.services.update_account_service_contract import ( +from app.context.user_account.domain.contracts.services import ( UpdateAccountServiceContract, ) -from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO -from app.context.user_account.domain.value_objects.account_id import AccountID -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + AccountName, + UserAccountBalance, + UserAccountCurrency, + UserAccountID, + UserAccountUserID, +) class UpdateAccountService(UpdateAccountServiceContract): @@ -18,23 +20,30 @@ def __init__(self, repository: UserAccountRepositoryContract): async def update_account( self, - account_id: AccountID, - user_id: UserID, + account_id: UserAccountID, + user_id: UserAccountUserID, name: AccountName, - currency: Currency, - balance: Balance, + currency: UserAccountCurrency, + balance: UserAccountBalance, ) -> UserAccountDTO: - # 1. Check account exists and user owns it - existing = await self._repository.find_account(account_id=account_id) + existing = await self._repository.find_user_accounts( + user_id=user_id, account_id=account_id + ) + if not existing: raise ValueError("Account not found") - if existing.user_id.value != user_id.value: - raise ValueError("Account not found") # Don't reveal it exists - # 2. If name changed, check for duplicates + # FIX: find_user_accounts use like instead of equal, error prone on this check if existing.name.value != name.value: - duplicate = await self._repository.find_account(user_id=user_id, name=name) - if duplicate and duplicate.account_id.value != account_id.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 duplicate.account_id + and duplicate.account_id.value != account_id.value + ): raise ValueError(f"Account with name '{name.value}' already exists") # 3. Update account diff --git a/app/context/user_account/domain/value_objects/__init__.py b/app/context/user_account/domain/value_objects/__init__.py index e69de29..67aa268 100644 --- a/app/context/user_account/domain/value_objects/__init__.py +++ 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 index 872fd8e..71e7f3d 100644 --- a/app/context/user_account/domain/value_objects/account_id.py +++ b/app/context/user_account/domain/value_objects/account_id.py @@ -1,20 +1,8 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass +from app.shared.domain.value_objects.shared_account_id import SharedAccountID -@dataclass(frozen=True) -class AccountID: - """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) -> "AccountID": - """Create AccountID from trusted source (e.g., database) - skips validation""" - return cls(value, _validated=True) +@dataclass(frozen=True) +class UserAccountID(SharedAccountID): + pass 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/mappers/__init__.py b/app/context/user_account/infrastructure/mappers/__init__.py index e69de29..e01c94e 100644 --- a/app/context/user_account/infrastructure/mappers/__init__.py +++ 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 index a9551b3..76c3ff7 100644 --- a/app/context/user_account/infrastructure/mappers/user_account_mapper.py +++ b/app/context/user_account/infrastructure/mappers/user_account_mapper.py @@ -1,9 +1,14 @@ -from app.context.user.domain.value_objects.user_id import UserID +from typing import Optional + from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO -from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.exceptions import UserAccountMapperError +from app.context.user_account.domain.value_objects import ( + UserAccountBalance, + UserAccountCurrency, + 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.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency from app.context.user_account.infrastructure.models.user_account_model import ( UserAccountModel, ) @@ -13,18 +18,29 @@ class UserAccountMapper: """Mapper for converting between UserAccountModel and UserAccountDTO""" @staticmethod - def toDTO(model: UserAccountModel) -> UserAccountDTO: + def to_dto(model: Optional[UserAccountModel]) -> Optional[UserAccountDTO]: """Convert database model to domain DTO""" - return UserAccountDTO( - account_id=AccountID.from_trusted_source(model.id), - user_id=UserID(model.user_id), - name=AccountName.from_trusted_source(model.name), - currency=Currency.from_trusted_source(model.currency), - balance=Balance.from_trusted_source(model.balance), + 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), + ) + if model + else None ) @staticmethod - def toModel(dto: UserAccountDTO) -> UserAccountModel: + 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, diff --git a/app/context/user_account/infrastructure/models/__init__.py b/app/context/user_account/infrastructure/models/__init__.py index e69de29..bdbbb4d 100644 --- a/app/context/user_account/infrastructure/models/__init__.py +++ 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/repositories/__init__.py b/app/context/user_account/infrastructure/repositories/__init__.py index e69de29..163c979 100644 --- a/app/context/user_account/infrastructure/repositories/__init__.py +++ 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 index f52d550..cff8297 100644 --- a/app/context/user_account/infrastructure/repositories/user_account_repository.py +++ b/app/context/user_account/infrastructure/repositories/user_account_repository.py @@ -1,21 +1,25 @@ -from datetime import datetime -from typing import Optional +from typing import Any, Optional, 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.domain.value_objects.user_id import UserID -from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( +from app.context.user_account.domain.contracts.infrastructure import ( UserAccountRepositoryContract, ) -from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO -from app.context.user_account.domain.value_objects.account_id import AccountID -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.infrastructure.mappers.user_account_mapper import ( +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.exceptions import UserAccountNameAlreadyExistError +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.user_account_model import ( +from app.context.user_account.infrastructure.models import ( UserAccountModel, ) @@ -28,35 +32,30 @@ def __init__(self, db: AsyncSession): async def save_account(self, account: UserAccountDTO) -> UserAccountDTO: """Create a new user account""" - # Convert DTO to model (without ID for new records) - model = UserAccountModel( - user_id=account.user_id.value, - name=account.name.value, - currency=account.currency.value, - balance=account.balance.value, - ) - + 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 ValueError( + raise UserAccountNameAlreadyExistError( f"Account with name '{account.name.value}' already exists for this user" ) from e - return UserAccountMapper.toDTO(model) + return UserAccountMapper.to_dto_or_fail(model) async def find_account( self, - account_id: Optional[AccountID] = None, - user_id: Optional[UserID] = None, + account_id: Optional[UserAccountID] = None, + user_id: Optional[UserAccountUserID] = None, name: Optional[AccountName] = None, + only_active: Optional[bool] = True, ) -> Optional[UserAccountDTO]: - """Find an account by ID or by user_id and name""" - stmt = select(UserAccountModel).where(UserAccountModel.deleted_at.is_(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) @@ -71,44 +70,78 @@ async def find_account( result = await self._db.execute(stmt) model = result.scalar_one_or_none() - return UserAccountMapper.toDTO(model) if model else None + return UserAccountMapper.to_dto(model) if model else None + + async def find_user_accounts( + self, + user_id: UserAccountUserID, + account_id: Optional[UserAccountID] = None, + name: Optional[AccountName] = None, + only_active: Optional[bool] = True, + ) -> Optional[list[UserAccountDTO]]: + """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)) - async def find_accounts_by_user(self, user_id: UserID) -> list[UserAccountDTO]: - """Find all non-deleted accounts for a user""" + 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: Optional[bool] = True, + ) -> Optional[UserAccountDTO]: 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) - models = result.scalars().all() - return [UserAccountMapper.toDTO(model) for model in models] + 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) + UserAccountModel.deleted_at.is_(None), ) .values( name=account.name.value, currency=account.currency.value, - balance=account.balance.value + balance=account.balance.value, ) ) - result = await self._db.execute(stmt) + result = cast(CursorResult[Any], await self._db.execute(stmt)) if result.rowcount == 0: raise ValueError("Account not found or already deleted") await self._db.commit() - # Fetch updated record - updated = await self.find_account(account_id=account.account_id) - return updated + return account - async def delete_account(self, account_id: AccountID, user_id: UserID) -> bool: + 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) @@ -121,12 +154,12 @@ async def delete_account(self, account_id: AccountID, user_id: UserID) -> bool: .where( UserAccountModel.id == account_id.value, UserAccountModel.user_id == user_id.value, - UserAccountModel.deleted_at.is_(None) + UserAccountModel.deleted_at.is_(None), ) - .values(deleted_at=datetime.utcnow()) + .values(deleted_at=UserAccountDeletedAt.now().value) ) - result = await self._db.execute(stmt) + 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/rest/controllers/create_account_controller.py b/app/context/user_account/interface/rest/controllers/create_account_controller.py index ec7fc5b..2e7b0ad 100644 --- a/app/context/user_account/interface/rest/controllers/create_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/create_account_controller.py @@ -1,15 +1,15 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user.domain.value_objects.user_id import UserID from app.context.user_account.application.commands.create_account_command import ( CreateAccountCommand, ) from app.context.user_account.application.contracts.create_account_handler_contract import ( CreateAccountHandlerContract, ) -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency +from app.context.user_account.domain.exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, +) from app.context.user_account.infrastructure.dependencies import ( get_create_account_handler, ) @@ -19,6 +19,7 @@ from app.context.user_account.interface.schemas.create_account_schema import ( CreateAccountRequest, ) +from app.shared.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -27,36 +28,30 @@ 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""" try: - # Convert request to command with value objects - # TODO: user_id from cookie header command = CreateAccountCommand( - user_id=UserID(1), - name=AccountName(request.name), - currency=Currency(request.currency), - balance=Balance.from_float(request.balance), + user_id=user_id, + name=request.name, + currency=request.currency, + balance=request.balance, ) - # Handle the command result = await handler.handle(command) if result.error is not None: raise HTTPException(status_code=400, detail=result.error) - # Return response return CreateAccountResponse( account_id=result.account_id, account_name=result.account_name, account_balance=result.account_balance, ) - - except ValueError as e: - # Business logic errors (duplicate account, validation errors, etc.) + except UserAccountNameAlreadyExistError as e: raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - # Unexpected errors + except UserAccountMapperError or Exception as e: raise HTTPException( status_code=500, detail=f"An unexpected error occurred: {str(e)}" ) 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 index 30bff78..5a3d8cd 100644 --- a/app/context/user_account/interface/rest/controllers/delete_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/delete_account_controller.py @@ -7,7 +7,7 @@ from app.context.user_account.application.contracts.delete_account_handler_contract import ( DeleteAccountHandlerContract, ) -from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_id import UserAccountID from app.context.user_account.infrastructure.dependencies import ( get_delete_account_handler, ) @@ -22,7 +22,8 @@ async def delete_account( ): """Delete a user account (soft delete)""" command = DeleteAccountCommand( - account_id=AccountID(account_id), user_id=UserID(1) # TODO: from cookie header + account_id=UserAccountID(account_id), + user_id=UserID(1), # TODO: from cookie header ) success = await handler.handle(command) 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 index c94ac33..5e3082c 100644 --- a/app/context/user_account/interface/rest/controllers/find_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -13,7 +13,7 @@ from app.context.user_account.application.queries.find_accounts_by_user_query import ( FindAccountsByUserQuery, ) -from app.context.user_account.domain.value_objects.account_id import AccountID +from app.context.user_account.domain.value_objects.account_id import UserAccountID from app.context.user_account.infrastructure.dependencies import ( get_find_account_by_id_handler, get_find_accounts_by_user_handler, @@ -30,7 +30,8 @@ async def get_account( ): """Get a specific user account by ID""" query = FindAccountByIdQuery( - account_id=AccountID(account_id), user_id=UserID(1) # TODO: from cookie header + account_id=UserAccountID(account_id), + user_id=UserID(1), # TODO: from cookie header ) result = await handler.handle(query) 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 index 5dd6038..8653ce4 100644 --- a/app/context/user_account/interface/rest/controllers/update_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/update_account_controller.py @@ -1,16 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user.domain.value_objects.user_id import UserID from app.context.user_account.application.commands.update_account_command import ( UpdateAccountCommand, ) from app.context.user_account.application.contracts.update_account_handler_contract import ( UpdateAccountHandlerContract, ) -from app.context.user_account.domain.value_objects.account_id import AccountID -from app.context.user_account.domain.value_objects.account_name import AccountName -from app.context.user_account.domain.value_objects.balance import Balance -from app.context.user_account.domain.value_objects.currency import Currency from app.context.user_account.infrastructure.dependencies import ( get_update_account_handler, ) @@ -20,6 +15,7 @@ from app.context.user_account.interface.schemas.update_account_schema import ( UpdateAccountRequest, ) +from app.shared.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -29,15 +25,16 @@ async def update_account( account_id: int, request: UpdateAccountRequest, handler: UpdateAccountHandlerContract = Depends(get_update_account_handler), + user_id: int = Depends(get_current_user_id), ): """Update a user account (full update - all fields required)""" try: command = UpdateAccountCommand( - account_id=AccountID(account_id), - user_id=UserID(1), # TODO: from cookie header - name=AccountName(request.name), - currency=Currency(request.currency), - balance=Balance.from_float(request.balance), + account_id=account_id, + user_id=user_id, + name=request.name, + currency=request.currency, + balance=request.balance, ) result = await handler.handle(command) diff --git a/app/main.py b/app/main.py index 73dc7f4..1448d3a 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ 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.user_account.interface.rest import user_account_routes app = FastAPI( @@ -14,6 +15,7 @@ app.include_router(auth_routes) app.include_router(user_account_routes) +app.include_router(credit_card_routes) @app.get("/") diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py index 24fd07f..34188f0 100644 --- a/app/shared/domain/value_objects/__init__.py +++ b/app/shared/domain/value_objects/__init__.py @@ -1,4 +1,17 @@ +from .shared_account_id import SharedAccountID +from .shared_balance import SharedBalance +from .shared_currency import SharedCurrency +from .shared_deleted_at import SharedDeletedAt from .shared_email import SharedEmail from .shared_password import SharedPassword +from .shared_user_id import SharedUserID -__all__ = ["SharedEmail", "SharedPassword"] +__all__ = [ + "SharedDeletedAt", + "SharedEmail", + "SharedPassword", + "SharedBalance", + "SharedCurrency", + "SharedAccountID", + "SharedUserID", +] 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/context/user_account/domain/value_objects/balance.py b/app/shared/domain/value_objects/shared_balance.py similarity index 86% rename from app/context/user_account/domain/value_objects/balance.py rename to app/shared/domain/value_objects/shared_balance.py index 3688a72..e0a5a26 100644 --- a/app/context/user_account/domain/value_objects/balance.py +++ b/app/shared/domain/value_objects/shared_balance.py @@ -1,9 +1,10 @@ from dataclasses import dataclass, field from decimal import Decimal +from typing import Self @dataclass(frozen=True) -class Balance: +class SharedBalance: """Value object for account balance (can be negative for overdrafts)""" value: Decimal @@ -20,11 +21,11 @@ def __post_init__(self): ) @classmethod - def from_float(cls, value: float) -> "Balance": + 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) -> "Balance": + 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/context/user_account/domain/value_objects/currency.py b/app/shared/domain/value_objects/shared_currency.py similarity index 65% rename from app/context/user_account/domain/value_objects/currency.py rename to app/shared/domain/value_objects/shared_currency.py index 4cbb9b1..dc19209 100644 --- a/app/context/user_account/domain/value_objects/currency.py +++ b/app/shared/domain/value_objects/shared_currency.py @@ -1,10 +1,9 @@ from dataclasses import dataclass, field +from typing import Self @dataclass(frozen=True) -class Currency: - """Value object for currency code (ISO 4217)""" - +class SharedCurrency: value: str _validated: bool = field(default=False, repr=False, compare=False) @@ -13,13 +12,17 @@ def __post_init__(self): 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)}") + 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}") + 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) -> "Currency": + 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_deleted_at.py b/app/shared/domain/value_objects/shared_deleted_at.py new file mode 100644 index 0000000..855750f --- /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 Optional, 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: Optional[datetime]) -> Optional[Self]: + """ + 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_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/infrastructure/middleware/__init__.py b/app/shared/infrastructure/middleware/__init__.py new file mode 100644 index 0000000..f55347a --- /dev/null +++ b/app/shared/infrastructure/middleware/__init__.py @@ -0,0 +1,3 @@ +from .session_auth_dependency import get_current_user_id + +__all__ = ["get_current_user_id"] 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..bffc6d1 --- /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 Optional + +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: 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( + access_token: Optional[str] = Cookie(default=None), + session_repo: SessionRepositoryContract = Depends(get_session_repository_for_auth), +) -> 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"}, + ) + except Exception as e: + # Unexpected error during session lookup + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Authentication service unavailable", + ) + + +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]: + """ + 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"}, + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Authentication service unavailable", + ) diff --git a/docs/ddd-patterns.md b/docs/ddd-patterns.md new file mode 100644 index 0000000..fbdb45e --- /dev/null +++ b/docs/ddd-patterns.md @@ -0,0 +1,992 @@ +# DDD & CQRS Implementation Patterns + +This document provides comprehensive guidance on implementing Domain-Driven Design (DDD) and Command Query Responsibility Segregation (CQRS) patterns in this codebase. + +## Table of Contents + +- [When to Use Commands vs Queries](#when-to-use-commands-vs-queries) +- [Command Data Flow](#command-data-flow) +- [Query Data Flow](#query-data-flow) +- [Interface Definitions (Contracts)](#interface-definitions-contracts) +- [Dependency Injection Setup](#dependency-injection-setup) +- [Commands and Queries Structure](#commands-and-queries-structure) +- [Complete Layer Communication Summary](#complete-layer-communication-summary) + +--- + +## When to Use Commands vs Queries + +### Use a Command When + +The operation **modifies state** (creates, updates, deletes): +- Business rules or validations are required +- Domain services need to be invoked +- Side effects occur (database writes, external API calls, events) + +**Examples:** +- `LoginCommand` - Creates a session/token +- `RegisterUserCommand` - Creates a new user +- `UpdateProfileCommand` - Modifies user data +- `DeleteAccountCommand` - Removes user data + +### Use a Query When + +The operation **only reads data** (no side effects): +- No business logic is needed, just data retrieval +- You're fetching data for display or decision-making +- No state changes occur + +**Examples:** +- `FindUserQuery` - Retrieves user by ID +- `GetUserProfileQuery` - Gets user profile data +- `ListUsersQuery` - Returns list of users +- `SearchEntriesQuery` - Searches for entries + +### Golden Rule + +**If it changes state → Command** +**If it only reads → Query** + +--- + +## Command Data Flow + +Commands flow through the system to execute business logic and modify state. + +### Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ COMMAND FLOW (Write Operations) │ +└─────────────────────────────────────────────────────────────────┘ + +1. Interface Layer (REST Controller) + ├─ Receives HTTP request + ├─ Validates with Pydantic schema (request) + └─ Creates Command object + ↓ +2. Application Layer (Command Handler) + ├─ Receives Command + ├─ Calls Domain Service (business logic) + └─ Returns Application DTO + ↓ +3. Domain Layer (Domain Service) + ├─ Executes business rules + ├─ Validates using Value Objects + ├─ Calls Repository Contract (interface) + └─ Returns Domain DTO + ↓ +4. Infrastructure Layer (Repository) + ├─ Receives domain data + ├─ Performs database operations + ├─ Maps SQLAlchemy models ↔ Domain DTOs + └─ Returns Domain DTO to service + ↓ +5. Back to Controller + └─ Converts Application DTO → Frozen Dataclass (response) +``` + +### Example: Login Command Flow + +#### 1. Interface Layer (Controller) + +```python +# File: app/context/auth/interface/rest/controllers/login_controller.py + +@router.post("/login") +async def login( + request: LoginRequest, # Pydantic (validation) + handler: LoginHandlerContract = Depends(get_login_handler), +) -> LoginResponse: # Frozen dataclass (performance) + # Create command from request + command = LoginCommand( + email=SharedEmail(request.email), + password=SharedPassword.from_plain_text(request.password), + ) + + # Execute via handler + result = await handler.handle(command) + + # Return frozen dataclass response + return LoginResponse( + message=result.message, + token=result.token, + ) +``` + +#### 2. Application Layer (Command Handler) + +```python +# File: app/context/auth/application/handlers/login_handler.py + +class LoginHandler(LoginHandlerContract): + def __init__(self, login_service: LoginServiceContract): + self._login_service = login_service + + async def handle(self, command: LoginCommand) -> LoginDTO: + # Delegate to domain service (business logic) + return await self._login_service.authenticate( + email=command.email, + password=command.password, + ) +``` + +#### 3. Domain Layer (Domain Service) + +```python +# File: app/context/auth/domain/services/login_service.py + +class LoginService(LoginServiceContract): + def __init__(self, user_repository: UserRepositoryContract): + self._user_repository = user_repository + + async def authenticate( + self, + email: SharedEmail, + password: SharedPassword, + ) -> LoginDTO: + # Business logic: find user + user = await self._user_repository.find_user(email=email) + + if not user: + raise ValueError("Invalid credentials") + + # Business logic: verify password + if not user.password.verify(password.value): + raise ValueError("Invalid credentials") + + # Business logic: generate token (example) + token = self._generate_token(user.user_id) + + # Return domain result + return LoginDTO( + user_id=user.user_id, + email=user.email, + message="Login successful", + token=token, + ) + + def _generate_token(self, user_id: UserID) -> str: + # Token generation logic here + pass +``` + +#### 4. Infrastructure Layer (Repository) + +```python +# File: app/context/user/infrastructure/repositories/user_repository.py + +class UserRepository(UserRepositoryContract): + def __init__(self, db: AsyncSession): + self._db = db + + async def find_user( + self, + email: Optional[SharedEmail] = None, + ) -> Optional[UserDTO]: + # Database query + query = select(UserModel).where(UserModel.email == email.value) + result = await self._db.execute(query) + model = result.scalar_one_or_none() + + # Map to domain DTO + return UserMapper.toDTO(model) if model else None +``` + +--- + +## Query Data Flow + +Queries bypass the domain layer and go directly to infrastructure for optimized read operations. + +### Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ QUERY FLOW (Read Operations) │ +└─────────────────────────────────────────────────────────────────┘ + +1. Interface Layer (REST Controller) + ├─ Receives HTTP request + ├─ Validates path/query parameters + └─ Creates Query object + ↓ +2. Application Layer (Query Handler) + ├─ Receives Query + ├─ Calls Repository Contract directly (NO domain service) + └─ Returns Application DTO + ↓ +3. Infrastructure Layer (Repository) + ├─ Performs database read + ├─ Maps SQLAlchemy model → Domain DTO + └─ Returns Domain DTO to handler + ↓ +4. Back to Controller + └─ Converts Application DTO → Frozen Dataclass (response) +``` + +### Key Difference + +**Queries skip the domain layer** because they don't need business logic - they're pure data retrieval. + +### Example: Find User Query Flow + +#### 1. Interface Layer (Controller) + +```python +# File: app/context/user/interface/rest/controllers/user_controller.py + +@router.get("/users/{user_id}") +async def get_user( + user_id: str, + handler: FindUserHandlerContract = Depends(get_find_user_handler), +) -> UserResponse: # Frozen dataclass + # Create query + query = FindUserQuery(user_id=UserID(user_id)) + + # Execute via handler + result = await handler.handle(query) + + if not result: + raise HTTPException(status_code=404, detail="User not found") + + # Return frozen dataclass response + return UserResponse( + user_id=result.user_id.value, + email=result.email.value, + created_at=result.created_at, + ) +``` + +#### 2. Application Layer (Query Handler) + +```python +# File: app/context/user/application/handlers/find_user_handler.py + +class FindUserHandler(FindUserHandlerContract): + def __init__(self, repository: UserRepositoryContract): + self._repository = repository + + async def handle(self, query: FindUserQuery) -> Optional[UserDTO]: + # Call repository directly (no domain service needed) + return await self._repository.find_user(user_id=query.user_id) +``` + +#### 3. Infrastructure Layer (Repository) + +```python +# File: app/context/user/infrastructure/repositories/user_repository.py + +class UserRepository(UserRepositoryContract): + def __init__(self, db: AsyncSession): + self._db = db + + async def find_user( + self, + user_id: Optional[UserID] = None, + ) -> Optional[UserDTO]: + # Database query + query = select(UserModel).where(UserModel.id == user_id.value) + result = await self._db.execute(query) + model = result.scalar_one_or_none() + + # Map to domain DTO + return UserMapper.toDTO(model) if model else None +``` + +--- + +## Interface Definitions (Contracts) + +All components are accessed via **contracts** (abstract base classes) to enable dependency injection and testability. + +### Why Contracts? + +1. **Dependency Inversion Principle** - High-level modules don't depend on low-level modules +2. **Testability** - Easy to mock for unit tests +3. **Flexibility** - Swap implementations without changing consumers +4. **Clear Interfaces** - Explicit contract between layers + +### Contract Types and Locations + +#### Domain Service Contract + +Located in `domain/contracts/{service}_contract.py` + +```python +# File: app/context/auth/domain/contracts/login_service_contract.py + +from abc import ABC, abstractmethod +from app.shared.domain.value_objects.shared_email import SharedEmail +from app.shared.domain.value_objects.shared_password import SharedPassword +from app.context.auth.domain.dto.login_dto import LoginDTO + + +class LoginServiceContract(ABC): + """Contract for login domain service.""" + + @abstractmethod + async def authenticate( + self, + email: SharedEmail, + password: SharedPassword, + ) -> LoginDTO: + """ + Authenticate user with email and password. + + Args: + email: User's email address + password: User's password (plain text) + + Returns: + LoginDTO with user info and token + + Raises: + ValueError: If credentials are invalid + """ + pass +``` + +#### Handler Contract + +Located in `application/contracts/{handler}_contract.py` + +```python +# File: app/context/auth/application/contracts/login_handler_contract.py + +from abc import ABC, abstractmethod +from app.context.auth.application.commands.login_command import LoginCommand +from app.context.auth.application.dto.login_dto import LoginDTO + + +class LoginHandlerContract(ABC): + """Contract for login command handler.""" + + @abstractmethod + async def handle(self, command: LoginCommand) -> LoginDTO: + """ + Handle login command. + + Args: + command: Login command with credentials + + Returns: + LoginDTO with result + """ + pass +``` + +#### Repository Contract + +Located in `domain/contracts/{repository}_contract.py` + +```python +# File: app/context/user/domain/contracts/user_repository_contract.py + +from abc import ABC, abstractmethod +from typing import Optional +from app.shared.domain.value_objects.shared_email import SharedEmail +from app.context.user.domain.value_objects.user_id import UserID +from app.context.user.domain.dto.user_dto import UserDTO + + +class UserRepositoryContract(ABC): + """Contract for user repository.""" + + @abstractmethod + async def find_user( + self, + user_id: Optional[UserID] = None, + email: Optional[SharedEmail] = None, + ) -> Optional[UserDTO]: + """ + Find user by ID or email. + + Args: + user_id: User ID to search for + email: Email to search for + + Returns: + UserDTO if found, None otherwise + """ + pass + + @abstractmethod + async def create_user(self, user: UserDTO) -> UserDTO: + """ + Create new user. + + Args: + user: User data to create + + Returns: + Created user DTO + """ + pass +``` + +### Contract Placement Rules + +| Contract Type | Location | Reason | +|--------------|----------|--------| +| **Domain Service Contract** | `domain/contracts/` | Domain defines its own interfaces | +| **Repository Contract** | `domain/contracts/` | Domain defines data needs (Dependency Inversion) | +| **Handler Contract** | `application/contracts/` | Application layer owns handlers | + +### Why Repositories in Domain Contracts? + +This follows the **Dependency Inversion Principle**: +- Domain layer defines **what data it needs** (the interface) +- Infrastructure layer provides **how to get it** (the implementation) +- Domain never depends on infrastructure + +--- + +## Dependency Injection Setup + +All dependencies are wired in `infrastructure/dependencies.py` using FastAPI's `Depends()` mechanism. + +### Dependency Chain + +Dependencies are built **bottom-up** (from infrastructure to interface): + +``` +get_db (shared infrastructure) + ↓ +get_repository (infrastructure) + ↓ +get_domain_service (domain) + ↓ +get_handler (application) + ↓ +controller (interface) +``` + +### Complete Example: Auth Context Dependencies + +```python +# File: app/context/auth/infrastructure/dependencies.py + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.shared.infrastructure.database import get_db + +# Cross-context dependency (from User context) +from app.context.user.application.contracts.find_user_handler_contract import ( + FindUserHandlerContract, +) +from app.context.user.infrastructure.dependencies import get_find_user_handler + +from app.context.auth.domain.contracts.login_service_contract import ( + LoginServiceContract, +) +from app.context.auth.domain.services.login_service import LoginService +from app.context.auth.application.contracts.login_handler_contract import ( + LoginHandlerContract, +) +from app.context.auth.application.handlers.login_handler import LoginHandler + + +# ───────────────────────────────────────────────────────────────── +# DOMAIN SERVICE LAYER +# ───────────────────────────────────────────────────────────────── + +def get_login_service( + # Inject handler from another context (cross-context communication) + find_user_handler: FindUserHandlerContract = Depends(get_find_user_handler), +) -> LoginServiceContract: + """ + Inject login domain service. + + Returns: + LoginServiceContract implementation + """ + return LoginService(find_user_handler) + + +# ───────────────────────────────────────────────────────────────── +# APPLICATION HANDLER LAYER +# ───────────────────────────────────────────────────────────────── + +def get_login_handler( + login_service: LoginServiceContract = Depends(get_login_service), +) -> LoginHandlerContract: + """ + Inject login command handler. + + Returns: + LoginHandlerContract implementation + """ + return LoginHandler(login_service) +``` + +### User Context Dependencies + +```python +# File: app/context/user/infrastructure/dependencies.py + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession +from app.shared.infrastructure.database import get_db + +from app.context.user.domain.contracts.user_repository_contract import ( + UserRepositoryContract, +) +from app.context.user.infrastructure.repositories.user_repository import ( + UserRepository, +) +from app.context.user.application.contracts.find_user_handler_contract import ( + FindUserHandlerContract, +) +from app.context.user.application.handlers.find_user_handler import FindUserHandler + + +# ───────────────────────────────────────────────────────────────── +# REPOSITORY LAYER +# ───────────────────────────────────────────────────────────────── + +def get_user_repository( + db: AsyncSession = Depends(get_db), +) -> UserRepositoryContract: + """ + Inject user repository. + + Args: + db: Database session from shared infrastructure + + Returns: + UserRepositoryContract implementation + """ + return UserRepository(db) + + +# ───────────────────────────────────────────────────────────────── +# APPLICATION HANDLER LAYER +# ───────────────────────────────────────────────────────────────── + +def get_find_user_handler( + repository: UserRepositoryContract = Depends(get_user_repository), +) -> FindUserHandlerContract: + """ + Inject find user query handler. + + Returns: + FindUserHandlerContract implementation + """ + return FindUserHandler(repository) +``` + +### Dependency Injection Rules + +#### 1. Always Inject Contracts, Never Implementations + +```python +# ✅ CORRECT - Inject contract +def get_handler( + service: ServiceContract = Depends(get_service), +) -> HandlerContract: + return Handler(service) + +# ❌ WRONG - Inject concrete implementation +def get_handler( + service: ConcreteService = Depends(get_service), +) -> HandlerContract: + return Handler(service) +``` + +#### 2. Database Sessions Come from Shared Infrastructure + +```python +# ✅ CORRECT +from app.shared.infrastructure.database import get_db + +def get_repository( + db: AsyncSession = Depends(get_db), +) -> RepositoryContract: + return Repository(db) +``` + +#### 3. Cross-Context Dependencies Use Handler Contracts + +When one context needs data from another context: + +```python +# ✅ CORRECT - Auth context uses User context via handler contract +from app.context.user.application.contracts.find_user_handler_contract import ( + FindUserHandlerContract, +) +from app.context.user.infrastructure.dependencies import get_find_user_handler + +def get_auth_service( + user_handler: FindUserHandlerContract = Depends(get_find_user_handler), +) -> AuthServiceContract: + return AuthService(user_handler) +``` + +```python +# ❌ WRONG - Never access repositories directly across contexts +from app.context.user.infrastructure.repositories.user_repository import UserRepository + +def get_auth_service( + user_repo: UserRepository = Depends(...), # WRONG! +): + return AuthService(user_repo) +``` + +#### 4. Return Type Must Match Contract + +```python +# ✅ CORRECT +def get_service() -> ServiceContract: + return ConcreteService() # ConcreteService implements ServiceContract + +# ❌ WRONG +def get_service() -> ConcreteService: # Should return contract type + return ConcreteService() +``` + +--- + +## Commands and Queries Structure + +### Command Structure + +Commands are immutable data containers representing write operations. + +```python +# File: app/context/{context}/application/commands/{action}_command.py + +from dataclasses import dataclass +from app.shared.domain.value_objects.shared_email import SharedEmail +from app.shared.domain.value_objects.shared_password import SharedPassword + + +@dataclass(frozen=True) +class LoginCommand: + """ + Command to authenticate a user. + + Attributes: + email: User's email address + password: User's password (plain text, will be hashed) + """ + email: SharedEmail + password: SharedPassword +``` + +### Query Structure + +Queries are immutable data containers representing read operations. + +```python +# File: app/context/{context}/application/queries/{action}_query.py + +from dataclasses import dataclass +from typing import Optional +from app.context.user.domain.value_objects.user_id import UserID +from app.shared.domain.value_objects.shared_email import SharedEmail + + +@dataclass(frozen=True) +class FindUserQuery: + """ + Query to find a user by ID or email. + + Attributes: + user_id: User ID to search for (optional) + email: Email to search for (optional) + """ + user_id: Optional[UserID] = None + email: Optional[SharedEmail] = None +``` + +### Key Characteristics + +| Aspect | Commands | Queries | +|--------|----------|---------| +| **Mutability** | Frozen (`frozen=True`) | Frozen (`frozen=True`) | +| **Purpose** | Represent write operations | Represent read operations | +| **Fields** | Usually **required** | Often **optional** (for filtering) | +| **Types** | Use Value Objects | Use Value Objects | +| **Logic** | No logic, just data | No logic, just data | + +### Best Practices + +1. **Always use Value Objects, not primitives** + ```python + # ✅ CORRECT + @dataclass(frozen=True) + class CreateUserCommand: + email: SharedEmail + password: SharedPassword + + # ❌ WRONG + @dataclass(frozen=True) + class CreateUserCommand: + email: str # Should be SharedEmail + password: str # Should be SharedPassword + ``` + +2. **Make them immutable with `frozen=True`** + ```python + # ✅ CORRECT + @dataclass(frozen=True) + class UpdateProfileCommand: + user_id: UserID + name: str + + # ❌ WRONG + @dataclass # Missing frozen=True + class UpdateProfileCommand: + user_id: UserID + name: str + ``` + +3. **Keep them simple - no methods or logic** + ```python + # ✅ CORRECT + @dataclass(frozen=True) + class LoginCommand: + email: SharedEmail + password: SharedPassword + + # ❌ WRONG + @dataclass(frozen=True) + class LoginCommand: + email: SharedEmail + password: SharedPassword + + def validate(self): # NO LOGIC IN COMMANDS! + if not self.email: + raise ValueError("Email required") + ``` + +--- + +## Complete Layer Communication Summary + +### Visual Layer Communication + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ LAYER COMMUNICATION RULES │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ INTERFACE LAYER (REST Controllers) │ +│ ├─ Depends on: Application Handlers (via contracts) │ +│ ├─ Receives: Pydantic schemas (request validation) │ +│ ├─ Creates: Commands/Queries │ +│ └─ Returns: Frozen dataclasses (response) │ +│ │ +│ ↓ │ +│ │ +│ APPLICATION LAYER (Handlers) │ +│ ├─ Depends on: Domain Services (Commands) OR │ +│ │ Repositories (Queries) │ +│ ├─ Receives: Commands/Queries │ +│ ├─ Orchestrates: Domain services or repository calls │ +│ └─ Returns: Application DTOs │ +│ │ +│ ↓ │ +│ │ +│ DOMAIN LAYER (Services) │ +│ ├─ Depends on: Repository Contracts ONLY │ +│ ├─ Contains: Business logic and rules │ +│ ├─ Uses: Value Objects, Domain DTOs │ +│ └─ Returns: Domain DTOs │ +│ │ +│ ↓ │ +│ │ +│ INFRASTRUCTURE LAYER (Repositories) │ +│ ├─ Depends on: Database session │ +│ ├─ Implements: Repository Contracts │ +│ ├─ Uses: SQLAlchemy models, Mappers │ +│ └─ Returns: Domain DTOs │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Dependency Flow Rules + +**CRITICAL RULE**: Dependencies only flow **INWARD** and **DOWNWARD** + +``` + ┌─────────────┐ + │ Interface │ (depends on Application) + └──────┬──────┘ + │ + ↓ + ┌─────────────┐ + │ Application │ (depends on Domain) + └──────┬──────┘ + │ + ↓ + ┌─────────────┐ + │ Domain │ (depends on NOTHING) + └──────┬──────┘ + ↑ + │ (implements contracts) + │ + ┌─────────────┐ + │Infrastructure│ + └─────────────┘ +``` + +### Layer Responsibilities + +#### Interface Layer +- **Concerns**: HTTP, REST, serialization +- **Depends on**: Application handlers (contracts) +- **Returns**: Frozen dataclasses for responses +- **Never**: Contains business logic + +#### Application Layer +- **Concerns**: Use case orchestration +- **Depends on**: Domain services (commands) or repositories (queries) +- **Coordinates**: Multiple domain services if needed +- **Never**: Contains business logic + +#### Domain Layer +- **Concerns**: Business rules and logic +- **Depends on**: Repository contracts only +- **Contains**: All business validation and rules +- **Never**: Depends on outer layers + +#### Infrastructure Layer +- **Concerns**: External systems (database, APIs, file system) +- **Implements**: Domain contracts +- **Uses**: Mappers to convert between models and DTOs +- **Never**: Contains business logic + +### Cross-Context Communication + +When Context A needs data from Context B: + +```python +# ✅ CORRECT - Use handler contracts +# Auth context needs User data + +# 1. Import handler contract from User context +from app.context.user.application.contracts.find_user_handler_contract import ( + FindUserHandlerContract, +) + +# 2. Import dependency function +from app.context.user.infrastructure.dependencies import get_find_user_handler + +# 3. Inject in Auth context +def get_login_service( + find_user_handler: FindUserHandlerContract = Depends(get_find_user_handler), +) -> LoginServiceContract: + return LoginService(find_user_handler) +``` + +```python +# ❌ WRONG - Never access repositories directly +from app.context.user.infrastructure.repositories.user_repository import UserRepository + +def get_login_service( + user_repo: UserRepository = Depends(...), # WRONG! +): + return LoginService(user_repo) +``` + +### Common Anti-Patterns to Avoid + +#### 1. Domain Depending on Infrastructure + +```python +# ❌ WRONG +# File: domain/services/user_service.py +from app.context.user.infrastructure.models.user_model import UserModel # WRONG! + +class UserService: + def create_user(self, model: UserModel): # WRONG! + pass +``` + +```python +# ✅ CORRECT +# File: domain/services/user_service.py +from app.context.user.domain.dto.user_dto import UserDTO + +class UserService: + def create_user(self, user: UserDTO): + pass +``` + +#### 2. Putting Business Logic in Controllers + +```python +# ❌ WRONG +@router.post("/login") +async def login(request: LoginRequest): + # Business logic in controller - WRONG! + user = await db.execute(select(UserModel)...) + if not user: + raise HTTPException(400) + if not verify_password(request.password, user.password): + raise HTTPException(400) + return {"token": generate_token(user.id)} +``` + +```python +# ✅ CORRECT +@router.post("/login") +async def login( + request: LoginRequest, + handler: LoginHandlerContract = Depends(get_login_handler), +): + command = LoginCommand(email=request.email, password=request.password) + result = await handler.handle(command) + return LoginResponse(token=result.token) +``` + +#### 3. Skipping the Handler Layer + +```python +# ❌ WRONG +@router.post("/users") +async def create_user( + request: CreateUserRequest, + service: UserServiceContract = Depends(get_user_service), # WRONG! +): + # Controller calling service directly - skip handler + result = await service.create_user(...) +``` + +```python +# ✅ CORRECT +@router.post("/users") +async def create_user( + request: CreateUserRequest, + handler: CreateUserHandlerContract = Depends(get_create_user_handler), +): + command = CreateUserCommand(...) + result = await handler.handle(command) + return UserResponse(...) +``` + +--- + +## Summary Checklist + +When implementing new features, ensure: + +- [ ] **Commands** for state changes, **Queries** for reads +- [ ] **Commands** flow: Interface → Handler → Domain Service → Repository +- [ ] **Queries** flow: Interface → Handler → Repository (skip domain) +- [ ] All components accessed via **contracts** (interfaces) +- [ ] Dependencies defined in `infrastructure/dependencies.py` +- [ ] Always inject **contracts**, never implementations +- [ ] Cross-context communication via **handler contracts** +- [ ] Domain layer has **no dependencies** on outer layers +- [ ] Business logic **only** in domain services +- [ ] Controllers return **frozen dataclasses** for responses +- [ ] Use **Value Objects** instead of primitives +- [ ] Repository contracts in **domain/contracts** +- [ ] Infrastructure **implements** domain contracts + +--- + +**Remember**: The goal of DDD and CQRS is to create a maintainable, testable, and flexible codebase by clearly separating concerns and enforcing dependency rules. When in doubt, ask: "Does this violate the dependency rule?" and "Is business logic where it belongs (domain layer)?" 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..ac4d870 --- /dev/null +++ b/migrations/versions/e19a954402db_create_credit_cards_table.py @@ -0,0 +1,44 @@ +"""Create credit-cards table + +Revision ID: e19a954402db +Revises: d756346442c3 +Create Date: 2025-12-25 20:59:00.164701 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "e19a954402db" +down_revision: Union[str, Sequence[str], None] = "d756346442c3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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(), 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/requests/base.sh b/requests/base.sh new file mode 100644 index 0000000..fd57a67 --- /dev/null +++ b/requests/base.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +export MYAPP_URL="http://localhost:8080/" + diff --git a/requests/login.sh b/requests/login.sh new file mode 100755 index 0000000..bd6c3a9 --- /dev/null +++ b/requests/login.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +source ./base.sh + +http --session=local_session POST "${MYAPP_URL}/api/auth/login" \ + email=user1@test.com \ + password=testonga diff --git a/requests/user_account_create.sh b/requests/user_account_create.sh new file mode 100755 index 0000000..6d4c5f7 --- /dev/null +++ b/requests/user_account_create.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +source ./base.sh + + http --session=local_session POST "${MYAPP_URL}/api/user-accounts/accounts" \ + name="my pindonga account" \ + balance=554.34 \ + currency="USD" diff --git a/requests/user_account_list.sh b/requests/user_account_list.sh new file mode 100755 index 0000000..944627e --- /dev/null +++ b/requests/user_account_list.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +source ./base.sh + + http --session=local_session GET "${MYAPP_URL}/api/user-accounts/accounts" From b789513d40d4bc16e6453436e1c540934f97f462 Mon Sep 17 00:00:00 2001 From: polivera Date: Sat, 27 Dec 2025 12:31:12 +0100 Subject: [PATCH 20/58] Fixing user_account functionality and code quality --- .claude/rules/code-style.md | 337 ++++++++++++++++++ CLAUDE.md | 208 +++++++++++ .../delete_account_handler_contract.py | 7 +- .../user_account/application/dto/__init__.py | 15 +- .../application/dto/create_account_result.py | 15 +- .../application/dto/delete_account_result.py | 22 ++ .../application/dto/update_account_result.py | 23 +- .../handlers/create_account_handler.py | 21 +- .../handlers/delete_account_handler.py | 37 +- .../handlers/update_account_handler.py | 65 +++- .../domain/exceptions/__init__.py | 12 +- .../domain/exceptions/exceptions.py | 4 + .../domain/services/update_account_service.py | 22 +- .../models/user_account_model.py | 4 +- .../repositories/user_account_repository.py | 15 +- .../controllers/create_account_controller.py | 63 ++-- .../controllers/delete_account_controller.py | 32 +- .../controllers/find_account_controller.py | 11 +- .../controllers/update_account_controller.py | 63 ++-- .../schemas/update_account_response.py | 5 +- app/shared/domain/value_objects/__init__.py | 2 + .../domain/value_objects/shared_date.py | 28 ++ .../domain/value_objects/shared_deleted_at.py | 4 +- .../infrastructure/middleware/__init__.py | 7 +- ...make_deleted_at_timezone_aware_in_user_.py | 42 +++ requests/user_account_create.sh | 6 +- requests/user_account_delete.sh | 5 + requests/user_account_update.sh | 10 + 28 files changed, 953 insertions(+), 132 deletions(-) create mode 100644 app/context/user_account/application/dto/delete_account_result.py create mode 100644 app/shared/domain/value_objects/shared_date.py create mode 100644 migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py create mode 100755 requests/user_account_delete.sh create mode 100755 requests/user_account_update.sh diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 781c72f..2408c84 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -125,6 +125,72 @@ class UserMapper: ) ``` +### 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: @@ -416,6 +482,196 @@ 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 @@ -445,6 +701,87 @@ async def login( 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 diff --git a/CLAUDE.md b/CLAUDE.md index 9a7ed11..4f1246f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,6 +187,214 @@ class Email: 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 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 index 5bf2722..f15fdde 100644 --- a/app/context/user_account/application/contracts/delete_account_handler_contract.py +++ b/app/context/user_account/application/contracts/delete_account_handler_contract.py @@ -1,11 +1,14 @@ from abc import ABC, abstractmethod -from app.context.user_account.application.commands.delete_account_command import ( +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) -> bool: + async def handle(self, command: DeleteAccountCommand) -> DeleteAccountResult: pass diff --git a/app/context/user_account/application/dto/__init__.py b/app/context/user_account/application/dto/__init__.py index dbc4cfd..047050a 100644 --- a/app/context/user_account/application/dto/__init__.py +++ b/app/context/user_account/application/dto/__init__.py @@ -1,5 +1,14 @@ from .account_response_dto import AccountResponseDTO -from .create_account_result import CreateAccountResult -from .update_account_result import UpdateAccountResult +from .create_account_result import CreateAccountErrorCode, CreateAccountResult +from .delete_account_result import DeleteAccountErrorCode, DeleteAccountResult +from .update_account_result import UpdateAccountErrorCode, UpdateAccountResult -__all__ = ["CreateAccountResult", "AccountResponseDTO", "UpdateAccountResult"] +__all__ = [ + "AccountResponseDTO", + "CreateAccountResult", + "CreateAccountErrorCode", + "DeleteAccountResult", + "DeleteAccountErrorCode", + "UpdateAccountResult", + "UpdateAccountErrorCode", +] diff --git a/app/context/user_account/application/dto/create_account_result.py b/app/context/user_account/application/dto/create_account_result.py index 43e31f1..547fa25 100644 --- a/app/context/user_account/application/dto/create_account_result.py +++ b/app/context/user_account/application/dto/create_account_result.py @@ -1,12 +1,25 @@ 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: Optional[str] = None + + # Error fields - populated when operation fails + error_code: Optional[CreateAccountErrorCode] = None + error_message: Optional[str] = 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..655d465 --- /dev/null +++ b/app/context/user_account/application/dto/delete_account_result.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[DeleteAccountErrorCode] = None + error_message: Optional[str] = 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 index 7ede4e7..3e21006 100644 --- a/app/context/user_account/application/dto/update_account_result.py +++ b/app/context/user_account/application/dto/update_account_result.py @@ -1,7 +1,26 @@ from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: - account_id: int - message: str + """Result of account update 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[UpdateAccountErrorCode] = None + error_message: Optional[str] = None diff --git a/app/context/user_account/application/handlers/create_account_handler.py b/app/context/user_account/application/handlers/create_account_handler.py index ac323a0..f61c827 100644 --- a/app/context/user_account/application/handlers/create_account_handler.py +++ b/app/context/user_account/application/handlers/create_account_handler.py @@ -5,6 +5,7 @@ CreateAccountHandlerContract, ) from app.context.user_account.application.dto import ( + CreateAccountErrorCode, CreateAccountResult, ) from app.context.user_account.domain.contracts.services import ( @@ -40,7 +41,10 @@ async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: ) if account_dto.account_id is None: - return CreateAccountResult(error="Error creating account") + return CreateAccountResult( + error_code=CreateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Error creating account", + ) return CreateAccountResult( account_id=account_dto.account_id.value, @@ -48,8 +52,17 @@ async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: account_balance=float(account_dto.balance.value), ) except UserAccountNameAlreadyExistError: - return CreateAccountResult(error="Account name already exist") + return CreateAccountResult( + error_code=CreateAccountErrorCode.NAME_ALREADY_EXISTS, + error_message="Account name already exist", + ) except UserAccountMapperError: - return CreateAccountResult(error="Error mapping model to dto") + return CreateAccountResult( + error_code=CreateAccountErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) except Exception: - return CreateAccountResult(error="Unexpected error") + 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 index a5e20b8..7d31330 100644 --- a/app/context/user_account/application/handlers/delete_account_handler.py +++ b/app/context/user_account/application/handlers/delete_account_handler.py @@ -1,10 +1,14 @@ -from app.context.user_account.application.commands.delete_account_command import ( +from app.context.user_account.application.commands import ( DeleteAccountCommand, ) -from app.context.user_account.application.contracts.delete_account_handler_contract import ( +from app.context.user_account.application.contracts import ( DeleteAccountHandlerContract, ) -from app.context.user_account.domain.contracts.infrastructure.user_account_repository_contract import ( +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 ( @@ -17,9 +21,24 @@ class DeleteAccountHandler(DeleteAccountHandlerContract): def __init__(self, repository: UserAccountRepositoryContract): self._repository = repository - async def handle(self, command: DeleteAccountCommand) -> bool: - # Call repository directly - no complex business logic needed - return await self._repository.delete_account( - account_id=UserAccountID(command.account_id), - user_id=UserAccountUserID(command.user_id), - ) + 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: + return DeleteAccountResult( + error_code=DeleteAccountErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/user_account/application/handlers/update_account_handler.py b/app/context/user_account/application/handlers/update_account_handler.py index be6e03f..43eea16 100644 --- a/app/context/user_account/application/handlers/update_account_handler.py +++ b/app/context/user_account/application/handlers/update_account_handler.py @@ -1,15 +1,21 @@ -from app.context.user_account.application.commands.update_account_command import ( +from app.context.user_account.application.commands import ( UpdateAccountCommand, ) -from app.context.user_account.application.contracts.update_account_handler_contract import ( +from app.context.user_account.application.contracts import ( UpdateAccountHandlerContract, ) -from app.context.user_account.application.dto.update_account_result import ( +from app.context.user_account.application.dto import ( + UpdateAccountErrorCode, UpdateAccountResult, ) -from app.context.user_account.domain.contracts.services.update_account_service_contract import ( +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, @@ -24,14 +30,45 @@ def __init__(self, service: UpdateAccountServiceContract): self._service = service async def handle(self, command: UpdateAccountCommand) -> UpdateAccountResult: - 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), - ) + """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: + return UpdateAccountResult( + error_code=UpdateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Error updating account", + ) - return UpdateAccountResult( - account_id=updated.account_id, message="Account updated successfully" - ) + 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: + return UpdateAccountResult( + error_code=UpdateAccountErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) diff --git a/app/context/user_account/domain/exceptions/__init__.py b/app/context/user_account/domain/exceptions/__init__.py index d4cacaa..ad9b0af 100644 --- a/app/context/user_account/domain/exceptions/__init__.py +++ b/app/context/user_account/domain/exceptions/__init__.py @@ -1,3 +1,11 @@ -from .exceptions import UserAccountMapperError, UserAccountNameAlreadyExistError +from .exceptions import ( + UserAccountMapperError, + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) -__all__ = ["UserAccountMapperError", "UserAccountNameAlreadyExistError"] +__all__ = [ + "UserAccountMapperError", + "UserAccountNameAlreadyExistError", + "UserAccountNotFoundError", +] diff --git a/app/context/user_account/domain/exceptions/exceptions.py b/app/context/user_account/domain/exceptions/exceptions.py index 3fc3fc5..fc27a94 100644 --- a/app/context/user_account/domain/exceptions/exceptions.py +++ b/app/context/user_account/domain/exceptions/exceptions.py @@ -4,3 +4,7 @@ class UserAccountMapperError(Exception): class UserAccountNameAlreadyExistError(Exception): pass + + +class UserAccountNotFoundError(Exception): + pass diff --git a/app/context/user_account/domain/services/update_account_service.py b/app/context/user_account/domain/services/update_account_service.py index 90540d4..93ff514 100644 --- a/app/context/user_account/domain/services/update_account_service.py +++ b/app/context/user_account/domain/services/update_account_service.py @@ -5,6 +5,10 @@ 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, @@ -26,12 +30,14 @@ async def update_account( currency: UserAccountCurrency, balance: UserAccountBalance, ) -> UserAccountDTO: - existing = await self._repository.find_user_accounts( + existing = await self._repository.find_user_account_by_id( user_id=user_id, account_id=account_id ) if not existing: - raise ValueError("Account not found") + 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: @@ -39,12 +45,14 @@ async def update_account( duplicate = await self._repository.find_user_accounts( user_id=user_id, name=name, only_active=False ) - if ( - duplicate - and duplicate.account_id - and duplicate.account_id.value != account_id.value + + if duplicate and any( + acc.account_id and acc.account_id.value != account_id.value + for acc in duplicate ): - raise ValueError(f"Account with name '{name.value}' already exists") + raise UserAccountNameAlreadyExistError( + f"Account with name '{name.value}' already exists" + ) # 3. Update account updated_dto = UserAccountDTO( diff --git a/app/context/user_account/infrastructure/models/user_account_model.py b/app/context/user_account/infrastructure/models/user_account_model.py index 4745dab..9e1efcc 100644 --- a/app/context/user_account/infrastructure/models/user_account_model.py +++ b/app/context/user_account/infrastructure/models/user_account_model.py @@ -18,4 +18,6 @@ class UserAccountModel(BaseDBModel): 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[Optional[datetime]] = mapped_column(DateTime, nullable=True, default=None) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) diff --git a/app/context/user_account/infrastructure/repositories/user_account_repository.py b/app/context/user_account/infrastructure/repositories/user_account_repository.py index cff8297..96cfc31 100644 --- a/app/context/user_account/infrastructure/repositories/user_account_repository.py +++ b/app/context/user_account/infrastructure/repositories/user_account_repository.py @@ -9,7 +9,10 @@ UserAccountRepositoryContract, ) from app.context.user_account.domain.dto import UserAccountDTO -from app.context.user_account.domain.exceptions import UserAccountNameAlreadyExistError +from app.context.user_account.domain.exceptions import ( + UserAccountNameAlreadyExistError, + UserAccountNotFoundError, +) from app.context.user_account.domain.value_objects import ( AccountName, UserAccountDeletedAt, @@ -55,7 +58,7 @@ async def find_account( """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)) + stmt = stmt.where(UserAccountModel.deleted_at.is_(None)) if account_id is not None: stmt = stmt.where(UserAccountModel.id == account_id.value) @@ -82,7 +85,7 @@ async def find_user_accounts( """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)) + stmt = stmt.where(UserAccountModel.deleted_at.is_(None)) if account_id is not None: stmt = stmt.where(UserAccountModel.id == account_id.value) @@ -108,7 +111,7 @@ async def find_user_account_by_id( UserAccountModel.user_id == user_id.value, ) if only_active: - stmt = stmt.where(UserAccountModel.deleted_at._is(None)) + stmt = stmt.where(UserAccountModel.deleted_at.is_(None)) model = (await self._db.execute(stmt)).scalar_one_or_none() return UserAccountMapper.to_dto(model) @@ -133,7 +136,9 @@ async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: result = cast(CursorResult[Any], await self._db.execute(stmt)) if result.rowcount == 0: - raise ValueError("Account not found or already deleted") + raise UserAccountNotFoundError( + f"Account with ID {account.account_id.value} not found or already deleted" + ) await self._db.commit() 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 index 2e7b0ad..1f066fb 100644 --- a/app/context/user_account/interface/rest/controllers/create_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/create_account_controller.py @@ -1,23 +1,20 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user_account.application.commands.create_account_command import ( +from app.context.user_account.application.commands import ( CreateAccountCommand, ) -from app.context.user_account.application.contracts.create_account_handler_contract import ( +from app.context.user_account.application.contracts import ( CreateAccountHandlerContract, ) -from app.context.user_account.domain.exceptions import ( - UserAccountMapperError, - UserAccountNameAlreadyExistError, +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.create_account_response import ( - CreateAccountResponse, -) -from app.context.user_account.interface.schemas.create_account_schema import ( +from app.context.user_account.interface.schemas import ( CreateAccountRequest, + CreateAccountResponse, ) from app.shared.infrastructure.middleware import get_current_user_id @@ -31,27 +28,29 @@ async def create_account( 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: + 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) - try: - command = CreateAccountCommand( - user_id=user_id, - name=request.name, - currency=request.currency, - balance=request.balance, - ) - - result = await handler.handle(command) - if result.error is not None: - raise HTTPException(status_code=400, detail=result.error) - - return CreateAccountResponse( - account_id=result.account_id, - account_name=result.account_name, - account_balance=result.account_balance, - ) - except UserAccountNameAlreadyExistError as e: - raise HTTPException(status_code=400, detail=str(e)) - except UserAccountMapperError or Exception as e: - raise HTTPException( - status_code=500, detail=f"An unexpected error occurred: {str(e)}" - ) + # Return success response + 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 index 5a3d8cd..205c6b5 100644 --- a/app/context/user_account/interface/rest/controllers/delete_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/delete_account_controller.py @@ -1,16 +1,18 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.application.commands.delete_account_command import ( +from app.context.user_account.application.commands import ( DeleteAccountCommand, ) -from app.context.user_account.application.contracts.delete_account_handler_contract import ( +from app.context.user_account.application.contracts import ( DeleteAccountHandlerContract, ) -from app.context.user_account.domain.value_objects.account_id import UserAccountID +from app.context.user_account.application.dto import ( + DeleteAccountErrorCode, +) from app.context.user_account.infrastructure.dependencies import ( get_delete_account_handler, ) +from app.shared.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -19,15 +21,25 @@ async def delete_account( account_id: int, handler: DeleteAccountHandlerContract = Depends(get_delete_account_handler), + user_id: int = Depends(get_current_user_id), ): """Delete a user account (soft delete)""" command = DeleteAccountCommand( - account_id=UserAccountID(account_id), - user_id=UserID(1), # TODO: from cookie header + account_id=account_id, + user_id=user_id, ) - success = await handler.handle(command) - if not success: - raise HTTPException(status_code=404, detail="Account not found") + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) - return # 204 No Content + # Return 204 No Content on success + 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 index 5e3082c..8b102c8 100644 --- a/app/context/user_account/interface/rest/controllers/find_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user.domain.value_objects.user_id import UserID from app.context.user_account.application.contracts.find_account_by_id_handler_contract import ( FindAccountByIdHandlerContract, ) @@ -13,12 +12,12 @@ from app.context.user_account.application.queries.find_accounts_by_user_query import ( FindAccountsByUserQuery, ) -from app.context.user_account.domain.value_objects.account_id import UserAccountID 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.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/accounts", tags=["accounts"]) @@ -27,11 +26,12 @@ async def get_account( account_id: int, handler: FindAccountByIdHandlerContract = Depends(get_find_account_by_id_handler), + user_id: int = Depends(get_current_user_id), ): """Get a specific user account by ID""" query = FindAccountByIdQuery( - account_id=UserAccountID(account_id), - user_id=UserID(1), # TODO: from cookie header + account_id=account_id, + user_id=user_id, ) result = await handler.handle(query) @@ -51,9 +51,10 @@ async def get_all_accounts( handler: FindAccountsByUserHandlerContract = Depends( get_find_accounts_by_user_handler ), + user_id: int = Depends(get_current_user_id), ): """Get all accounts for the authenticated user""" - query = FindAccountsByUserQuery(user_id=UserID(1)) # TODO: from cookie + query = FindAccountsByUserQuery(user_id=user_id) results = await handler.handle(query) return [ 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 index 8653ce4..896c589 100644 --- a/app/context/user_account/interface/rest/controllers/update_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/update_account_controller.py @@ -1,19 +1,20 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user_account.application.commands.update_account_command import ( +from app.context.user_account.application.commands import ( UpdateAccountCommand, ) -from app.context.user_account.application.contracts.update_account_handler_contract import ( +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.update_account_response import ( - UpdateAccountResponse, -) -from app.context.user_account.interface.schemas.update_account_schema import ( +from app.context.user_account.interface.schemas import ( UpdateAccountRequest, + UpdateAccountResponse, ) from app.shared.infrastructure.middleware import get_current_user_id @@ -28,25 +29,31 @@ async def update_account( user_id: int = Depends(get_current_user_id), ): """Update a user account (full update - all fields required)""" - try: - command = UpdateAccountCommand( - account_id=account_id, - user_id=user_id, - name=request.name, - currency=request.currency, - balance=request.balance, - ) - - result = await handler.handle(command) - - return UpdateAccountResponse( - account_id=result.account_id.value, message=result.message - ) - except ValueError as e: - if "not found" in str(e).lower(): - raise HTTPException(status_code=404, detail=str(e)) - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - raise HTTPException( - status_code=500, detail=f"An unexpected error occurred: {str(e)}" - ) + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + 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/schemas/update_account_response.py b/app/context/user_account/interface/schemas/update_account_response.py index 386834f..bb209fe 100644 --- a/app/context/user_account/interface/schemas/update_account_response.py +++ b/app/context/user_account/interface/schemas/update_account_response.py @@ -3,5 +3,8 @@ @dataclass(frozen=True) class UpdateAccountResponse: + """Response schema for account update""" + account_id: int - message: str + account_name: str + account_balance: float diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py index 34188f0..c88ec65 100644 --- a/app/shared/domain/value_objects/__init__.py +++ b/app/shared/domain/value_objects/__init__.py @@ -1,6 +1,7 @@ from .shared_account_id import SharedAccountID from .shared_balance import SharedBalance from .shared_currency import SharedCurrency +from .shared_date import SharedDateTime from .shared_deleted_at import SharedDeletedAt from .shared_email import SharedEmail from .shared_password import SharedPassword @@ -14,4 +15,5 @@ "SharedCurrency", "SharedAccountID", "SharedUserID", + "SharedDateTime", ] 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..621077b --- /dev/null +++ b/app/shared/domain/value_objects/shared_date.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Self + + +@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) + + @classmethod + def from_trusted_source(cls, value: datetime) -> Self: + """Skip validation - for database reads""" + return cls(value=value, _validated=True) diff --git a/app/shared/domain/value_objects/shared_deleted_at.py b/app/shared/domain/value_objects/shared_deleted_at.py index 855750f..738ca16 100644 --- a/app/shared/domain/value_objects/shared_deleted_at.py +++ b/app/shared/domain/value_objects/shared_deleted_at.py @@ -21,7 +21,9 @@ def __post_init__(self): # Ensure timezone-aware comparison now = datetime.now(UTC) - value_utc = self.value if self.value.tzinfo else self.value.replace(tzinfo=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") diff --git a/app/shared/infrastructure/middleware/__init__.py b/app/shared/infrastructure/middleware/__init__.py index f55347a..39860b1 100644 --- a/app/shared/infrastructure/middleware/__init__.py +++ b/app/shared/infrastructure/middleware/__init__.py @@ -1,3 +1,6 @@ -from .session_auth_dependency import get_current_user_id +from .session_auth_dependency import ( + get_current_user_id, + get_current_user_id_optional, +) -__all__ = ["get_current_user_id"] +__all__ = ["get_current_user_id", "get_current_user_id_optional"] diff --git a/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py b/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py new file mode 100644 index 0000000..7ef8127 --- /dev/null +++ b/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py @@ -0,0 +1,42 @@ +"""make deleted_at timezone aware in user_accounts + +Revision ID: d96343c7a2a6 +Revises: e19a954402db +Create Date: 2025-12-27 11:26:08.433758 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd96343c7a2a6' +down_revision: Union[str, Sequence[str], None] = 'e19a954402db' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # Change deleted_at from TIMESTAMP to TIMESTAMPTZ (timezone-aware) + op.alter_column( + 'user_accounts', + 'deleted_at', + type_=sa.DateTime(timezone=True), + existing_type=sa.DateTime(), + existing_nullable=True, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + # Revert deleted_at from TIMESTAMPTZ back to TIMESTAMP + op.alter_column( + 'user_accounts', + 'deleted_at', + type_=sa.DateTime(), + existing_type=sa.DateTime(timezone=True), + existing_nullable=True, + ) diff --git a/requests/user_account_create.sh b/requests/user_account_create.sh index 6d4c5f7..1450f26 100755 --- a/requests/user_account_create.sh +++ b/requests/user_account_create.sh @@ -3,6 +3,6 @@ source ./base.sh http --session=local_session POST "${MYAPP_URL}/api/user-accounts/accounts" \ - name="my pindonga account" \ - balance=554.34 \ - currency="USD" + name="my third account" \ + balance=10554.34 \ + currency="ARS" diff --git a/requests/user_account_delete.sh b/requests/user_account_delete.sh new file mode 100755 index 0000000..8872d71 --- /dev/null +++ b/requests/user_account_delete.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +source ./base.sh + + http --session=local_session DELETE "${MYAPP_URL}/api/user-accounts/accounts/3" diff --git a/requests/user_account_update.sh b/requests/user_account_update.sh new file mode 100755 index 0000000..1826aa8 --- /dev/null +++ b/requests/user_account_update.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +source ./base.sh + + http --session=local_session PUT "${MYAPP_URL}/api/user-accounts/accounts/2" \ + name="my-updated-account" \ + currency="ARS" \ + balance=423.55 + + From b602cf7b4ec2e08260e826426f68899fea52a522 Mon Sep 17 00:00:00 2001 From: polivera Date: Sat, 27 Dec 2025 13:09:10 +0100 Subject: [PATCH 21/58] Credit card fixes --- .../delete_credit_card_handler_contract.py | 9 +- .../credit_card/application/dto/__init__.py | 9 +- .../dto/create_credit_card_result.py | 15 ++- .../dto/delete_credit_card_result.py | 19 ++++ .../dto/update_credit_card_result.py | 19 +++- .../handlers/create_credit_card_handler.py | 59 +++++++--- .../handlers/delete_credit_card_handler.py | 46 ++++++-- .../find_credit_card_by_id_handler.py | 18 ++- .../find_credit_cards_by_user_handler.py | 14 +-- .../handlers/update_credit_card_handler.py | 72 +++++++++--- .../queries/find_credit_card_by_id_query.py | 7 +- .../find_credit_cards_by_user_query.py | 4 +- .../create_credit_card_controller.py | 70 ++++++------ .../delete_credit_card_controller.py | 59 ++++------ .../find_credit_card_controller.py | 104 ++++++------------ .../update_credit_card_controller.py | 81 +++++--------- app/main.py | 2 - 17 files changed, 339 insertions(+), 268 deletions(-) create mode 100644 app/context/credit_card/application/dto/delete_credit_card_result.py 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 index 109cdfe..0938244 100644 --- 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 @@ -1,14 +1,13 @@ from abc import ABC, abstractmethod -from app.context.credit_card.application.commands.delete_credit_card_command import ( - DeleteCreditCardCommand, -) +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) -> bool: - """Handle the delete credit card command. Returns True if deleted, False otherwise""" + async def handle(self, command: DeleteCreditCardCommand) -> DeleteCreditCardResult: + """Handle the delete credit card command""" pass diff --git a/app/context/credit_card/application/dto/__init__.py b/app/context/credit_card/application/dto/__init__.py index fcfc8a0..2e62779 100644 --- a/app/context/credit_card/application/dto/__init__.py +++ b/app/context/credit_card/application/dto/__init__.py @@ -1,9 +1,14 @@ -from .create_credit_card_result import CreateCreditCardResult +from .create_credit_card_result import CreateCreditCardErrorCode, CreateCreditCardResult from .credit_card_response_dto import CreditCardResponseDTO -from .update_credit_card_result import UpdateCreditCardResult +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 index 9a42210..914dade 100644 --- a/app/context/credit_card/application/dto/create_credit_card_result.py +++ b/app/context/credit_card/application/dto/create_credit_card_result.py @@ -1,10 +1,23 @@ from dataclasses import dataclass +from enum import Enum from typing import Optional +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: Optional[int] = None - error: Optional[str] = None + + # Error fields - populated when operation fails + error_code: Optional[CreateCreditCardErrorCode] = None + error_message: Optional[str] = None 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..5cf1643 --- /dev/null +++ b/app/context/credit_card/application/dto/delete_credit_card_result.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[DeleteCreditCardErrorCode] = None + error_message: Optional[str] = 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 index cdf1d1d..33d7f59 100644 --- a/app/context/credit_card/application/dto/update_credit_card_result.py +++ b/app/context/credit_card/application/dto/update_credit_card_result.py @@ -1,10 +1,25 @@ from dataclasses import dataclass +from enum import Enum from typing import Optional +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: bool = False - error: Optional[str] = None + # Success fields - populated when operation succeeds + credit_card_id: Optional[int] = None + credit_card_name: Optional[str] = None + + # Error fields - populated when operation fails + error_code: Optional[UpdateCreditCardErrorCode] = None + error_message: Optional[str] = None 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 index c128bec..e9800d7 100644 --- a/app/context/credit_card/application/handlers/create_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/create_credit_card_handler.py @@ -1,15 +1,18 @@ -from app.context.credit_card.application.commands.create_credit_card_command import ( - CreateCreditCardCommand, -) -from app.context.credit_card.application.contracts.create_credit_card_handler_contract import ( +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.create_credit_card_result import ( +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, @@ -28,15 +31,41 @@ def __init__(self, service: CreateCreditCardServiceContract): async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResult: """Execute the create credit card command""" - 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), - ) + 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: + 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) - if card_dto.credit_card_id is None: - return CreateCreditCardResult(error="Error creating credit card") + # Catch specific domain exceptions and return error codes + except CreditCardNameAlreadyExistError: + return CreateCreditCardResult( + error_code=CreateCreditCardErrorCode.NAME_ALREADY_EXISTS, + error_message="Credit card name already exists", + ) + except CreditCardMapperError: + return CreateCreditCardResult( + error_code=CreateCreditCardErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) - return CreateCreditCardResult(credit_card_id=card_dto.credit_card_id.value) + # Always catch generic Exception as final fallback + except Exception: + 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 index aa64efc..83faf33 100644 --- a/app/context/credit_card/application/handlers/delete_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/delete_credit_card_handler.py @@ -1,12 +1,16 @@ -from app.context.credit_card.application.commands.delete_credit_card_command import ( - DeleteCreditCardCommand, -) -from app.context.credit_card.application.contracts.delete_credit_card_handler_contract import ( +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 class DeleteCreditCardHandler(DeleteCreditCardHandlerContract): @@ -15,10 +19,34 @@ class DeleteCreditCardHandler(DeleteCreditCardHandlerContract): def __init__(self, repository: CreditCardRepositoryContract): self._repository = repository - async def handle(self, command: DeleteCreditCardCommand) -> bool: + async def handle(self, command: DeleteCreditCardCommand) -> DeleteCreditCardResult: """Execute the delete credit card command""" - return await self._repository.delete_credit_card( - 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: + 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: + return DeleteCreditCardResult( + error_code=DeleteCreditCardErrorCode.NOT_FOUND, + error_message="Credit card not found", + ) + + # Always catch generic Exception as final fallback + except Exception: + 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 index 5f543e7..75fa9d6 100644 --- 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 @@ -1,17 +1,14 @@ from typing import Optional -from app.context.credit_card.application.contracts.find_credit_card_by_id_handler_contract import ( +from app.context.credit_card.application.contracts import ( FindCreditCardByIdHandlerContract, ) -from app.context.credit_card.application.queries.find_credit_card_by_id_query import ( - FindCreditCardByIdQuery, -) -from app.context.credit_card.application.dto.credit_card_response_dto import ( - CreditCardResponseDTO, -) +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.credit_card_repository_contract import ( CreditCardRepositoryContract, ) +from app.context.credit_card.domain.value_objects import CreditCardID, CreditCardUserID class FindCreditCardByIdHandler(FindCreditCardByIdHandlerContract): @@ -25,15 +22,16 @@ async def handle( ) -> Optional[CreditCardResponseDTO]: """Execute the find credit card by ID query""" + # Convert query primitives to value objects card_dto = await self._repository.find_credit_card( - card_id=query.credit_card_id + card_id=CreditCardID(query.credit_card_id) ) if not card_dto: return None - # Verify ownership - if card_dto.user_id.value != query.user_id.value: + # Verify ownership (query uses primitive, card_dto has value object) + if card_dto.user_id.value != 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 index 578bc84..be86254 100644 --- 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 @@ -1,15 +1,12 @@ -from app.context.credit_card.application.contracts.find_credit_cards_by_user_handler_contract import ( +from app.context.credit_card.application.contracts import ( FindCreditCardsByUserHandlerContract, ) -from app.context.credit_card.application.queries.find_credit_cards_by_user_query import ( - FindCreditCardsByUserQuery, -) -from app.context.credit_card.application.dto.credit_card_response_dto import ( - CreditCardResponseDTO, -) +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 class FindCreditCardsByUserHandler(FindCreditCardsByUserHandlerContract): @@ -23,8 +20,9 @@ async def handle( ) -> list[CreditCardResponseDTO]: """Execute the find credit cards by user query""" + # Convert query primitive to value object card_dtos = await self._repository.find_credit_cards_by_user( - user_id=query.user_id + user_id=CreditCardUserID(query.user_id) ) return [CreditCardResponseDTO.from_domain_dto(dto) for dto in card_dtos] 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 index 610a441..6b5bfb2 100644 --- a/app/context/credit_card/application/handlers/update_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/update_credit_card_handler.py @@ -1,15 +1,26 @@ -from app.context.credit_card.application.commands.update_credit_card_command import ( - UpdateCreditCardCommand, -) -from app.context.credit_card.application.contracts.update_credit_card_handler_contract import ( +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.update_credit_card_result import ( +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, + CreditCardID, + CreditCardName, + CreditCardUserID, +) class UpdateCreditCardHandler(UpdateCreditCardHandlerContract): @@ -24,15 +35,48 @@ async def handle( """Execute the update credit card command""" try: - await self._service.update_credit_card( - credit_card_id=command.credit_card_id, - user_id=command.user_id, - name=command.name, - limit=command.limit, - used=command.used, + # 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 + 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, + 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, ) - return UpdateCreditCardResult(success=True) + # Catch specific domain exceptions and return error codes + except CreditCardNotFoundError: + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.NOT_FOUND, + error_message="Credit card not found", + ) + except CreditCardNameAlreadyExistError: + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.NAME_ALREADY_EXISTS, + error_message="Credit card name already exists", + ) + except CreditCardMapperError: + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) - except ValueError as e: - return UpdateCreditCardResult(success=False, error=str(e)) + # Always catch generic Exception as final fallback + except Exception: + return UpdateCreditCardResult( + error_code=UpdateCreditCardErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) 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 index def7e89..1f1dc7d 100644 --- 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 @@ -1,12 +1,9 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects.user_id import UserID -from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID - @dataclass(frozen=True) class FindCreditCardByIdQuery: """Query to find a credit card by ID""" - credit_card_id: CreditCardID - user_id: UserID + 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 index 6884d9e..7c40b6c 100644 --- 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 @@ -1,10 +1,8 @@ from dataclasses import dataclass -from app.context.user.domain.value_objects.user_id import UserID - @dataclass(frozen=True) class FindCreditCardsByUserQuery: """Query to find all credit cards for a user""" - user_id: UserID + user_id: int 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 index c1af427..5fe1cb8 100644 --- 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 @@ -1,12 +1,11 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.credit_card.application.commands.create_credit_card_command import ( - CreateCreditCardCommand, -) -from app.context.credit_card.application.contracts.create_credit_card_handler_contract import ( +from app.context.credit_card.application.commands import CreateCreditCardCommand +from app.context.credit_card.application.contracts import ( CreateCreditCardHandlerContract, ) -from app.context.credit_card.domain.value_objects.card_limit import CardLimit +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, ) @@ -16,6 +15,7 @@ from app.context.credit_card.interface.schemas.create_credit_card_schema import ( CreateCreditCardRequest, ) +from app.shared.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/cards", tags=["credit-cards"]) @@ -24,37 +24,37 @@ async def create_credit_card( request: CreateCreditCardRequest, handler: CreateCreditCardHandlerContract = Depends(get_create_credit_card_handler), + user_id: int = Depends(get_current_user_id), ): """Create a new credit card""" - - try: - # Convert request to command with value objects - # TODO: user_id from cookie header - command = CreateCreditCardCommand( - user_id=1, - account_id=request.account_id, - name=request.name, - currency=request.currency, - limit=request.limit, - ) - - # Handle the command - result = await handler.handle(command) - if result.error is not None: - raise HTTPException(status_code=400, detail=result.error) - - # Fetch the created card details to return complete response - return CreateCreditCardResponse( - credit_card_id=result.credit_card_id.value, - name=request.name, - limit=CardLimit.from_float(request.limit).value, - ) - - except ValueError as e: - # Business logic errors (duplicate card, validation errors, etc.) - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - # Unexpected errors + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + if result.credit_card_id is None: raise HTTPException( - status_code=500, detail=f"An unexpected error occurred: {str(e)}" + status_code=500, + detail="credit card id is not present", ) + + 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 index 8a0098d..4c97efa 100644 --- 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 @@ -1,16 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException, status -from app.context.user.domain.value_objects.user_id import UserID -from app.context.credit_card.application.commands.delete_credit_card_command import ( - DeleteCreditCardCommand, -) -from app.context.credit_card.application.contracts.delete_credit_card_handler_contract import ( +from app.context.credit_card.application.commands import DeleteCreditCardCommand +from app.context.credit_card.application.contracts import ( DeleteCreditCardHandlerContract, ) -from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID +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.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/cards", tags=["credit-cards"]) @@ -19,36 +17,23 @@ async def delete_credit_card( credit_card_id: int, handler: DeleteCreditCardHandlerContract = Depends(get_delete_credit_card_handler), + user_id: int = Depends(get_current_user_id), ): """Delete a credit card (soft delete)""" - - try: - # Convert to command - # TODO: user_id from cookie header - command = DeleteCreditCardCommand( - credit_card_id=CreditCardID(credit_card_id), - user_id=UserID(1), - ) - - # Handle the command - deleted = await handler.handle(command) - - if not deleted: - raise HTTPException( - status_code=404, - detail=f"Credit card with ID {credit_card_id} not found or not owned by user", - ) - - return None - - except ValueError as e: - # Validation errors - raise HTTPException(status_code=400, detail=str(e)) - except HTTPException: - # Re-raise HTTP exceptions - raise - except Exception as e: - # Unexpected errors - raise HTTPException( - status_code=500, detail=f"An unexpected error occurred: {str(e)}" - ) + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + return None diff --git a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py index 4f3b74c..8c65f2d 100644 --- a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py +++ b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py @@ -1,19 +1,13 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user.domain.value_objects.user_id import UserID -from app.context.credit_card.application.contracts.find_credit_card_by_id_handler_contract import ( +from app.context.credit_card.application.contracts import ( FindCreditCardByIdHandlerContract, -) -from app.context.credit_card.application.contracts.find_credit_cards_by_user_handler_contract import ( FindCreditCardsByUserHandlerContract, ) -from app.context.credit_card.application.queries.find_credit_card_by_id_query import ( +from app.context.credit_card.application.queries import ( FindCreditCardByIdQuery, -) -from app.context.credit_card.application.queries.find_credit_cards_by_user_query import ( FindCreditCardsByUserQuery, ) -from app.context.credit_card.domain.value_objects.credit_card_id import CreditCardID from app.context.credit_card.infrastructure.dependencies import ( get_find_credit_card_by_id_handler, get_find_credit_cards_by_user_handler, @@ -21,6 +15,7 @@ from app.context.credit_card.interface.schemas.credit_card_response import ( CreditCardResponse, ) +from app.shared.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/cards", tags=["credit-cards"]) @@ -31,81 +26,54 @@ async def get_credit_card( handler: FindCreditCardByIdHandlerContract = Depends( get_find_credit_card_by_id_handler ), + user_id: int = Depends(get_current_user_id), ): """Get a credit card by ID""" + query = FindCreditCardByIdQuery( + credit_card_id=credit_card_id, + user_id=user_id, + ) - try: - # TODO: user_id from cookie header - query = FindCreditCardByIdQuery( - credit_card_id=CreditCardID(credit_card_id), - user_id=UserID(1), - ) - - # Handle the query - result = await handler.handle(query) + result = await handler.handle(query) - if not result: - raise HTTPException( - status_code=404, - detail=f"Credit card with ID {credit_card_id} not found", - ) - - 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, - ) - - except ValueError as e: - # Validation errors - raise HTTPException(status_code=400, detail=str(e)) - except HTTPException: - # Re-raise HTTP exceptions - raise - except Exception as e: - # Unexpected errors + if not result: raise HTTPException( - status_code=500, detail=f"An unexpected error occurred: {str(e)}" + status_code=404, + detail=f"Credit card with ID {credit_card_id} not found", ) + 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, + ) + @router.get("", response_model=list[CreditCardResponse]) async def get_credit_cards( handler: FindCreditCardsByUserHandlerContract = Depends( get_find_credit_cards_by_user_handler ), + user_id: int = Depends(get_current_user_id), ): """Get all credit cards for the current user""" + query = FindCreditCardsByUserQuery(user_id=user_id) - try: - # TODO: user_id from cookie header - query = FindCreditCardsByUserQuery(user_id=UserID(1)) - - # Handle the query - results = await handler.handle(query) + results = await handler.handle(query) - 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 - ] - - except ValueError as e: - # Validation errors - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - # Unexpected errors - raise HTTPException( - status_code=500, detail=f"An unexpected error occurred: {str(e)}" + 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 index da66b64..da4902a 100644 --- 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 @@ -2,19 +2,11 @@ from fastapi import APIRouter, Depends, HTTPException -from app.context.user.domain.value_objects.user_id import UserID -from app.context.credit_card.application.commands.update_credit_card_command import ( - UpdateCreditCardCommand, -) -from app.context.credit_card.application.contracts.update_credit_card_handler_contract import ( +from app.context.credit_card.application.commands import UpdateCreditCardCommand +from app.context.credit_card.application.contracts import ( UpdateCreditCardHandlerContract, ) -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.context.credit_card.application.dto import UpdateCreditCardErrorCode from app.context.credit_card.infrastructure.dependencies import ( get_update_credit_card_handler, ) @@ -24,6 +16,7 @@ from app.context.credit_card.interface.schemas.update_credit_card_schema import ( UpdateCreditCardRequest, ) +from app.shared.infrastructure.middleware import get_current_user_id router = APIRouter(prefix="/cards", tags=["credit-cards"]) @@ -33,46 +26,30 @@ async def update_credit_card( credit_card_id: int, request: UpdateCreditCardRequest, handler: UpdateCreditCardHandlerContract = Depends(get_update_credit_card_handler), + user_id: int = Depends(get_current_user_id), ): """Update an existing credit card""" - - try: - # Build optional value objects - name: Optional[CreditCardName] = ( - CreditCardName(request.name) if request.name else None - ) - limit: Optional[CardLimit] = ( - CardLimit.from_float(request.limit) if request.limit is not None else None - ) - used: Optional[CardUsed] = ( - CardUsed.from_float(request.used) if request.used is not None else None - ) - - # Convert request to command - # TODO: user_id from cookie header - command = UpdateCreditCardCommand( - credit_card_id=CreditCardID(credit_card_id), - user_id=UserID(1), - name=name, - limit=limit, - used=used, - ) - - # Handle the command - result = await handler.handle(command) - - if not result.success: - raise HTTPException(status_code=400, detail=result.error) - - return UpdateCreditCardResponse( - success=True, message="Credit card updated successfully" - ) - - except ValueError as e: - # Business logic errors (validation errors, etc.) - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - # Unexpected errors - raise HTTPException( - status_code=500, detail=f"An unexpected error occurred: {str(e)}" - ) + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + return UpdateCreditCardResponse( + success=True, message="Credit card updated successfully" + ) diff --git a/app/main.py b/app/main.py index 1448d3a..40f1f6c 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,3 @@ -from contextlib import asynccontextmanager - from fastapi import FastAPI from app.context.auth.interface.rest import auth_routes From 8c87799f854c434f21dc236d48a7831cbf1bbc59 Mon Sep 17 00:00:00 2001 From: polivera Date: Sat, 27 Dec 2025 15:24:12 +0100 Subject: [PATCH 22/58] Finish with credit cards --- .claude/rules/database.md | 160 ++++++++++- .../commands/update_credit_card_command.py | 1 + .../find_credit_card_by_id_handler.py | 15 +- .../find_credit_cards_by_user_handler.py | 4 +- .../handlers/update_credit_card_handler.py | 19 +- .../credit_card_repository_contract.py | 99 ++++++- .../update_credit_card_service_contract.py | 8 +- .../credit_card/domain/dto/credit_card_dto.py | 2 +- .../credit_card/domain/exceptions/__init__.py | 2 + .../domain/exceptions/exceptions.py | 4 + .../services/create_credit_card_service.py | 4 - .../services/update_credit_card_service.py | 12 +- .../domain/value_objects/__init__.py | 2 + .../value_objects/credit_card_deleted_at.py | 8 + .../mappers/credit_card_mapper.py | 56 ++-- .../models/credit_card_model.py | 2 +- .../repositories/credit_card_repository.py | 251 +++++++++++------- app/shared/infrastructure/database.py | 15 ++ app/shared/infrastructure/models/__init__.py | 2 + requests/credit_card_create.sh | 9 + requests/credit_card_delete.sh | 5 + requests/credit_card_list.sh | 5 + requests/credit_card_update.sh | 9 + 23 files changed, 535 insertions(+), 159 deletions(-) create mode 100644 app/context/credit_card/domain/value_objects/credit_card_deleted_at.py create mode 100755 requests/credit_card_create.sh create mode 100755 requests/credit_card_delete.sh create mode 100755 requests/credit_card_list.sh create mode 100755 requests/credit_card_update.sh diff --git a/.claude/rules/database.md b/.claude/rules/database.md index 176e578..b23a976 100644 --- a/.claude/rules/database.md +++ b/.claude/rules/database.md @@ -22,6 +22,58 @@ async def my_handler(db: AsyncSession = Depends(get_db)): 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: @@ -73,13 +125,14 @@ 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=datetime.utcnow) + created_at: Mapped[datetime] = mapped_column(default=lambda: datetime.now(UTC)) ``` ### Type Annotations @@ -95,13 +148,113 @@ is_active: Mapped[bool] # Optional/nullable phone: Mapped[Optional[str]] = mapped_column(nullable=True) -# With defaults -created_at: Mapped[datetime] = mapped_column(default=datetime.utcnow) +# 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`) @@ -327,6 +480,7 @@ engine = create_async_engine( 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 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 index 25f5130..0fababd 100644 --- a/app/context/credit_card/application/commands/update_credit_card_command.py +++ b/app/context/credit_card/application/commands/update_credit_card_command.py @@ -11,3 +11,4 @@ class UpdateCreditCardCommand: name: Optional[str] = None limit: Optional[float] = None used: Optional[float] = None + currency: Optional[str] = None 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 index 75fa9d6..1de0f2a 100644 --- 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 @@ -8,7 +8,7 @@ from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( CreditCardRepositoryContract, ) -from app.context.credit_card.domain.value_objects import CreditCardID, CreditCardUserID +from app.context.credit_card.domain.value_objects import CreditCardID class FindCreditCardByIdHandler(FindCreditCardByIdHandlerContract): @@ -22,16 +22,15 @@ async def handle( ) -> Optional[CreditCardResponseDTO]: """Execute the find credit card by ID query""" - # Convert query primitives to value objects - card_dto = await self._repository.find_credit_card( - card_id=CreditCardID(query.credit_card_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: return None - # Verify ownership (query uses primitive, card_dto has value object) - if card_dto.user_id.value != 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 index be86254..807c21a 100644 --- 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 @@ -21,8 +21,8 @@ async def handle( """Execute the find credit cards by user query""" # Convert query primitive to value object - card_dtos = await self._repository.find_credit_cards_by_user( + 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] + 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 index 6b5bfb2..b2695ac 100644 --- a/app/context/credit_card/application/handlers/update_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/update_credit_card_handler.py @@ -17,6 +17,7 @@ from app.context.credit_card.domain.value_objects import ( CardLimit, CardUsed, + CreditCardCurrency, CreditCardID, CreditCardName, CreditCardUserID, @@ -29,9 +30,7 @@ class UpdateCreditCardHandler(UpdateCreditCardHandlerContract): def __init__(self, service: UpdateCreditCardServiceContract): self._service = service - async def handle( - self, command: UpdateCreditCardCommand - ) -> UpdateCreditCardResult: + async def handle(self, command: UpdateCreditCardCommand) -> UpdateCreditCardResult: """Execute the update credit card command""" try: @@ -39,13 +38,23 @@ async def handle( credit_card_id = CreditCardID(command.credit_card_id) user_id = CreditCardUserID(command.user_id) name = CreditCardName(command.name) if command.name 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 + 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, 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 index 1786967..3343d8c 100644 --- 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 @@ -1,8 +1,8 @@ from abc import ABC, abstractmethod from typing import Optional -from app.context.user.domain.value_objects.user_id import UserID 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, @@ -14,32 +14,113 @@ class CreditCardRepositoryContract(ABC): @abstractmethod async def save_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: - """Create a new credit card""" + """ + 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: Optional[CreditCardID] = None, - user_id: Optional[UserID] = None, + user_id: Optional[CreditCardUserID] = None, name: Optional[CreditCardName] = None, + only_active: Optional[bool] = True, ) -> Optional[CreditCardDTO]: - """Find a credit card by ID or by user_id and name""" + """ + 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: Optional[CreditCardID] = None, + name: Optional[CreditCardName] = None, + only_active: Optional[bool] = True, + ) -> Optional[list[CreditCardDTO]]: + """ + 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_credit_cards_by_user(self, user_id: UserID) -> list[CreditCardDTO]: - """Find all non-deleted credit cards for a user""" + async def find_user_credit_card_by_id( + self, + user_id: CreditCardUserID, + card_id: CreditCardID, + only_active: Optional[bool] = True, + ) -> Optional[CreditCardDTO]: + """ + 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""" + """ + 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: UserID + self, card_id: CreditCardID, user_id: CreditCardUserID ) -> bool: - """Soft delete a credit card. Returns True if deleted, False if not found/unauthorized""" + """ + 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/update_credit_card_service_contract.py b/app/context/credit_card/domain/contracts/services/update_credit_card_service_contract.py index e1bf0e3..10fee4f 100644 --- 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 @@ -1,8 +1,11 @@ from abc import ABC, abstractmethod from typing import Optional -from app.context.user.domain.value_objects.user_id import UserID 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 @@ -18,10 +21,11 @@ class UpdateCreditCardServiceContract(ABC): async def update_credit_card( self, credit_card_id: CreditCardID, - user_id: UserID, + user_id: CreditCardUserID, name: Optional[CreditCardName] = None, limit: Optional[CardLimit] = None, used: Optional[CardUsed] = None, + currency: Optional[CreditCardCurrency] = None, ) -> CreditCardDTO: """Update an existing credit card""" pass diff --git a/app/context/credit_card/domain/dto/credit_card_dto.py b/app/context/credit_card/domain/dto/credit_card_dto.py index ccd3a95..4181f93 100644 --- a/app/context/credit_card/domain/dto/credit_card_dto.py +++ b/app/context/credit_card/domain/dto/credit_card_dto.py @@ -23,5 +23,5 @@ class CreditCardDTO: name: CreditCardName currency: CreditCardCurrency limit: CardLimit - used: CardUsed + used: Optional[CardUsed] = None credit_card_id: Optional[CreditCardID] = None diff --git a/app/context/credit_card/domain/exceptions/__init__.py b/app/context/credit_card/domain/exceptions/__init__.py index 077869b..cb58d1d 100644 --- a/app/context/credit_card/domain/exceptions/__init__.py +++ b/app/context/credit_card/domain/exceptions/__init__.py @@ -1,5 +1,6 @@ from .exceptions import ( CreditCardCreationError, + CreditCardDatabaseError, CreditCardMapperError, CreditCardNameAlreadyExistError, CreditCardNotFoundError, @@ -24,6 +25,7 @@ __all__ = [ "CreditCardCreationError", + "CreditCardDatabaseError", "CreditCardMapperError", "CreditCardNameAlreadyExistError", "CreditCardNotFoundError", diff --git a/app/context/credit_card/domain/exceptions/exceptions.py b/app/context/credit_card/domain/exceptions/exceptions.py index a46038f..67ce8b7 100644 --- a/app/context/credit_card/domain/exceptions/exceptions.py +++ b/app/context/credit_card/domain/exceptions/exceptions.py @@ -86,3 +86,7 @@ class CreditCardUpdateError(Exception): class CreditCardMapperError(Exception): pass + + +class CreditCardDatabaseError(Exception): + pass 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 index 158c5a9..63610ce 100644 --- a/app/context/credit_card/domain/services/create_credit_card_service.py +++ b/app/context/credit_card/domain/services/create_credit_card_service.py @@ -1,5 +1,3 @@ -from decimal import Decimal - from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( CreditCardRepositoryContract, ) @@ -13,7 +11,6 @@ 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_name import ( CreditCardName, ) @@ -41,7 +38,6 @@ async def create_credit_card( name=name, currency=currency, limit=limit, - used=CardUsed(Decimal("0.00")), ) # Save and return the new credit card 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 index 796df4b..111e9c0 100644 --- a/app/context/credit_card/domain/services/update_credit_card_service.py +++ b/app/context/credit_card/domain/services/update_credit_card_service.py @@ -13,13 +13,16 @@ 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.context.user.domain.value_objects.user_id import UserID class UpdateCreditCardService(UpdateCreditCardServiceContract): @@ -31,15 +34,18 @@ def __init__(self, repository: CreditCardRepositoryContract): async def update_credit_card( self, credit_card_id: CreditCardID, - user_id: UserID, + user_id: CreditCardUserID, name: Optional[CreditCardName] = None, limit: Optional[CardLimit] = None, used: Optional[CardUsed] = None, + currency: Optional[CreditCardCurrency] = None, ) -> CreditCardDTO: """Update an existing credit card with validation""" # Find the existing card - existing_card = await self._repository.find_credit_card(card_id=credit_card_id) + existing_card = await self._repository.find_credit_card( + card_id=credit_card_id + ) if not existing_card: raise CreditCardNotFoundError( diff --git a/app/context/credit_card/domain/value_objects/__init__.py b/app/context/credit_card/domain/value_objects/__init__.py index 26ce325..f9fa78e 100644 --- a/app/context/credit_card/domain/value_objects/__init__.py +++ b/app/context/credit_card/domain/value_objects/__init__.py @@ -2,6 +2,7 @@ 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 @@ -9,6 +10,7 @@ __all__ = [ "CardLimit", "CardUsed", + "CreditCardDeletedAt", "CreditCardID", "CreditCardName", "CreditCardAccountID", 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/infrastructure/mappers/credit_card_mapper.py b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py index 1920084..5903f96 100644 --- a/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py +++ b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py @@ -1,37 +1,49 @@ -from app.context.user_account.domain.value_objects.currency import Currency +from typing import Optional -from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO -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 ( +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, + CreditCardID, CreditCardName, + CreditCardUserID, ) -from app.context.credit_card.infrastructure.models.credit_card_model import ( - CreditCardModel, -) -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user_account.domain.value_objects.account_id import UserAccountID +from app.context.credit_card.infrastructure.models import CreditCardModel class CreditCardMapper: """Mapper for converting between CreditCardModel and CreditCardDTO""" @staticmethod - def toDTO(model: CreditCardModel) -> CreditCardDTO: + def to_dto(model: Optional[CreditCardModel]) -> Optional[CreditCardDTO]: """Convert database model to domain DTO""" - return CreditCardDTO( - credit_card_id=CreditCardID.from_trusted_source(model.id), - user_id=UserID(model.user_id), - account_id=UserAccountID.from_trusted_source(model.account_id), - name=CreditCardName.from_trusted_source(model.name), - currency=Currency.from_trusted_source(model.currency), - limit=CardLimit.from_trusted_source(model.limit), - used=CardUsed.from_trusted_source(model.used), + 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), + ) + if model + else None ) @staticmethod - def toModel(dto: CreditCardDTO) -> CreditCardModel: + 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, @@ -40,5 +52,5 @@ def toModel(dto: CreditCardDTO) -> CreditCardModel: name=dto.name.value, currency=dto.currency.value, limit=dto.limit.value, - used=dto.used.value, + used=dto.used.value if dto.used is not None else 0, ) diff --git a/app/context/credit_card/infrastructure/models/credit_card_model.py b/app/context/credit_card/infrastructure/models/credit_card_model.py index 3d70140..d3b48bc 100644 --- a/app/context/credit_card/infrastructure/models/credit_card_model.py +++ b/app/context/credit_card/infrastructure/models/credit_card_model.py @@ -23,5 +23,5 @@ class CreditCardModel(BaseDBModel): limit: Mapped[Decimal] = mapped_column(DECIMAL(15, 2), nullable=False) used: Mapped[Decimal] = mapped_column(DECIMAL(15, 2), nullable=False) deleted_at: Mapped[Optional[datetime]] = mapped_column( - DateTime, nullable=True, default=None + DateTime(timezone=True), nullable=True, default=None ) diff --git a/app/context/credit_card/infrastructure/repositories/credit_card_repository.py b/app/context/credit_card/infrastructure/repositories/credit_card_repository.py index 755a1b8..cefe50b 100644 --- a/app/context/credit_card/infrastructure/repositories/credit_card_repository.py +++ b/app/context/credit_card/infrastructure/repositories/credit_card_repository.py @@ -1,154 +1,207 @@ -from datetime import datetime -from typing import Optional +from typing import Any, Optional, cast from sqlalchemy import select, update -from sqlalchemy.exc import IntegrityError +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.credit_card_repository_contract import ( +from app.context.credit_card.domain.contracts.infrastructure import ( CreditCardRepositoryContract, ) -from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO +from app.context.credit_card.domain.dto import CreditCardDTO from app.context.credit_card.domain.exceptions import ( - CreditCardCreationError, + CreditCardDatabaseError, CreditCardNameAlreadyExistError, CreditCardNotFoundError, - CreditCardRepositoryInvalidParametersError, - CreditCardUpdateError, - CreditCardUpdateWithoutIdError, ) -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 ( +from app.context.credit_card.domain.value_objects import ( + CreditCardDeletedAt, + CreditCardID, CreditCardName, + CreditCardUserID, ) -from app.context.credit_card.infrastructure.mappers.credit_card_mapper import ( - CreditCardMapper, -) -from app.context.credit_card.infrastructure.models.credit_card_model import ( - CreditCardModel, -) -from app.context.user.domain.value_objects.user_id import UserID +from app.context.credit_card.infrastructure.mappers import CreditCardMapper +from app.context.credit_card.infrastructure.models import CreditCardModel class CreditCardRepository(CreditCardRepositoryContract): - """Repository for credit card persistence operations""" + """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 in the database""" - model = CreditCardMapper.toModel(card) - - self._db.add(model) - + """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() - if "uq_credit_cards_user_id_name" in str(e.orig): - raise CreditCardNameAlreadyExistError( - f"Credit card with name '{card.name.value}' already exists for this user" - ) from e - raise CreditCardCreationError(f"Failed to create credit card: {str(e)}") from e - - return CreditCardMapper.toDTO(model) + 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: Optional[CreditCardID] = None, - user_id: Optional[UserID] = None, + user_id: Optional[CreditCardUserID] = None, name: Optional[CreditCardName] = None, + only_active: Optional[bool] = True, ) -> Optional[CreditCardDTO]: - """Find a credit card by ID or by user_id and name""" - stmt = select(CreditCardModel).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 CreditCardRepositoryInvalidParametersError( - "Must provide either card_id or both user_id and name" - ) + """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() + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() - return CreditCardMapper.toDTO(model) if model else 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_credit_cards_by_user(self, user_id: UserID) -> list[CreditCardDTO]: - """Find all non-deleted credit cards for a user""" - stmt = select(CreditCardModel).where( - CreditCardModel.user_id == user_id.value, - CreditCardModel.deleted_at.is_(None), - ) + async def find_user_credit_cards( + self, + user_id: CreditCardUserID, + card_id: Optional[CreditCardID] = None, + name: Optional[CreditCardName] = None, + only_active: Optional[bool] = True, + ) -> Optional[list[CreditCardDTO]]: + """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 - result = await self._db.execute(stmt) - models = result.scalars().all() + async def find_user_credit_card_by_id( + self, + user_id: CreditCardUserID, + card_id: CreditCardID, + only_active: Optional[bool] = True, + ) -> Optional[CreditCardDTO]: + """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)) - return [CreditCardMapper.toDTO(model) for model in models] + 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 CreditCardUpdateWithoutIdError("Cannot update credit card without an ID") - - 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, - used=card.used.value, - ) - ) + raise ValueError("Credit Card ID not given") try: - result = await self._db.execute(stmt) - await self._db.commit() + 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" + f"Credit card with ID {card.credit_card_id.value} not found or already deleted" ) - # Fetch and return updated card - return await self.find_credit_card(card_id=card.credit_card_id) + await self._db.commit() + return card except IntegrityError as e: await self._db.rollback() - if "uq_credit_cards_user_id_name" in str(e.orig): - raise CreditCardNameAlreadyExistError( - f"Credit card with name '{card.name.value}' already exists for this user" - ) from e - raise CreditCardUpdateError(f"Failed to update credit card: {str(e)}") from e + 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: UserID) -> bool: + async def delete_credit_card( + self, card_id: CreditCardID, user_id: CreditCardUserID + ) -> bool: """Soft delete a credit card""" - # Verify the card exists and belongs to the user - card = await self.find_credit_card(card_id=card_id) - if not card or card.user_id.value != user_id.value: - return False - - stmt = ( - update(CreditCardModel) - .where( - CreditCardModel.id == card_id.value, - CreditCardModel.user_id == user_id.value, - CreditCardModel.deleted_at.is_(None), + 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) ) - .values(deleted_at=datetime.utcnow()) - ) - result = await self._db.execute(stmt) - await self._db.commit() + result = cast(CursorResult[Any], await self._db.execute(stmt)) + await self._db.commit() - return result.rowcount > 0 + return result.rowcount > 0 + except Exception or SQLAlchemyError as e: + await self._db.rollback() + raise CreditCardDatabaseError( + f"Database error while deleting credit card: {str(e)}" + ) from e diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index 59a802e..d4ad4bd 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -35,3 +35,18 @@ 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, +) diff --git a/app/shared/infrastructure/models/__init__.py b/app/shared/infrastructure/models/__init__.py index 987e1a4..612d2f0 100644 --- a/app/shared/infrastructure/models/__init__.py +++ b/app/shared/infrastructure/models/__init__.py @@ -1 +1,3 @@ from .base_model import BaseDBModel as BaseDBModel + +__all__ = ["BaseDBModel"] diff --git a/requests/credit_card_create.sh b/requests/credit_card_create.sh new file mode 100755 index 0000000..c8da918 --- /dev/null +++ b/requests/credit_card_create.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +source ./base.sh + + http --session=local_session POST "${MYAPP_URL}/api/credit-cards/cards" \ + account_id=1 \ + name="My pepino credit card" \ + currency="USD" \ + limit="4320" diff --git a/requests/credit_card_delete.sh b/requests/credit_card_delete.sh new file mode 100755 index 0000000..40d2b20 --- /dev/null +++ b/requests/credit_card_delete.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +source ./base.sh + +http --session=local_session DELETE "${MYAPP_URL}/api/credit-cards/cards/8" diff --git a/requests/credit_card_list.sh b/requests/credit_card_list.sh new file mode 100755 index 0000000..20b44a7 --- /dev/null +++ b/requests/credit_card_list.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +source ./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..610056e --- /dev/null +++ b/requests/credit_card_update.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +source ./base.sh + + http --session=local_session PUT "${MYAPP_URL}/api/credit-cards/cards/8" \ + account_id=2 \ + name="My booooo credit card" \ + currency="USD" \ + limit="2501" From 7785d32c4e4ce6673245eb243531a58a47b0aa34 Mon Sep 17 00:00:00 2001 From: polivera Date: Sat, 27 Dec 2025 21:28:20 +0100 Subject: [PATCH 23/58] Start working on household --- .../find_credit_card_by_id_handler.py | 2 +- app/context/household/__init__.py | 0 app/context/household/application/__init__.py | 0 .../application/commands/__init__.py | 3 + .../commands/create_household_command.py | 7 ++ .../application/contracts/__init__.py | 3 + .../create_household_handler_contract.py | 11 ++ .../household/application/dto/__init__.py | 3 + .../dto/create_household_result.py | 24 ++++ .../application/handlers/__init__.py | 3 + .../handlers/create_household_handler.py | 55 ++++++++++ app/context/household/domain/__init__.py | 0 .../household/domain/contracts/__init__.py | 4 + .../create_household_service_contract.py | 13 +++ .../household_repository_contract.py | 26 +++++ app/context/household/domain/dto/__init__.py | 3 + .../household/domain/dto/household_dto.py | 17 +++ .../household/domain/exceptions/__init__.py | 11 ++ .../household/domain/exceptions/exceptions.py | 10 ++ .../household/domain/services/__init__.py | 3 + .../services/create_household_service.py | 30 +++++ .../domain/value_objects/__init__.py | 5 + .../domain/value_objects/household_id.py | 12 ++ .../domain/value_objects/household_name.py | 22 ++++ .../domain/value_objects/household_user_id.py | 12 ++ .../household/infrastructure/__init__.py | 0 .../household/infrastructure/dependencies.py | 33 ++++++ .../infrastructure/mappers/__init__.py | 3 + .../mappers/household_mapper.py | 58 ++++++++++ .../infrastructure/models/__init__.py | 3 + .../infrastructure/models/household_model.py | 43 ++++++++ .../infrastructure/repositories/__init__.py | 3 + .../repositories/household_repository.py | 79 ++++++++++++++ app/context/household/interface/__init__.py | 0 .../household/interface/rest/__init__.py | 1 + .../interface/rest/controllers/__init__.py | 3 + .../create_household_controller.py | 51 +++++++++ .../household/interface/rest/routes.py | 8 ++ .../household/interface/schemas/__init__.py | 4 + .../schemas/create_household_request.py | 7 ++ .../schemas/create_household_response.py | 7 ++ app/main.py | 2 + app/shared/infrastructure/database.py | 4 + .../01770bb99438_create_household_tables.py | 103 ++++++++++++++++++ requests/credit_card_create.sh | 3 +- requests/credit_card_delete.sh | 3 +- requests/credit_card_list.sh | 3 +- requests/credit_card_update.sh | 3 +- requests/household_create.sh | 7 ++ requests/login.sh | 3 +- requests/user_account_create.sh | 3 +- requests/user_account_delete.sh | 3 +- requests/user_account_list.sh | 3 +- requests/user_account_update.sh | 11 +- 54 files changed, 719 insertions(+), 14 deletions(-) create mode 100644 app/context/household/__init__.py create mode 100644 app/context/household/application/__init__.py create mode 100644 app/context/household/application/commands/__init__.py create mode 100644 app/context/household/application/commands/create_household_command.py create mode 100644 app/context/household/application/contracts/__init__.py create mode 100644 app/context/household/application/contracts/create_household_handler_contract.py create mode 100644 app/context/household/application/dto/__init__.py create mode 100644 app/context/household/application/dto/create_household_result.py create mode 100644 app/context/household/application/handlers/__init__.py create mode 100644 app/context/household/application/handlers/create_household_handler.py create mode 100644 app/context/household/domain/__init__.py create mode 100644 app/context/household/domain/contracts/__init__.py create mode 100644 app/context/household/domain/contracts/create_household_service_contract.py create mode 100644 app/context/household/domain/contracts/household_repository_contract.py create mode 100644 app/context/household/domain/dto/__init__.py create mode 100644 app/context/household/domain/dto/household_dto.py create mode 100644 app/context/household/domain/exceptions/__init__.py create mode 100644 app/context/household/domain/exceptions/exceptions.py create mode 100644 app/context/household/domain/services/__init__.py create mode 100644 app/context/household/domain/services/create_household_service.py create mode 100644 app/context/household/domain/value_objects/__init__.py create mode 100644 app/context/household/domain/value_objects/household_id.py create mode 100644 app/context/household/domain/value_objects/household_name.py create mode 100644 app/context/household/domain/value_objects/household_user_id.py create mode 100644 app/context/household/infrastructure/__init__.py create mode 100644 app/context/household/infrastructure/dependencies.py create mode 100644 app/context/household/infrastructure/mappers/__init__.py create mode 100644 app/context/household/infrastructure/mappers/household_mapper.py create mode 100644 app/context/household/infrastructure/models/__init__.py create mode 100644 app/context/household/infrastructure/models/household_model.py create mode 100644 app/context/household/infrastructure/repositories/__init__.py create mode 100644 app/context/household/infrastructure/repositories/household_repository.py create mode 100644 app/context/household/interface/__init__.py create mode 100644 app/context/household/interface/rest/__init__.py create mode 100644 app/context/household/interface/rest/controllers/__init__.py create mode 100644 app/context/household/interface/rest/controllers/create_household_controller.py create mode 100644 app/context/household/interface/rest/routes.py create mode 100644 app/context/household/interface/schemas/__init__.py create mode 100644 app/context/household/interface/schemas/create_household_request.py create mode 100644 app/context/household/interface/schemas/create_household_response.py create mode 100644 migrations/versions/01770bb99438_create_household_tables.py create mode 100755 requests/household_create.sh 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 index 1de0f2a..f8807e8 100644 --- 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 @@ -5,7 +5,7 @@ ) 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.credit_card_repository_contract import ( +from app.context.credit_card.domain.contracts.infrastructure import ( CreditCardRepositoryContract, ) from app.context.credit_card.domain.value_objects import CreditCardID 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..02f6770 --- /dev/null +++ b/app/context/household/application/commands/__init__.py @@ -0,0 +1,3 @@ +from .create_household_command import CreateHouseholdCommand + +__all__ = ["CreateHouseholdCommand"] 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/contracts/__init__.py b/app/context/household/application/contracts/__init__.py new file mode 100644 index 0000000..c4a1f79 --- /dev/null +++ b/app/context/household/application/contracts/__init__.py @@ -0,0 +1,3 @@ +from .create_household_handler_contract import CreateHouseholdHandlerContract + +__all__ = ["CreateHouseholdHandlerContract"] 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/dto/__init__.py b/app/context/household/application/dto/__init__.py new file mode 100644 index 0000000..7a06fb0 --- /dev/null +++ b/app/context/household/application/dto/__init__.py @@ -0,0 +1,3 @@ +from .create_household_result import CreateHouseholdErrorCode, CreateHouseholdResult + +__all__ = ["CreateHouseholdErrorCode", "CreateHouseholdResult"] 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..28bebff --- /dev/null +++ b/app/context/household/application/dto/create_household_result.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[int] = None + household_name: Optional[str] = None + + # Error fields - populated when operation fails + error_code: Optional[CreateHouseholdErrorCode] = None + error_message: Optional[str] = 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..b4e69c9 --- /dev/null +++ b/app/context/household/application/handlers/__init__.py @@ -0,0 +1,3 @@ +from .create_household_handler import CreateHouseholdHandler + +__all__ = ["CreateHouseholdHandler"] 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..4da9fb6 --- /dev/null +++ b/app/context/household/application/handlers/create_household_handler.py @@ -0,0 +1,55 @@ +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 + + +class CreateHouseholdHandler(CreateHouseholdHandlerContract): + """Handler for create household command""" + + def __init__(self, service: CreateHouseholdServiceContract): + self._service = service + + async def handle(self, command: CreateHouseholdCommand) -> CreateHouseholdResult: + """Execute the create household command""" + + 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: + 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: + return CreateHouseholdResult( + error_code=CreateHouseholdErrorCode.NAME_ALREADY_EXISTS, + error_message="Household name already exists", + ) + except HouseholdMapperError: + return CreateHouseholdResult( + error_code=CreateHouseholdErrorCode.MAPPER_ERROR, + error_message="Error mapping model to DTO", + ) + except Exception: + return CreateHouseholdResult( + error_code=CreateHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) 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..bae50cb --- /dev/null +++ b/app/context/household/domain/contracts/__init__.py @@ -0,0 +1,4 @@ +from .create_household_service_contract import CreateHouseholdServiceContract +from .household_repository_contract import HouseholdRepositoryContract + +__all__ = ["CreateHouseholdServiceContract", "HouseholdRepositoryContract"] 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..da09c15 --- /dev/null +++ b/app/context/household/domain/contracts/create_household_service_contract.py @@ -0,0 +1,13 @@ +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/household_repository_contract.py b/app/context/household/domain/contracts/household_repository_contract.py new file mode 100644 index 0000000..3ecc037 --- /dev/null +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -0,0 +1,26 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.value_objects import HouseholdID, HouseholdName, HouseholdUserID + + +class HouseholdRepositoryContract(ABC): + @abstractmethod + async def create_household( + self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID + ) -> 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 + ) -> Optional[HouseholdDTO]: + """Find a household by name for a specific user""" + pass + + @abstractmethod + async def find_household_by_id(self, household_id: HouseholdID) -> Optional[HouseholdDTO]: + """Find a household by ID""" + 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..02dd193 --- /dev/null +++ b/app/context/household/domain/dto/__init__.py @@ -0,0 +1,3 @@ +from .household_dto import HouseholdDTO + +__all__ = ["HouseholdDTO"] 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..d6ae6df --- /dev/null +++ b/app/context/household/domain/dto/household_dto.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) + + +@dataclass(frozen=True) +class HouseholdDTO: + household_id: Optional[HouseholdID] + owner_user_id: HouseholdUserID + name: HouseholdName + created_at: Optional[datetime] = 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..cb4efae --- /dev/null +++ b/app/context/household/domain/exceptions/__init__.py @@ -0,0 +1,11 @@ +from .exceptions import ( + HouseholdMapperError, + HouseholdNameAlreadyExistError, + HouseholdNotFoundError, +) + +__all__ = [ + "HouseholdMapperError", + "HouseholdNameAlreadyExistError", + "HouseholdNotFoundError", +] diff --git a/app/context/household/domain/exceptions/exceptions.py b/app/context/household/domain/exceptions/exceptions.py new file mode 100644 index 0000000..1c2b666 --- /dev/null +++ b/app/context/household/domain/exceptions/exceptions.py @@ -0,0 +1,10 @@ +class HouseholdNotFoundError(Exception): + pass + + +class HouseholdNameAlreadyExistError(Exception): + pass + + +class HouseholdMapperError(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..76ce2f3 --- /dev/null +++ b/app/context/household/domain/services/__init__.py @@ -0,0 +1,3 @@ +from .create_household_service import CreateHouseholdService + +__all__ = ["CreateHouseholdService"] 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..fcff323 --- /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 + + +class CreateHouseholdService(CreateHouseholdServiceContract): + def __init__(self, household_repository: HouseholdRepositoryContract): + self._household_repository = household_repository + + async def create_household( + self, name: HouseholdName, creator_user_id: HouseholdUserID + ) -> HouseholdDTO: + """Create a new household with the creator as owner""" + + # 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, creator_user_id=creator_user_id + ) + + return created_household 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..d8f0301 --- /dev/null +++ b/app/context/household/domain/value_objects/__init__.py @@ -0,0 +1,5 @@ +from .household_id import HouseholdID +from .household_name import HouseholdName +from .household_user_id import HouseholdUserID + +__all__ = ["HouseholdID", "HouseholdName", "HouseholdUserID"] 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_name.py b/app/context/household/domain/value_objects/household_name.py new file mode 100644 index 0000000..20d7e0d --- /dev/null +++ b/app/context/household/domain/value_objects/household_name.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass, field +from typing_extensions 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_user_id.py b/app/context/household/domain/value_objects/household_user_id.py new file mode 100644 index 0000000..a072b70 --- /dev/null +++ b/app/context/household/domain/value_objects/household_user_id.py @@ -0,0 +1,12 @@ +from dataclasses import dataclass + + +@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}") 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..246ba6b --- /dev/null +++ b/app/context/household/infrastructure/dependencies.py @@ -0,0 +1,33 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.household.application.contracts import CreateHouseholdHandlerContract +from app.context.household.application.handlers import CreateHouseholdHandler +from app.context.household.domain.contracts import ( + CreateHouseholdServiceContract, + HouseholdRepositoryContract, +) +from app.context.household.domain.services import CreateHouseholdService +from app.context.household.infrastructure.repositories import HouseholdRepository +from app.shared.infrastructure.database import get_db + + +# Repository dependencies +def get_household_repository( + db: AsyncSession = Depends(get_db), +) -> HouseholdRepositoryContract: + return HouseholdRepository(db) + + +# Service dependencies +def get_create_household_service( + household_repository: HouseholdRepositoryContract = Depends(get_household_repository), +) -> CreateHouseholdServiceContract: + return CreateHouseholdService(household_repository) + + +# Handler dependencies +def get_create_household_handler( + service: CreateHouseholdServiceContract = Depends(get_create_household_service), +) -> CreateHouseholdHandlerContract: + return CreateHouseholdHandler(service) diff --git a/app/context/household/infrastructure/mappers/__init__.py b/app/context/household/infrastructure/mappers/__init__.py new file mode 100644 index 0000000..5b1ed4f --- /dev/null +++ b/app/context/household/infrastructure/mappers/__init__.py @@ -0,0 +1,3 @@ +from .household_mapper import HouseholdMapper + +__all__ = ["HouseholdMapper"] 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..8e6dd85 --- /dev/null +++ b/app/context/household/infrastructure/mappers/household_mapper.py @@ -0,0 +1,58 @@ +from typing import Optional + +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: Optional[HouseholdModel]) -> Optional[HouseholdDTO]: + """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: Optional[HouseholdModel]) -> 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/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..28c9616 --- /dev/null +++ b/app/context/household/infrastructure/models/household_model.py @@ -0,0 +1,43 @@ +from datetime import UTC, datetime +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, String +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), + ) + + +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") + joined_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(UTC), + ) + left_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) 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..d4dcfdc --- /dev/null +++ b/app/context/household/infrastructure/repositories/household_repository.py @@ -0,0 +1,79 @@ +from typing import Optional + +from sqlalchemy import and_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.household.domain.contracts import HouseholdRepositoryContract +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.exceptions import HouseholdNameAlreadyExistError +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) +from app.context.household.infrastructure.mappers import HouseholdMapper +from app.context.household.infrastructure.models import ( + HouseholdModel, +) + + +class HouseholdRepository(HouseholdRepositoryContract): + def __init__(self, db: AsyncSession): + self._db = db + + async def create_household( + self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID + ) -> HouseholdDTO: + """Create a new household with the owner stored in the household table""" + + # Convert DTO to model + household_model = HouseholdMapper.to_model(household_dto) + + # Add household to database + self._db.add(household_model) + + try: + # Commit transaction - will raise IntegrityError if duplicate (owner_user_id, name) + await self._db.commit() + await self._db.refresh(household_model) + except IntegrityError as e: + await self._db.rollback() + # Check if it's the unique constraint violation + if "uq_households_owner_name" in str(e.orig): + raise HouseholdNameAlreadyExistError( + f"Household with name '{household_dto.name.value}' already exists for this user" + ) + raise Exception(e) + + # Convert back to DTO + return HouseholdMapper.to_dto_or_fail(household_model) + + async def find_household_by_name( + self, name: HouseholdName, user_id: HouseholdUserID + ) -> Optional[HouseholdDTO]: + """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, + ) + ) + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return HouseholdMapper.to_dto(model) + + async def find_household_by_id( + self, household_id: HouseholdID + ) -> Optional[HouseholdDTO]: + """Find a household by ID""" + + stmt = select(HouseholdModel).where(HouseholdModel.id == household_id.value) + + result = await self._db.execute(stmt) + model = result.scalar_one_or_none() + + return HouseholdMapper.to_dto(model) 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..ae6e114 --- /dev/null +++ b/app/context/household/interface/rest/__init__.py @@ -0,0 +1 @@ +from .routes import 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..980b763 --- /dev/null +++ b/app/context/household/interface/rest/controllers/__init__.py @@ -0,0 +1,3 @@ +from .create_household_controller import router as create_household_router + +__all__ = ["create_household_router"] 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..6a46ad5 --- /dev/null +++ b/app/context/household/interface/rest/controllers/create_household_controller.py @@ -0,0 +1,51 @@ +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.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.post("/", status_code=201) +async def create_household( + request: CreateHouseholdRequest, + handler: CreateHouseholdHandlerContract = Depends(get_create_household_handler), + user_id: int = Depends(get_current_user_id), +) -> CreateHouseholdResponse: + """Create a new household""" + + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + elif not result.household_id or not result.household_name: + raise HTTPException(status_code=500, detail="unexpected server error") + + # Return success response + return CreateHouseholdResponse( + id=result.household_id, + name=result.household_name, + ) diff --git a/app/context/household/interface/rest/routes.py b/app/context/household/interface/rest/routes.py new file mode 100644 index 0000000..87122e0 --- /dev/null +++ b/app/context/household/interface/rest/routes.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.context.household.interface.rest.controllers import create_household_router + +household_routes = APIRouter(prefix="/api/households", tags=["households"]) + +# Include all household-related routes +household_routes.include_router(create_household_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..62f7fe4 --- /dev/null +++ b/app/context/household/interface/schemas/__init__.py @@ -0,0 +1,4 @@ +from .create_household_request import CreateHouseholdRequest +from .create_household_response import CreateHouseholdResponse + +__all__ = ["CreateHouseholdRequest", "CreateHouseholdResponse"] 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/main.py b/app/main.py index 40f1f6c..6311ba5 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ from app.context.auth.interface.rest import auth_routes from app.context.credit_card.interface.rest import credit_card_routes +from app.context.household.interface.rest import household_routes from app.context.user_account.interface.rest import user_account_routes app = FastAPI( @@ -14,6 +15,7 @@ app.include_router(auth_routes) app.include_router(user_account_routes) app.include_router(credit_card_routes) +app.include_router(household_routes) @app.get("/") diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index d4ad4bd..0db8aa4 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -50,3 +50,7 @@ async def get_db(): from app.context.credit_card.infrastructure.models.credit_card_model import ( # noqa: F401, E402 CreditCardModel, ) +from app.context.household.infrastructure.models.household_model import ( # noqa: F401, E402 + HouseholdModel, + HouseholdMemberModel, +) diff --git a/migrations/versions/01770bb99438_create_household_tables.py b/migrations/versions/01770bb99438_create_household_tables.py new file mode 100644 index 0000000..f5aca91 --- /dev/null +++ b/migrations/versions/01770bb99438_create_household_tables.py @@ -0,0 +1,103 @@ +"""Create household tables + +Revision ID: 01770bb99438 +Revises: d96343c7a2a6 +Create Date: 2025-12-27 15:30:16.811732 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "01770bb99438" +down_revision: Union[str, Sequence[str], None] = "d96343c7a2a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[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"), + ), + # 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=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column("left_at", sa.DateTime(timezone=True), nullable=True), + 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", + ), + ) + + # Create index for querying active members by household + op.create_index( + "ix_household_members_household_id", + "household_members", + ["household_id"], + ) + + # Create index for querying active households by user + op.create_index( + "ix_household_members_user_id", + "household_members", + ["user_id"], + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_household_members_user_id", table_name="household_members") + op.drop_index("ix_household_members_household_id", table_name="household_members") + op.drop_table("household_members") + op.drop_table("households") diff --git a/requests/credit_card_create.sh b/requests/credit_card_create.sh index c8da918..ced713f 100755 --- a/requests/credit_card_create.sh +++ b/requests/credit_card_create.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -source ./base.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" http --session=local_session POST "${MYAPP_URL}/api/credit-cards/cards" \ account_id=1 \ diff --git a/requests/credit_card_delete.sh b/requests/credit_card_delete.sh index 40d2b20..0f0c95a 100755 --- a/requests/credit_card_delete.sh +++ b/requests/credit_card_delete.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -source ./base.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" http --session=local_session DELETE "${MYAPP_URL}/api/credit-cards/cards/8" diff --git a/requests/credit_card_list.sh b/requests/credit_card_list.sh index 20b44a7..e557b74 100755 --- a/requests/credit_card_list.sh +++ b/requests/credit_card_list.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -source ./base.sh +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 index 610056e..d8acf16 100755 --- a/requests/credit_card_update.sh +++ b/requests/credit_card_update.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -source ./base.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" http --session=local_session PUT "${MYAPP_URL}/api/credit-cards/cards/8" \ account_id=2 \ 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/login.sh b/requests/login.sh index bd6c3a9..4ce4b8b 100755 --- a/requests/login.sh +++ b/requests/login.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -source ./base.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" http --session=local_session POST "${MYAPP_URL}/api/auth/login" \ email=user1@test.com \ diff --git a/requests/user_account_create.sh b/requests/user_account_create.sh index 1450f26..e0f10f2 100755 --- a/requests/user_account_create.sh +++ b/requests/user_account_create.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -source ./base.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" http --session=local_session POST "${MYAPP_URL}/api/user-accounts/accounts" \ name="my third account" \ diff --git a/requests/user_account_delete.sh b/requests/user_account_delete.sh index 8872d71..cfdbba4 100755 --- a/requests/user_account_delete.sh +++ b/requests/user_account_delete.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -source ./base.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" http --session=local_session DELETE "${MYAPP_URL}/api/user-accounts/accounts/3" diff --git a/requests/user_account_list.sh b/requests/user_account_list.sh index 944627e..fbe97e7 100755 --- a/requests/user_account_list.sh +++ b/requests/user_account_list.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -source ./base.sh +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 index 1826aa8..62f26bf 100755 --- a/requests/user_account_update.sh +++ b/requests/user_account_update.sh @@ -1,10 +1,11 @@ #!/usr/bin/env bash -source ./base.sh +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/base.sh" - http --session=local_session PUT "${MYAPP_URL}/api/user-accounts/accounts/2" \ - name="my-updated-account" \ - currency="ARS" \ - balance=423.55 +http --session=local_session PUT "${MYAPP_URL}/api/user-accounts/accounts/2" \ + name="my-updated-account" \ + currency="ARS" \ + balance=423.55 From 4cf60a5f68bd14e77721ebc19c0d5ba17740d3ec Mon Sep 17 00:00:00 2001 From: polivera Date: Sun, 28 Dec 2025 02:06:48 +0100 Subject: [PATCH 24/58] Fixes on deleted_at for all models and working on household invitation --- .../auth/infrastructure/dependencies.py | 6 +- .../credit_card/domain/dto/credit_card_dto.py | 7 + .../mappers/credit_card_mapper.py | 3 + .../household/domain/contracts/__init__.py | 15 +- .../accept_invite_service_contract.py | 20 ++ .../decline_invite_service_contract.py | 19 ++ .../household_repository_contract.py | 59 ++++- .../contracts/invite_user_service_contract.py | 35 +++ .../remove_member_service_contract.py | 24 ++ .../revoke_invite_service_contract.py | 22 ++ app/context/household/domain/dto/__init__.py | 3 +- .../domain/dto/household_member_dto.py | 44 ++++ .../household/domain/exceptions/__init__.py | 18 ++ .../household/domain/exceptions/exceptions.py | 37 +++ .../household/domain/services/__init__.py | 14 +- .../domain/services/accept_invite_service.py | 30 +++ .../domain/services/decline_invite_service.py | 29 +++ .../domain/services/invite_user_service.py | 70 ++++++ .../domain/services/remove_member_service.py | 47 ++++ .../domain/services/revoke_invite_service.py | 40 ++++ .../domain/value_objects/__init__.py | 10 +- .../value_objects/household_member_id.py | 12 + .../domain/value_objects/household_role.py | 24 ++ .../infrastructure/mappers/__init__.py | 3 +- .../mappers/household_member_mapper.py | 55 +++++ .../infrastructure/models/household_model.py | 12 +- .../repositories/household_repository.py | 214 +++++++++++++++++- .../user/application/contracts/__init__.py | 4 +- ...tract.py => find_user_handler_contract.py} | 0 app/context/user/domain/dto/user_dto.py | 14 +- .../user/domain/value_objects/__init__.py | 3 +- .../user/domain/value_objects/deleted_at.py | 8 + .../infrastructure/mappers/user_mapper.py | 3 +- .../user/infrastructure/models/user_model.py | 6 +- .../domain/dto/user_account_dto.py | 5 + .../mappers/user_account_mapper.py | 3 + .../01770bb99438_create_household_tables.py | 54 ++++- 37 files changed, 931 insertions(+), 41 deletions(-) create mode 100644 app/context/household/domain/contracts/accept_invite_service_contract.py create mode 100644 app/context/household/domain/contracts/decline_invite_service_contract.py create mode 100644 app/context/household/domain/contracts/invite_user_service_contract.py create mode 100644 app/context/household/domain/contracts/remove_member_service_contract.py create mode 100644 app/context/household/domain/contracts/revoke_invite_service_contract.py create mode 100644 app/context/household/domain/dto/household_member_dto.py create mode 100644 app/context/household/domain/services/accept_invite_service.py create mode 100644 app/context/household/domain/services/decline_invite_service.py create mode 100644 app/context/household/domain/services/invite_user_service.py create mode 100644 app/context/household/domain/services/remove_member_service.py create mode 100644 app/context/household/domain/services/revoke_invite_service.py create mode 100644 app/context/household/domain/value_objects/household_member_id.py create mode 100644 app/context/household/domain/value_objects/household_role.py create mode 100644 app/context/household/infrastructure/mappers/household_member_mapper.py rename app/context/user/application/contracts/{find_user_query_handler_contract.py => find_user_handler_contract.py} (100%) create mode 100644 app/context/user/domain/value_objects/deleted_at.py diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index 229db84..c12dbd8 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -15,11 +15,7 @@ ) from app.context.auth.domain.services import LoginService from app.context.auth.infrastructure.repositories import SessionRepository -from app.shared.infrastructure.middleware.session_auth_dependency import ( - get_current_user_id, - get_current_user_id_optional, -) -from app.context.user.application.contracts.find_user_query_handler_contract import ( +from app.context.user.application.contracts import ( FindUserHandlerContract, ) from app.context.user.infrastructure.dependency import ( diff --git a/app/context/credit_card/domain/dto/credit_card_dto.py b/app/context/credit_card/domain/dto/credit_card_dto.py index 4181f93..feaef0f 100644 --- a/app/context/credit_card/domain/dto/credit_card_dto.py +++ b/app/context/credit_card/domain/dto/credit_card_dto.py @@ -4,6 +4,7 @@ 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 @@ -25,3 +26,9 @@ class CreditCardDTO: limit: CardLimit used: Optional[CardUsed] = None credit_card_id: Optional[CreditCardID] = None + deleted_at: Optional[CreditCardDeletedAt] = 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/infrastructure/mappers/credit_card_mapper.py b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py index 5903f96..16a9d76 100644 --- a/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py +++ b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py @@ -7,6 +7,7 @@ CardUsed, CreditCardAccountID, CreditCardCurrency, + CreditCardDeletedAt, CreditCardID, CreditCardName, CreditCardUserID, @@ -29,6 +30,7 @@ def to_dto(model: Optional[CreditCardModel]) -> Optional[CreditCardDTO]: 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 @@ -53,4 +55,5 @@ def to_model(dto: CreditCardDTO) -> CreditCardModel: 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/household/domain/contracts/__init__.py b/app/context/household/domain/contracts/__init__.py index bae50cb..f58703c 100644 --- a/app/context/household/domain/contracts/__init__.py +++ b/app/context/household/domain/contracts/__init__.py @@ -1,4 +1,17 @@ +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 -__all__ = ["CreateHouseholdServiceContract", "HouseholdRepositoryContract"] +__all__ = [ + "AcceptInviteServiceContract", + "CreateHouseholdServiceContract", + "DeclineInviteServiceContract", + "HouseholdRepositoryContract", + "InviteUserServiceContract", + "RemoveMemberServiceContract", + "RevokeInviteServiceContract", +] 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/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 index 3ecc037..83628f6 100644 --- a/app/context/household/domain/contracts/household_repository_contract.py +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from typing import Optional +from typing import List, Optional -from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO from app.context.household.domain.value_objects import HouseholdID, HouseholdName, HouseholdUserID @@ -24,3 +24,58 @@ async def find_household_by_name( async def find_household_by_id(self, household_id: HouseholdID) -> Optional[HouseholdDTO]: """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 + ) -> Optional[HouseholdMemberDTO]: + """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 + ) -> List[HouseholdMemberDTO]: + """List all pending invites for a household""" + 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/dto/__init__.py b/app/context/household/domain/dto/__init__.py index 02dd193..f46cc01 100644 --- a/app/context/household/domain/dto/__init__.py +++ b/app/context/household/domain/dto/__init__.py @@ -1,3 +1,4 @@ from .household_dto import HouseholdDTO +from .household_member_dto import HouseholdMemberDTO -__all__ = ["HouseholdDTO"] +__all__ = ["HouseholdDTO", "HouseholdMemberDTO"] 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..1446ff5 --- /dev/null +++ b/app/context/household/domain/dto/household_member_dto.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdRole, + HouseholdUserID, +) + + +@dataclass(frozen=True) +class HouseholdMemberDTO: + """Domain DTO for household member""" + + member_id: Optional[HouseholdMemberID] + household_id: HouseholdID + user_id: HouseholdUserID + role: HouseholdRole + joined_at: Optional[datetime] = None + left_at: Optional[datetime] = None + invited_by_user_id: Optional[HouseholdUserID] = None + invited_at: Optional[datetime] = None + + @property + def is_invited(self) -> bool: + """Check if this is a pending invite (not yet accepted)""" + return self.joined_at is None and self.left_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 and self.left_at is None + + @property + def has_left(self) -> bool: + """Check if the member has left the household""" + return self.left_at is not None + + @property + def has_declined(self) -> bool: + """Check if the invite was declined (left without joining)""" + return self.left_at is not None and self.joined_at is None diff --git a/app/context/household/domain/exceptions/__init__.py b/app/context/household/domain/exceptions/__init__.py index cb4efae..d9d6924 100644 --- a/app/context/household/domain/exceptions/__init__.py +++ b/app/context/household/domain/exceptions/__init__.py @@ -1,11 +1,29 @@ from .exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + CannotRemoveSelfError, HouseholdMapperError, HouseholdNameAlreadyExistError, HouseholdNotFoundError, + InviteNotFoundError, + NotInvitedError, + OnlyOwnerCanInviteError, + OnlyOwnerCanRemoveMemberError, + OnlyOwnerCanRevokeError, + UserNotFoundError, ) __all__ = [ + "AlreadyActiveMemberError", + "AlreadyInvitedError", + "CannotRemoveSelfError", "HouseholdMapperError", "HouseholdNameAlreadyExistError", "HouseholdNotFoundError", + "InviteNotFoundError", + "NotInvitedError", + "OnlyOwnerCanInviteError", + "OnlyOwnerCanRemoveMemberError", + "OnlyOwnerCanRevokeError", + "UserNotFoundError", ] diff --git a/app/context/household/domain/exceptions/exceptions.py b/app/context/household/domain/exceptions/exceptions.py index 1c2b666..6121228 100644 --- a/app/context/household/domain/exceptions/exceptions.py +++ b/app/context/household/domain/exceptions/exceptions.py @@ -8,3 +8,40 @@ class HouseholdNameAlreadyExistError(Exception): 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 diff --git a/app/context/household/domain/services/__init__.py b/app/context/household/domain/services/__init__.py index 76ce2f3..3f26aee 100644 --- a/app/context/household/domain/services/__init__.py +++ b/app/context/household/domain/services/__init__.py @@ -1,3 +1,15 @@ +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 -__all__ = ["CreateHouseholdService"] +__all__ = [ + "AcceptInviteService", + "CreateHouseholdService", + "DeclineInviteService", + "InviteUserService", + "RemoveMemberService", + "RevokeInviteService", +] 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..4b6ba42 --- /dev/null +++ b/app/context/household/domain/services/accept_invite_service.py @@ -0,0 +1,30 @@ +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 + + +class AcceptInviteService(AcceptInviteServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract): + self._household_repo = household_repo + + async def accept_invite( + self, + user_id: HouseholdUserID, + household_id: HouseholdID, + ) -> HouseholdMemberDTO: + """Accept a pending household invite""" + + # 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: + 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/decline_invite_service.py b/app/context/household/domain/services/decline_invite_service.py new file mode 100644 index 0000000..1144725 --- /dev/null +++ b/app/context/household/domain/services/decline_invite_service.py @@ -0,0 +1,29 @@ +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 + + +class DeclineInviteService(DeclineInviteServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract): + self._household_repo = household_repo + + async def decline_invite( + self, + user_id: HouseholdUserID, + household_id: HouseholdID, + ) -> None: + """Decline a pending household invite""" + + # 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: + raise NotInvitedError( + "No pending invite found for this household" + ) + + # Decline the invite (sets left_at) + 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..ed6fe69 --- /dev/null +++ b/app/context/household/domain/services/invite_user_service.py @@ -0,0 +1,70 @@ +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, +) + + +class InviteUserService(InviteUserServiceContract): + def __init__( + self, + household_repo: HouseholdRepositoryContract, + ): + self._household_repo = household_repo + + 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. + """ + + # 1. Check if inviter is the owner + household = await self._household_repo.find_household_by_id(household_id) + if not household or household.owner_user_id.value != inviter_user_id.value: + raise OnlyOwnerCanInviteError("Only the household owner can invite users") + + # 2. Check for existing relationship + existing_member = await self._household_repo.find_member( + household_id, invitee_user_id + ) + + if existing_member: + if existing_member.is_active: + raise AlreadyActiveMemberError( + "User is already an active member of this household" + ) + if existing_member.is_invited: + 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 + left_at=None, + 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..1befe9b --- /dev/null +++ b/app/context/household/domain/services/remove_member_service.py @@ -0,0 +1,47 @@ +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 + + +class RemoveMemberService(RemoveMemberServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract): + self._household_repo = household_repo + + async def remove_member( + self, + remover_user_id: HouseholdUserID, + household_id: HouseholdID, + member_user_id: HouseholdUserID, + ) -> None: + """Remove an active member from a household""" + + # 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: + raise OnlyOwnerCanRemoveMemberError( + "Only the household owner can remove members" + ) + + # Owner cannot remove themselves + if remover_user_id.value == member_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: + raise InviteNotFoundError( + "No active member found with this user ID" + ) + + # Remove the member (sets left_at) + 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..2c752bb --- /dev/null +++ b/app/context/household/domain/services/revoke_invite_service.py @@ -0,0 +1,40 @@ +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 + + +class RevokeInviteService(RevokeInviteServiceContract): + def __init__(self, household_repo: HouseholdRepositoryContract): + self._household_repo = household_repo + + async def revoke_invite( + self, + revoker_user_id: HouseholdUserID, + household_id: HouseholdID, + invitee_user_id: HouseholdUserID, + ) -> None: + """Revoke a pending household invite""" + + # 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: + 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: + raise InviteNotFoundError( + "No pending invite found for this user" + ) + + # Revoke the invite (sets left_at) + await self._household_repo.revoke_or_remove(household_id, invitee_user_id) diff --git a/app/context/household/domain/value_objects/__init__.py b/app/context/household/domain/value_objects/__init__.py index d8f0301..cbf5e5b 100644 --- a/app/context/household/domain/value_objects/__init__.py +++ b/app/context/household/domain/value_objects/__init__.py @@ -1,5 +1,13 @@ 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 -__all__ = ["HouseholdID", "HouseholdName", "HouseholdUserID"] +__all__ = [ + "HouseholdID", + "HouseholdMemberID", + "HouseholdName", + "HouseholdRole", + "HouseholdUserID", +] 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_role.py b/app/context/household/domain/value_objects/household_role.py new file mode 100644 index 0000000..30ff042 --- /dev/null +++ b/app/context/household/domain/value_objects/household_role.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass, field +from typing_extensions import Self + + +@dataclass(frozen=True) +class HouseholdRole: + value: str + _validated: bool = field(default=False, repr=False, compare=False) + + VALID_ROLES = {"owner", "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/infrastructure/mappers/__init__.py b/app/context/household/infrastructure/mappers/__init__.py index 5b1ed4f..edea2b7 100644 --- a/app/context/household/infrastructure/mappers/__init__.py +++ b/app/context/household/infrastructure/mappers/__init__.py @@ -1,3 +1,4 @@ from .household_mapper import HouseholdMapper +from .household_member_mapper import HouseholdMemberMapper -__all__ = ["HouseholdMapper"] +__all__ = ["HouseholdMapper", "HouseholdMemberMapper"] 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..654e521 --- /dev/null +++ b/app/context/household/infrastructure/mappers/household_member_mapper.py @@ -0,0 +1,55 @@ +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, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.infrastructure.models import HouseholdMemberModel + + +class HouseholdMemberMapper: + """Mapper for converting between HouseholdMemberModel and HouseholdMemberDTO""" + + @staticmethod + def to_dto(model: HouseholdMemberModel) -> 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, + left_at=model.left_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, + ) + + @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, + left_at=dto.left_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)}") diff --git a/app/context/household/infrastructure/models/household_model.py b/app/context/household/infrastructure/models/household_model.py index 28c9616..45d7204 100644 --- a/app/context/household/infrastructure/models/household_model.py +++ b/app/context/household/infrastructure/models/household_model.py @@ -33,11 +33,15 @@ class HouseholdMemberModel(BaseDBModel): Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False ) role: Mapped[str] = mapped_column(String(20), nullable=False, default="participant") - joined_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), - nullable=False, - default=lambda: datetime.now(UTC), + joined_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, default=None ) left_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) + invited_by_user_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, default=None + ) + invited_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, default=lambda: datetime.now(UTC) + ) diff --git a/app/context/household/infrastructure/repositories/household_repository.py b/app/context/household/infrastructure/repositories/household_repository.py index d4dcfdc..8f83b5b 100644 --- a/app/context/household/infrastructure/repositories/household_repository.py +++ b/app/context/household/infrastructure/repositories/household_repository.py @@ -1,19 +1,27 @@ -from typing import Optional +from datetime import UTC, datetime +from typing import List, Optional from sqlalchemy import and_, select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.context.household.domain.contracts import HouseholdRepositoryContract -from app.context.household.domain.dto import HouseholdDTO -from app.context.household.domain.exceptions import HouseholdNameAlreadyExistError +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.exceptions import ( + HouseholdNameAlreadyExistError, + InviteNotFoundError, +) from app.context.household.domain.value_objects import ( HouseholdID, HouseholdName, HouseholdUserID, ) -from app.context.household.infrastructure.mappers import HouseholdMapper +from app.context.household.infrastructure.mappers import ( + HouseholdMapper, + HouseholdMemberMapper, +) from app.context.household.infrastructure.models import ( + HouseholdMemberModel, HouseholdModel, ) @@ -27,26 +35,21 @@ async def create_household( ) -> HouseholdDTO: """Create a new household with the owner stored in the household table""" - # Convert DTO to model household_model = HouseholdMapper.to_model(household_dto) - # Add household to database self._db.add(household_model) try: - # Commit transaction - will raise IntegrityError if duplicate (owner_user_id, name) await self._db.commit() await self._db.refresh(household_model) except IntegrityError as e: await self._db.rollback() - # Check if it's the unique constraint violation if "uq_households_owner_name" in str(e.orig): raise HouseholdNameAlreadyExistError( f"Household with name '{household_dto.name.value}' already exists for this user" ) raise Exception(e) - # Convert back to DTO return HouseholdMapper.to_dto_or_fail(household_model) async def find_household_by_name( @@ -64,7 +67,7 @@ async def find_household_by_name( result = await self._db.execute(stmt) model = result.scalar_one_or_none() - return HouseholdMapper.to_dto(model) + return HouseholdMapper.to_dto(model) if model else None async def find_household_by_id( self, household_id: HouseholdID @@ -76,4 +79,193 @@ async def find_household_by_id( result = await self._db.execute(stmt) model = result.scalar_one_or_none() - return HouseholdMapper.to_dto(model) + 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 + ) -> Optional[HouseholdMemberDTO]: + """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), + HouseholdMemberModel.left_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, + HouseholdMemberModel.left_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 active invite or membership found") + + # Set left_at + member_model.left_at = datetime.now(UTC) + + 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( + HouseholdModel.owner_user_id == user_id.value + ) + + # 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), + HouseholdMemberModel.left_at.is_(None), + ) + ) + ) + + # Execute both queries + owner_result = await self._db.execute(owner_stmt) + member_result = await self._db.execute(member_stmt) + + owner_households = owner_result.scalars().all() + member_households = member_result.scalars().all() + + # Combine and dedupe (in case owner is also a member) + all_households = {h.id: h for h in owner_households} + all_households.update({h.id: h for h in member_households}) + + return [HouseholdMapper.to_dto(h) for h in all_households.values()] + + 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), + HouseholdMemberModel.left_at.is_(None), + ) + ) + ) + + result = await self._db.execute(stmt) + households = result.scalars().all() + + return [HouseholdMapper.to_dto(h) for h in households] + + async def list_household_pending_invites( + self, household_id: HouseholdID + ) -> List[HouseholdMemberDTO]: + """List all pending invites for a household""" + stmt = select(HouseholdMemberModel).where( + and_( + HouseholdMemberModel.household_id == household_id.value, + HouseholdMemberModel.joined_at.is_(None), + HouseholdMemberModel.left_at.is_(None), + ) + ) + + result = await self._db.execute(stmt) + members = result.scalars().all() + + return [HouseholdMemberMapper.to_dto(m) for m in members] + + 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/user/application/contracts/__init__.py b/app/context/user/application/contracts/__init__.py index ee566d6..e4d34ed 100644 --- a/app/context/user/application/contracts/__init__.py +++ b/app/context/user/application/contracts/__init__.py @@ -1 +1,3 @@ -from .find_user_query_handler_contract import FindUserHandlerContract +from .find_user_handler_contract import FindUserHandlerContract + +__all__ = ["FindUserHandlerContract"] diff --git a/app/context/user/application/contracts/find_user_query_handler_contract.py b/app/context/user/application/contracts/find_user_handler_contract.py similarity index 100% rename from app/context/user/application/contracts/find_user_query_handler_contract.py rename to app/context/user/application/contracts/find_user_handler_contract.py diff --git a/app/context/user/domain/dto/user_dto.py b/app/context/user/domain/dto/user_dto.py index de37b49..3790fd6 100644 --- a/app/context/user/domain/dto/user_dto.py +++ b/app/context/user/domain/dto/user_dto.py @@ -1,6 +1,12 @@ from dataclasses import dataclass +from typing import Optional -from app.context.user.domain.value_objects import Email, Password, UserID +from app.context.user.domain.value_objects import ( + Email, + Password, + UserDeletedAt, + UserID, +) @dataclass(frozen=True) @@ -8,3 +14,9 @@ class UserDTO: user_id: UserID email: Email password: Password + deleted_at: Optional[UserDeletedAt] = 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/value_objects/__init__.py b/app/context/user/domain/value_objects/__init__.py index 2050067..1b080b8 100644 --- a/app/context/user/domain/value_objects/__init__.py +++ b/app/context/user/domain/value_objects/__init__.py @@ -1,5 +1,6 @@ +from .deleted_at import UserDeletedAt from .email import Email from .password import Password from .user_id import UserID -__all__ = ["Email", "Password", "UserID"] +__all__ = ["Email", "Password", "UserID", "UserDeletedAt"] 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/infrastructure/mappers/user_mapper.py b/app/context/user/infrastructure/mappers/user_mapper.py index cf62b79..c47932e 100644 --- a/app/context/user/infrastructure/mappers/user_mapper.py +++ b/app/context/user/infrastructure/mappers/user_mapper.py @@ -2,7 +2,7 @@ from typing import Optional from app.context.user.domain.dto.user_dto import UserDTO -from app.context.user.domain.value_objects import Email, Password, UserID +from app.context.user.domain.value_objects import Email, Password, UserDeletedAt, UserID from app.context.user.infrastructure.models.user_model import UserModel @@ -17,4 +17,5 @@ def toDTO(model: Optional[UserModel]) -> Optional[UserDTO]: user_id=UserID(model.id), email=Email(model.email), password=Password.from_hash(model.password), + deleted_at=UserDeletedAt.from_optional(model.deleted_at), ) diff --git a/app/context/user/infrastructure/models/user_model.py b/app/context/user/infrastructure/models/user_model.py index f35be5e..96b8e02 100644 --- a/app/context/user/infrastructure/models/user_model.py +++ b/app/context/user/infrastructure/models/user_model.py @@ -1,6 +1,7 @@ +from datetime import UTC, datetime from typing import Optional -from sqlalchemy import String +from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column from app.shared.infrastructure.models import BaseDBModel @@ -13,3 +14,6 @@ class UserModel(BaseDBModel): email: Mapped[str] = mapped_column(String(100)) password: Mapped[str] = mapped_column(String(150)) username: Mapped[Optional[str]] + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, default=None + ) diff --git a/app/context/user_account/domain/dto/user_account_dto.py b/app/context/user_account/domain/dto/user_account_dto.py index 68e536c..b8dcca4 100644 --- a/app/context/user_account/domain/dto/user_account_dto.py +++ b/app/context/user_account/domain/dto/user_account_dto.py @@ -21,3 +21,8 @@ class UserAccountDTO: balance: UserAccountBalance account_id: Optional[UserAccountID] = None deleted_at: Optional[UserAccountDeletedAt] = 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/infrastructure/mappers/user_account_mapper.py b/app/context/user_account/infrastructure/mappers/user_account_mapper.py index 76c3ff7..7bcd01b 100644 --- a/app/context/user_account/infrastructure/mappers/user_account_mapper.py +++ b/app/context/user_account/infrastructure/mappers/user_account_mapper.py @@ -5,6 +5,7 @@ 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 @@ -27,6 +28,7 @@ def to_dto(model: Optional[UserAccountModel]) -> Optional[UserAccountDTO]: 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 @@ -48,4 +50,5 @@ def to_model(dto: UserAccountDTO) -> UserAccountModel: 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/migrations/versions/01770bb99438_create_household_tables.py b/migrations/versions/01770bb99438_create_household_tables.py index f5aca91..6198ae1 100644 --- a/migrations/versions/01770bb99438_create_household_tables.py +++ b/migrations/versions/01770bb99438_create_household_tables.py @@ -62,10 +62,16 @@ def upgrade() -> None: sa.Column( "joined_at", sa.DateTime(timezone=True), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=True, # NULL for invited members, set when they accept ), sa.Column("left_at", sa.DateTime(timezone=True), nullable=True), + 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"], @@ -78,26 +84,56 @@ def upgrade() -> None: name="fk_household_members_user", ondelete="RESTRICT", ), + sa.ForeignKeyConstraint( + ["invited_by_user_id"], + ["users.id"], + name="fk_household_members_inviter", + ondelete="RESTRICT", + ), ) - # Create index for querying active members by household + # Partial index: Get user's active households + # Used for: "What households do I have access to?" op.create_index( - "ix_household_members_household_id", + "ix_household_members_user_active", "household_members", - ["household_id"], + ["user_id"], + postgresql_where=sa.text("joined_at IS NOT NULL AND left_at IS 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 AND left_at IS NULL"), ) - # Create index for querying active households by user + # Partial index: Get user's pending invites + # Used for: "What households has user been invited to?" op.create_index( - "ix_household_members_user_id", + "ix_household_members_pending_invites", "household_members", ["user_id"], + postgresql_where=sa.text("joined_at IS NULL AND left_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 AND left_at IS NULL"), ) def downgrade() -> None: """Downgrade schema.""" - op.drop_index("ix_household_members_user_id", table_name="household_members") - op.drop_index("ix_household_members_household_id", table_name="household_members") + 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") From c4aeb333cb621648bcd735ea11bc05c4efd47071 Mon Sep 17 00:00:00 2001 From: polivera Date: Sun, 28 Dec 2025 12:25:48 +0100 Subject: [PATCH 25/58] Prototyping household invitation system --- .../domain/contracts/household_repository_contract.py | 10 ++++++++-- .../household/domain/services/invite_user_service.py | 2 -- .../infrastructure/models/household_model.py | 11 +++++++---- .../versions/01770bb99438_create_household_tables.py | 11 ++++++----- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/app/context/household/domain/contracts/household_repository_contract.py b/app/context/household/domain/contracts/household_repository_contract.py index 83628f6..4402df1 100644 --- a/app/context/household/domain/contracts/household_repository_contract.py +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -2,7 +2,11 @@ from typing import List, Optional from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO -from app.context.household.domain.value_objects import HouseholdID, HouseholdName, HouseholdUserID +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) class HouseholdRepositoryContract(ABC): @@ -21,7 +25,9 @@ async def find_household_by_name( pass @abstractmethod - async def find_household_by_id(self, household_id: HouseholdID) -> Optional[HouseholdDTO]: + async def find_household_by_id( + self, household_id: HouseholdID + ) -> Optional[HouseholdDTO]: """Find a household by ID""" pass diff --git a/app/context/household/domain/services/invite_user_service.py b/app/context/household/domain/services/invite_user_service.py index ed6fe69..7d643c4 100644 --- a/app/context/household/domain/services/invite_user_service.py +++ b/app/context/household/domain/services/invite_user_service.py @@ -35,12 +35,10 @@ async def invite_user( Invite a user to a household. """ - # 1. Check if inviter is the owner household = await self._household_repo.find_household_by_id(household_id) if not household or household.owner_user_id.value != inviter_user_id.value: raise OnlyOwnerCanInviteError("Only the household owner can invite users") - # 2. Check for existing relationship existing_member = await self._household_repo.find_member( household_id, invitee_user_id ) diff --git a/app/context/household/infrastructure/models/household_model.py b/app/context/household/infrastructure/models/household_model.py index 45d7204..98cdc1a 100644 --- a/app/context/household/infrastructure/models/household_model.py +++ b/app/context/household/infrastructure/models/household_model.py @@ -1,7 +1,7 @@ from datetime import UTC, datetime from typing import Optional -from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from app.shared.infrastructure.models import BaseDBModel @@ -33,15 +33,18 @@ class HouseholdMemberModel(BaseDBModel): 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[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) - left_at: Mapped[Optional[datetime]] = mapped_column( - DateTime(timezone=True), nullable=True, default=None - ) invited_by_user_id: Mapped[Optional[int]] = mapped_column( Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, default=None ) invited_at: Mapped[Optional[datetime]] = 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/migrations/versions/01770bb99438_create_household_tables.py b/migrations/versions/01770bb99438_create_household_tables.py index 6198ae1..f5ee918 100644 --- a/migrations/versions/01770bb99438_create_household_tables.py +++ b/migrations/versions/01770bb99438_create_household_tables.py @@ -64,7 +64,6 @@ def upgrade() -> None: sa.DateTime(timezone=True), nullable=True, # NULL for invited members, set when they accept ), - sa.Column("left_at", sa.DateTime(timezone=True), nullable=True), sa.Column("invited_by_user_id", sa.Integer, nullable=True), sa.Column( "invited_at", @@ -90,6 +89,8 @@ def upgrade() -> None: 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 @@ -98,7 +99,7 @@ def upgrade() -> None: "ix_household_members_user_active", "household_members", ["user_id"], - postgresql_where=sa.text("joined_at IS NOT NULL AND left_at IS NULL"), + postgresql_where=sa.text("joined_at IS NOT NULL"), ) # Partial index: Check if user has access to specific household @@ -107,7 +108,7 @@ def upgrade() -> None: "ix_household_members_access_check", "household_members", ["household_id", "user_id"], - postgresql_where=sa.text("joined_at IS NOT NULL AND left_at IS NULL"), + postgresql_where=sa.text("joined_at IS NOT NULL"), ) # Partial index: Get user's pending invites @@ -116,7 +117,7 @@ def upgrade() -> None: "ix_household_members_pending_invites", "household_members", ["user_id"], - postgresql_where=sa.text("joined_at IS NULL AND left_at IS NULL"), + postgresql_where=sa.text("joined_at IS NULL"), ) # Partial index: Get household's pending invites (for owner to see) @@ -125,7 +126,7 @@ def upgrade() -> None: "ix_household_members_household_pending", "household_members", ["household_id"], - postgresql_where=sa.text("joined_at IS NULL AND left_at IS NULL"), + postgresql_where=sa.text("joined_at IS NULL"), ) From 8555e83c8eadd23723d8e2cdb7d5b1aaf5702b54 Mon Sep 17 00:00:00 2001 From: polivera Date: Sun, 28 Dec 2025 20:03:36 +0100 Subject: [PATCH 26/58] Working on household invitation system --- .../application/commands/__init__.py | 12 ++- .../commands/accept_invite_command.py | 9 ++ .../commands/decline_invite_command.py | 9 ++ .../commands/invite_user_command.py | 11 +++ .../commands/remove_member_command.py | 10 ++ .../application/contracts/__init__.py | 20 +++- .../accept_invite_handler_contract.py | 13 +++ .../decline_invite_handler_contract.py | 13 +++ .../contracts/invite_user_handler_contract.py | 13 +++ ...list_household_invites_handler_contract.py | 15 +++ ...t_user_pending_invites_handler_contract.py | 15 +++ .../remove_member_handler_contract.py | 13 +++ .../household/application/dto/__init__.py | 19 +++- .../application/dto/accept_invite_result.py | 26 +++++ .../application/dto/decline_invite_result.py | 22 +++++ .../dto/household_member_response_dto.py | 43 +++++++++ .../application/dto/invite_user_result.py | 28 ++++++ .../application/dto/remove_member_result.py | 24 +++++ .../application/handlers/__init__.py | 16 +++- .../handlers/accept_invite_handler.py | 57 +++++++++++ .../handlers/decline_invite_handler.py | 38 ++++++++ .../handlers/invite_user_handler.py | 72 ++++++++++++++ .../list_household_invites_handler.py | 30 ++++++ .../list_user_pending_invites_handler.py | 29 ++++++ .../handlers/remove_member_handler.py | 53 +++++++++++ .../household/application/queries/__init__.py | 4 + .../queries/list_household_invites_query.py | 9 ++ .../list_user_pending_invites_query.py | 8 ++ .../household_repository_contract.py | 9 +- .../domain/dto/household_member_dto.py | 19 ++-- .../domain/services/decline_invite_service.py | 5 +- .../domain/services/invite_user_service.py | 1 - .../domain/services/remove_member_service.py | 5 +- .../domain/services/revoke_invite_service.py | 9 +- .../domain/value_objects/household_role.py | 4 +- .../value_objects/household_user_name.py | 8 ++ .../household/infrastructure/dependencies.py | 95 ++++++++++++++++++- .../mappers/household_member_mapper.py | 28 +++++- .../infrastructure/models/household_model.py | 5 +- .../repositories/household_repository.py | 84 ++++++++++++---- .../interface/rest/controllers/__init__.py | 18 +++- .../controllers/accept_invite_controller.py | 47 +++++++++ .../controllers/decline_invite_controller.py | 41 ++++++++ .../controllers/invite_user_controller.py | 55 +++++++++++ .../list_household_invites_controller.py | 43 +++++++++ .../list_user_pending_invites_controller.py | 42 ++++++++ .../controllers/remove_member_controller.py | 45 +++++++++ .../household/interface/rest/routes.py | 16 +++- .../household/interface/schemas/__init__.py | 17 +++- .../schemas/accept_invite_response.py | 9 ++ .../schemas/decline_invite_response.py | 7 ++ .../schemas/household_member_response.py | 18 ++++ .../interface/schemas/invite_user_request.py | 12 +++ .../interface/schemas/invite_user_response.py | 9 ++ .../schemas/remove_member_response.py | 7 ++ .../user/domain/value_objects/username.py | 8 ++ app/shared/domain/value_objects/__init__.py | 2 + .../domain/value_objects/shared_username.py | 6 ++ requests/base.sh | 6 ++ requests/credit_card_create.sh | 18 +++- requests/credit_card_delete.sh | 7 +- requests/credit_card_update.sh | 19 +++- requests/household_accept_invite.sh | 11 +++ requests/household_decline_invite.sh | 11 +++ requests/household_invite_user.sh | 15 +++ requests/household_list_invites.sh | 11 +++ requests/household_my_invites.sh | 9 ++ requests/household_remove_member.sh | 12 +++ requests/login.sh | 10 +- requests/user_account_create.sh | 15 ++- requests/user_account_delete.sh | 7 +- requests/user_account_update.sh | 16 +++- 72 files changed, 1385 insertions(+), 87 deletions(-) create mode 100644 app/context/household/application/commands/accept_invite_command.py create mode 100644 app/context/household/application/commands/decline_invite_command.py create mode 100644 app/context/household/application/commands/invite_user_command.py create mode 100644 app/context/household/application/commands/remove_member_command.py create mode 100644 app/context/household/application/contracts/accept_invite_handler_contract.py create mode 100644 app/context/household/application/contracts/decline_invite_handler_contract.py create mode 100644 app/context/household/application/contracts/invite_user_handler_contract.py create mode 100644 app/context/household/application/contracts/list_household_invites_handler_contract.py create mode 100644 app/context/household/application/contracts/list_user_pending_invites_handler_contract.py create mode 100644 app/context/household/application/contracts/remove_member_handler_contract.py create mode 100644 app/context/household/application/dto/accept_invite_result.py create mode 100644 app/context/household/application/dto/decline_invite_result.py create mode 100644 app/context/household/application/dto/household_member_response_dto.py create mode 100644 app/context/household/application/dto/invite_user_result.py create mode 100644 app/context/household/application/dto/remove_member_result.py create mode 100644 app/context/household/application/handlers/accept_invite_handler.py create mode 100644 app/context/household/application/handlers/decline_invite_handler.py create mode 100644 app/context/household/application/handlers/invite_user_handler.py create mode 100644 app/context/household/application/handlers/list_household_invites_handler.py create mode 100644 app/context/household/application/handlers/list_user_pending_invites_handler.py create mode 100644 app/context/household/application/handlers/remove_member_handler.py create mode 100644 app/context/household/application/queries/__init__.py create mode 100644 app/context/household/application/queries/list_household_invites_query.py create mode 100644 app/context/household/application/queries/list_user_pending_invites_query.py create mode 100644 app/context/household/domain/value_objects/household_user_name.py create mode 100644 app/context/household/interface/rest/controllers/accept_invite_controller.py create mode 100644 app/context/household/interface/rest/controllers/decline_invite_controller.py create mode 100644 app/context/household/interface/rest/controllers/invite_user_controller.py create mode 100644 app/context/household/interface/rest/controllers/list_household_invites_controller.py create mode 100644 app/context/household/interface/rest/controllers/list_user_pending_invites_controller.py create mode 100644 app/context/household/interface/rest/controllers/remove_member_controller.py create mode 100644 app/context/household/interface/schemas/accept_invite_response.py create mode 100644 app/context/household/interface/schemas/decline_invite_response.py create mode 100644 app/context/household/interface/schemas/household_member_response.py create mode 100644 app/context/household/interface/schemas/invite_user_request.py create mode 100644 app/context/household/interface/schemas/invite_user_response.py create mode 100644 app/context/household/interface/schemas/remove_member_response.py create mode 100644 app/context/user/domain/value_objects/username.py create mode 100644 app/shared/domain/value_objects/shared_username.py create mode 100755 requests/household_accept_invite.sh create mode 100755 requests/household_decline_invite.sh create mode 100755 requests/household_invite_user.sh create mode 100755 requests/household_list_invites.sh create mode 100755 requests/household_my_invites.sh create mode 100755 requests/household_remove_member.sh diff --git a/app/context/household/application/commands/__init__.py b/app/context/household/application/commands/__init__.py index 02f6770..08fc5a5 100644 --- a/app/context/household/application/commands/__init__.py +++ b/app/context/household/application/commands/__init__.py @@ -1,3 +1,13 @@ +from .accept_invite_command import AcceptInviteCommand from .create_household_command import CreateHouseholdCommand +from .decline_invite_command import DeclineInviteCommand +from .invite_user_command import InviteUserCommand +from .remove_member_command import RemoveMemberCommand -__all__ = ["CreateHouseholdCommand"] +__all__ = [ + "CreateHouseholdCommand", + "InviteUserCommand", + "AcceptInviteCommand", + "DeclineInviteCommand", + "RemoveMemberCommand", +] 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/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/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/contracts/__init__.py b/app/context/household/application/contracts/__init__.py index c4a1f79..f2bc822 100644 --- a/app/context/household/application/contracts/__init__.py +++ b/app/context/household/application/contracts/__init__.py @@ -1,3 +1,21 @@ +from .accept_invite_handler_contract import AcceptInviteHandlerContract from .create_household_handler_contract import CreateHouseholdHandlerContract +from .decline_invite_handler_contract import DeclineInviteHandlerContract +from .invite_user_handler_contract import InviteUserHandlerContract +from .list_household_invites_handler_contract import ( + ListHouseholdInvitesHandlerContract, +) +from .list_user_pending_invites_handler_contract import ( + ListUserPendingInvitesHandlerContract, +) +from .remove_member_handler_contract import RemoveMemberHandlerContract -__all__ = ["CreateHouseholdHandlerContract"] +__all__ = [ + "CreateHouseholdHandlerContract", + "InviteUserHandlerContract", + "AcceptInviteHandlerContract", + "DeclineInviteHandlerContract", + "RemoveMemberHandlerContract", + "ListHouseholdInvitesHandlerContract", + "ListUserPendingInvitesHandlerContract", +] 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/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/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..c5ff253 --- /dev/null +++ b/app/context/household/application/contracts/list_household_invites_handler_contract.py @@ -0,0 +1,15 @@ +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_pending_invites_handler_contract.py b/app/context/household/application/contracts/list_user_pending_invites_handler_contract.py new file mode 100644 index 0000000..69fc219 --- /dev/null +++ b/app/context/household/application/contracts/list_user_pending_invites_handler_contract.py @@ -0,0 +1,15 @@ +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/dto/__init__.py b/app/context/household/application/dto/__init__.py index 7a06fb0..6cb4836 100644 --- a/app/context/household/application/dto/__init__.py +++ b/app/context/household/application/dto/__init__.py @@ -1,3 +1,20 @@ +from .accept_invite_result import AcceptInviteErrorCode, AcceptInviteResult from .create_household_result import CreateHouseholdErrorCode, CreateHouseholdResult +from .decline_invite_result import DeclineInviteErrorCode, DeclineInviteResult +from .household_member_response_dto import HouseholdMemberResponseDTO +from .invite_user_result import InviteUserErrorCode, InviteUserResult +from .remove_member_result import RemoveMemberErrorCode, RemoveMemberResult -__all__ = ["CreateHouseholdErrorCode", "CreateHouseholdResult"] +__all__ = [ + "CreateHouseholdErrorCode", + "CreateHouseholdResult", + "InviteUserErrorCode", + "InviteUserResult", + "AcceptInviteErrorCode", + "AcceptInviteResult", + "DeclineInviteErrorCode", + "DeclineInviteResult", + "RemoveMemberErrorCode", + "RemoveMemberResult", + "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..ec656d5 --- /dev/null +++ b/app/context/household/application/dto/accept_invite_result.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[int] = None + household_id: Optional[int] = None + user_id: Optional[int] = None + role: Optional[str] = None + + # Error fields - populated when operation fails + error_code: Optional[AcceptInviteErrorCode] = None + error_message: Optional[str] = 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..8b4e1b8 --- /dev/null +++ b/app/context/household/application/dto/decline_invite_result.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[DeclineInviteErrorCode] = None + error_message: Optional[str] = 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..2c86e7c --- /dev/null +++ b/app/context/household/application/dto/household_member_response_dto.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + +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: Optional[datetime] + invited_by_user_id: Optional[int] + invited_at: Optional[datetime] + household_name: Optional[str] = None + inviter: Optional[str] = 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..f3a6ffe --- /dev/null +++ b/app/context/household/application/dto/invite_user_result.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[int] = None + household_id: Optional[int] = None + user_id: Optional[int] = None + role: Optional[str] = None + + # Error fields - populated when operation fails + error_code: Optional[InviteUserErrorCode] = None + error_message: Optional[str] = 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..d85fbdd --- /dev/null +++ b/app/context/household/application/dto/remove_member_result.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[RemoveMemberErrorCode] = None + error_message: Optional[str] = None diff --git a/app/context/household/application/handlers/__init__.py b/app/context/household/application/handlers/__init__.py index b4e69c9..eb731e4 100644 --- a/app/context/household/application/handlers/__init__.py +++ b/app/context/household/application/handlers/__init__.py @@ -1,3 +1,17 @@ +from .accept_invite_handler import AcceptInviteHandler from .create_household_handler import CreateHouseholdHandler +from .decline_invite_handler import DeclineInviteHandler +from .invite_user_handler import InviteUserHandler +from .list_household_invites_handler import ListHouseholdInvitesHandler +from .list_user_pending_invites_handler import ListUserPendingInvitesHandler +from .remove_member_handler import RemoveMemberHandler -__all__ = ["CreateHouseholdHandler"] +__all__ = [ + "CreateHouseholdHandler", + "InviteUserHandler", + "AcceptInviteHandler", + "DeclineInviteHandler", + "RemoveMemberHandler", + "ListHouseholdInvitesHandler", + "ListUserPendingInvitesHandler", +] 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..9662854 --- /dev/null +++ b/app/context/household/application/handlers/accept_invite_handler.py @@ -0,0 +1,57 @@ +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 + + +class AcceptInviteHandler(AcceptInviteHandlerContract): + """Handler for accept invite command""" + + def __init__(self, service: AcceptInviteServiceContract): + self._service = service + + async def handle(self, command: AcceptInviteCommand) -> AcceptInviteResult: + """Execute the accept invite command""" + + 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: + 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: + return AcceptInviteResult( + error_code=AcceptInviteErrorCode.NOT_INVITED, + error_message="No pending invite found for this household", + ) + except HouseholdMapperError: + return AcceptInviteResult( + error_code=AcceptInviteErrorCode.MAPPER_ERROR, + error_message="Error mapping model to DTO", + ) + except Exception: + return AcceptInviteResult( + error_code=AcceptInviteErrorCode.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..1f7543c --- /dev/null +++ b/app/context/household/application/handlers/decline_invite_handler.py @@ -0,0 +1,38 @@ +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 + + +class DeclineInviteHandler(DeclineInviteHandlerContract): + """Handler for decline invite command""" + + def __init__(self, service: DeclineInviteServiceContract): + self._service = service + + async def handle(self, command: DeclineInviteCommand) -> DeclineInviteResult: + """Execute the decline invite command""" + + try: + await self._service.decline_invite( + user_id=HouseholdUserID(command.user_id), + household_id=HouseholdID(command.household_id), + ) + + return DeclineInviteResult(success=True) + + except NotInvitedError: + return DeclineInviteResult( + error_code=DeclineInviteErrorCode.NOT_INVITED, + error_message="No pending invite found for this household", + ) + except Exception: + return DeclineInviteResult( + error_code=DeclineInviteErrorCode.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..240225d --- /dev/null +++ b/app/context/household/application/handlers/invite_user_handler.py @@ -0,0 +1,72 @@ +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, +) + + +class InviteUserHandler(InviteUserHandlerContract): + """Handler for invite user command""" + + def __init__(self, service: InviteUserServiceContract): + self._service = service + + async def handle(self, command: InviteUserCommand) -> InviteUserResult: + """Execute the invite user command""" + + 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: + 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: + return InviteUserResult( + error_code=InviteUserErrorCode.ONLY_OWNER_CAN_INVITE, + error_message="Only the household owner can invite users", + ) + except AlreadyActiveMemberError: + return InviteUserResult( + error_code=InviteUserErrorCode.ALREADY_ACTIVE_MEMBER, + error_message="User is already an active member of this household", + ) + except AlreadyInvitedError: + return InviteUserResult( + error_code=InviteUserErrorCode.ALREADY_INVITED, + error_message="User already has a pending invite to this household", + ) + except HouseholdMapperError: + return InviteUserResult( + error_code=InviteUserErrorCode.MAPPER_ERROR, + error_message="Error mapping model to DTO", + ) + except Exception: + 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..ced7ad8 --- /dev/null +++ b/app/context/household/application/handlers/list_household_invites_handler.py @@ -0,0 +1,30 @@ +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 + + +class ListHouseholdInvitesHandler(ListHouseholdInvitesHandlerContract): + """Handler for list household invites query""" + + def __init__(self, repository: HouseholdRepositoryContract): + self._repository = repository + + async def handle( + self, query: ListHouseholdInvitesQuery + ) -> list[HouseholdMemberResponseDTO]: + """Execute the list household invites query""" + + 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_pending_invites_handler.py b/app/context/household/application/handlers/list_user_pending_invites_handler.py new file mode 100644 index 0000000..0756ebe --- /dev/null +++ b/app/context/household/application/handlers/list_user_pending_invites_handler.py @@ -0,0 +1,29 @@ +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 + + +class ListUserPendingInvitesHandler(ListUserPendingInvitesHandlerContract): + """Handler for list user pending invites query""" + + def __init__(self, repository: HouseholdRepositoryContract): + self._repository = repository + + async def handle( + self, query: ListUserPendingInvitesQuery + ) -> list[HouseholdMemberResponseDTO]: + """Execute the list user pending invites query""" + + 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..94ffcc1 --- /dev/null +++ b/app/context/household/application/handlers/remove_member_handler.py @@ -0,0 +1,53 @@ +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 + + +class RemoveMemberHandler(RemoveMemberHandlerContract): + """Handler for remove member command""" + + def __init__(self, service: RemoveMemberServiceContract): + self._service = service + + async def handle(self, command: RemoveMemberCommand) -> RemoveMemberResult: + """Execute the remove member command""" + + 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: + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.ONLY_OWNER_CAN_REMOVE, + error_message="Only the household owner can remove members", + ) + except CannotRemoveSelfError: + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.CANNOT_REMOVE_SELF, + error_message="Owner cannot remove themselves from the household", + ) + except InviteNotFoundError: + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.MEMBER_NOT_FOUND, + error_message="No active member found with this user ID", + ) + except Exception: + return RemoveMemberResult( + error_code=RemoveMemberErrorCode.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..c7e0ec6 --- /dev/null +++ b/app/context/household/application/queries/__init__.py @@ -0,0 +1,4 @@ +from .list_household_invites_query import ListHouseholdInvitesQuery +from .list_user_pending_invites_query import ListUserPendingInvitesQuery + +__all__ = ["ListHouseholdInvitesQuery", "ListUserPendingInvitesQuery"] 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_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/contracts/household_repository_contract.py b/app/context/household/domain/contracts/household_repository_contract.py index 4402df1..c2bf9e1 100644 --- a/app/context/household/domain/contracts/household_repository_contract.py +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -74,11 +74,18 @@ async def list_user_pending_invites( @abstractmethod async def list_household_pending_invites( - self, household_id: HouseholdID + 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 user_has_access( self, user_id: HouseholdUserID, household_id: HouseholdID diff --git a/app/context/household/domain/dto/household_member_dto.py b/app/context/household/domain/dto/household_member_dto.py index 1446ff5..81c4ce8 100644 --- a/app/context/household/domain/dto/household_member_dto.py +++ b/app/context/household/domain/dto/household_member_dto.py @@ -5,9 +5,11 @@ from app.context.household.domain.value_objects import ( HouseholdID, HouseholdMemberID, + HouseholdName, HouseholdRole, HouseholdUserID, ) +from app.shared.domain.value_objects import SharedUsername @dataclass(frozen=True) @@ -19,26 +21,17 @@ class HouseholdMemberDTO: user_id: HouseholdUserID role: HouseholdRole joined_at: Optional[datetime] = None - left_at: Optional[datetime] = None invited_by_user_id: Optional[HouseholdUserID] = None invited_at: Optional[datetime] = None + household_name: Optional[HouseholdName] = None + inviter_username: Optional[SharedUsername] = None @property def is_invited(self) -> bool: """Check if this is a pending invite (not yet accepted)""" - return self.joined_at is None and self.left_at is None + 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 and self.left_at is None - - @property - def has_left(self) -> bool: - """Check if the member has left the household""" - return self.left_at is not None - - @property - def has_declined(self) -> bool: - """Check if the invite was declined (left without joining)""" - return self.left_at is not None and self.joined_at is None + return self.joined_at is not None diff --git a/app/context/household/domain/services/decline_invite_service.py b/app/context/household/domain/services/decline_invite_service.py index 1144725..6d51520 100644 --- a/app/context/household/domain/services/decline_invite_service.py +++ b/app/context/household/domain/services/decline_invite_service.py @@ -21,9 +21,6 @@ async def decline_invite( member = await self._household_repo.find_member(household_id, user_id) if not member or not member.is_invited: - raise NotInvitedError( - "No pending invite found for this household" - ) + raise NotInvitedError("No pending invite found for this household") - # Decline the invite (sets left_at) 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 index 7d643c4..92f7be4 100644 --- a/app/context/household/domain/services/invite_user_service.py +++ b/app/context/household/domain/services/invite_user_service.py @@ -60,7 +60,6 @@ async def invite_user( user_id=invitee_user_id, role=role, joined_at=None, # NULL = invited - left_at=None, invited_by_user_id=inviter_user_id, invited_at=datetime.now(UTC), ) diff --git a/app/context/household/domain/services/remove_member_service.py b/app/context/household/domain/services/remove_member_service.py index 1befe9b..0347375 100644 --- a/app/context/household/domain/services/remove_member_service.py +++ b/app/context/household/domain/services/remove_member_service.py @@ -39,9 +39,6 @@ async def remove_member( member = await self._household_repo.find_member(household_id, member_user_id) if not member or not member.is_active: - raise InviteNotFoundError( - "No active member found with this user ID" - ) + raise InviteNotFoundError("No active member found with this user ID") - # Remove the member (sets left_at) 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 index 2c752bb..4f5c828 100644 --- a/app/context/household/domain/services/revoke_invite_service.py +++ b/app/context/household/domain/services/revoke_invite_service.py @@ -24,17 +24,12 @@ async def revoke_invite( # 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: - raise OnlyOwnerCanRevokeError( - "Only the household owner can revoke invites" - ) + 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: - raise InviteNotFoundError( - "No pending invite found for this user" - ) + raise InviteNotFoundError("No pending invite found for this user") - # Revoke the invite (sets left_at) await self._household_repo.revoke_or_remove(household_id, invitee_user_id) diff --git a/app/context/household/domain/value_objects/household_role.py b/app/context/household/domain/value_objects/household_role.py index 30ff042..7511a78 100644 --- a/app/context/household/domain/value_objects/household_role.py +++ b/app/context/household/domain/value_objects/household_role.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field + from typing_extensions import Self @@ -7,7 +8,8 @@ class HouseholdRole: value: str _validated: bool = field(default=False, repr=False, compare=False) - VALID_ROLES = {"owner", "participant"} + # Only role available for now is participant + VALID_ROLES = {"participant"} def __post_init__(self): if not self._validated: 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/dependencies.py b/app/context/household/infrastructure/dependencies.py index 246ba6b..76d4023 100644 --- a/app/context/household/infrastructure/dependencies.py +++ b/app/context/household/infrastructure/dependencies.py @@ -1,13 +1,39 @@ from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from app.context.household.application.contracts import CreateHouseholdHandlerContract -from app.context.household.application.handlers import CreateHouseholdHandler +from app.context.household.application.contracts import ( + AcceptInviteHandlerContract, + CreateHouseholdHandlerContract, + DeclineInviteHandlerContract, + InviteUserHandlerContract, + ListHouseholdInvitesHandlerContract, + ListUserPendingInvitesHandlerContract, + RemoveMemberHandlerContract, +) +from app.context.household.application.handlers import ( + AcceptInviteHandler, + CreateHouseholdHandler, + DeclineInviteHandler, + InviteUserHandler, + ListHouseholdInvitesHandler, + ListUserPendingInvitesHandler, + RemoveMemberHandler, +) from app.context.household.domain.contracts import ( + AcceptInviteServiceContract, CreateHouseholdServiceContract, + DeclineInviteServiceContract, HouseholdRepositoryContract, + InviteUserServiceContract, + RemoveMemberServiceContract, +) +from app.context.household.domain.services import ( + AcceptInviteService, + CreateHouseholdService, + DeclineInviteService, + InviteUserService, + RemoveMemberService, ) -from app.context.household.domain.services import CreateHouseholdService from app.context.household.infrastructure.repositories import HouseholdRepository from app.shared.infrastructure.database import get_db @@ -26,8 +52,69 @@ def get_create_household_service( return CreateHouseholdService(household_repository) -# Handler dependencies +def get_invite_user_service( + household_repository: HouseholdRepositoryContract = Depends(get_household_repository), +) -> InviteUserServiceContract: + return InviteUserService(household_repository) + + +def get_accept_invite_service( + household_repository: HouseholdRepositoryContract = Depends(get_household_repository), +) -> AcceptInviteServiceContract: + return AcceptInviteService(household_repository) + + +def get_decline_invite_service( + household_repository: HouseholdRepositoryContract = Depends(get_household_repository), +) -> DeclineInviteServiceContract: + return DeclineInviteService(household_repository) + + +def get_remove_member_service( + household_repository: HouseholdRepositoryContract = Depends(get_household_repository), +) -> RemoveMemberServiceContract: + return RemoveMemberService(household_repository) + + +# Handler dependencies (Commands) def get_create_household_handler( service: CreateHouseholdServiceContract = Depends(get_create_household_service), ) -> CreateHouseholdHandlerContract: return CreateHouseholdHandler(service) + + +def get_invite_user_handler( + service: InviteUserServiceContract = Depends(get_invite_user_service), +) -> InviteUserHandlerContract: + return InviteUserHandler(service) + + +def get_accept_invite_handler( + service: AcceptInviteServiceContract = Depends(get_accept_invite_service), +) -> AcceptInviteHandlerContract: + return AcceptInviteHandler(service) + + +def get_decline_invite_handler( + service: DeclineInviteServiceContract = Depends(get_decline_invite_service), +) -> DeclineInviteHandlerContract: + return DeclineInviteHandler(service) + + +def get_remove_member_handler( + service: RemoveMemberServiceContract = Depends(get_remove_member_service), +) -> RemoveMemberHandlerContract: + return RemoveMemberHandler(service) + + +# Handler dependencies (Queries) +def get_list_household_invites_handler( + repository: HouseholdRepositoryContract = Depends(get_household_repository), +) -> ListHouseholdInvitesHandlerContract: + return ListHouseholdInvitesHandler(repository) + + +def get_list_user_pending_invites_handler( + repository: HouseholdRepositoryContract = Depends(get_household_repository), +) -> ListUserPendingInvitesHandlerContract: + return ListUserPendingInvitesHandler(repository) diff --git a/app/context/household/infrastructure/mappers/household_member_mapper.py b/app/context/household/infrastructure/mappers/household_member_mapper.py index 654e521..8c410dc 100644 --- a/app/context/household/infrastructure/mappers/household_member_mapper.py +++ b/app/context/household/infrastructure/mappers/household_member_mapper.py @@ -1,19 +1,33 @@ +from typing import Optional + 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.infrastructure.models import HouseholdMemberModel +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) -> HouseholdMemberDTO: + def to_dto( + model: HouseholdMemberModel, + household_model: Optional[HouseholdModel] = None, + user_model: Optional[UserModel] = None, + ) -> HouseholdMemberDTO: """Convert database model to domain DTO""" return HouseholdMemberDTO( member_id=HouseholdMemberID(model.id), @@ -21,13 +35,20 @@ def to_dto(model: HouseholdMemberModel) -> HouseholdMemberDTO: user_id=HouseholdUserID(model.user_id), role=HouseholdRole.from_trusted_source(model.role), joined_at=model.joined_at, - left_at=model.left_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 @@ -39,7 +60,6 @@ def to_model(dto: HouseholdMemberDTO) -> HouseholdMemberModel: user_id=dto.user_id.value, role=dto.role.value, joined_at=dto.joined_at, - left_at=dto.left_at, invited_by_user_id=( dto.invited_by_user_id.value if dto.invited_by_user_id else None ), diff --git a/app/context/household/infrastructure/models/household_model.py b/app/context/household/infrastructure/models/household_model.py index 98cdc1a..5f3b36c 100644 --- a/app/context/household/infrastructure/models/household_model.py +++ b/app/context/household/infrastructure/models/household_model.py @@ -38,7 +38,10 @@ class HouseholdMemberModel(BaseDBModel): DateTime(timezone=True), nullable=True, default=None ) invited_by_user_id: Mapped[Optional[int]] = mapped_column( - Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, default=None + Integer, + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=True, + default=None, ) invited_at: Mapped[Optional[datetime]] = mapped_column( DateTime(timezone=True), nullable=True, default=lambda: datetime.now(UTC) diff --git a/app/context/household/infrastructure/repositories/household_repository.py b/app/context/household/infrastructure/repositories/household_repository.py index 8f83b5b..2d3708a 100644 --- a/app/context/household/infrastructure/repositories/household_repository.py +++ b/app/context/household/infrastructure/repositories/household_repository.py @@ -4,6 +4,7 @@ from sqlalchemy import and_, select 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 @@ -24,6 +25,7 @@ HouseholdMemberModel, HouseholdModel, ) +from app.context.user.infrastructure.models.user_model import UserModel class HouseholdRepository(HouseholdRepositoryContract): @@ -125,7 +127,6 @@ async def accept_invite( HouseholdMemberModel.household_id == household_id.value, HouseholdMemberModel.user_id == user_id.value, HouseholdMemberModel.joined_at.is_(None), - HouseholdMemberModel.left_at.is_(None), ) ) .order_by(HouseholdMemberModel.invited_at.desc()) @@ -157,7 +158,6 @@ async def revoke_or_remove( and_( HouseholdMemberModel.household_id == household_id.value, HouseholdMemberModel.user_id == user_id.value, - HouseholdMemberModel.left_at.is_(None), ) ) .order_by(HouseholdMemberModel.invited_at.desc()) @@ -170,9 +170,6 @@ async def revoke_or_remove( if not member_model: raise InviteNotFoundError("No active invite or membership found") - # Set left_at - member_model.left_at = datetime.now(UTC) - await self._db.commit() async def list_user_households( @@ -195,7 +192,6 @@ async def list_user_households( and_( HouseholdMemberModel.user_id == user_id.value, HouseholdMemberModel.joined_at.isnot(None), - HouseholdMemberModel.left_at.is_(None), ) ) ) @@ -227,7 +223,6 @@ async def list_user_pending_invites( and_( HouseholdMemberModel.user_id == user_id.value, HouseholdMemberModel.joined_at.is_(None), - HouseholdMemberModel.left_at.is_(None), ) ) ) @@ -235,24 +230,79 @@ async def list_user_pending_invites( result = await self._db.execute(stmt) households = result.scalars().all() - return [HouseholdMapper.to_dto(h) for h in households] + 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), + ) + ) + ) + + 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 + self, household_id: HouseholdID, owner_id: HouseholdUserID ) -> List[HouseholdMemberDTO]: - """List all pending invites for a household""" - stmt = select(HouseholdMemberModel).where( - and_( - HouseholdMemberModel.household_id == household_id.value, - HouseholdMemberModel.joined_at.is_(None), - HouseholdMemberModel.left_at.is_(None), + """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), + ) ) ) result = await self._db.execute(stmt) - members = result.scalars().all() + 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 [HouseholdMemberMapper.to_dto(m) for m in members] + return member_list async def user_has_access( self, user_id: HouseholdUserID, household_id: HouseholdID diff --git a/app/context/household/interface/rest/controllers/__init__.py b/app/context/household/interface/rest/controllers/__init__.py index 980b763..9615844 100644 --- a/app/context/household/interface/rest/controllers/__init__.py +++ b/app/context/household/interface/rest/controllers/__init__.py @@ -1,3 +1,19 @@ +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 .invite_user_controller import router as invite_user_router +from .list_household_invites_controller import router as list_household_invites_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 -__all__ = ["create_household_router"] +__all__ = [ + "create_household_router", + "invite_user_router", + "accept_invite_router", + "decline_invite_router", + "remove_member_router", + "list_household_invites_router", + "list_user_pending_invites_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..06cf7b1 --- /dev/null +++ b/app/context/household/interface/rest/controllers/accept_invite_controller.py @@ -0,0 +1,47 @@ +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.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: AcceptInviteHandlerContract = Depends(get_accept_invite_handler), + user_id: int = Depends(get_current_user_id), +) -> AcceptInviteResponse: + """Accept a household invitation""" + + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.member_id: + raise HTTPException(status_code=500, detail="Unexpected server error") + + 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/decline_invite_controller.py b/app/context/household/interface/rest/controllers/decline_invite_controller.py new file mode 100644 index 0000000..8a26fdb --- /dev/null +++ b/app/context/household/interface/rest/controllers/decline_invite_controller.py @@ -0,0 +1,41 @@ +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.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: DeclineInviteHandlerContract = Depends(get_decline_invite_handler), + user_id: int = Depends(get_current_user_id), +) -> DeclineInviteResponse: + """Decline a household invitation""" + + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + return DeclineInviteResponse( + success=True, + message="Invitation declined successfully", + ) 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..dac1736 --- /dev/null +++ b/app/context/household/interface/rest/controllers/invite_user_controller.py @@ -0,0 +1,55 @@ +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.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: InviteUserHandlerContract = Depends(get_invite_user_handler), + user_id: int = Depends(get_current_user_id), +) -> InviteUserResponse: + """Invite a user to a household""" + + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.member_id: + raise HTTPException(status_code=500, detail="Unexpected server error") + + 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..03796f1 --- /dev/null +++ b/app/context/household/interface/rest/controllers/list_household_invites_controller.py @@ -0,0 +1,43 @@ +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.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: ListHouseholdInvitesHandlerContract = Depends( + get_list_household_invites_handler + ), + user_id: int = Depends(get_current_user_id), +) -> list[HouseholdMemberResponse]: + """List pending invitations for a household""" + + query = ListHouseholdInvitesQuery(household_id=household_id, user_id=user_id) + + members = await handler.handle(query) + + 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_pending_invites_controller.py b/app/context/household/interface/rest/controllers/list_user_pending_invites_controller.py new file mode 100644 index 0000000..2ef1ab8 --- /dev/null +++ b/app/context/household/interface/rest/controllers/list_user_pending_invites_controller.py @@ -0,0 +1,42 @@ +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.infrastructure.middleware import get_current_user_id + +router = APIRouter() + + +@router.get("/invites/pending", status_code=200) +async def list_user_pending_invites( + handler: ListUserPendingInvitesHandlerContract = Depends( + get_list_user_pending_invites_handler + ), + user_id: int = Depends(get_current_user_id), +) -> list[HouseholdMemberResponse]: + """List all pending invitations for the authenticated user""" + + query = ListUserPendingInvitesQuery(user_id=user_id) + + members = await handler.handle(query) + + 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..7b0f4ab --- /dev/null +++ b/app/context/household/interface/rest/controllers/remove_member_controller.py @@ -0,0 +1,45 @@ +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.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: RemoveMemberHandlerContract = Depends(get_remove_member_handler), + user_id: int = Depends(get_current_user_id), +) -> RemoveMemberResponse: + """Remove a member from a household""" + + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + return RemoveMemberResponse( + success=True, + message="Member removed successfully", + ) diff --git a/app/context/household/interface/rest/routes.py b/app/context/household/interface/rest/routes.py index 87122e0..0eef2c7 100644 --- a/app/context/household/interface/rest/routes.py +++ b/app/context/household/interface/rest/routes.py @@ -1,8 +1,22 @@ from fastapi import APIRouter -from app.context.household.interface.rest.controllers import create_household_router +from app.context.household.interface.rest.controllers import ( + accept_invite_router, + create_household_router, + decline_invite_router, + invite_user_router, + list_household_invites_router, + list_user_pending_invites_router, + remove_member_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(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 index 62f7fe4..54ca64c 100644 --- a/app/context/household/interface/schemas/__init__.py +++ b/app/context/household/interface/schemas/__init__.py @@ -1,4 +1,19 @@ +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 .invite_user_request import InviteUserRequest +from .invite_user_response import InviteUserResponse +from .remove_member_response import RemoveMemberResponse -__all__ = ["CreateHouseholdRequest", "CreateHouseholdResponse"] +__all__ = [ + "CreateHouseholdRequest", + "CreateHouseholdResponse", + "InviteUserRequest", + "InviteUserResponse", + "AcceptInviteResponse", + "DeclineInviteResponse", + "RemoveMemberResponse", + "HouseholdMemberResponse", +] 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/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..4e37e95 --- /dev/null +++ b/app/context/household/interface/schemas/household_member_response.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import Optional + + +@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: Optional[datetime] + invited_by_user_id: Optional[int] + invited_at: Optional[datetime] + household_name: Optional[str] + inviter: Optional[str] 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..537e0cc --- /dev/null +++ b/app/context/household/interface/schemas/invite_user_request.py @@ -0,0 +1,12 @@ +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/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/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/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py index c88ec65..57efa03 100644 --- a/app/shared/domain/value_objects/__init__.py +++ b/app/shared/domain/value_objects/__init__.py @@ -6,6 +6,7 @@ from .shared_email import SharedEmail from .shared_password import SharedPassword from .shared_user_id import SharedUserID +from .shared_username import SharedUsername __all__ = [ "SharedDeletedAt", @@ -16,4 +17,5 @@ "SharedAccountID", "SharedUserID", "SharedDateTime", + "SharedUsername", ] 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..7d9f5ca --- /dev/null +++ b/app/shared/domain/value_objects/shared_username.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SharedUsername: + value: str diff --git a/requests/base.sh b/requests/base.sh index fd57a67..40469ad 100644 --- a/requests/base.sh +++ b/requests/base.sh @@ -2,3 +2,9 @@ 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 index ced713f..c091717 100755 --- a/requests/credit_card_create.sh +++ b/requests/credit_card_create.sh @@ -3,8 +3,16 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/base.sh" - http --session=local_session POST "${MYAPP_URL}/api/credit-cards/cards" \ - account_id=1 \ - name="My pepino credit card" \ - currency="USD" \ - limit="4320" +# 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 index 0f0c95a..3c56053 100755 --- a/requests/credit_card_delete.sh +++ b/requests/credit_card_delete.sh @@ -3,4 +3,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/base.sh" -http --session=local_session DELETE "${MYAPP_URL}/api/credit-cards/cards/8" +# 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_update.sh b/requests/credit_card_update.sh index d8acf16..1d8f4d0 100755 --- a/requests/credit_card_update.sh +++ b/requests/credit_card_update.sh @@ -3,8 +3,17 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/base.sh" - http --session=local_session PUT "${MYAPP_URL}/api/credit-cards/cards/8" \ - account_id=2 \ - name="My booooo credit card" \ - currency="USD" \ - limit="2501" +# 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_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_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_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/login.sh b/requests/login.sh index 4ce4b8b..7dffe80 100755 --- a/requests/login.sh +++ b/requests/login.sh @@ -3,6 +3,12 @@ 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:-"user1@test.com"} +PASSWORD=${2:-"testonga"} + http --session=local_session POST "${MYAPP_URL}/api/auth/login" \ - email=user1@test.com \ - password=testonga + email="${EMAIL}" \ + password="${PASSWORD}" diff --git a/requests/user_account_create.sh b/requests/user_account_create.sh index e0f10f2..46aef62 100755 --- a/requests/user_account_create.sh +++ b/requests/user_account_create.sh @@ -3,7 +3,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/base.sh" - http --session=local_session POST "${MYAPP_URL}/api/user-accounts/accounts" \ - name="my third account" \ - balance=10554.34 \ - currency="ARS" +# 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 index cfdbba4..38bbeaf 100755 --- a/requests/user_account_delete.sh +++ b/requests/user_account_delete.sh @@ -3,4 +3,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/base.sh" - http --session=local_session DELETE "${MYAPP_URL}/api/user-accounts/accounts/3" +# 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_update.sh b/requests/user_account_update.sh index 62f26bf..4b634f4 100755 --- a/requests/user_account_update.sh +++ b/requests/user_account_update.sh @@ -3,9 +3,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${SCRIPT_DIR}/base.sh" -http --session=local_session PUT "${MYAPP_URL}/api/user-accounts/accounts/2" \ - name="my-updated-account" \ - currency="ARS" \ - balance=423.55 +# 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}" From 97addc2411d553e660517633e378fb71b9cc281a Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 00:47:02 +0100 Subject: [PATCH 27/58] Vibe coding tests (need to check validity) --- .claude/rules/testing.md | 110 +- docs/ddd-patterns.md | 992 ------------------ docs/login-throttle-implementation.md | 533 ---------- justfile | 6 + pyproject.toml | 2 +- tests/conftest.py | 25 + .../credit_card/credit_card_fixtures.py | 219 ++++ tests/fixtures/user_account/__init__.py | 159 +++ ...handler.py => get_session_handler_test.py} | 0 ...login_handler.py => login_handler_test.py} | 0 ...login_service.py => login_service_test.py} | 0 .../create_credit_card_handler_test.py | 196 ++++ .../delete_credit_card_handler_test.py | 99 ++ .../find_credit_card_by_id_handler_test.py | 136 +++ .../find_credit_cards_by_user_handler_test.py | 189 ++++ .../update_credit_card_handler_test.py | 315 ++++++ .../credit_card/domain/card_limit_test.py | 88 ++ .../credit_card/domain/card_used_test.py | 86 ++ .../domain/create_credit_card_service_test.py | 157 +++ .../domain/credit_card_account_id_test.py | 42 + .../domain/credit_card_currency_test.py | 69 ++ .../domain/credit_card_deleted_at_test.py | 65 ++ .../domain/credit_card_dto_test.py | 128 +++ .../credit_card/domain/credit_card_id_test.py | 52 + .../domain/credit_card_name_test.py | 66 ++ .../domain/credit_card_user_id_test.py | 31 + .../domain/update_credit_card_service_test.py | 292 ++++++ .../infrastructure/credit_card_mapper_test.py | 223 ++++ tests/unit/context/user_account/__init__.py | 0 .../user_account/application/__init__.py | 0 .../create_account_handler_test.py | 181 ++++ .../delete_account_handler_test.py | 76 ++ .../find_account_by_id_handler_test.py | 93 ++ .../find_accounts_by_user_handler_test.py | 97 ++ .../update_account_handler_test.py | 141 +++ .../context/user_account/domain/__init__.py | 0 .../user_account/domain/account_name_test.py | 58 + .../domain/create_account_service_test.py | 115 ++ .../domain/update_account_service_test.py | 291 +++++ .../domain/user_account_balance_test.py | 52 + .../domain/user_account_currency_test.py | 46 + .../domain/user_account_deleted_at_test.py | 44 + .../domain/user_account_dto_test.py | 168 +++ .../domain/user_account_id_test.py | 42 + .../domain/user_account_user_id_test.py | 26 + .../user_account/infrastructure/__init__.py | 0 .../user_account_mapper_test.py | 252 +++++ 47 files changed, 4435 insertions(+), 1527 deletions(-) delete mode 100644 docs/ddd-patterns.md delete mode 100644 docs/login-throttle-implementation.md create mode 100644 tests/fixtures/credit_card/credit_card_fixtures.py create mode 100644 tests/fixtures/user_account/__init__.py rename tests/unit/context/auth/application/{test_get_session_handler.py => get_session_handler_test.py} (100%) rename tests/unit/context/auth/application/{test_login_handler.py => login_handler_test.py} (100%) rename tests/unit/context/auth/domain/{test_login_service.py => login_service_test.py} (100%) create mode 100644 tests/unit/context/credit_card/application/create_credit_card_handler_test.py create mode 100644 tests/unit/context/credit_card/application/delete_credit_card_handler_test.py create mode 100644 tests/unit/context/credit_card/application/find_credit_card_by_id_handler_test.py create mode 100644 tests/unit/context/credit_card/application/find_credit_cards_by_user_handler_test.py create mode 100644 tests/unit/context/credit_card/application/update_credit_card_handler_test.py create mode 100644 tests/unit/context/credit_card/domain/card_limit_test.py create mode 100644 tests/unit/context/credit_card/domain/card_used_test.py create mode 100644 tests/unit/context/credit_card/domain/create_credit_card_service_test.py create mode 100644 tests/unit/context/credit_card/domain/credit_card_account_id_test.py create mode 100644 tests/unit/context/credit_card/domain/credit_card_currency_test.py create mode 100644 tests/unit/context/credit_card/domain/credit_card_deleted_at_test.py create mode 100644 tests/unit/context/credit_card/domain/credit_card_dto_test.py create mode 100644 tests/unit/context/credit_card/domain/credit_card_id_test.py create mode 100644 tests/unit/context/credit_card/domain/credit_card_name_test.py create mode 100644 tests/unit/context/credit_card/domain/credit_card_user_id_test.py create mode 100644 tests/unit/context/credit_card/domain/update_credit_card_service_test.py create mode 100644 tests/unit/context/credit_card/infrastructure/credit_card_mapper_test.py create mode 100644 tests/unit/context/user_account/__init__.py create mode 100644 tests/unit/context/user_account/application/__init__.py create mode 100644 tests/unit/context/user_account/application/create_account_handler_test.py create mode 100644 tests/unit/context/user_account/application/delete_account_handler_test.py create mode 100644 tests/unit/context/user_account/application/find_account_by_id_handler_test.py create mode 100644 tests/unit/context/user_account/application/find_accounts_by_user_handler_test.py create mode 100644 tests/unit/context/user_account/application/update_account_handler_test.py create mode 100644 tests/unit/context/user_account/domain/__init__.py create mode 100644 tests/unit/context/user_account/domain/account_name_test.py create mode 100644 tests/unit/context/user_account/domain/create_account_service_test.py create mode 100644 tests/unit/context/user_account/domain/update_account_service_test.py create mode 100644 tests/unit/context/user_account/domain/user_account_balance_test.py create mode 100644 tests/unit/context/user_account/domain/user_account_currency_test.py create mode 100644 tests/unit/context/user_account/domain/user_account_deleted_at_test.py create mode 100644 tests/unit/context/user_account/domain/user_account_dto_test.py create mode 100644 tests/unit/context/user_account/domain/user_account_id_test.py create mode 100644 tests/unit/context/user_account/domain/user_account_user_id_test.py create mode 100644 tests/unit/context/user_account/infrastructure/__init__.py create mode 100644 tests/unit/context/user_account/infrastructure/user_account_mapper_test.py diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 4f2b58f..cd56552 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -22,15 +22,111 @@ tests/ └── 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: @@ -170,12 +266,24 @@ Use descriptive names following the pattern: # 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/test_login_service.py +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/docs/ddd-patterns.md b/docs/ddd-patterns.md deleted file mode 100644 index fbdb45e..0000000 --- a/docs/ddd-patterns.md +++ /dev/null @@ -1,992 +0,0 @@ -# DDD & CQRS Implementation Patterns - -This document provides comprehensive guidance on implementing Domain-Driven Design (DDD) and Command Query Responsibility Segregation (CQRS) patterns in this codebase. - -## Table of Contents - -- [When to Use Commands vs Queries](#when-to-use-commands-vs-queries) -- [Command Data Flow](#command-data-flow) -- [Query Data Flow](#query-data-flow) -- [Interface Definitions (Contracts)](#interface-definitions-contracts) -- [Dependency Injection Setup](#dependency-injection-setup) -- [Commands and Queries Structure](#commands-and-queries-structure) -- [Complete Layer Communication Summary](#complete-layer-communication-summary) - ---- - -## When to Use Commands vs Queries - -### Use a Command When - -The operation **modifies state** (creates, updates, deletes): -- Business rules or validations are required -- Domain services need to be invoked -- Side effects occur (database writes, external API calls, events) - -**Examples:** -- `LoginCommand` - Creates a session/token -- `RegisterUserCommand` - Creates a new user -- `UpdateProfileCommand` - Modifies user data -- `DeleteAccountCommand` - Removes user data - -### Use a Query When - -The operation **only reads data** (no side effects): -- No business logic is needed, just data retrieval -- You're fetching data for display or decision-making -- No state changes occur - -**Examples:** -- `FindUserQuery` - Retrieves user by ID -- `GetUserProfileQuery` - Gets user profile data -- `ListUsersQuery` - Returns list of users -- `SearchEntriesQuery` - Searches for entries - -### Golden Rule - -**If it changes state → Command** -**If it only reads → Query** - ---- - -## Command Data Flow - -Commands flow through the system to execute business logic and modify state. - -### Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ COMMAND FLOW (Write Operations) │ -└─────────────────────────────────────────────────────────────────┘ - -1. Interface Layer (REST Controller) - ├─ Receives HTTP request - ├─ Validates with Pydantic schema (request) - └─ Creates Command object - ↓ -2. Application Layer (Command Handler) - ├─ Receives Command - ├─ Calls Domain Service (business logic) - └─ Returns Application DTO - ↓ -3. Domain Layer (Domain Service) - ├─ Executes business rules - ├─ Validates using Value Objects - ├─ Calls Repository Contract (interface) - └─ Returns Domain DTO - ↓ -4. Infrastructure Layer (Repository) - ├─ Receives domain data - ├─ Performs database operations - ├─ Maps SQLAlchemy models ↔ Domain DTOs - └─ Returns Domain DTO to service - ↓ -5. Back to Controller - └─ Converts Application DTO → Frozen Dataclass (response) -``` - -### Example: Login Command Flow - -#### 1. Interface Layer (Controller) - -```python -# File: app/context/auth/interface/rest/controllers/login_controller.py - -@router.post("/login") -async def login( - request: LoginRequest, # Pydantic (validation) - handler: LoginHandlerContract = Depends(get_login_handler), -) -> LoginResponse: # Frozen dataclass (performance) - # Create command from request - command = LoginCommand( - email=SharedEmail(request.email), - password=SharedPassword.from_plain_text(request.password), - ) - - # Execute via handler - result = await handler.handle(command) - - # Return frozen dataclass response - return LoginResponse( - message=result.message, - token=result.token, - ) -``` - -#### 2. Application Layer (Command Handler) - -```python -# File: app/context/auth/application/handlers/login_handler.py - -class LoginHandler(LoginHandlerContract): - def __init__(self, login_service: LoginServiceContract): - self._login_service = login_service - - async def handle(self, command: LoginCommand) -> LoginDTO: - # Delegate to domain service (business logic) - return await self._login_service.authenticate( - email=command.email, - password=command.password, - ) -``` - -#### 3. Domain Layer (Domain Service) - -```python -# File: app/context/auth/domain/services/login_service.py - -class LoginService(LoginServiceContract): - def __init__(self, user_repository: UserRepositoryContract): - self._user_repository = user_repository - - async def authenticate( - self, - email: SharedEmail, - password: SharedPassword, - ) -> LoginDTO: - # Business logic: find user - user = await self._user_repository.find_user(email=email) - - if not user: - raise ValueError("Invalid credentials") - - # Business logic: verify password - if not user.password.verify(password.value): - raise ValueError("Invalid credentials") - - # Business logic: generate token (example) - token = self._generate_token(user.user_id) - - # Return domain result - return LoginDTO( - user_id=user.user_id, - email=user.email, - message="Login successful", - token=token, - ) - - def _generate_token(self, user_id: UserID) -> str: - # Token generation logic here - pass -``` - -#### 4. Infrastructure Layer (Repository) - -```python -# File: app/context/user/infrastructure/repositories/user_repository.py - -class UserRepository(UserRepositoryContract): - def __init__(self, db: AsyncSession): - self._db = db - - async def find_user( - self, - email: Optional[SharedEmail] = None, - ) -> Optional[UserDTO]: - # Database query - query = select(UserModel).where(UserModel.email == email.value) - result = await self._db.execute(query) - model = result.scalar_one_or_none() - - # Map to domain DTO - return UserMapper.toDTO(model) if model else None -``` - ---- - -## Query Data Flow - -Queries bypass the domain layer and go directly to infrastructure for optimized read operations. - -### Flow Diagram - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ QUERY FLOW (Read Operations) │ -└─────────────────────────────────────────────────────────────────┘ - -1. Interface Layer (REST Controller) - ├─ Receives HTTP request - ├─ Validates path/query parameters - └─ Creates Query object - ↓ -2. Application Layer (Query Handler) - ├─ Receives Query - ├─ Calls Repository Contract directly (NO domain service) - └─ Returns Application DTO - ↓ -3. Infrastructure Layer (Repository) - ├─ Performs database read - ├─ Maps SQLAlchemy model → Domain DTO - └─ Returns Domain DTO to handler - ↓ -4. Back to Controller - └─ Converts Application DTO → Frozen Dataclass (response) -``` - -### Key Difference - -**Queries skip the domain layer** because they don't need business logic - they're pure data retrieval. - -### Example: Find User Query Flow - -#### 1. Interface Layer (Controller) - -```python -# File: app/context/user/interface/rest/controllers/user_controller.py - -@router.get("/users/{user_id}") -async def get_user( - user_id: str, - handler: FindUserHandlerContract = Depends(get_find_user_handler), -) -> UserResponse: # Frozen dataclass - # Create query - query = FindUserQuery(user_id=UserID(user_id)) - - # Execute via handler - result = await handler.handle(query) - - if not result: - raise HTTPException(status_code=404, detail="User not found") - - # Return frozen dataclass response - return UserResponse( - user_id=result.user_id.value, - email=result.email.value, - created_at=result.created_at, - ) -``` - -#### 2. Application Layer (Query Handler) - -```python -# File: app/context/user/application/handlers/find_user_handler.py - -class FindUserHandler(FindUserHandlerContract): - def __init__(self, repository: UserRepositoryContract): - self._repository = repository - - async def handle(self, query: FindUserQuery) -> Optional[UserDTO]: - # Call repository directly (no domain service needed) - return await self._repository.find_user(user_id=query.user_id) -``` - -#### 3. Infrastructure Layer (Repository) - -```python -# File: app/context/user/infrastructure/repositories/user_repository.py - -class UserRepository(UserRepositoryContract): - def __init__(self, db: AsyncSession): - self._db = db - - async def find_user( - self, - user_id: Optional[UserID] = None, - ) -> Optional[UserDTO]: - # Database query - query = select(UserModel).where(UserModel.id == user_id.value) - result = await self._db.execute(query) - model = result.scalar_one_or_none() - - # Map to domain DTO - return UserMapper.toDTO(model) if model else None -``` - ---- - -## Interface Definitions (Contracts) - -All components are accessed via **contracts** (abstract base classes) to enable dependency injection and testability. - -### Why Contracts? - -1. **Dependency Inversion Principle** - High-level modules don't depend on low-level modules -2. **Testability** - Easy to mock for unit tests -3. **Flexibility** - Swap implementations without changing consumers -4. **Clear Interfaces** - Explicit contract between layers - -### Contract Types and Locations - -#### Domain Service Contract - -Located in `domain/contracts/{service}_contract.py` - -```python -# File: app/context/auth/domain/contracts/login_service_contract.py - -from abc import ABC, abstractmethod -from app.shared.domain.value_objects.shared_email import SharedEmail -from app.shared.domain.value_objects.shared_password import SharedPassword -from app.context.auth.domain.dto.login_dto import LoginDTO - - -class LoginServiceContract(ABC): - """Contract for login domain service.""" - - @abstractmethod - async def authenticate( - self, - email: SharedEmail, - password: SharedPassword, - ) -> LoginDTO: - """ - Authenticate user with email and password. - - Args: - email: User's email address - password: User's password (plain text) - - Returns: - LoginDTO with user info and token - - Raises: - ValueError: If credentials are invalid - """ - pass -``` - -#### Handler Contract - -Located in `application/contracts/{handler}_contract.py` - -```python -# File: app/context/auth/application/contracts/login_handler_contract.py - -from abc import ABC, abstractmethod -from app.context.auth.application.commands.login_command import LoginCommand -from app.context.auth.application.dto.login_dto import LoginDTO - - -class LoginHandlerContract(ABC): - """Contract for login command handler.""" - - @abstractmethod - async def handle(self, command: LoginCommand) -> LoginDTO: - """ - Handle login command. - - Args: - command: Login command with credentials - - Returns: - LoginDTO with result - """ - pass -``` - -#### Repository Contract - -Located in `domain/contracts/{repository}_contract.py` - -```python -# File: app/context/user/domain/contracts/user_repository_contract.py - -from abc import ABC, abstractmethod -from typing import Optional -from app.shared.domain.value_objects.shared_email import SharedEmail -from app.context.user.domain.value_objects.user_id import UserID -from app.context.user.domain.dto.user_dto import UserDTO - - -class UserRepositoryContract(ABC): - """Contract for user repository.""" - - @abstractmethod - async def find_user( - self, - user_id: Optional[UserID] = None, - email: Optional[SharedEmail] = None, - ) -> Optional[UserDTO]: - """ - Find user by ID or email. - - Args: - user_id: User ID to search for - email: Email to search for - - Returns: - UserDTO if found, None otherwise - """ - pass - - @abstractmethod - async def create_user(self, user: UserDTO) -> UserDTO: - """ - Create new user. - - Args: - user: User data to create - - Returns: - Created user DTO - """ - pass -``` - -### Contract Placement Rules - -| Contract Type | Location | Reason | -|--------------|----------|--------| -| **Domain Service Contract** | `domain/contracts/` | Domain defines its own interfaces | -| **Repository Contract** | `domain/contracts/` | Domain defines data needs (Dependency Inversion) | -| **Handler Contract** | `application/contracts/` | Application layer owns handlers | - -### Why Repositories in Domain Contracts? - -This follows the **Dependency Inversion Principle**: -- Domain layer defines **what data it needs** (the interface) -- Infrastructure layer provides **how to get it** (the implementation) -- Domain never depends on infrastructure - ---- - -## Dependency Injection Setup - -All dependencies are wired in `infrastructure/dependencies.py` using FastAPI's `Depends()` mechanism. - -### Dependency Chain - -Dependencies are built **bottom-up** (from infrastructure to interface): - -``` -get_db (shared infrastructure) - ↓ -get_repository (infrastructure) - ↓ -get_domain_service (domain) - ↓ -get_handler (application) - ↓ -controller (interface) -``` - -### Complete Example: Auth Context Dependencies - -```python -# File: app/context/auth/infrastructure/dependencies.py - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from app.shared.infrastructure.database import get_db - -# Cross-context dependency (from User context) -from app.context.user.application.contracts.find_user_handler_contract import ( - FindUserHandlerContract, -) -from app.context.user.infrastructure.dependencies import get_find_user_handler - -from app.context.auth.domain.contracts.login_service_contract import ( - LoginServiceContract, -) -from app.context.auth.domain.services.login_service import LoginService -from app.context.auth.application.contracts.login_handler_contract import ( - LoginHandlerContract, -) -from app.context.auth.application.handlers.login_handler import LoginHandler - - -# ───────────────────────────────────────────────────────────────── -# DOMAIN SERVICE LAYER -# ───────────────────────────────────────────────────────────────── - -def get_login_service( - # Inject handler from another context (cross-context communication) - find_user_handler: FindUserHandlerContract = Depends(get_find_user_handler), -) -> LoginServiceContract: - """ - Inject login domain service. - - Returns: - LoginServiceContract implementation - """ - return LoginService(find_user_handler) - - -# ───────────────────────────────────────────────────────────────── -# APPLICATION HANDLER LAYER -# ───────────────────────────────────────────────────────────────── - -def get_login_handler( - login_service: LoginServiceContract = Depends(get_login_service), -) -> LoginHandlerContract: - """ - Inject login command handler. - - Returns: - LoginHandlerContract implementation - """ - return LoginHandler(login_service) -``` - -### User Context Dependencies - -```python -# File: app/context/user/infrastructure/dependencies.py - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncSession -from app.shared.infrastructure.database import get_db - -from app.context.user.domain.contracts.user_repository_contract import ( - UserRepositoryContract, -) -from app.context.user.infrastructure.repositories.user_repository import ( - UserRepository, -) -from app.context.user.application.contracts.find_user_handler_contract import ( - FindUserHandlerContract, -) -from app.context.user.application.handlers.find_user_handler import FindUserHandler - - -# ───────────────────────────────────────────────────────────────── -# REPOSITORY LAYER -# ───────────────────────────────────────────────────────────────── - -def get_user_repository( - db: AsyncSession = Depends(get_db), -) -> UserRepositoryContract: - """ - Inject user repository. - - Args: - db: Database session from shared infrastructure - - Returns: - UserRepositoryContract implementation - """ - return UserRepository(db) - - -# ───────────────────────────────────────────────────────────────── -# APPLICATION HANDLER LAYER -# ───────────────────────────────────────────────────────────────── - -def get_find_user_handler( - repository: UserRepositoryContract = Depends(get_user_repository), -) -> FindUserHandlerContract: - """ - Inject find user query handler. - - Returns: - FindUserHandlerContract implementation - """ - return FindUserHandler(repository) -``` - -### Dependency Injection Rules - -#### 1. Always Inject Contracts, Never Implementations - -```python -# ✅ CORRECT - Inject contract -def get_handler( - service: ServiceContract = Depends(get_service), -) -> HandlerContract: - return Handler(service) - -# ❌ WRONG - Inject concrete implementation -def get_handler( - service: ConcreteService = Depends(get_service), -) -> HandlerContract: - return Handler(service) -``` - -#### 2. Database Sessions Come from Shared Infrastructure - -```python -# ✅ CORRECT -from app.shared.infrastructure.database import get_db - -def get_repository( - db: AsyncSession = Depends(get_db), -) -> RepositoryContract: - return Repository(db) -``` - -#### 3. Cross-Context Dependencies Use Handler Contracts - -When one context needs data from another context: - -```python -# ✅ CORRECT - Auth context uses User context via handler contract -from app.context.user.application.contracts.find_user_handler_contract import ( - FindUserHandlerContract, -) -from app.context.user.infrastructure.dependencies import get_find_user_handler - -def get_auth_service( - user_handler: FindUserHandlerContract = Depends(get_find_user_handler), -) -> AuthServiceContract: - return AuthService(user_handler) -``` - -```python -# ❌ WRONG - Never access repositories directly across contexts -from app.context.user.infrastructure.repositories.user_repository import UserRepository - -def get_auth_service( - user_repo: UserRepository = Depends(...), # WRONG! -): - return AuthService(user_repo) -``` - -#### 4. Return Type Must Match Contract - -```python -# ✅ CORRECT -def get_service() -> ServiceContract: - return ConcreteService() # ConcreteService implements ServiceContract - -# ❌ WRONG -def get_service() -> ConcreteService: # Should return contract type - return ConcreteService() -``` - ---- - -## Commands and Queries Structure - -### Command Structure - -Commands are immutable data containers representing write operations. - -```python -# File: app/context/{context}/application/commands/{action}_command.py - -from dataclasses import dataclass -from app.shared.domain.value_objects.shared_email import SharedEmail -from app.shared.domain.value_objects.shared_password import SharedPassword - - -@dataclass(frozen=True) -class LoginCommand: - """ - Command to authenticate a user. - - Attributes: - email: User's email address - password: User's password (plain text, will be hashed) - """ - email: SharedEmail - password: SharedPassword -``` - -### Query Structure - -Queries are immutable data containers representing read operations. - -```python -# File: app/context/{context}/application/queries/{action}_query.py - -from dataclasses import dataclass -from typing import Optional -from app.context.user.domain.value_objects.user_id import UserID -from app.shared.domain.value_objects.shared_email import SharedEmail - - -@dataclass(frozen=True) -class FindUserQuery: - """ - Query to find a user by ID or email. - - Attributes: - user_id: User ID to search for (optional) - email: Email to search for (optional) - """ - user_id: Optional[UserID] = None - email: Optional[SharedEmail] = None -``` - -### Key Characteristics - -| Aspect | Commands | Queries | -|--------|----------|---------| -| **Mutability** | Frozen (`frozen=True`) | Frozen (`frozen=True`) | -| **Purpose** | Represent write operations | Represent read operations | -| **Fields** | Usually **required** | Often **optional** (for filtering) | -| **Types** | Use Value Objects | Use Value Objects | -| **Logic** | No logic, just data | No logic, just data | - -### Best Practices - -1. **Always use Value Objects, not primitives** - ```python - # ✅ CORRECT - @dataclass(frozen=True) - class CreateUserCommand: - email: SharedEmail - password: SharedPassword - - # ❌ WRONG - @dataclass(frozen=True) - class CreateUserCommand: - email: str # Should be SharedEmail - password: str # Should be SharedPassword - ``` - -2. **Make them immutable with `frozen=True`** - ```python - # ✅ CORRECT - @dataclass(frozen=True) - class UpdateProfileCommand: - user_id: UserID - name: str - - # ❌ WRONG - @dataclass # Missing frozen=True - class UpdateProfileCommand: - user_id: UserID - name: str - ``` - -3. **Keep them simple - no methods or logic** - ```python - # ✅ CORRECT - @dataclass(frozen=True) - class LoginCommand: - email: SharedEmail - password: SharedPassword - - # ❌ WRONG - @dataclass(frozen=True) - class LoginCommand: - email: SharedEmail - password: SharedPassword - - def validate(self): # NO LOGIC IN COMMANDS! - if not self.email: - raise ValueError("Email required") - ``` - ---- - -## Complete Layer Communication Summary - -### Visual Layer Communication - -``` -┌──────────────────────────────────────────────────────────────────┐ -│ LAYER COMMUNICATION RULES │ -├──────────────────────────────────────────────────────────────────┤ -│ │ -│ INTERFACE LAYER (REST Controllers) │ -│ ├─ Depends on: Application Handlers (via contracts) │ -│ ├─ Receives: Pydantic schemas (request validation) │ -│ ├─ Creates: Commands/Queries │ -│ └─ Returns: Frozen dataclasses (response) │ -│ │ -│ ↓ │ -│ │ -│ APPLICATION LAYER (Handlers) │ -│ ├─ Depends on: Domain Services (Commands) OR │ -│ │ Repositories (Queries) │ -│ ├─ Receives: Commands/Queries │ -│ ├─ Orchestrates: Domain services or repository calls │ -│ └─ Returns: Application DTOs │ -│ │ -│ ↓ │ -│ │ -│ DOMAIN LAYER (Services) │ -│ ├─ Depends on: Repository Contracts ONLY │ -│ ├─ Contains: Business logic and rules │ -│ ├─ Uses: Value Objects, Domain DTOs │ -│ └─ Returns: Domain DTOs │ -│ │ -│ ↓ │ -│ │ -│ INFRASTRUCTURE LAYER (Repositories) │ -│ ├─ Depends on: Database session │ -│ ├─ Implements: Repository Contracts │ -│ ├─ Uses: SQLAlchemy models, Mappers │ -│ └─ Returns: Domain DTOs │ -│ │ -└──────────────────────────────────────────────────────────────────┘ -``` - -### Dependency Flow Rules - -**CRITICAL RULE**: Dependencies only flow **INWARD** and **DOWNWARD** - -``` - ┌─────────────┐ - │ Interface │ (depends on Application) - └──────┬──────┘ - │ - ↓ - ┌─────────────┐ - │ Application │ (depends on Domain) - └──────┬──────┘ - │ - ↓ - ┌─────────────┐ - │ Domain │ (depends on NOTHING) - └──────┬──────┘ - ↑ - │ (implements contracts) - │ - ┌─────────────┐ - │Infrastructure│ - └─────────────┘ -``` - -### Layer Responsibilities - -#### Interface Layer -- **Concerns**: HTTP, REST, serialization -- **Depends on**: Application handlers (contracts) -- **Returns**: Frozen dataclasses for responses -- **Never**: Contains business logic - -#### Application Layer -- **Concerns**: Use case orchestration -- **Depends on**: Domain services (commands) or repositories (queries) -- **Coordinates**: Multiple domain services if needed -- **Never**: Contains business logic - -#### Domain Layer -- **Concerns**: Business rules and logic -- **Depends on**: Repository contracts only -- **Contains**: All business validation and rules -- **Never**: Depends on outer layers - -#### Infrastructure Layer -- **Concerns**: External systems (database, APIs, file system) -- **Implements**: Domain contracts -- **Uses**: Mappers to convert between models and DTOs -- **Never**: Contains business logic - -### Cross-Context Communication - -When Context A needs data from Context B: - -```python -# ✅ CORRECT - Use handler contracts -# Auth context needs User data - -# 1. Import handler contract from User context -from app.context.user.application.contracts.find_user_handler_contract import ( - FindUserHandlerContract, -) - -# 2. Import dependency function -from app.context.user.infrastructure.dependencies import get_find_user_handler - -# 3. Inject in Auth context -def get_login_service( - find_user_handler: FindUserHandlerContract = Depends(get_find_user_handler), -) -> LoginServiceContract: - return LoginService(find_user_handler) -``` - -```python -# ❌ WRONG - Never access repositories directly -from app.context.user.infrastructure.repositories.user_repository import UserRepository - -def get_login_service( - user_repo: UserRepository = Depends(...), # WRONG! -): - return LoginService(user_repo) -``` - -### Common Anti-Patterns to Avoid - -#### 1. Domain Depending on Infrastructure - -```python -# ❌ WRONG -# File: domain/services/user_service.py -from app.context.user.infrastructure.models.user_model import UserModel # WRONG! - -class UserService: - def create_user(self, model: UserModel): # WRONG! - pass -``` - -```python -# ✅ CORRECT -# File: domain/services/user_service.py -from app.context.user.domain.dto.user_dto import UserDTO - -class UserService: - def create_user(self, user: UserDTO): - pass -``` - -#### 2. Putting Business Logic in Controllers - -```python -# ❌ WRONG -@router.post("/login") -async def login(request: LoginRequest): - # Business logic in controller - WRONG! - user = await db.execute(select(UserModel)...) - if not user: - raise HTTPException(400) - if not verify_password(request.password, user.password): - raise HTTPException(400) - return {"token": generate_token(user.id)} -``` - -```python -# ✅ CORRECT -@router.post("/login") -async def login( - request: LoginRequest, - handler: LoginHandlerContract = Depends(get_login_handler), -): - command = LoginCommand(email=request.email, password=request.password) - result = await handler.handle(command) - return LoginResponse(token=result.token) -``` - -#### 3. Skipping the Handler Layer - -```python -# ❌ WRONG -@router.post("/users") -async def create_user( - request: CreateUserRequest, - service: UserServiceContract = Depends(get_user_service), # WRONG! -): - # Controller calling service directly - skip handler - result = await service.create_user(...) -``` - -```python -# ✅ CORRECT -@router.post("/users") -async def create_user( - request: CreateUserRequest, - handler: CreateUserHandlerContract = Depends(get_create_user_handler), -): - command = CreateUserCommand(...) - result = await handler.handle(command) - return UserResponse(...) -``` - ---- - -## Summary Checklist - -When implementing new features, ensure: - -- [ ] **Commands** for state changes, **Queries** for reads -- [ ] **Commands** flow: Interface → Handler → Domain Service → Repository -- [ ] **Queries** flow: Interface → Handler → Repository (skip domain) -- [ ] All components accessed via **contracts** (interfaces) -- [ ] Dependencies defined in `infrastructure/dependencies.py` -- [ ] Always inject **contracts**, never implementations -- [ ] Cross-context communication via **handler contracts** -- [ ] Domain layer has **no dependencies** on outer layers -- [ ] Business logic **only** in domain services -- [ ] Controllers return **frozen dataclasses** for responses -- [ ] Use **Value Objects** instead of primitives -- [ ] Repository contracts in **domain/contracts** -- [ ] Infrastructure **implements** domain contracts - ---- - -**Remember**: The goal of DDD and CQRS is to create a maintainable, testable, and flexible codebase by clearly separating concerns and enforcing dependency rules. When in doubt, ask: "Does this violate the dependency rule?" and "Is business logic where it belongs (domain layer)?" diff --git a/docs/login-throttle-implementation.md b/docs/login-throttle-implementation.md deleted file mode 100644 index 4e78b89..0000000 --- a/docs/login-throttle-implementation.md +++ /dev/null @@ -1,533 +0,0 @@ -# Login Throttle Implementation Guide - -This document provides implementation options for adding login throttling to the FastAPI application. - -## Overview - -Login throttling prevents brute-force attacks by limiting the number of login attempts within a specific time window. This can be implemented in several ways depending on your requirements. - -## Option 1: Using SlowAPI (Simple & Quick) - -Best for: Small to medium applications, quick implementation - -### Installation - -```bash -pip install slowapi -``` - -### Implementation - -#### 1. Create rate limiter instance - -```python -# app/shared/infrastructure/rate_limiter.py -from slowapi import Limiter -from slowapi.util import get_remote_address - -limiter = Limiter(key_func=get_remote_address) -``` - -#### 2. Update login controller - -```python -# app/context/auth/interface/rest/controllers/login_rest_controller.py -from fastapi import APIRouter, Depends -from app.shared.infrastructure.rate_limiter import limiter -from app.context.auth.application.commands import LoginCommand -from app.context.auth.application.contracts import LoginHandlerContract -from app.context.auth.interface.rest.dependencies import get_login_handler -from app.context.auth.interface.rest.schemas import LoginRequest - -router = APIRouter(prefix="/login", tags=["login"]) - -@router.post("") -@limiter.limit("5/minute") # 5 attempts per minute per IP -async def login( - request: LoginRequest, - handler: LoginHandlerContract = Depends(get_login_handler) -): - """User login endpoint with rate limiting""" - return await handler.handle( - LoginCommand(email=request.email, password=request.password) - ) -``` - -#### 3. Register in main application - -```python -# main.py -from fastapi import FastAPI -from slowapi import _rate_limit_exceeded_handler -from slowapi.errors import RateLimitExceeded -from app.shared.infrastructure.rate_limiter import limiter - -app = FastAPI() -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) -``` - -### Pros -- Quick to implement -- Minimal code changes -- Works out of the box - -### Cons -- Throttles by IP only (can't throttle by username/email) -- In-memory only (doesn't work across multiple instances without Redis backend) - ---- - -## Option 2: Redis-Based Throttle Service (Production Ready) - -Best for: Production applications, distributed systems, throttling by username/email - -### Installation - -```bash -pip install redis -``` - -### Implementation - -#### 1. Create throttle service - -```python -# app/shared/infrastructure/services/throttle_service.py -from datetime import datetime, timedelta -from typing import Optional -import redis.asyncio as redis - - -class LoginThrottleService: - """ - Redis-based login throttle service. - - Tracks login attempts per identifier (email/username or IP) and enforces - rate limits to prevent brute-force attacks. - """ - - def __init__(self, redis_client: redis.Redis): - self.redis = redis_client - self.max_attempts = 5 - self.window_seconds = 300 # 5 minutes - self.lockout_seconds = 900 # 15 minutes after max attempts - - async def is_throttled(self, identifier: str) -> tuple[bool, Optional[int]]: - """ - Check if login attempts are throttled for the given identifier. - - Args: - identifier: Email, username, or IP address to check - - Returns: - Tuple of (is_throttled, retry_after_seconds) - """ - key = f"login_throttle:{identifier}" - attempts = await self.redis.get(key) - - if attempts and int(attempts) >= self.max_attempts: - ttl = await self.redis.ttl(key) - return True, ttl - - return False, None - - async def record_attempt(self, identifier: str): - """ - Record a failed login attempt. - - Args: - identifier: Email, username, or IP address - """ - key = f"login_throttle:{identifier}" - current = await self.redis.get(key) - - if current is None: - # First attempt - set with normal window - await self.redis.setex(key, self.window_seconds, 1) - else: - # Increment attempts - await self.redis.incr(key) - - # If we've hit the max, extend the lockout period - if int(current) + 1 >= self.max_attempts: - await self.redis.expire(key, self.lockout_seconds) - - async def clear_attempts(self, identifier: str): - """ - Clear all attempts for an identifier (call after successful login). - - Args: - identifier: Email, username, or IP address - """ - key = f"login_throttle:{identifier}" - await self.redis.delete(key) -``` - -#### 2. Create Redis connection - -```python -# app/shared/infrastructure/redis_client.py -import redis.asyncio as redis -from typing import AsyncGenerator - - -class RedisClient: - def __init__(self, url: str = "redis://localhost:6379/0"): - self.url = url - self._pool = None - - async def get_pool(self) -> redis.Redis: - if self._pool is None: - self._pool = redis.from_url( - self.url, - encoding="utf-8", - decode_responses=True - ) - return self._pool - - async def close(self): - if self._pool: - await self._pool.close() - - -# Global instance -redis_client = RedisClient() - - -async def get_redis() -> AsyncGenerator[redis.Redis, None]: - pool = await redis_client.get_pool() - yield pool -``` - -#### 3. Create dependency for throttle check - -```python -# app/context/auth/interface/rest/dependencies.py -from fastapi import HTTPException, Request, status, Depends -import redis.asyncio as redis - -from app.context.auth.interface.rest.schemas import LoginRequest -from app.shared.infrastructure.services.throttle_service import LoginThrottleService -from app.shared.infrastructure.redis_client import get_redis - - -async def get_throttle_service( - redis_conn: redis.Redis = Depends(get_redis) -) -> LoginThrottleService: - return LoginThrottleService(redis_conn) - - -async def check_login_throttle( - request: LoginRequest, - throttle_service: LoginThrottleService = Depends(get_throttle_service) -): - """ - Dependency that checks if login attempts are throttled. - Raises HTTPException if throttled. - """ - # Throttle by email/username - is_throttled, retry_after = await throttle_service.is_throttled(request.email) - - if is_throttled: - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=f"Too many login attempts. Try again in {retry_after} seconds.", - headers={"Retry-After": str(retry_after)} - ) -``` - -#### 4. Update login controller - -```python -# app/context/auth/interface/rest/controllers/login_rest_controller.py -from fastapi import APIRouter, Depends, HTTPException, status - -from app.context.auth.application.commands import LoginCommand -from app.context.auth.application.contracts import LoginHandlerContract -from app.context.auth.interface.rest.dependencies import ( - get_login_handler, - check_login_throttle, - get_throttle_service -) -from app.context.auth.interface.rest.schemas import LoginRequest -from app.shared.infrastructure.services.throttle_service import LoginThrottleService - -router = APIRouter(prefix="/login", tags=["login"]) - - -@router.post("") -async def login( - request: LoginRequest, - handler: LoginHandlerContract = Depends(get_login_handler), - throttle_service: LoginThrottleService = Depends(get_throttle_service), - _: None = Depends(check_login_throttle) -): - """User login endpoint with throttling""" - try: - result = await handler.handle( - LoginCommand(email=request.email, password=request.password) - ) - - # Clear throttle on successful login - await throttle_service.clear_attempts(request.email) - - return result - - except Exception as e: - # Record failed attempt - await throttle_service.record_attempt(request.email) - raise -``` - -#### 5. Update main.py for Redis lifecycle - -```python -# main.py -from contextlib import asynccontextmanager -from fastapi import FastAPI -from app.shared.infrastructure.redis_client import redis_client - - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup - await redis_client.get_pool() - yield - # Shutdown - await redis_client.close() - - -app = FastAPI(lifespan=lifespan) -``` - -### Pros -- Works across multiple instances -- Can throttle by username/email -- Production-ready -- Configurable limits -- Persistent across restarts - -### Cons -- Requires Redis infrastructure -- More complex setup - ---- - -## Option 3: In-Memory Throttle Service (Development) - -Best for: Development, single-instance deployments, no external dependencies - -### Implementation - -```python -# app/shared/infrastructure/services/in_memory_throttle_service.py -from collections import defaultdict -from datetime import datetime, timedelta -from typing import Dict, List - - -class InMemoryThrottleService: - """ - In-memory login throttle service. - - WARNING: This implementation does not work across multiple instances. - Use Redis-based implementation for production. - """ - - def __init__(self): - self.attempts: Dict[str, List[datetime]] = defaultdict(list) - self.max_attempts = 5 - self.window_minutes = 5 - - def is_throttled(self, identifier: str) -> tuple[bool, int]: - """ - Check if login attempts are throttled. - - Returns: - Tuple of (is_throttled, retry_after_seconds) - """ - now = datetime.now() - cutoff = now - timedelta(minutes=self.window_minutes) - - # Clean old attempts - self.attempts[identifier] = [ - attempt for attempt in self.attempts[identifier] - if attempt > cutoff - ] - - if len(self.attempts[identifier]) >= self.max_attempts: - oldest = self.attempts[identifier][0] - retry_after = int( - (oldest + timedelta(minutes=self.window_minutes) - now).total_seconds() - ) - return True, max(0, retry_after) - - return False, 0 - - def record_attempt(self, identifier: str): - """Record a failed login attempt.""" - self.attempts[identifier].append(datetime.now()) - - def clear_attempts(self, identifier: str): - """Clear attempts after successful login.""" - self.attempts.pop(identifier, None) - - -# Global instance -throttle_service = InMemoryThrottleService() - - -def get_throttle_service() -> InMemoryThrottleService: - return throttle_service -``` - -### Usage in controller - -```python -# app/context/auth/interface/rest/controllers/login_rest_controller.py -from fastapi import APIRouter, Depends, HTTPException, status -from app.shared.infrastructure.services.in_memory_throttle_service import ( - get_throttle_service, - InMemoryThrottleService -) - -router = APIRouter(prefix="/login", tags=["login"]) - - -@router.post("") -async def login( - request: LoginRequest, - handler: LoginHandlerContract = Depends(get_login_handler), - throttle_service: InMemoryThrottleService = Depends(get_throttle_service) -): - """User login endpoint with in-memory throttling""" - # Check throttle - is_throttled, retry_after = throttle_service.is_throttled(request.email) - if is_throttled: - raise HTTPException( - status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail=f"Too many login attempts. Try again in {retry_after} seconds.", - headers={"Retry-After": str(retry_after)} - ) - - try: - result = await handler.handle( - LoginCommand(email=request.email, password=request.password) - ) - - # Clear on success - throttle_service.clear_attempts(request.email) - return result - - except Exception as e: - # Record failed attempt - throttle_service.record_attempt(request.email) - raise -``` - -### Pros -- No external dependencies -- Simple implementation -- Good for development - -### Cons -- Doesn't work across multiple instances -- Lost on restart -- Not suitable for production - ---- - -## Configuration Recommendations - -### Rate Limit Settings - -| Environment | Max Attempts | Window | Lockout | -|-------------|--------------|--------|---------| -| Development | 10 | 5 min | 5 min | -| Staging | 5 | 5 min | 15 min | -| Production | 5 | 5 min | 30 min | - -### What to Throttle By - -1. **Email/Username** (recommended) - Prevents brute-force on specific accounts -2. **IP Address** - Prevents distributed attacks but can affect shared IPs -3. **Both** - Most secure, implement separate limits for each - -Example for dual throttling: - -```python -async def check_login_throttle( - request: Request, - login_request: LoginRequest, - throttle_service: LoginThrottleService = Depends(get_throttle_service) -): - # Check by email - is_throttled, retry_after = await throttle_service.is_throttled( - f"email:{login_request.email}" - ) - if is_throttled: - raise HTTPException(...) - - # Check by IP - client_ip = request.client.host - is_throttled, retry_after = await throttle_service.is_throttled( - f"ip:{client_ip}" - ) - if is_throttled: - raise HTTPException(...) -``` - -## Security Best Practices - -1. **Log throttled attempts** - Monitor for attack patterns -2. **Use HTTPS** - Prevent credential interception -3. **Return generic errors** - Don't reveal if user exists -4. **Consider CAPTCHA** - After X failed attempts -5. **Monitor from infrastructure** - Use WAF/rate limiting at load balancer level -6. **Alert on patterns** - Set up monitoring for unusual login attempt patterns - -## Testing - -Example test for throttle service: - -```python -import pytest -from app.shared.infrastructure.services.throttle_service import LoginThrottleService - - -@pytest.mark.asyncio -async def test_throttle_after_max_attempts(redis_client): - service = LoginThrottleService(redis_client) - identifier = "test@example.com" - - # Make max attempts - for _ in range(5): - await service.record_attempt(identifier) - - # Should be throttled - is_throttled, retry_after = await service.is_throttled(identifier) - assert is_throttled is True - assert retry_after > 0 - - -@pytest.mark.asyncio -async def test_clear_attempts_on_success(redis_client): - service = LoginThrottleService(redis_client) - identifier = "test@example.com" - - await service.record_attempt(identifier) - await service.clear_attempts(identifier) - - is_throttled, _ = await service.is_throttled(identifier) - assert is_throttled is False -``` - -## Recommendation - -For your DDD-based FastAPI application: - -**Development**: Start with Option 3 (In-Memory) -**Production**: Use Option 2 (Redis-based) - -The Redis implementation fits well with your DDD architecture and provides the robustness needed for production use. diff --git a/justfile b/justfile index 9194619..5679152 100644 --- a/justfile +++ b/justfile @@ -18,3 +18,9 @@ pgcli: pgcli-test: pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:5433/homecomp_test + +test-unit: + uv run pytest -m unit + +test-integration: + uv run pytest -m integration diff --git a/pyproject.toml b/pyproject.toml index 15693bb..60a6007 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,6 @@ markers = [ "integration: marks tests as integration tests (slower, uses database)", ] testpaths = ["tests"] -python_files = ["test_*.py"] +python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] diff --git a/tests/conftest.py b/tests/conftest.py index 32161e3..01a200a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,6 +15,20 @@ # 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_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, +) + # Make fixtures available to pytest __all__ = [ # Auth fixtures @@ -25,4 +39,15 @@ # 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/credit_card/credit_card_fixtures.py b/tests/fixtures/credit_card/credit_card_fixtures.py new file mode 100644 index 0000000..687a688 --- /dev/null +++ b/tests/fixtures/credit_card/credit_card_fixtures.py @@ -0,0 +1,219 @@ +"""Test fixtures for credit card context""" + +import pytest +from decimal import Decimal +from datetime import UTC, datetime + +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/user_account/__init__.py b/tests/fixtures/user_account/__init__.py new file mode 100644 index 0000000..54f565e --- /dev/null +++ b/tests/fixtures/user_account/__init__.py @@ -0,0 +1,159 @@ +"""User account context test fixtures""" + +from decimal import Decimal +from datetime import datetime +import pytest + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + UserAccountUserID, + UserAccountDeletedAt, +) +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/unit/context/auth/application/test_get_session_handler.py b/tests/unit/context/auth/application/get_session_handler_test.py similarity index 100% rename from tests/unit/context/auth/application/test_get_session_handler.py rename to tests/unit/context/auth/application/get_session_handler_test.py diff --git a/tests/unit/context/auth/application/test_login_handler.py b/tests/unit/context/auth/application/login_handler_test.py similarity index 100% rename from tests/unit/context/auth/application/test_login_handler.py rename to tests/unit/context/auth/application/login_handler_test.py diff --git a/tests/unit/context/auth/domain/test_login_service.py b/tests/unit/context/auth/domain/login_service_test.py similarity index 100% rename from tests/unit/context/auth/domain/test_login_service.py rename to tests/unit/context/auth/domain/login_service_test.py 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..041f93d --- /dev/null +++ b/tests/unit/context/credit_card/application/create_credit_card_handler_test.py @@ -0,0 +1,196 @@ +"""Unit tests for CreateCreditCardHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.credit_card.application.handlers.create_credit_card_handler import ( + CreateCreditCardHandler, +) +from app.context.credit_card.application.commands import CreateCreditCardCommand +from app.context.credit_card.application.dto import CreateCreditCardErrorCode +from app.context.credit_card.domain.dto import CreditCardDTO +from app.context.credit_card.domain.value_objects import ( + CardLimit, + CreditCardAccountID, + CreditCardCurrency, + CreditCardID, + CreditCardName, + CreditCardUserID, +) +from app.context.credit_card.domain.exceptions import ( + CreditCardNameAlreadyExistError, + CreditCardMapperError, +) + + +@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 handler(self, mock_service): + """Create handler with mocked service""" + return CreateCreditCardHandler(mock_service) + + @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..7aa6334 --- /dev/null +++ b/tests/unit/context/credit_card/application/delete_credit_card_handler_test.py @@ -0,0 +1,99 @@ +"""Unit tests for DeleteCreditCardHandler""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from app.context.credit_card.application.handlers.delete_credit_card_handler import ( + DeleteCreditCardHandler, +) +from app.context.credit_card.application.commands import DeleteCreditCardCommand +from app.context.credit_card.application.dto import DeleteCreditCardErrorCode +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 handler(self, mock_repository): + """Create handler with mocked repository""" + return DeleteCreditCardHandler(mock_repository) + + @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..f03fbb7 --- /dev/null +++ b/tests/unit/context/credit_card/application/find_credit_card_by_id_handler_test.py @@ -0,0 +1,136 @@ +"""Unit tests for FindCreditCardByIdHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +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 handler(self, mock_repository): + """Create handler with mocked repository""" + return FindCreditCardByIdHandler(mock_repository) + + @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..1f6631e --- /dev/null +++ b/tests/unit/context/credit_card/application/find_credit_cards_by_user_handler_test.py @@ -0,0 +1,189 @@ +"""Unit tests for FindCreditCardsByUserHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +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 handler(self, mock_repository): + """Create handler with mocked repository""" + return FindCreditCardsByUserHandler(mock_repository) + + @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..82f4fa3 --- /dev/null +++ b/tests/unit/context/credit_card/application/update_credit_card_handler_test.py @@ -0,0 +1,315 @@ +"""Unit tests for UpdateCreditCardHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.credit_card.application.handlers.update_credit_card_handler import ( + UpdateCreditCardHandler, +) +from app.context.credit_card.application.commands import UpdateCreditCardCommand +from app.context.credit_card.application.dto import UpdateCreditCardErrorCode +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, +) +from app.context.credit_card.domain.exceptions import ( + CreditCardNotFoundError, + CreditCardNameAlreadyExistError, + CreditCardMapperError, +) + + +@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 handler(self, mock_service): + """Create handler with mocked service""" + return UpdateCreditCardHandler(mock_service) + + @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..7ad8cc9 --- /dev/null +++ b/tests/unit/context/credit_card/domain/card_limit_test.py @@ -0,0 +1,88 @@ +"""Unit tests for CardLimit value object""" + +import pytest +from decimal import Decimal + +from app.context.credit_card.domain.value_objects import CardLimit +from app.context.credit_card.domain.exceptions import ( + InvalidCardLimitTypeError, + InvalidCardLimitValueError, + InvalidCardLimitPrecisionError, + InvalidCardLimitFormatError, +) + + +@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(Exception): # 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..ac25cd6 --- /dev/null +++ b/tests/unit/context/credit_card/domain/card_used_test.py @@ -0,0 +1,86 @@ +"""Unit tests for CardUsed value object""" + +import pytest +from decimal import Decimal + +from app.context.credit_card.domain.value_objects import CardUsed +from app.context.credit_card.domain.exceptions import ( + InvalidCardUsedTypeError, + InvalidCardUsedValueError, + InvalidCardUsedPrecisionError, + InvalidCardUsedFormatError, +) + + +@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(Exception): # 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..90eb0f2 --- /dev/null +++ b/tests/unit/context/credit_card/domain/create_credit_card_service_test.py @@ -0,0 +1,157 @@ +"""Unit tests for CreateCreditCardService""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.credit_card.domain.services.create_credit_card_service import ( + CreateCreditCardService, +) +from app.context.credit_card.domain.dto import CreditCardDTO +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 service(self, mock_repository): + """Create service with mocked repository""" + return CreateCreditCardService(mock_repository) + + @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..1dd2993 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_account_id_test.py @@ -0,0 +1,42 @@ +"""Unit tests for CreditCardAccountID value object""" + +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(Exception): # 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..5141504 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_currency_test.py @@ -0,0 +1,69 @@ +"""Unit tests for CreditCardCurrency value object""" + +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(Exception): # 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..bcd1e47 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_deleted_at_test.py @@ -0,0 +1,65 @@ +"""Unit tests for CreditCardDeletedAt value object""" + +import pytest +from datetime import UTC, datetime, timedelta + +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(Exception): # 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..7b61c23 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_dto_test.py @@ -0,0 +1,128 @@ +"""Unit tests for CreditCardDTO""" + +import pytest +from decimal import Decimal +from datetime import UTC, datetime + +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(Exception): # 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..cf117c4 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_id_test.py @@ -0,0 +1,52 @@ +"""Unit tests for CreditCardID value object""" + +import pytest + +from app.context.credit_card.domain.value_objects import CreditCardID +from app.context.credit_card.domain.exceptions import ( + InvalidCreditCardIdTypeError, + InvalidCreditCardIdValueError, +) + + +@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(Exception): # 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..339fde3 --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_name_test.py @@ -0,0 +1,66 @@ +"""Unit tests for CreditCardName value object""" + +import pytest + +from app.context.credit_card.domain.value_objects import CreditCardName +from app.context.credit_card.domain.exceptions import ( + InvalidCreditCardNameTypeError, + InvalidCreditCardNameLengthError, +) + + +@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(Exception): # 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..cf1da8e --- /dev/null +++ b/tests/unit/context/credit_card/domain/credit_card_user_id_test.py @@ -0,0 +1,31 @@ +"""Unit tests for CreditCardUserID value object""" + +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(Exception): # 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..2c84fd9 --- /dev/null +++ b/tests/unit/context/credit_card/domain/update_credit_card_service_test.py @@ -0,0 +1,292 @@ +"""Unit tests for UpdateCreditCardService""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.credit_card.domain.services.update_credit_card_service import ( + UpdateCreditCardService, +) +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, +) +from app.context.credit_card.domain.exceptions import ( + CreditCardNotFoundError, + CreditCardUnauthorizedAccessError, + CreditCardNameAlreadyExistError, + CreditCardUsedExceedsLimitError, +) + + +@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 service(self, mock_repository): + """Create service with mocked repository""" + return UpdateCreditCardService(mock_repository) + + @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..b18082b --- /dev/null +++ b/tests/unit/context/credit_card/infrastructure/credit_card_mapper_test.py @@ -0,0 +1,223 @@ +"""Unit tests for CreditCardMapper""" + +import pytest +from decimal import Decimal +from datetime import UTC, datetime + +from app.context.credit_card.infrastructure.mappers.credit_card_mapper import ( + CreditCardMapper, +) +from app.context.credit_card.infrastructure.models import CreditCardModel +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.domain.exceptions import CreditCardMapperError + + +@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/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..4cde8d8 --- /dev/null +++ b/tests/unit/context/user_account/application/create_account_handler_test.py @@ -0,0 +1,181 @@ +"""Unit tests for CreateAccountHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.user_account.application.handlers.create_account_handler import ( + CreateAccountHandler, +) +from app.context.user_account.application.commands import CreateAccountCommand +from app.context.user_account.application.dto import CreateAccountErrorCode +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + UserAccountUserID, +) +from app.context.user_account.domain.exceptions import ( + UserAccountNameAlreadyExistError, + UserAccountMapperError, +) + + +@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): + """Create handler with mocked service""" + return CreateAccountHandler(mock_service) + + @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..e730009 --- /dev/null +++ b/tests/unit/context/user_account/application/delete_account_handler_test.py @@ -0,0 +1,76 @@ +"""Unit tests for DeleteAccountHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.user_account.application.handlers.delete_account_handler import ( + DeleteAccountHandler, +) +from app.context.user_account.application.commands import DeleteAccountCommand +from app.context.user_account.application.dto import DeleteAccountErrorCode +from app.context.user_account.domain.value_objects import ( + UserAccountID, + UserAccountUserID, +) + + +@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): + """Create handler with mocked repository""" + return DeleteAccountHandler(mock_repository) + + @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..dd35390 --- /dev/null +++ b/tests/unit/context/user_account/application/find_account_by_id_handler_test.py @@ -0,0 +1,93 @@ +"""Unit tests for FindAccountByIdHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +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 ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + 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): + """Create handler with mocked repository""" + return FindAccountByIdHandler(mock_repository) + + @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), + ) + + mock_repository.find_user_accounts = AsyncMock(return_value=account_dto) + + # Act + result = await handler.handle(query) + + # Assert + assert result is not None + assert result.account_id == 10 + assert result.user_id == 1 + assert result.name == "My Account" + assert result.currency == "USD" + assert result.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 None""" + # Arrange + query = FindAccountByIdQuery(account_id=999, user_id=1) + mock_repository.find_user_accounts = AsyncMock(return_value=None) + + # Act + result = await handler.handle(query) + + # Assert + assert result 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) + mock_repository.find_user_accounts = AsyncMock(return_value=None) + + # 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 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..0fbe803 --- /dev/null +++ b/tests/unit/context/user_account/application/find_accounts_by_user_handler_test.py @@ -0,0 +1,97 @@ +"""Unit tests for FindAccountsByUserHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +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 ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + 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): + """Create handler with mocked repository""" + return FindAccountsByUserHandler(mock_repository) + + @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 len(result) == 2 + assert result[0].account_id == 10 + assert result[0].name == "Account 1" + assert result[1].account_id == 11 + assert result[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 == [] + + @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 == [] 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..6e5dbe2 --- /dev/null +++ b/tests/unit/context/user_account/application/update_account_handler_test.py @@ -0,0 +1,141 @@ +"""Unit tests for UpdateAccountHandler""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.user_account.application.handlers.update_account_handler import ( + UpdateAccountHandler, +) +from app.context.user_account.application.commands import UpdateAccountCommand +from app.context.user_account.application.dto import UpdateAccountErrorCode +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + UserAccountUserID, +) +from app.context.user_account.domain.exceptions import ( + UserAccountNotFoundError, + UserAccountNameAlreadyExistError, + UserAccountMapperError, +) + + +@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): + """Create handler with mocked service""" + return UpdateAccountHandler(mock_service) + + @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..1086e2e --- /dev/null +++ b/tests/unit/context/user_account/domain/account_name_test.py @@ -0,0 +1,58 @@ +"""Unit tests for AccountName value object""" + +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(Exception): # 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..c73d87c --- /dev/null +++ b/tests/unit/context/user_account/domain/create_account_service_test.py @@ -0,0 +1,115 @@ +"""Unit tests for CreateAccountService""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.user_account.domain.services.create_account_service import ( + CreateAccountService, +) +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + 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): + """Create service with mocked repository""" + return CreateAccountService(mock_repository) + + @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..af3a399 --- /dev/null +++ b/tests/unit/context/user_account/domain/update_account_service_test.py @@ -0,0 +1,291 @@ +"""Unit tests for UpdateAccountService""" + +import pytest +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock + +from app.context.user_account.domain.services.update_account_service import ( + UpdateAccountService, +) +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + UserAccountUserID, +) +from app.context.user_account.domain.exceptions import ( + UserAccountNotFoundError, + UserAccountNameAlreadyExistError, +) + + +@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): + """Create service with mocked repository""" + return UpdateAccountService(mock_repository) + + @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..ea1ec80 --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_balance_test.py @@ -0,0 +1,52 @@ +"""Unit tests for UserAccountBalance value object""" + +import pytest +from decimal import Decimal + +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(Exception): # 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..0b0e386 --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_currency_test.py @@ -0,0 +1,46 @@ +"""Unit tests for UserAccountCurrency value object""" + +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(Exception): # 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..21c6308 --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_deleted_at_test.py @@ -0,0 +1,44 @@ +"""Unit tests for UserAccountDeletedAt value object""" + +import pytest +from datetime import datetime, UTC + +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(Exception): # 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..c6302fd --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_dto_test.py @@ -0,0 +1,168 @@ +"""Unit tests for user_account domain DTOs""" + +import pytest +from decimal import Decimal +from datetime import datetime + +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + UserAccountUserID, + UserAccountDeletedAt, +) + + +@pytest.mark.unit +class TestUserAccountDTO: + """Tests for UserAccountDTO""" + + def test_create_dto_with_all_fields(self): + """Test creating DTO with all fields populated""" + from datetime import UTC + now = datetime.now(UTC) + 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.value == 10 + 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(Exception): # 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..978dfff --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_id_test.py @@ -0,0 +1,42 @@ +"""Unit tests for UserAccountID value object""" + +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(Exception): # 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..b7d0f00 --- /dev/null +++ b/tests/unit/context/user_account/domain/user_account_user_id_test.py @@ -0,0 +1,26 @@ +"""Unit tests for UserAccountUserID value object""" + +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(Exception): # 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..40f181b --- /dev/null +++ b/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py @@ -0,0 +1,252 @@ +"""Unit tests for user_account infrastructure mapper""" + +import pytest +from decimal import Decimal +from datetime import datetime + +from app.context.user_account.infrastructure.mappers.user_account_mapper import ( + UserAccountMapper, +) +from app.context.user_account.infrastructure.models.user_account_model import ( + UserAccountModel, +) +from app.context.user_account.domain.dto import UserAccountDTO +from app.context.user_account.domain.value_objects import ( + UserAccountID, + AccountName, + UserAccountCurrency, + UserAccountBalance, + UserAccountUserID, + UserAccountDeletedAt, +) +from app.context.user_account.domain.exceptions import UserAccountMapperError + + +@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) From d9be18c2b1986f9c41405ce0d9db43c8dfab8cdb Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 01:32:36 +0100 Subject: [PATCH 28/58] DDD Fixes on user context --- .../login_attempts_service_contract.py | 4 +- .../auth/infrastructure/dependencies.py | 2 +- .../contracts/find_user_handler_contract.py | 16 +++- app/context/user/application/dto/__init__.py | 3 + .../user/application/dto/find_user_result.py | 26 +++++++ .../application/handlers/find_user_handler.py | 78 ++++++++++++++----- .../user/application/queries/__init__.py | 2 + .../__init__.py | 2 + .../user_repository_contract.py | 25 ++++++ .../user_repository_contract.py | 13 ---- app/context/user/domain/dto/__init__.py | 2 + app/context/user/domain/dto/user_dto.py | 12 ++- .../user/domain/exceptions/__init__.py | 17 ++++ .../user/domain/exceptions/exceptions.py | 37 +++++++++ .../user/domain/value_objects/__init__.py | 7 +- .../user/domain/value_objects/email.py | 18 ++--- .../user/domain/value_objects/password.py | 36 ++------- .../user/domain/value_objects/user_id.py | 18 ++++- .../{dependency.py => dependencies.py} | 2 +- .../infrastructure/mappers/user_mapper.py | 59 +++++++++++--- .../user/infrastructure/models/user_model.py | 17 +++- .../repositories/user_repository.py | 22 +++--- .../domain/value_objects/shared_email.py | 23 ++++-- .../f8333e5b2bac_create_user_table.py | 16 +++- 24 files changed, 338 insertions(+), 119 deletions(-) create mode 100644 app/context/user/application/dto/find_user_result.py rename app/context/user/domain/contracts/{infrastrutcure => infrastructure}/__init__.py (61%) create mode 100644 app/context/user/domain/contracts/infrastructure/user_repository_contract.py delete mode 100644 app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py create mode 100644 app/context/user/domain/exceptions/__init__.py create mode 100644 app/context/user/domain/exceptions/exceptions.py rename app/context/user/infrastructure/{dependency.py => dependencies.py} (92%) diff --git a/app/context/auth/domain/contracts/login_attempts_service_contract.py b/app/context/auth/domain/contracts/login_attempts_service_contract.py index ca0a3fe..3e3017b 100644 --- a/app/context/auth/domain/contracts/login_attempts_service_contract.py +++ b/app/context/auth/domain/contracts/login_attempts_service_contract.py @@ -1,9 +1,9 @@ from abc import ABC, abstractmethod -from app.context.user.domain.value_objects import Email +from app.context.user.domain.value_objects import UserEmail class LoginAttemptsServiceContract(ABC): @abstractmethod - async def handle(self, email: Email): + async def handle(self, email: UserEmail): pass diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index c12dbd8..ec17f60 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -18,7 +18,7 @@ from app.context.user.application.contracts import ( FindUserHandlerContract, ) -from app.context.user.infrastructure.dependency import ( +from app.context.user.infrastructure.dependencies import ( get_find_user_query_handler, ) from app.shared.infrastructure.database import get_db diff --git a/app/context/user/application/contracts/find_user_handler_contract.py b/app/context/user/application/contracts/find_user_handler_contract.py index ba62b46..c2c2cfe 100644 --- a/app/context/user/application/contracts/find_user_handler_contract.py +++ b/app/context/user/application/contracts/find_user_handler_contract.py @@ -1,11 +1,21 @@ from abc import ABC, abstractmethod -from typing import Optional -from app.context.user.application.dto import UserContextDTO +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) -> Optional[UserContextDTO]: + 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 index 8a3f0c7..6040527 100644 --- a/app/context/user/application/dto/__init__.py +++ b/app/context/user/application/dto/__init__.py @@ -1 +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..7969b89 --- /dev/null +++ b/app/context/user/application/dto/find_user_result.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Optional + + +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: Optional[int] = None + email: Optional[str] = None + username: Optional[str] = None + + # Error fields - populated when operation fails + error_code: Optional[FindUserErrorCode] = None + error_message: Optional[str] = None diff --git a/app/context/user/application/handlers/find_user_handler.py b/app/context/user/application/handlers/find_user_handler.py index cb1c1ea..556379d 100644 --- a/app/context/user/application/handlers/find_user_handler.py +++ b/app/context/user/application/handlers/find_user_handler.py @@ -1,26 +1,66 @@ -from typing import Optional - from app.context.user.application.contracts import FindUserHandlerContract -from app.context.user.application.dto import UserContextDTO +from app.context.user.application.dto import FindUserErrorCode, FindUserResult from app.context.user.application.queries import FindUserQuery -from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract -from app.context.user.domain.value_objects import Email, UserID +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) -> Optional[UserContextDTO]: - email = Email(value=query.email) if query.email is not None else None - user_id = UserID(value=query.user_id) if query.user_id is not None else None - res = await self.user_repo.find_user(user_id=user_id, email=email) - return ( - UserContextDTO( - user_id=res.user_id.value, - email=res.email.value, - password=res.password.value, + 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, + 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", ) - if res is not None - else None - ) diff --git a/app/context/user/application/queries/__init__.py b/app/context/user/application/queries/__init__.py index 2965ba3..7b72db0 100644 --- a/app/context/user/application/queries/__init__.py +++ b/app/context/user/application/queries/__init__.py @@ -1 +1,3 @@ from .find_user_query import FindUserQuery + +__all__ = ["FindUserQuery"] diff --git a/app/context/user/domain/contracts/infrastrutcure/__init__.py b/app/context/user/domain/contracts/infrastructure/__init__.py similarity index 61% rename from app/context/user/domain/contracts/infrastrutcure/__init__.py rename to app/context/user/domain/contracts/infrastructure/__init__.py index b952a4a..99cadbe 100644 --- a/app/context/user/domain/contracts/infrastrutcure/__init__.py +++ b/app/context/user/domain/contracts/infrastructure/__init__.py @@ -1 +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..65d6eb0 --- /dev/null +++ b/app/context/user/domain/contracts/infrastructure/user_repository_contract.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod +from typing import Optional + +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: Optional[UserID] = None, email: Optional[UserEmail] = None + ) -> Optional[UserDTO]: + """ + 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/contracts/infrastrutcure/user_repository_contract.py b/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py deleted file mode 100644 index 9739bc4..0000000 --- a/app/context/user/domain/contracts/infrastrutcure/user_repository_contract.py +++ /dev/null @@ -1,13 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Optional - -from app.context.user.domain.dto import UserDTO -from app.context.user.domain.value_objects import Email, UserID - - -class UserRepositoryContract(ABC): - @abstractmethod - async def find_user( - self, user_id: Optional[UserID] = None, email: Optional[Email] = None - ) -> Optional[UserDTO]: - pass diff --git a/app/context/user/domain/dto/__init__.py b/app/context/user/domain/dto/__init__.py index 4b35baf..fb383d5 100644 --- a/app/context/user/domain/dto/__init__.py +++ b/app/context/user/domain/dto/__init__.py @@ -1 +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 index 3790fd6..efd2c87 100644 --- a/app/context/user/domain/dto/user_dto.py +++ b/app/context/user/domain/dto/user_dto.py @@ -2,18 +2,22 @@ from typing import Optional from app.context.user.domain.value_objects import ( - Email, - Password, UserDeletedAt, + UserEmail, UserID, + UserName, + UserPassword, ) @dataclass(frozen=True) class UserDTO: + """Domain data transfer object for User aggregate""" + user_id: UserID - email: Email - password: Password + email: UserEmail + password: UserPassword + username: Optional[UserName] = None deleted_at: Optional[UserDeletedAt] = None @property 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 index 1b080b8..027a0fe 100644 --- a/app/context/user/domain/value_objects/__init__.py +++ b/app/context/user/domain/value_objects/__init__.py @@ -1,6 +1,7 @@ from .deleted_at import UserDeletedAt -from .email import Email -from .password import Password +from .email import UserEmail +from .password import UserPassword from .user_id import UserID +from .username import UserName -__all__ = ["Email", "Password", "UserID", "UserDeletedAt"] +__all__ = ["UserEmail", "UserPassword", "UserID", "UserDeletedAt", "UserName"] diff --git a/app/context/user/domain/value_objects/email.py b/app/context/user/domain/value_objects/email.py index a42c3c0..4ebe76f 100644 --- a/app/context/user/domain/value_objects/email.py +++ b/app/context/user/domain/value_objects/email.py @@ -1,15 +1,13 @@ -import re from dataclasses import dataclass +from app.shared.domain.value_objects.shared_email import SharedEmail -@dataclass(frozen=True) -class Email: - value: str - def __post_init__(self): - if not self.value or not isinstance(self.value, str): - raise ValueError("Email cannot be empty") +@dataclass(frozen=True) +class UserEmail(SharedEmail): + """ + Context-specific Email value object for User context. + Extends SharedEmail to maintain bounded context isolation. + """ - 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}") + pass diff --git a/app/context/user/domain/value_objects/password.py b/app/context/user/domain/value_objects/password.py index dfce699..e14406e 100644 --- a/app/context/user/domain/value_objects/password.py +++ b/app/context/user/domain/value_objects/password.py @@ -1,38 +1,14 @@ from dataclasses import dataclass -from argon2 import PasswordHasher -from argon2.low_level import VerifyMismatchError +from app.shared.domain.value_objects.shared_password import SharedPassword @dataclass(frozen=True) -class Password: +class UserPassword(SharedPassword): """ - Password value object. It will always represent hashed password in the domain + Context-specific Password value object for User context. + Extends SharedPassword to maintain bounded context isolation. + Password always represents a hashed value in the domain. """ - value: str - - @classmethod - def from_plain_text(cls, plain_password: str) -> "Password": - ph = PasswordHasher() - return cls(value=ph.hash(plain_password)) - - @classmethod - def from_hash(cls, hashed_password: str) -> "Password": - return cls(value=hashed_password) - - @classmethod - def keep_plain(cls, plain_password: str) -> "Password": - return Password(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 + pass diff --git a/app/context/user/domain/value_objects/user_id.py b/app/context/user/domain/value_objects/user_id.py index 8b8353e..e6523ce 100644 --- a/app/context/user/domain/value_objects/user_id.py +++ b/app/context/user/domain/value_objects/user_id.py @@ -1,6 +1,22 @@ -from dataclasses import dataclass +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/infrastructure/dependency.py b/app/context/user/infrastructure/dependencies.py similarity index 92% rename from app/context/user/infrastructure/dependency.py rename to app/context/user/infrastructure/dependencies.py index bd773a8..1e49560 100644 --- a/app/context/user/infrastructure/dependency.py +++ b/app/context/user/infrastructure/dependencies.py @@ -3,7 +3,7 @@ from app.context.user.application.contracts import FindUserHandlerContract from app.context.user.application.handlers import FindUserHandler -from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract +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 diff --git a/app/context/user/infrastructure/mappers/user_mapper.py b/app/context/user/infrastructure/mappers/user_mapper.py index c47932e..503eddf 100644 --- a/app/context/user/infrastructure/mappers/user_mapper.py +++ b/app/context/user/infrastructure/mappers/user_mapper.py @@ -1,21 +1,58 @@ -from dataclasses import dataclass from typing import Optional from app.context.user.domain.dto.user_dto import UserDTO -from app.context.user.domain.value_objects import Email, Password, UserDeletedAt, UserID +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 -@dataclass(frozen=True) class UserMapper: + """Mapper between UserModel (database) and UserDTO (domain)""" + @staticmethod - def toDTO(model: Optional[UserModel]) -> Optional[UserDTO]: - if model is None: - return None + def to_dto(model: Optional[UserModel]) -> Optional[UserDTO]: + """ + 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 + ) - return UserDTO( - user_id=UserID(model.id), - email=Email(model.email), - password=Password.from_hash(model.password), - deleted_at=UserDeletedAt.from_optional(model.deleted_at), + @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/user_model.py b/app/context/user/infrastructure/models/user_model.py index 96b8e02..5c3280e 100644 --- a/app/context/user/infrastructure/models/user_model.py +++ b/app/context/user/infrastructure/models/user_model.py @@ -8,12 +8,23 @@ 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)) - password: Mapped[str] = mapped_column(String(150)) - username: Mapped[Optional[str]] + email: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + password: Mapped[str] = mapped_column(String(150), nullable=False) + username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) deleted_at: Mapped[Optional[datetime]] = 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/user_repository.py b/app/context/user/infrastructure/repositories/user_repository.py index 8fa3605..f6de361 100644 --- a/app/context/user/infrastructure/repositories/user_repository.py +++ b/app/context/user/infrastructure/repositories/user_repository.py @@ -3,30 +3,32 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.context.user.domain.contracts.infrastrutcure import UserRepositoryContract +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 Email, UserID +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): - _db: AsyncSession + """Repository implementation for User aggregate""" def __init__(self, db: AsyncSession): self._db = db async def find_user( - self, user_id: Optional[UserID] = None, email: Optional[Email] = None + self, user_id: Optional[UserID] = None, email: Optional[UserEmail] = None ) -> Optional[UserDTO]: + """ + 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) - else: - if email is not None: - stmt = stmt.where(UserModel.email == email.value) + if email is not None: + stmt = stmt.where(UserModel.email == email.value) - res = await self._db.execute(stmt) - - return UserMapper.toDTO(res.scalar_one_or_none()) + result = await self._db.execute(stmt) + return UserMapper.to_dto(result.scalar_one_or_none()) diff --git a/app/shared/domain/value_objects/shared_email.py b/app/shared/domain/value_objects/shared_email.py index 0e38c4f..e5f70a0 100644 --- a/app/shared/domain/value_objects/shared_email.py +++ b/app/shared/domain/value_objects/shared_email.py @@ -1,15 +1,26 @@ import re -from dataclasses import dataclass +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.value or not isinstance(self.value, str): - raise ValueError("Email cannot be empty") + 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}") + 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/migrations/versions/f8333e5b2bac_create_user_table.py b/migrations/versions/f8333e5b2bac_create_user_table.py index f774873..1c5eb9e 100644 --- a/migrations/versions/f8333e5b2bac_create_user_table.py +++ b/migrations/versions/f8333e5b2bac_create_user_table.py @@ -25,8 +25,20 @@ def upgrade() -> None: 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), unique=True), - sa.Column("deleted_at", sa.DateTime, nullable=True), + 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"), + ), ) From 90f2fa82346f9effef5ca778293a74052ca7a202 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 01:43:40 +0100 Subject: [PATCH 29/58] Adding more seeds --- justfile | 9 ++- scripts/db_clear.py | 46 +++++++++++ scripts/seed.py | 43 +++++++--- scripts/seeds/README.md | 101 ++++++++++++++++++++++++ scripts/seeds/__init__.py | 13 +++ scripts/seeds/seed_credit_cards.py | 69 ++++++++++++++++ scripts/seeds/seed_household_members.py | 80 +++++++++++++++++++ scripts/seeds/seed_households.py | 36 +++++++++ scripts/seeds/seed_user_accounts.py | 86 ++++++++++++++++++++ scripts/seeds/seed_users.py | 50 ++++++++++++ 10 files changed, 519 insertions(+), 14 deletions(-) create mode 100644 scripts/db_clear.py create mode 100644 scripts/seeds/README.md create mode 100644 scripts/seeds/__init__.py create mode 100644 scripts/seeds/seed_credit_cards.py create mode 100644 scripts/seeds/seed_household_members.py create mode 100644 scripts/seeds/seed_households.py create mode 100644 scripts/seeds/seed_user_accounts.py create mode 100644 scripts/seeds/seed_users.py diff --git a/justfile b/justfile index 5679152..ef10fe7 100644 --- a/justfile +++ b/justfile @@ -10,8 +10,15 @@ migrate: migrate-test: DB_PORT=5434 DB_NAME=homecomp_test uv run alembic upgrade head +db-clear: + uv run python scripts/db_clear.py + seed: - uv run python -m scripts.seed + uv run python scripts/seed.py + +db-reset: + just db-clear + just seed pgcli: pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:$DB_PORT/$DB_NAME 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 index b3a4293..4544e29 100644 --- a/scripts/seed.py +++ b/scripts/seed.py @@ -2,25 +2,42 @@ import sys from pathlib import Path -from app.context.user.domain.value_objects.password import Password -from app.context.user.infrastructure.models import UserModel -from app.shared.infrastructure.database import AsyncSessionLocal - # 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(): - passwd = Password.from_plain_text("testonga") async with AsyncSessionLocal() as session: - # Add your seed data - print("Seeding users") - users = [ - UserModel(email="user1@test.com", password=passwd.value), - UserModel(email="user2@test.com", password=passwd.value), - ] - session.add_all(users) - await session.commit() + 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__": 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..3d30519 --- /dev/null +++ b/scripts/seeds/__init__.py @@ -0,0 +1,13 @@ +from .seed_users import seed_users +from .seed_households import seed_households +from .seed_user_accounts import seed_user_accounts +from .seed_household_members import seed_household_members +from .seed_credit_cards import seed_credit_cards + +__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..cc443d4 --- /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_account.infrastructure.models import UserAccountModel +from app.context.user.infrastructure.models import UserModel + + +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..43aa7a0 --- /dev/null +++ b/scripts/seeds/seed_household_members.py @@ -0,0 +1,80 @@ +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["john.doe@example.com"].id, + "role": "owner", + "joined_at": now, + "invited_by_user_id": None, # Owner joined automatically + "invited_at": None, + }, + { + "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["charlie.brown@example.com"].id, + "role": "participant", + "joined_at": None, # Invited but not joined yet + "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["jane.smith@example.com"].id, + "role": "owner", + "joined_at": now, + "invited_by_user_id": None, + "invited_at": None, + }, + { + "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, + }, + # Williams Home members + { + "household_id": households["Williams Home"].id, + "user_id": users["alice.williams@example.com"].id, + "role": "owner", + "joined_at": now, + "invited_by_user_id": None, + "invited_at": None, + }, + ] + + 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..d95cd97 --- /dev/null +++ b/scripts/seeds/seed_households.py @@ -0,0 +1,36 @@ +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", + }, + ] + + 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..576a81d --- /dev/null +++ b/scripts/seeds/seed_user_accounts.py @@ -0,0 +1,86 @@ +from decimal import Decimal + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.user_account.infrastructure.models import UserAccountModel +from app.context.user.infrastructure.models import UserModel + + +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 From 6db90a10ff48d2a3936b8ff6016093f5cfc23e2b Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 12:56:36 +0100 Subject: [PATCH 30/58] Adding unit tests for household - Adding coverage test dependencies --- .env.template | 4 + .gitignore | 2 + .../domain/value_objects/__init__.py | 2 + docker-compose.yml | 15 + justfile | 3 + pyproject.toml | 24 ++ tests/fixtures/household/__init__.py | 1 + tests/unit/context/household/__init__.py | 1 + .../context/household/application/__init__.py | 1 + .../create_household_handler_test.py | 181 +++++++++ .../application/invite_user_handler_test.py | 236 ++++++++++++ .../unit/context/household/domain/__init__.py | 1 + .../domain/accept_invite_service_test.py | 144 ++++++++ .../domain/create_household_service_test.py | 147 ++++++++ .../domain/decline_invite_service_test.py | 133 +++++++ .../household/domain/household_id_test.py | 46 +++ .../domain/household_member_id_test.py | 50 +++ .../household/domain/household_name_test.py | 70 ++++ .../household/domain/household_role_test.py | 60 +++ .../domain/household_user_id_test.py | 46 +++ .../domain/household_user_name_test.py | 31 ++ .../domain/invite_user_service_test.py | 269 ++++++++++++++ .../domain/remove_member_service_test.py | 264 +++++++++++++ .../domain/revoke_invite_service_test.py | 237 ++++++++++++ .../household/infrastructure/__init__.py | 1 + .../infrastructure/household_mapper_test.py | 198 ++++++++++ .../household_member_mapper_test.py | 346 ++++++++++++++++++ uv.lock | 77 ++++ 28 files changed, 2590 insertions(+) create mode 100644 tests/fixtures/household/__init__.py create mode 100644 tests/unit/context/household/__init__.py create mode 100644 tests/unit/context/household/application/__init__.py create mode 100644 tests/unit/context/household/application/create_household_handler_test.py create mode 100644 tests/unit/context/household/application/invite_user_handler_test.py create mode 100644 tests/unit/context/household/domain/__init__.py create mode 100644 tests/unit/context/household/domain/accept_invite_service_test.py create mode 100644 tests/unit/context/household/domain/create_household_service_test.py create mode 100644 tests/unit/context/household/domain/decline_invite_service_test.py create mode 100644 tests/unit/context/household/domain/household_id_test.py create mode 100644 tests/unit/context/household/domain/household_member_id_test.py create mode 100644 tests/unit/context/household/domain/household_name_test.py create mode 100644 tests/unit/context/household/domain/household_role_test.py create mode 100644 tests/unit/context/household/domain/household_user_id_test.py create mode 100644 tests/unit/context/household/domain/household_user_name_test.py create mode 100644 tests/unit/context/household/domain/invite_user_service_test.py create mode 100644 tests/unit/context/household/domain/remove_member_service_test.py create mode 100644 tests/unit/context/household/domain/revoke_invite_service_test.py create mode 100644 tests/unit/context/household/infrastructure/__init__.py create mode 100644 tests/unit/context/household/infrastructure/household_mapper_test.py create mode 100644 tests/unit/context/household/infrastructure/household_member_mapper_test.py diff --git a/.env.template b/.env.template index 02a8e4f..f4157af 100644 --- a/.env.template +++ b/.env.template @@ -1,6 +1,10 @@ APP_ENV=dev + DB_HOST=localhost DB_PORT=5432 DB_USER=uhomecomp DB_PASS=homecomppass DB_NAME=homecomp + +VALKEY_HOST=localhost +VALKEY_PORT=6379 diff --git a/.gitignore b/.gitignore index 5070896..0cd73e8 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ ENV/ Thumbs.db .claude/settings.local.json +htmlcov +.coverage diff --git a/app/context/household/domain/value_objects/__init__.py b/app/context/household/domain/value_objects/__init__.py index cbf5e5b..ea5f204 100644 --- a/app/context/household/domain/value_objects/__init__.py +++ b/app/context/household/domain/value_objects/__init__.py @@ -3,6 +3,7 @@ from .household_name import HouseholdName from .household_role import HouseholdRole from .household_user_id import HouseholdUserID +from .household_user_name import HouseholdUserName __all__ = [ "HouseholdID", @@ -10,4 +11,5 @@ "HouseholdName", "HouseholdRole", "HouseholdUserID", + "HouseholdUserName", ] diff --git a/docker-compose.yml b/docker-compose.yml index a70ad66..abb9b13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,21 @@ services: timeout: 5s retries: 5 + 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 + volumes: postgres_data: postgres_test_data: + valkey_data: diff --git a/justfile b/justfile index ef10fe7..101cadc 100644 --- a/justfile +++ b/justfile @@ -29,5 +29,8 @@ pgcli-test: test-unit: uv run pytest -m unit +test-unit-cov: + uv run pytest -m unit --cov --cov-report=term --cov-report=html + test-integration: uv run pytest -m integration diff --git a/pyproject.toml b/pyproject.toml index 60a6007..1361c38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ dev = [ "httpx>=0.28.1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", + "pytest-cov>=6.0.0", ] [tool.pytest.ini_options] @@ -32,3 +33,26 @@ 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", +] 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/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..a65f228 --- /dev/null +++ b/tests/unit/context/household/application/create_household_handler_test.py @@ -0,0 +1,181 @@ +"""Unit tests for CreateHouseholdHandler""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.application.handlers.create_household_handler import ( + CreateHouseholdHandler, +) +from app.context.household.application.commands import CreateHouseholdCommand +from app.context.household.application.dto import CreateHouseholdErrorCode +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) +from app.context.household.domain.exceptions import ( + HouseholdMapperError, + HouseholdNameAlreadyExistError, +) + + +@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): + """Create handler with mocked service""" + return CreateHouseholdHandler(mock_service) + + @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..aea6205 --- /dev/null +++ b/tests/unit/context/household/application/invite_user_handler_test.py @@ -0,0 +1,236 @@ +"""Unit tests for InviteUserHandler""" + +import pytest +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.application.handlers.invite_user_handler import ( + InviteUserHandler, +) +from app.context.household.application.commands import InviteUserCommand +from app.context.household.application.dto import InviteUserErrorCode +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.domain.exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + HouseholdMapperError, + OnlyOwnerCanInviteError, +) + + +@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): + """Create handler with mocked service""" + return InviteUserHandler(mock_service) + + @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..9bd9b3f --- /dev/null +++ b/tests/unit/context/household/domain/accept_invite_service_test.py @@ -0,0 +1,144 @@ +"""Unit tests for AcceptInviteService""" + +import pytest +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.domain.services.accept_invite_service import ( + AcceptInviteService, +) +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.domain.exceptions import NotInvitedError + + +@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): + """Create service with mocked repository""" + return AcceptInviteService(mock_repository) + + @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..4632887 --- /dev/null +++ b/tests/unit/context/household/domain/create_household_service_test.py @@ -0,0 +1,147 @@ +"""Unit tests for CreateHouseholdService""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.domain.services.create_household_service import ( + CreateHouseholdService, +) +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) +from app.context.household.domain.exceptions import HouseholdNameAlreadyExistError + + +@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): + """Create service with mocked repository""" + return CreateHouseholdService(mock_repository) + + @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 + assert call_args.kwargs["creator_user_id"] == creator_user_id + + @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..14dd2b4 --- /dev/null +++ b/tests/unit/context/household/domain/decline_invite_service_test.py @@ -0,0 +1,133 @@ +"""Unit tests for DeclineInviteService""" + +import pytest +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.domain.services.decline_invite_service import ( + DeclineInviteService, +) +from app.context.household.domain.dto import HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.domain.exceptions import NotInvitedError + + +@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): + """Create service with mocked repository""" + return DeclineInviteService(mock_repository) + + @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..9de551a --- /dev/null +++ b/tests/unit/context/household/domain/household_id_test.py @@ -0,0 +1,46 @@ +"""Unit tests for HouseholdID value object""" + +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(Exception): # 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..5ddaad1 --- /dev/null +++ b/tests/unit/context/household/domain/household_member_id_test.py @@ -0,0 +1,50 @@ +"""Unit tests for HouseholdMemberID value object""" + +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(Exception): # 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..0323cbf --- /dev/null +++ b/tests/unit/context/household/domain/household_name_test.py @@ -0,0 +1,70 @@ +"""Unit tests for HouseholdName value object""" + +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(Exception): # 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..1179e3a --- /dev/null +++ b/tests/unit/context/household/domain/household_role_test.py @@ -0,0 +1,60 @@ +"""Unit tests for HouseholdRole value object""" + +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(Exception): # 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..ead182d --- /dev/null +++ b/tests/unit/context/household/domain/household_user_id_test.py @@ -0,0 +1,46 @@ +"""Unit tests for HouseholdUserID value object""" + +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(Exception): # 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..59447d9 --- /dev/null +++ b/tests/unit/context/household/domain/household_user_name_test.py @@ -0,0 +1,31 @@ +"""Unit tests for HouseholdUserName value object""" + +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(Exception): # 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..50f8414 --- /dev/null +++ b/tests/unit/context/household/domain/invite_user_service_test.py @@ -0,0 +1,269 @@ +"""Unit tests for InviteUserService""" + +import pytest +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.domain.services.invite_user_service import InviteUserService +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.domain.exceptions import ( + AlreadyActiveMemberError, + AlreadyInvitedError, + OnlyOwnerCanInviteError, +) + + +@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): + """Create service with mocked repository""" + return InviteUserService(mock_repository) + + @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..c3d0420 --- /dev/null +++ b/tests/unit/context/household/domain/remove_member_service_test.py @@ -0,0 +1,264 @@ +"""Unit tests for RemoveMemberService""" + +import pytest +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.domain.services.remove_member_service import ( + RemoveMemberService, +) +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.domain.exceptions import ( + CannotRemoveSelfError, + InviteNotFoundError, + OnlyOwnerCanRemoveMemberError, +) + + +@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): + """Create service with mocked repository""" + return RemoveMemberService(mock_repository) + + @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..dfcfb57 --- /dev/null +++ b/tests/unit/context/household/domain/revoke_invite_service_test.py @@ -0,0 +1,237 @@ +"""Unit tests for RevokeInviteService""" + +import pytest +from datetime import UTC, datetime +from unittest.mock import AsyncMock, MagicMock + +from app.context.household.domain.services.revoke_invite_service import ( + RevokeInviteService, +) +from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdMemberID, + HouseholdName, + HouseholdRole, + HouseholdUserID, +) +from app.context.household.domain.exceptions import ( + InviteNotFoundError, + OnlyOwnerCanRevokeError, +) + + +@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): + """Create service with mocked repository""" + return RevokeInviteService(mock_repository) + + @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..b047fc0 --- /dev/null +++ b/tests/unit/context/household/infrastructure/household_mapper_test.py @@ -0,0 +1,198 @@ +"""Unit tests for HouseholdMapper""" + +import pytest +from datetime import UTC, datetime + +from app.context.household.infrastructure.mappers.household_mapper import HouseholdMapper +from app.context.household.infrastructure.models import HouseholdModel +from app.context.household.domain.dto import HouseholdDTO +from app.context.household.domain.value_objects import ( + HouseholdID, + HouseholdName, + HouseholdUserID, +) +from app.context.household.domain.exceptions import HouseholdMapperError + + +@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..b5632d5 --- /dev/null +++ b/tests/unit/context/household/infrastructure/household_member_mapper_test.py @@ -0,0 +1,346 @@ +"""Unit tests for HouseholdMemberMapper""" + +import pytest +from datetime import UTC, datetime + +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 +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.domain.exceptions import HouseholdMapperError + + +@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/uv.lock b/uv.lock index 97b5717..161b167 100644 --- a/uv.lock +++ b/uv.lock @@ -200,6 +200,67 @@ 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" @@ -298,6 +359,7 @@ dev = [ { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, ] [package.metadata] @@ -318,6 +380,7 @@ 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" }, ] [[package]] @@ -597,6 +660,20 @@ 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 = "sqlalchemy" version = "2.0.45" From c651f4b1e0c3b640d8644d4ade91be5d83bfcf9a Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 13:13:03 +0100 Subject: [PATCH 31/58] Adding github actions for unit and integration tests --- .github/workflows/tests.yml | 111 ++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..5024647 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,111 @@ +name: Tests + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + types: [opened, synchronize, reopened, ready_for_review] + +# Cancel in-progress runs when a new one starts +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + # Skip draft PRs to save CI minutes + if: github.event.pull_request.draft == false || github.event_name == 'push' + + 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 --tb=short + + - name: Upload coverage to artifacts (optional) + if: always() + run: | + uv run pytest -m unit --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: + # Only run integration tests on push to main/dev, not on every PR + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + + runs-on: ubuntu-latest + + 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 + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: uhomecomp + DB_PASS: homecomppass + DB_NAME: homecomp_test + run: uv run alembic upgrade head + + - name: Run integration tests + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: uhomecomp + DB_PASS: homecomppass + DB_NAME: homecomp_test + run: uv run pytest -m integration --tb=short From 97b8b5fb944a704f097f6609b55c3437e2962af0 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 13:30:26 +0100 Subject: [PATCH 32/58] Attempt to fix action config file --- .github/workflows/tests.yml | 4 ++-- justfile | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5024647..7840ebe 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -37,12 +37,12 @@ jobs: run: uv sync --frozen - name: Run unit tests - run: uv run pytest -m unit --tb=short + 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 --cov --cov-report=xml --cov-report=term + uv run pytest -m unit --ignore=tests/integration --cov --cov-report=xml --cov-report=term continue-on-error: true - name: Upload coverage artifact diff --git a/justfile b/justfile index 101cadc..36eb5de 100644 --- a/justfile +++ b/justfile @@ -27,10 +27,10 @@ pgcli-test: pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:5433/homecomp_test test-unit: - uv run pytest -m unit + uv run pytest -m unit --ignore=tests/integration test-unit-cov: - uv run pytest -m unit --cov --cov-report=term --cov-report=html + uv run pytest -m unit --ignore=tests/integration --cov --cov-report=term --cov-report=html test-integration: uv run pytest -m integration From 48e606153d1dfe64f86a9868898e55d3c232b5bc Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 13:37:54 +0100 Subject: [PATCH 33/58] Fixes on github actions for integration tests --- .github/workflows/tests.yml | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7840ebe..626760f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,6 +60,13 @@ jobs: runs-on: ubuntu-latest + env: + DB_HOST: localhost + DB_PORT: 5432 + DB_USER: uhomecomp + DB_PASS: homecomppass + DB_NAME: homecomp_test + services: postgres: image: postgres:16 @@ -93,19 +100,7 @@ jobs: run: uv sync --frozen - name: Run migrations - env: - DB_HOST: localhost - DB_PORT: 5432 - DB_USER: uhomecomp - DB_PASS: homecomppass - DB_NAME: homecomp_test run: uv run alembic upgrade head - name: Run integration tests - env: - DB_HOST: localhost - DB_PORT: 5432 - DB_USER: uhomecomp - DB_PASS: homecomppass - DB_NAME: homecomp_test run: uv run pytest -m integration --tb=short From 6aa817315b630e5d3f11171da1ff1b2f6c6a944e Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 13:53:23 +0100 Subject: [PATCH 34/58] Updating github action triggers --- .github/workflows/tests.yml | 15 +++++++++------ justfile | 3 +++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 626760f..f15bfa3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,11 +1,10 @@ name: Tests on: - push: - branches: [main, dev] 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: @@ -14,8 +13,8 @@ concurrency: jobs: unit-tests: - # Skip draft PRs to save CI minutes - if: github.event.pull_request.draft == false || github.event_name == 'push' + # 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 @@ -55,8 +54,12 @@ jobs: continue-on-error: true integration-tests: - # Only run integration tests on push to main/dev, not on every PR - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') + # Run ONLY on PRs targeting main (not dev) or manual triggers + 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 diff --git a/justfile b/justfile index 36eb5de..77d48f2 100644 --- a/justfile +++ b/justfile @@ -1,6 +1,9 @@ run: uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 +run-with-test: + DB_PORT=5434 DB_NAME=homecomp_test uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 + migration-generate comment: alembic revision -m "{{comment}}" From c8876e38b9aaca3b4f63c404ac1c82d7effc7862 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 14:50:58 +0100 Subject: [PATCH 35/58] Fixing auth unit tests and integration tests --- .../application/handlers/login_handler.py | 8 +- .../user/application/dto/find_user_result.py | 1 + .../application/handlers/find_user_handler.py | 1 + .../domain/value_objects/shared_username.py | 9 +- requests/login.sh | 2 +- tests/fixtures/user/handlers.py | 5 +- .../auth/application/login_handler_test.py | 20 +- tests/unit/context/auth/domain/__init__.py | 1 + .../context/auth/domain/auth_email_test.py | 72 +++++ .../context/auth/domain/auth_password_test.py | 82 ++++++ .../context/auth/domain/auth_user_id_test.py | 66 +++++ .../context/auth/domain/blocked_time_test.py | 131 +++++++++ .../auth/domain/failed_login_attempts_test.py | 107 ++++++++ .../context/auth/domain/session_token_test.py | 81 ++++++ .../context/auth/domain/throttle_time_test.py | 85 ++++++ .../context/auth/infrastructure/__init__.py | 1 + .../infrastructure/session_mapper_test.py | 258 ++++++++++++++++++ 17 files changed, 915 insertions(+), 15 deletions(-) create mode 100644 tests/unit/context/auth/domain/__init__.py create mode 100644 tests/unit/context/auth/domain/auth_email_test.py create mode 100644 tests/unit/context/auth/domain/auth_password_test.py create mode 100644 tests/unit/context/auth/domain/auth_user_id_test.py create mode 100644 tests/unit/context/auth/domain/blocked_time_test.py create mode 100644 tests/unit/context/auth/domain/failed_login_attempts_test.py create mode 100644 tests/unit/context/auth/domain/session_token_test.py create mode 100644 tests/unit/context/auth/domain/throttle_time_test.py create mode 100644 tests/unit/context/auth/infrastructure/__init__.py create mode 100644 tests/unit/context/auth/infrastructure/session_mapper_test.py diff --git a/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py index f732504..6026b76 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -30,12 +30,18 @@ def __init__( async def handle(self, command: LoginCommand) -> LoginHandlerResultDTO: user = await self._user_handler.handle(FindUserQuery(email=command.email)) - if user is None: + if user is None or user.error_code is not None: 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: + return LoginHandlerResultDTO( + status=LoginHandlerResultStatus.UNEXPECTED_ERROR, + error_msg="Invalid user data", + ) + try: user_token = await self._login_service.handle( user_password=AuthPassword(command.password), diff --git a/app/context/user/application/dto/find_user_result.py b/app/context/user/application/dto/find_user_result.py index 7969b89..5c2e6e3 100644 --- a/app/context/user/application/dto/find_user_result.py +++ b/app/context/user/application/dto/find_user_result.py @@ -20,6 +20,7 @@ class FindUserResult: user_id: Optional[int] = None email: Optional[str] = None username: Optional[str] = None + password: Optional[str] = None # Error fields - populated when operation fails error_code: Optional[FindUserErrorCode] = None diff --git a/app/context/user/application/handlers/find_user_handler.py b/app/context/user/application/handlers/find_user_handler.py index 556379d..981edec 100644 --- a/app/context/user/application/handlers/find_user_handler.py +++ b/app/context/user/application/handlers/find_user_handler.py @@ -36,6 +36,7 @@ async def handle(self, query: FindUserQuery) -> FindUserResult: 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, ) diff --git a/app/shared/domain/value_objects/shared_username.py b/app/shared/domain/value_objects/shared_username.py index 7d9f5ca..385edc1 100644 --- a/app/shared/domain/value_objects/shared_username.py +++ b/app/shared/domain/value_objects/shared_username.py @@ -1,6 +1,13 @@ -from dataclasses import dataclass +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/requests/login.sh b/requests/login.sh index 7dffe80..8c3e94d 100755 --- a/requests/login.sh +++ b/requests/login.sh @@ -6,7 +6,7 @@ source "${SCRIPT_DIR}/base.sh" # Usage: ./login.sh # Example: ./login.sh user1@test.com mypassword -EMAIL=${1:-"user1@test.com"} +EMAIL=${1:-"john.doe@example.com"} PASSWORD=${2:-"testonga"} http --session=local_session POST "${MYAPP_URL}/api/auth/login" \ diff --git a/tests/fixtures/user/handlers.py b/tests/fixtures/user/handlers.py index 1f3514f..5e87894 100644 --- a/tests/fixtures/user/handlers.py +++ b/tests/fixtures/user/handlers.py @@ -1,12 +1,11 @@ """Mock handlers for user context testing.""" -from typing import Optional from unittest.mock import AsyncMock import pytest from app.context.user.application.contracts import FindUserHandlerContract -from app.context.user.application.dto import UserContextDTO +from app.context.user.application.dto import FindUserResult from app.context.user.application.queries import FindUserQuery @@ -16,7 +15,7 @@ class MockFindUserHandler(FindUserHandlerContract): def __init__(self): self.handle_mock = AsyncMock(return_value=None) - async def handle(self, query: FindUserQuery) -> Optional[UserContextDTO]: + async def handle(self, query: FindUserQuery) -> FindUserResult: return await self.handle_mock(query) diff --git a/tests/unit/context/auth/application/login_handler_test.py b/tests/unit/context/auth/application/login_handler_test.py index cf8a5e0..4c83838 100644 --- a/tests/unit/context/auth/application/login_handler_test.py +++ b/tests/unit/context/auth/application/login_handler_test.py @@ -13,7 +13,7 @@ InvalidCredentialsException, ) from app.context.auth.domain.value_objects import SessionToken -from app.context.user.application.dto import UserContextDTO +from app.context.user.application.dto import FindUserResult @pytest.mark.unit @@ -32,7 +32,7 @@ async def test_handle_successful_login( hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" token_value = "secure-session-token-xyz" - mock_user = UserContextDTO( + mock_user = FindUserResult( user_id=user_id, email=email, password=hashed_password ) mock_find_user_handler.handle_mock.return_value = mock_user @@ -98,7 +98,7 @@ async def test_handle_invalid_credentials_exception( user_id = 10 hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" - mock_user = UserContextDTO( + mock_user = FindUserResult( user_id=user_id, email=email, password=hashed_password ) mock_find_user_handler.handle_mock.return_value = mock_user @@ -132,7 +132,7 @@ async def test_handle_account_blocked_exception( hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" blocked_until = datetime.now() + timedelta(minutes=15) - mock_user = UserContextDTO( + mock_user = FindUserResult( user_id=user_id, email=email, password=hashed_password ) mock_find_user_handler.handle_mock.return_value = mock_user @@ -167,7 +167,7 @@ async def test_handle_unexpected_error( user_id = 50 hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" - mock_user = UserContextDTO( + mock_user = FindUserResult( user_id=user_id, email=email, password=hashed_password ) mock_find_user_handler.handle_mock.return_value = mock_user @@ -200,11 +200,13 @@ async def test_handle_database_exception( user_id = 25 hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$somehash" - mock_user = UserContextDTO( + 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") + mock_login_service.handle_mock.side_effect = Exception( + "Database connection lost" + ) handler = LoginHandler(mock_find_user_handler, mock_login_service) command = LoginCommand(email=email, password=password) @@ -227,7 +229,7 @@ async def test_handle_passes_correct_auth_user_dto( user_id = 777 hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$correcthash" - mock_user = UserContextDTO( + mock_user = FindUserResult( user_id=user_id, email=email, password=hashed_password ) mock_find_user_handler.handle_mock.return_value = mock_user @@ -285,7 +287,7 @@ async def test_handle_with_different_user_scenarios( password = "testpassword" hashed_password = "$argon2id$v=19$m=65536,t=3,p=4$hash" - mock_user = UserContextDTO( + mock_user = FindUserResult( user_id=user_id, email=email, password=hashed_password ) mock_find_user_handler.handle_mock.return_value = mock_user 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..9b9d6e2 --- /dev/null +++ b/tests/unit/context/auth/domain/auth_email_test.py @@ -0,0 +1,72 @@ +"""Unit tests for AuthEmail value object""" + +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(Exception): # 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..292c851 --- /dev/null +++ b/tests/unit/context/auth/domain/auth_password_test.py @@ -0,0 +1,82 @@ +"""Unit tests for AuthPassword value object""" + +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(Exception): # 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..cffeec2 --- /dev/null +++ b/tests/unit/context/auth/domain/auth_user_id_test.py @@ -0,0 +1,66 @@ +"""Unit tests for AuthUserID value object""" + +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(Exception): # 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..2b839ed --- /dev/null +++ b/tests/unit/context/auth/domain/failed_login_attempts_test.py @@ -0,0 +1,107 @@ +"""Unit tests for FailedLoginAttempts value object""" + +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(Exception): # 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/session_token_test.py b/tests/unit/context/auth/domain/session_token_test.py new file mode 100644 index 0000000..e2ec522 --- /dev/null +++ b/tests/unit/context/auth/domain/session_token_test.py @@ -0,0 +1,81 @@ +"""Unit tests for SessionToken value object""" + +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(Exception): # 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..2dc3842 --- /dev/null +++ b/tests/unit/context/auth/domain/throttle_time_test.py @@ -0,0 +1,85 @@ +"""Unit tests for ThrottleTime value object""" + +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(Exception): # 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) From 95df107c3068d0369d4b59b0dedad044e908c441 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 15:14:19 +0100 Subject: [PATCH 36/58] Fixing integration test configuration --- .env.template | 6 ++++++ .github/workflows/tests.yml | 10 +++++----- tests/integration/fixtures/database.py | 14 +++++++++----- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.env.template b/.env.template index f4157af..08ba587 100644 --- a/.env.template +++ b/.env.template @@ -6,5 +6,11 @@ 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 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f15bfa3..6e97e3b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -64,11 +64,11 @@ jobs: runs-on: ubuntu-latest env: - DB_HOST: localhost - DB_PORT: 5432 - DB_USER: uhomecomp - DB_PASS: homecomppass - DB_NAME: homecomp_test + TEST_DB_HOST: localhost + TEST_DB_PORT: 5432 + TEST_DB_USER: uhomecomp + TEST_DB_PASS: homecomppass + TEST_DB_NAME: homecomp_test services: postgres: diff --git a/tests/integration/fixtures/database.py b/tests/integration/fixtures/database.py index 32c49fc..d07fb8f 100644 --- a/tests/integration/fixtures/database.py +++ b/tests/integration/fixtures/database.py @@ -11,11 +11,15 @@ from app.shared.infrastructure.models import BaseDBModel -# Test database URL - using a separate test database on port 5433 -TEST_DB_URL = os.getenv( - "TEST_DATABASE_URL", - "postgresql+asyncpg://uhomecomp:homecomppass@localhost:5433/homecomp_test", -) +# 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") From 209dfd32817d3fabe987139cb4e26afb7ca0e521 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 18:38:45 +0100 Subject: [PATCH 37/58] Fixing integration tests --- .github/workflows/tests.yml | 7 ++++--- migrations/env.py | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6e97e3b..bbe5754 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,7 +4,7 @@ on: pull_request: branches: [main, dev] types: [opened, synchronize, reopened, ready_for_review] - workflow_dispatch: # Allow manual trigger from GitHub UI + workflow_dispatch: # Allow manual trigger from GitHub UI # Cancel in-progress runs when a new one starts concurrency: @@ -24,7 +24,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: "3.13" - name: Install uv uses: astral-sh/setup-uv@v5 @@ -64,6 +64,7 @@ jobs: runs-on: ubuntu-latest env: + APP_ENV: test TEST_DB_HOST: localhost TEST_DB_PORT: 5432 TEST_DB_USER: uhomecomp @@ -91,7 +92,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: "3.13" - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/migrations/env.py b/migrations/env.py index 3725889..d513605 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -20,6 +20,13 @@ 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}", From c1c4cf619bb758131cc32bab383b01569c2da7ff Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 18:51:14 +0100 Subject: [PATCH 38/58] Another fix for integration tests --- app/shared/infrastructure/database.py | 8 ++++++++ tests/integration/fixtures/database.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index 0db8aa4..36e333f 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -3,12 +3,20 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import declarative_base +# Database configuration - use TEST_ prefixed variables when APP_ENV=test 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+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", diff --git a/tests/integration/fixtures/database.py b/tests/integration/fixtures/database.py index d07fb8f..91febf8 100644 --- a/tests/integration/fixtures/database.py +++ b/tests/integration/fixtures/database.py @@ -13,7 +13,7 @@ # 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_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") From 9eecadfe076ed203659418be03a1ba79b1fd3c49 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 20:21:14 +0100 Subject: [PATCH 39/58] Fixing linting issues and add linting to github actions --- .github/workflows/tests.yml | 29 +++++++ .../auth/application/commands/__init__.py | 2 + .../contracts/get_session_handler_contract.py | 3 +- .../application/dto/get_session_result_dto.py | 5 +- .../dto/login_handler_result_dto.py | 9 +-- .../handlers/get_session_handler.py | 3 +- .../application/queries/get_session_query.py | 5 +- .../contracts/session_repository_contract.py | 5 +- app/context/auth/domain/dto/session_dto.py | 5 +- .../auth/infrastructure/dependencies.py | 12 +-- .../infrastructure/mappers/session_mapper.py | 3 +- .../infrastructure/models/session_model.py | 5 +- .../repositories/session_repository.py | 5 +- app/context/auth/interface/rest/__init__.py | 2 + .../rest/controllers/login_rest_controller.py | 3 +- .../commands/update_credit_card_command.py | 9 +-- ...find_credit_card_by_id_handler_contract.py | 9 +-- ...d_credit_cards_by_user_handler_contract.py | 6 +- .../dto/create_credit_card_result.py | 7 +- .../dto/delete_credit_card_result.py | 5 +- .../dto/update_credit_card_result.py | 9 +-- .../find_credit_card_by_id_handler.py | 3 +- .../credit_card_repository_contract.py | 23 +++--- .../update_credit_card_service_contract.py | 9 +-- .../credit_card/domain/dto/credit_card_dto.py | 7 +- .../services/update_credit_card_service.py | 9 +-- .../infrastructure/dependencies.py | 45 +++++------ .../mappers/credit_card_mapper.py | 3 +- .../models/credit_card_model.py | 3 +- .../repositories/credit_card_repository.py | 67 +++++++---------- .../create_credit_card_controller.py | 6 +- .../delete_credit_card_controller.py | 6 +- .../find_credit_card_controller.py | 18 +++-- .../update_credit_card_controller.py | 6 +- .../schemas/update_credit_card_schema.py | 7 +- .../commands/create_entry_command.py | 3 +- .../application/dto/accept_invite_result.py | 13 ++-- .../dto/create_household_result.py | 9 +-- .../application/dto/decline_invite_result.py | 5 +- .../dto/household_member_response_dto.py | 11 ++- .../application/dto/invite_user_result.py | 13 ++-- .../application/dto/remove_member_result.py | 5 +- .../household_repository_contract.py | 15 ++-- .../household/domain/dto/household_dto.py | 5 +- .../domain/dto/household_member_dto.py | 13 ++-- .../domain/value_objects/household_name.py | 2 +- .../domain/value_objects/household_role.py | 3 +- .../household/infrastructure/dependencies.py | 28 +++---- .../mappers/household_mapper.py | 5 +- .../mappers/household_member_mapper.py | 26 ++----- .../infrastructure/models/household_model.py | 7 +- .../repositories/household_repository.py | 75 +++++-------------- .../household/interface/rest/__init__.py | 2 + .../controllers/accept_invite_controller.py | 6 +- .../create_household_controller.py | 6 +- .../controllers/decline_invite_controller.py | 6 +- .../controllers/invite_user_controller.py | 6 +- .../list_household_invites_controller.py | 10 ++- .../list_user_pending_invites_controller.py | 10 ++- .../controllers/remove_member_controller.py | 6 +- .../schemas/household_member_response.py | 11 ++- .../user/application/dto/find_user_result.py | 13 ++-- .../user/application/handlers/__init__.py | 2 + .../application/queries/find_user_query.py | 5 +- .../user_repository_contract.py | 5 +- app/context/user/domain/dto/user_dto.py | 5 +- .../user/infrastructure/dependencies.py | 6 +- .../infrastructure/mappers/user_mapper.py | 3 +- .../user/infrastructure/models/user_model.py | 5 +- .../infrastructure/repositories/__init__.py | 2 + .../repositories/user_repository.py | 5 +- .../find_account_by_id_handler_contract.py | 3 +- .../application/dto/create_account_result.py | 11 ++- .../application/dto/delete_account_result.py | 5 +- .../application/dto/update_account_result.py | 11 ++- .../handlers/find_account_by_id_handler.py | 3 +- .../user_account_repository_contract.py | 21 +++--- .../domain/dto/user_account_dto.py | 5 +- .../infrastructure/dependencies.py | 22 +++--- .../mappers/user_account_mapper.py | 3 +- .../models/user_account_model.py | 3 +- .../repositories/user_account_repository.py | 24 +++--- .../user_account/interface/rest/__init__.py | 2 + .../controllers/create_account_controller.py | 6 +- .../controllers/delete_account_controller.py | 6 +- .../controllers/find_account_controller.py | 14 ++-- .../controllers/update_account_controller.py | 6 +- .../schemas/create_account_response.py | 7 +- .../domain/value_objects/shared_deleted_at.py | 4 +- app/shared/infrastructure/database.py | 10 +-- .../middleware/session_auth_dependency.py | 26 +++---- justfile | 26 ++++++- .../01770bb99438_create_household_tables.py | 8 +- .../b8067948709f_create_session_table.py | 8 +- ...d756346442c3_create_user_accounts_table.py | 8 +- ...make_deleted_at_timezone_aware_in_user_.py | 11 ++- .../e19a954402db_create_credit_cards_table.py | 8 +- .../f8333e5b2bac_create_user_table.py | 8 +- pyproject.toml | 22 ++++++ scripts/seeds/__init__.py | 6 +- scripts/seeds/seed_credit_cards.py | 2 +- scripts/seeds/seed_user_accounts.py | 2 +- tests/conftest.py | 10 +-- tests/fixtures/auth/repositories.py | 5 +- .../credit_card/credit_card_fixtures.py | 5 +- tests/fixtures/user_account/__init__.py | 10 +-- tests/integration/conftest.py | 5 +- tests/integration/fixtures/__init__.py | 5 +- tests/integration/fixtures/client.py | 4 +- tests/integration/fixtures/database.py | 4 +- tests/integration/test_login_flow.py | 2 +- .../context/auth/domain/auth_email_test.py | 4 +- .../context/auth/domain/auth_password_test.py | 4 +- .../context/auth/domain/auth_user_id_test.py | 4 +- .../auth/domain/failed_login_attempts_test.py | 4 +- .../context/auth/domain/session_token_test.py | 4 +- .../context/auth/domain/throttle_time_test.py | 4 +- .../create_credit_card_handler_test.py | 15 ++-- .../delete_credit_card_handler_test.py | 7 +- .../find_credit_card_by_id_handler_test.py | 3 +- .../find_credit_cards_by_user_handler_test.py | 3 +- .../update_credit_card_handler_test.py | 17 +++-- .../credit_card/domain/card_limit_test.py | 12 +-- .../credit_card/domain/card_used_test.py | 12 +-- .../domain/create_credit_card_service_test.py | 5 +- .../domain/credit_card_account_id_test.py | 4 +- .../domain/credit_card_currency_test.py | 4 +- .../domain/credit_card_deleted_at_test.py | 6 +- .../domain/credit_card_dto_test.py | 7 +- .../credit_card/domain/credit_card_id_test.py | 6 +- .../domain/credit_card_name_test.py | 12 +-- .../domain/credit_card_user_id_test.py | 4 +- .../domain/update_credit_card_service_test.py | 17 +++-- .../infrastructure/credit_card_mapper_test.py | 15 ++-- .../create_household_handler_test.py | 15 ++-- .../application/invite_user_handler_test.py | 19 ++--- .../domain/accept_invite_service_test.py | 7 +- .../domain/create_household_service_test.py | 7 +- .../domain/decline_invite_service_test.py | 7 +- .../household/domain/household_id_test.py | 4 +- .../domain/household_member_id_test.py | 4 +- .../household/domain/household_name_test.py | 4 +- .../household/domain/household_role_test.py | 4 +- .../domain/household_user_id_test.py | 4 +- .../domain/household_user_name_test.py | 4 +- .../domain/invite_user_service_test.py | 15 ++-- .../domain/remove_member_service_test.py | 15 ++-- .../domain/revoke_invite_service_test.py | 13 ++-- .../infrastructure/household_mapper_test.py | 9 ++- .../household_member_mapper_test.py | 20 ++--- .../create_account_handler_test.py | 19 ++--- .../delete_account_handler_test.py | 12 +-- .../find_account_by_id_handler_test.py | 7 +- .../find_accounts_by_user_handler_test.py | 7 +- .../update_account_handler_test.py | 21 +++--- .../user_account/domain/account_name_test.py | 4 +- .../domain/create_account_service_test.py | 9 ++- .../domain/update_account_service_test.py | 17 +++-- .../domain/user_account_balance_test.py | 6 +- .../domain/user_account_currency_test.py | 4 +- .../domain/user_account_deleted_at_test.py | 6 +- .../domain/user_account_dto_test.py | 18 +++-- .../domain/user_account_id_test.py | 4 +- .../domain/user_account_user_id_test.py | 4 +- .../user_account_mapper_test.py | 25 ++++--- 165 files changed, 808 insertions(+), 735 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bbe5754..4781e60 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,6 +12,35 @@ concurrency: 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' diff --git a/app/context/auth/application/commands/__init__.py b/app/context/auth/application/commands/__init__.py index eb44419..d4731e0 100644 --- a/app/context/auth/application/commands/__init__.py +++ b/app/context/auth/application/commands/__init__.py @@ -1 +1,3 @@ from .login_command import LoginCommand + +__all__ = ["LoginCommand"] diff --git a/app/context/auth/application/contracts/get_session_handler_contract.py b/app/context/auth/application/contracts/get_session_handler_contract.py index 49b24e6..dafc6c6 100644 --- a/app/context/auth/application/contracts/get_session_handler_contract.py +++ b/app/context/auth/application/contracts/get_session_handler_contract.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.auth.application.dto.get_session_result_dto import GetSessionResultDTO from app.context.auth.application.queries import GetSessionQuery @@ -7,5 +6,5 @@ class GetSessionHandlerContract(ABC): @abstractmethod - async def handle(self, query: GetSessionQuery) -> Optional[GetSessionResultDTO]: + async def handle(self, query: GetSessionQuery) -> GetSessionResultDTO | None: pass diff --git a/app/context/auth/application/dto/get_session_result_dto.py b/app/context/auth/application/dto/get_session_result_dto.py index 9df8fc0..2583d9e 100644 --- a/app/context/auth/application/dto/get_session_result_dto.py +++ b/app/context/auth/application/dto/get_session_result_dto.py @@ -1,10 +1,9 @@ from dataclasses import dataclass -from typing import Optional @dataclass(frozen=True) class GetSessionResultDTO: user_id: int - token: Optional[str] + token: str | None failed_attempts: int - blocked_until: Optional[str] + 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 index fbc245a..bc860c1 100644 --- a/app/context/auth/application/dto/login_handler_result_dto.py +++ b/app/context/auth/application/dto/login_handler_result_dto.py @@ -1,7 +1,6 @@ from dataclasses import dataclass from datetime import datetime from enum import Enum -from typing import Optional class LoginHandlerResultStatus(Enum): @@ -14,7 +13,7 @@ class LoginHandlerResultStatus(Enum): @dataclass(frozen=True) class LoginHandlerResultDTO: status: LoginHandlerResultStatus - token: Optional[str] = None - user_id: Optional[int] = None - error_msg: Optional[str] = None - retry_after: Optional[datetime] = None + 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/get_session_handler.py b/app/context/auth/application/handlers/get_session_handler.py index db9db02..a55755a 100644 --- a/app/context/auth/application/handlers/get_session_handler.py +++ b/app/context/auth/application/handlers/get_session_handler.py @@ -1,4 +1,3 @@ -from typing import Optional from app.context.auth.application.contracts import GetSessionHandlerContract from app.context.auth.application.dto.get_session_result_dto import GetSessionResultDTO @@ -13,7 +12,7 @@ class GetSessionHandler(GetSessionHandlerContract): def __init__(self, session_repo: SessionRepositoryContract): self._session_repo = session_repo - async def handle(self, query: GetSessionQuery) -> Optional[GetSessionResultDTO]: + 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, diff --git a/app/context/auth/application/queries/get_session_query.py b/app/context/auth/application/queries/get_session_query.py index ce803d5..9d29e84 100644 --- a/app/context/auth/application/queries/get_session_query.py +++ b/app/context/auth/application/queries/get_session_query.py @@ -1,8 +1,7 @@ from dataclasses import dataclass -from typing import Optional @dataclass(frozen=True) class GetSessionQuery: - user_id: Optional[int] = None - token: Optional[str] = None + user_id: int | None = None + token: str | None = None diff --git a/app/context/auth/domain/contracts/session_repository_contract.py b/app/context/auth/domain/contracts/session_repository_contract.py index 1565769..675ab71 100644 --- a/app/context/auth/domain/contracts/session_repository_contract.py +++ b/app/context/auth/domain/contracts/session_repository_contract.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.auth.domain.dto.session_dto import SessionDTO from app.context.auth.domain.value_objects import ( @@ -11,8 +10,8 @@ class SessionRepositoryContract(ABC): @abstractmethod async def getSession( - self, user_id: Optional[AuthUserID] = None, token: Optional[SessionToken] = None - ) -> Optional[SessionDTO]: + self, user_id: AuthUserID | None = None, token: SessionToken | None = None + ) -> SessionDTO | None: pass @abstractmethod diff --git a/app/context/auth/domain/dto/session_dto.py b/app/context/auth/domain/dto/session_dto.py index 27f097f..a5e6732 100644 --- a/app/context/auth/domain/dto/session_dto.py +++ b/app/context/auth/domain/dto/session_dto.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional from app.context.auth.domain.value_objects import ( AuthUserID, @@ -12,6 +11,6 @@ @dataclass(frozen=True) class SessionDTO: user_id: AuthUserID - token: Optional[SessionToken] + token: SessionToken | None failed_attempts: FailedLoginAttempts - blocked_until: Optional[BlockedTime] + blocked_until: BlockedTime | None diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index ec17f60..f2f2026 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -25,13 +27,13 @@ def get_session_repository( - db: AsyncSession = Depends(get_db), + db: Annotated[AsyncSession, Depends(get_db)], ) -> SessionRepositoryContract: return SessionRepository(db) def get_login_service( - session_repo: SessionRepositoryContract = Depends(get_session_repository), + session_repo: Annotated[SessionRepositoryContract, Depends(get_session_repository)], ) -> LoginServiceContract: """ LoginService dependency injection @@ -40,14 +42,14 @@ def get_login_service( def get_session_handler( - session_repo: SessionRepository = Depends(get_session_repository), + session_repo: Annotated[SessionRepository, Depends(get_session_repository)], ) -> GetSessionHandlerContract: return GetSessionHandler(session_repo) def get_login_handler( - user_query_handler: FindUserHandlerContract = Depends(get_find_user_query_handler), - login_service: LoginServiceContract = Depends(get_login_service), + user_query_handler: Annotated[FindUserHandlerContract, Depends(get_find_user_query_handler)], + login_service: Annotated[LoginServiceContract, Depends(get_login_service)], ) -> LoginHandlerContract: """ LoginHandler dependency injection diff --git a/app/context/auth/infrastructure/mappers/session_mapper.py b/app/context/auth/infrastructure/mappers/session_mapper.py index 4b61617..567c070 100644 --- a/app/context/auth/infrastructure/mappers/session_mapper.py +++ b/app/context/auth/infrastructure/mappers/session_mapper.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional from app.context.auth.domain.dto import SessionDTO from app.context.auth.domain.value_objects import ( @@ -14,7 +13,7 @@ @dataclass(frozen=True) class SessionMapper: @staticmethod - def toDTO(model: Optional[SessionModel]) -> Optional[SessionDTO]: + def toDTO(model: SessionModel | None) -> SessionDTO | None: if model is None: return None diff --git a/app/context/auth/infrastructure/models/session_model.py b/app/context/auth/infrastructure/models/session_model.py index 3dffd36..4e6ca33 100644 --- a/app/context/auth/infrastructure/models/session_model.py +++ b/app/context/auth/infrastructure/models/session_model.py @@ -1,5 +1,4 @@ from datetime import datetime -from typing import Optional from sqlalchemy import DateTime, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column @@ -13,6 +12,6 @@ class SessionModel(BaseDBModel): user_id: Mapped[int] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True ) - token: Mapped[Optional[str]] = mapped_column(String(100), unique=True, index=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[Optional[datetime]] = mapped_column(DateTime, nullable=True) + blocked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) diff --git a/app/context/auth/infrastructure/repositories/session_repository.py b/app/context/auth/infrastructure/repositories/session_repository.py index 9485e20..249d7c0 100644 --- a/app/context/auth/infrastructure/repositories/session_repository.py +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -1,4 +1,3 @@ -from typing import Optional from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -17,8 +16,8 @@ def __init__(self, db: AsyncSession): self._db = db async def getSession( - self, user_id: Optional[AuthUserID] = None, token: Optional[SessionToken] = None - ) -> Optional[SessionDTO]: + 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) diff --git a/app/context/auth/interface/rest/__init__.py b/app/context/auth/interface/rest/__init__.py index af14aba..05f0b83 100644 --- a/app/context/auth/interface/rest/__init__.py +++ b/app/context/auth/interface/rest/__init__.py @@ -1 +1,3 @@ from .routes import auth_routes + +__all__ = ["auth_routes"] diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 09f1f2d..6ea7bac 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -1,4 +1,5 @@ from os import getenv +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Response @@ -15,7 +16,7 @@ async def login( response: Response, request: LoginRequest, - handler: LoginHandlerContract = Depends(get_login_handler), + handler: Annotated[LoginHandlerContract, Depends(get_login_handler)], ): """User login endpoint""" login_result = await handler.handle( 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 index 0fababd..4d79407 100644 --- a/app/context/credit_card/application/commands/update_credit_card_command.py +++ b/app/context/credit_card/application/commands/update_credit_card_command.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional @dataclass(frozen=True) @@ -8,7 +7,7 @@ class UpdateCreditCardCommand: credit_card_id: int user_id: int - name: Optional[str] = None - limit: Optional[float] = None - used: Optional[float] = None - currency: Optional[str] = None + 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/find_credit_card_by_id_handler_contract.py b/app/context/credit_card/application/contracts/find_credit_card_by_id_handler_contract.py index 1ee779d..8211eb1 100644 --- 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 @@ -1,12 +1,11 @@ from abc import ABC, abstractmethod -from typing import Optional -from app.context.credit_card.application.queries.find_credit_card_by_id_query import ( - FindCreditCardByIdQuery, -) 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): @@ -15,6 +14,6 @@ class FindCreditCardByIdHandlerContract(ABC): @abstractmethod async def handle( self, query: FindCreditCardByIdQuery - ) -> Optional[CreditCardResponseDTO]: + ) -> 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 index ee38778..7ed5438 100644 --- 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 @@ -1,11 +1,11 @@ from abc import ABC, abstractmethod -from app.context.credit_card.application.queries.find_credit_cards_by_user_query import ( - FindCreditCardsByUserQuery, -) 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): 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 index 914dade..e7747fc 100644 --- a/app/context/credit_card/application/dto/create_credit_card_result.py +++ b/app/context/credit_card/application/dto/create_credit_card_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class CreateCreditCardErrorCode(str, Enum): @@ -16,8 +15,8 @@ class CreateCreditCardResult: """Result of credit card creation operation""" # Success fields - populated when operation succeeds - credit_card_id: Optional[int] = None + credit_card_id: int | None = None # Error fields - populated when operation fails - error_code: Optional[CreateCreditCardErrorCode] = None - error_message: Optional[str] = None + error_code: CreateCreditCardErrorCode | None = None + error_message: str | None = None 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 index 5cf1643..328f520 100644 --- a/app/context/credit_card/application/dto/delete_credit_card_result.py +++ b/app/context/credit_card/application/dto/delete_credit_card_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class DeleteCreditCardErrorCode(str, Enum): @@ -15,5 +14,5 @@ class DeleteCreditCardResult: """Result of credit card deletion operation""" success: bool = False - error_code: Optional[DeleteCreditCardErrorCode] = None - error_message: Optional[str] = None + 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 index 33d7f59..83fc957 100644 --- a/app/context/credit_card/application/dto/update_credit_card_result.py +++ b/app/context/credit_card/application/dto/update_credit_card_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class UpdateCreditCardErrorCode(str, Enum): @@ -17,9 +16,9 @@ class UpdateCreditCardResult: """Result of credit card update operation""" # Success fields - populated when operation succeeds - credit_card_id: Optional[int] = None - credit_card_name: Optional[str] = None + credit_card_id: int | None = None + credit_card_name: str | None = None # Error fields - populated when operation fails - error_code: Optional[UpdateCreditCardErrorCode] = None - error_message: Optional[str] = None + error_code: UpdateCreditCardErrorCode | None = None + error_message: str | None = None 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 index f8807e8..a627372 100644 --- 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 @@ -1,4 +1,3 @@ -from typing import Optional from app.context.credit_card.application.contracts import ( FindCreditCardByIdHandlerContract, @@ -19,7 +18,7 @@ def __init__(self, repository: CreditCardRepositoryContract): async def handle( self, query: FindCreditCardByIdQuery - ) -> Optional[CreditCardResponseDTO]: + ) -> CreditCardResponseDTO | None: """Execute the find credit card by ID query""" # Convert query primitives to value objects and find card for user 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 index 3343d8c..aab4ca7 100644 --- 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 @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO from app.context.credit_card.domain.value_objects import CreditCardUserID @@ -32,11 +31,11 @@ async def save_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: @abstractmethod async def find_credit_card( self, - card_id: Optional[CreditCardID] = None, - user_id: Optional[CreditCardUserID] = None, - name: Optional[CreditCardName] = None, - only_active: Optional[bool] = True, - ) -> Optional[CreditCardDTO]: + 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) @@ -55,10 +54,10 @@ async def find_credit_card( async def find_user_credit_cards( self, user_id: CreditCardUserID, - card_id: Optional[CreditCardID] = None, - name: Optional[CreditCardName] = None, - only_active: Optional[bool] = True, - ) -> Optional[list[CreditCardDTO]]: + 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) @@ -78,8 +77,8 @@ async def find_user_credit_card_by_id( self, user_id: CreditCardUserID, card_id: CreditCardID, - only_active: Optional[bool] = True, - ) -> Optional[CreditCardDTO]: + only_active: bool | None = True, + ) -> CreditCardDTO | None: """ Find a specific credit card by ID for a user 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 index 10fee4f..e8e0e8f 100644 --- 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 @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.credit_card.domain.dto.credit_card_dto import CreditCardDTO from app.context.credit_card.domain.value_objects import ( @@ -22,10 +21,10 @@ async def update_credit_card( self, credit_card_id: CreditCardID, user_id: CreditCardUserID, - name: Optional[CreditCardName] = None, - limit: Optional[CardLimit] = None, - used: Optional[CardUsed] = None, - currency: Optional[CreditCardCurrency] = None, + 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/credit_card_dto.py b/app/context/credit_card/domain/dto/credit_card_dto.py index feaef0f..68a78e2 100644 --- a/app/context/credit_card/domain/dto/credit_card_dto.py +++ b/app/context/credit_card/domain/dto/credit_card_dto.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional from app.context.credit_card.domain.value_objects import ( CreditCardAccountID, @@ -24,9 +23,9 @@ class CreditCardDTO: name: CreditCardName currency: CreditCardCurrency limit: CardLimit - used: Optional[CardUsed] = None - credit_card_id: Optional[CreditCardID] = None - deleted_at: Optional[CreditCardDeletedAt] = None + used: CardUsed | None = None + credit_card_id: CreditCardID | None = None + deleted_at: CreditCardDeletedAt | None = None @property def is_deleted(self) -> bool: 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 index 111e9c0..b2023de 100644 --- a/app/context/credit_card/domain/services/update_credit_card_service.py +++ b/app/context/credit_card/domain/services/update_credit_card_service.py @@ -1,4 +1,3 @@ -from typing import Optional from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( CreditCardRepositoryContract, @@ -35,10 +34,10 @@ async def update_credit_card( self, credit_card_id: CreditCardID, user_id: CreditCardUserID, - name: Optional[CreditCardName] = None, - limit: Optional[CardLimit] = None, - used: Optional[CardUsed] = None, - currency: Optional[CreditCardCurrency] = None, + name: CreditCardName | None = None, + limit: CardLimit | None = None, + used: CardUsed | None = None, + currency: CreditCardCurrency | None = None, ) -> CreditCardDTO: """Update an existing credit card with validation""" diff --git a/app/context/credit_card/infrastructure/dependencies.py b/app/context/credit_card/infrastructure/dependencies.py index e46c943..cd083a4 100644 --- a/app/context/credit_card/infrastructure/dependencies.py +++ b/app/context/credit_card/infrastructure/dependencies.py @@ -1,22 +1,11 @@ +from typing import Annotated + from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession -from app.shared.infrastructure.database import get_db -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.context.credit_card.application.contracts.create_credit_card_handler_contract import ( CreateCreditCardHandlerContract, ) -from app.context.credit_card.application.contracts.update_credit_card_handler_contract import ( - UpdateCreditCardHandlerContract, -) from app.context.credit_card.application.contracts.delete_credit_card_handler_contract import ( DeleteCreditCardHandlerContract, ) @@ -26,7 +15,19 @@ 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.infrastructure.database import get_db # ───────────────────────────────────────────────────────────────── # REPOSITORY @@ -34,7 +35,7 @@ def get_credit_card_repository( - db: AsyncSession = Depends(get_db), + db: Annotated[AsyncSession, Depends(get_db)], ) -> CreditCardRepositoryContract: """CreditCardRepository dependency injection""" from app.context.credit_card.infrastructure.repositories.credit_card_repository import ( @@ -50,7 +51,7 @@ def get_credit_card_repository( def get_create_credit_card_service( - card_repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), + card_repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], ) -> CreateCreditCardServiceContract: """CreateCreditCardService dependency injection""" from app.context.credit_card.domain.services.create_credit_card_service import ( @@ -61,7 +62,7 @@ def get_create_credit_card_service( def get_create_credit_card_handler( - service: CreateCreditCardServiceContract = Depends(get_create_credit_card_service), + service: Annotated[CreateCreditCardServiceContract, Depends(get_create_credit_card_service)], ) -> CreateCreditCardHandlerContract: """CreateCreditCardHandler dependency injection""" from app.context.credit_card.application.handlers.create_credit_card_handler import ( @@ -72,7 +73,7 @@ def get_create_credit_card_handler( def get_update_credit_card_service( - repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], ) -> UpdateCreditCardServiceContract: """UpdateCreditCardService dependency injection""" from app.context.credit_card.domain.services.update_credit_card_service import ( @@ -83,7 +84,7 @@ def get_update_credit_card_service( def get_update_credit_card_handler( - service: UpdateCreditCardServiceContract = Depends(get_update_credit_card_service), + service: Annotated[UpdateCreditCardServiceContract, Depends(get_update_credit_card_service)], ) -> UpdateCreditCardHandlerContract: """UpdateCreditCardHandler dependency injection""" from app.context.credit_card.application.handlers.update_credit_card_handler import ( @@ -94,7 +95,7 @@ def get_update_credit_card_handler( def get_delete_credit_card_handler( - repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], ) -> DeleteCreditCardHandlerContract: """DeleteCreditCardHandler dependency injection""" from app.context.credit_card.application.handlers.delete_credit_card_handler import ( @@ -110,7 +111,7 @@ def get_delete_credit_card_handler( def get_find_credit_card_by_id_handler( - repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], ) -> FindCreditCardByIdHandlerContract: """FindCreditCardByIdHandler dependency injection""" from app.context.credit_card.application.handlers.find_credit_card_by_id_handler import ( @@ -121,7 +122,7 @@ def get_find_credit_card_by_id_handler( def get_find_credit_cards_by_user_handler( - repository: CreditCardRepositoryContract = Depends(get_credit_card_repository), + repository: Annotated[CreditCardRepositoryContract, Depends(get_credit_card_repository)], ) -> FindCreditCardsByUserHandlerContract: """FindCreditCardsByUserHandler dependency injection""" from app.context.credit_card.application.handlers.find_credit_cards_by_user_handler import ( diff --git a/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py index 16a9d76..2a65066 100644 --- a/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py +++ b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py @@ -1,4 +1,3 @@ -from typing import Optional from app.context.credit_card.domain.dto import CreditCardDTO from app.context.credit_card.domain.exceptions import CreditCardMapperError @@ -19,7 +18,7 @@ class CreditCardMapper: """Mapper for converting between CreditCardModel and CreditCardDTO""" @staticmethod - def to_dto(model: Optional[CreditCardModel]) -> Optional[CreditCardDTO]: + def to_dto(model: CreditCardModel | None) -> CreditCardDTO | None: """Convert database model to domain DTO""" return ( CreditCardDTO( diff --git a/app/context/credit_card/infrastructure/models/credit_card_model.py b/app/context/credit_card/infrastructure/models/credit_card_model.py index d3b48bc..ed38380 100644 --- a/app/context/credit_card/infrastructure/models/credit_card_model.py +++ b/app/context/credit_card/infrastructure/models/credit_card_model.py @@ -1,6 +1,5 @@ from datetime import datetime from decimal import Decimal -from typing import Optional from sqlalchemy import DECIMAL, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column @@ -22,6 +21,6 @@ class CreditCardModel(BaseDBModel): 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[Optional[datetime]] = mapped_column( + deleted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) diff --git a/app/context/credit_card/infrastructure/repositories/credit_card_repository.py b/app/context/credit_card/infrastructure/repositories/credit_card_repository.py index cefe50b..fd5fc55 100644 --- a/app/context/credit_card/infrastructure/repositories/credit_card_repository.py +++ b/app/context/credit_card/infrastructure/repositories/credit_card_repository.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, cast +from typing import Any, cast from sqlalchemy import select, update from sqlalchemy.engine import CursorResult @@ -45,17 +45,15 @@ async def save_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: ) from e except SQLAlchemyError as e: await self._db.rollback() - raise CreditCardDatabaseError( - f"Database error while saving credit card: {str(e)}" - ) from e + raise CreditCardDatabaseError(f"Database error while saving credit card: {str(e)}") from e async def find_credit_card( self, - card_id: Optional[CreditCardID] = None, - user_id: Optional[CreditCardUserID] = None, - name: Optional[CreditCardName] = None, - only_active: Optional[bool] = True, - ) -> Optional[CreditCardDTO]: + 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) @@ -77,22 +75,18 @@ async def find_credit_card( 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 + 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: Optional[CreditCardID] = None, - name: Optional[CreditCardName] = None, - only_active: Optional[bool] = True, - ) -> Optional[list[CreditCardDTO]]: + 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 - ) + stmt = select(CreditCardModel).where(CreditCardModel.user_id == user_id.value) if only_active: stmt = stmt.where(CreditCardModel.deleted_at.is_(None)) @@ -103,22 +97,16 @@ async def find_user_credit_cards( 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 [] - ) + 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 + 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: Optional[bool] = True, - ) -> Optional[CreditCardDTO]: + only_active: bool | None = True, + ) -> CreditCardDTO | None: """Find a specific credit card by ID for a user""" try: stmt = select(CreditCardModel).where( @@ -131,9 +119,7 @@ async def find_user_credit_card_by_id( 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 + 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""" @@ -171,13 +157,9 @@ async def update_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: ) from e except SQLAlchemyError as e: await self._db.rollback() - raise CreditCardDatabaseError( - f"Database error while updating credit card: {str(e)}" - ) from e + 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: + 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 @@ -200,8 +182,9 @@ async def delete_credit_card( await self._db.commit() return result.rowcount > 0 - except Exception or SQLAlchemyError as e: + except SQLAlchemyError as e: await self._db.rollback() - raise CreditCardDatabaseError( - f"Database error while deleting credit card: {str(e)}" - ) from e + 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/rest/controllers/create_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/create_credit_card_controller.py index 5fe1cb8..9e167a1 100644 --- 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 @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.credit_card.application.commands import CreateCreditCardCommand @@ -23,8 +25,8 @@ @router.post("", response_model=CreateCreditCardResponse, status_code=201) async def create_credit_card( request: CreateCreditCardRequest, - handler: CreateCreditCardHandlerContract = Depends(get_create_credit_card_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[CreateCreditCardHandlerContract, Depends(get_create_credit_card_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Create a new credit card""" command = CreateCreditCardCommand( 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 index 4c97efa..d22957f 100644 --- 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 @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException, status from app.context.credit_card.application.commands import DeleteCreditCardCommand @@ -16,8 +18,8 @@ @router.delete("/{credit_card_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_credit_card( credit_card_id: int, - handler: DeleteCreditCardHandlerContract = Depends(get_delete_credit_card_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[DeleteCreditCardHandlerContract, Depends(get_delete_credit_card_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Delete a credit card (soft delete)""" command = DeleteCreditCardCommand( diff --git a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py index 8c65f2d..eb57e36 100644 --- a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py +++ b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.credit_card.application.contracts import ( @@ -23,10 +25,10 @@ @router.get("/{credit_card_id}", response_model=CreditCardResponse) async def get_credit_card( credit_card_id: int, - handler: FindCreditCardByIdHandlerContract = Depends( - get_find_credit_card_by_id_handler - ), - user_id: int = Depends(get_current_user_id), + handler: Annotated[ + FindCreditCardByIdHandlerContract, Depends(get_find_credit_card_by_id_handler) + ], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Get a credit card by ID""" query = FindCreditCardByIdQuery( @@ -55,10 +57,10 @@ async def get_credit_card( @router.get("", response_model=list[CreditCardResponse]) async def get_credit_cards( - handler: FindCreditCardsByUserHandlerContract = Depends( - get_find_credit_cards_by_user_handler - ), - user_id: int = Depends(get_current_user_id), + handler: Annotated[ + FindCreditCardsByUserHandlerContract, Depends(get_find_credit_cards_by_user_handler) + ], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Get all credit cards for the current user""" query = FindCreditCardsByUserQuery(user_id=user_id) 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 index da4902a..3fac160 100644 --- 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 @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Annotated from fastapi import APIRouter, Depends, HTTPException @@ -25,8 +25,8 @@ async def update_credit_card( credit_card_id: int, request: UpdateCreditCardRequest, - handler: UpdateCreditCardHandlerContract = Depends(get_update_credit_card_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[UpdateCreditCardHandlerContract, Depends(get_update_credit_card_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Update an existing credit card""" command = UpdateCreditCardCommand( 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 index cd2d888..f4f2c4d 100644 --- a/app/context/credit_card/interface/schemas/update_credit_card_schema.py +++ b/app/context/credit_card/interface/schemas/update_credit_card_schema.py @@ -1,4 +1,3 @@ -from typing import Optional from pydantic import BaseModel, ConfigDict, Field @@ -6,8 +5,8 @@ class UpdateCreditCardRequest(BaseModel): model_config = ConfigDict(frozen=True) - name: Optional[str] = Field( + name: str | None = Field( None, min_length=3, max_length=100, description="Credit card name" ) - limit: Optional[float] = Field(None, gt=0, description="Credit card limit") - used: Optional[float] = Field(None, ge=0, description="Credit card used amount") + 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/create_entry_command.py b/app/context/entry/application/commands/create_entry_command.py index f0fd659..1e7e02a 100644 --- a/app/context/entry/application/commands/create_entry_command.py +++ b/app/context/entry/application/commands/create_entry_command.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional @dataclass(frozen=True) @@ -9,6 +8,6 @@ class CreateEntryCommand: expense_date: datetime account_id: int category_id: int - household_id: Optional[int] + household_id: int | None amount: float description: str diff --git a/app/context/household/application/dto/accept_invite_result.py b/app/context/household/application/dto/accept_invite_result.py index ec656d5..dfb6b9e 100644 --- a/app/context/household/application/dto/accept_invite_result.py +++ b/app/context/household/application/dto/accept_invite_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class AcceptInviteErrorCode(str, Enum): @@ -16,11 +15,11 @@ class AcceptInviteResult: """Result of accept invitation operation""" # Success fields - populated when operation succeeds - member_id: Optional[int] = None - household_id: Optional[int] = None - user_id: Optional[int] = None - role: Optional[str] = None + 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: Optional[AcceptInviteErrorCode] = None - error_message: Optional[str] = None + 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 index 28bebff..525369f 100644 --- a/app/context/household/application/dto/create_household_result.py +++ b/app/context/household/application/dto/create_household_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class CreateHouseholdErrorCode(str, Enum): @@ -16,9 +15,9 @@ class CreateHouseholdResult: """Result of household creation operation""" # Success fields - populated when operation succeeds - household_id: Optional[int] = None - household_name: Optional[str] = None + household_id: int | None = None + household_name: str | None = None # Error fields - populated when operation fails - error_code: Optional[CreateHouseholdErrorCode] = None - error_message: Optional[str] = None + 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 index 8b4e1b8..2354241 100644 --- a/app/context/household/application/dto/decline_invite_result.py +++ b/app/context/household/application/dto/decline_invite_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class DeclineInviteErrorCode(str, Enum): @@ -18,5 +17,5 @@ class DeclineInviteResult: success: bool = False # Error fields - populated when operation fails - error_code: Optional[DeclineInviteErrorCode] = None - error_message: Optional[str] = None + error_code: DeclineInviteErrorCode | 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 index 2c86e7c..eaa2ef8 100644 --- a/app/context/household/application/dto/household_member_response_dto.py +++ b/app/context/household/application/dto/household_member_response_dto.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional from app.context.household.domain.dto import HouseholdMemberDTO @@ -13,11 +12,11 @@ class HouseholdMemberResponseDTO: household_id: int user_id: int role: str - joined_at: Optional[datetime] - invited_by_user_id: Optional[int] - invited_at: Optional[datetime] - household_name: Optional[str] = None - inviter: Optional[str] = None + 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": diff --git a/app/context/household/application/dto/invite_user_result.py b/app/context/household/application/dto/invite_user_result.py index f3a6ffe..1fe3374 100644 --- a/app/context/household/application/dto/invite_user_result.py +++ b/app/context/household/application/dto/invite_user_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class InviteUserErrorCode(str, Enum): @@ -18,11 +17,11 @@ class InviteUserResult: """Result of user invitation operation""" # Success fields - populated when operation succeeds - member_id: Optional[int] = None - household_id: Optional[int] = None - user_id: Optional[int] = None - role: Optional[str] = None + 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: Optional[InviteUserErrorCode] = None - error_message: Optional[str] = None + error_code: InviteUserErrorCode | 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 index d85fbdd..bbdf075 100644 --- a/app/context/household/application/dto/remove_member_result.py +++ b/app/context/household/application/dto/remove_member_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class RemoveMemberErrorCode(str, Enum): @@ -20,5 +19,5 @@ class RemoveMemberResult: success: bool = False # Error fields - populated when operation fails - error_code: Optional[RemoveMemberErrorCode] = None - error_message: Optional[str] = None + error_code: RemoveMemberErrorCode | None = None + error_message: str | None = None diff --git a/app/context/household/domain/contracts/household_repository_contract.py b/app/context/household/domain/contracts/household_repository_contract.py index c2bf9e1..2e37836 100644 --- a/app/context/household/domain/contracts/household_repository_contract.py +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import List, Optional from app.context.household.domain.dto import HouseholdDTO, HouseholdMemberDTO from app.context.household.domain.value_objects import ( @@ -20,14 +19,14 @@ async def create_household( @abstractmethod async def find_household_by_name( self, name: HouseholdName, user_id: HouseholdUserID - ) -> Optional[HouseholdDTO]: + ) -> HouseholdDTO | None: """Find a household by name for a specific user""" pass @abstractmethod async def find_household_by_id( self, household_id: HouseholdID - ) -> Optional[HouseholdDTO]: + ) -> HouseholdDTO | None: """Find a household by ID""" pass @@ -40,7 +39,7 @@ async def create_member(self, member: HouseholdMemberDTO) -> HouseholdMemberDTO: @abstractmethod async def find_member( self, household_id: HouseholdID, user_id: HouseholdUserID - ) -> Optional[HouseholdMemberDTO]: + ) -> HouseholdMemberDTO | None: """Find the most recent member record for user in household""" pass @@ -61,28 +60,28 @@ async def revoke_or_remove( @abstractmethod async def list_user_households( self, user_id: HouseholdUserID - ) -> List[HouseholdDTO]: + ) -> 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[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[HouseholdMemberDTO]: """List all pending invites for a household""" pass @abstractmethod async def list_user_pending_household_invites( self, user_id: HouseholdUserID - ) -> List[HouseholdMemberDTO]: + ) -> list[HouseholdMemberDTO]: """List user pending invitation to households""" pass diff --git a/app/context/household/domain/dto/household_dto.py b/app/context/household/domain/dto/household_dto.py index d6ae6df..e8d3c60 100644 --- a/app/context/household/domain/dto/household_dto.py +++ b/app/context/household/domain/dto/household_dto.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional from app.context.household.domain.value_objects import ( HouseholdID, @@ -11,7 +10,7 @@ @dataclass(frozen=True) class HouseholdDTO: - household_id: Optional[HouseholdID] + household_id: HouseholdID | None owner_user_id: HouseholdUserID name: HouseholdName - created_at: Optional[datetime] = None + 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 index 81c4ce8..bba90df 100644 --- a/app/context/household/domain/dto/household_member_dto.py +++ b/app/context/household/domain/dto/household_member_dto.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional from app.context.household.domain.value_objects import ( HouseholdID, @@ -16,15 +15,15 @@ class HouseholdMemberDTO: """Domain DTO for household member""" - member_id: Optional[HouseholdMemberID] + member_id: HouseholdMemberID | None household_id: HouseholdID user_id: HouseholdUserID role: HouseholdRole - joined_at: Optional[datetime] = None - invited_by_user_id: Optional[HouseholdUserID] = None - invited_at: Optional[datetime] = None - household_name: Optional[HouseholdName] = None - inviter_username: Optional[SharedUsername] = None + 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: diff --git a/app/context/household/domain/value_objects/household_name.py b/app/context/household/domain/value_objects/household_name.py index 20d7e0d..058268d 100644 --- a/app/context/household/domain/value_objects/household_name.py +++ b/app/context/household/domain/value_objects/household_name.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing_extensions import Self +from typing import Self @dataclass(frozen=True) diff --git a/app/context/household/domain/value_objects/household_role.py b/app/context/household/domain/value_objects/household_role.py index 7511a78..74da14d 100644 --- a/app/context/household/domain/value_objects/household_role.py +++ b/app/context/household/domain/value_objects/household_role.py @@ -1,6 +1,5 @@ from dataclasses import dataclass, field - -from typing_extensions import Self +from typing import Self @dataclass(frozen=True) diff --git a/app/context/household/infrastructure/dependencies.py b/app/context/household/infrastructure/dependencies.py index 76d4023..6d7bd12 100644 --- a/app/context/household/infrastructure/dependencies.py +++ b/app/context/household/infrastructure/dependencies.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -40,81 +42,81 @@ # Repository dependencies def get_household_repository( - db: AsyncSession = Depends(get_db), + db: Annotated[AsyncSession, Depends(get_db)], ) -> HouseholdRepositoryContract: return HouseholdRepository(db) # Service dependencies def get_create_household_service( - household_repository: HouseholdRepositoryContract = Depends(get_household_repository), + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> CreateHouseholdServiceContract: return CreateHouseholdService(household_repository) def get_invite_user_service( - household_repository: HouseholdRepositoryContract = Depends(get_household_repository), + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> InviteUserServiceContract: return InviteUserService(household_repository) def get_accept_invite_service( - household_repository: HouseholdRepositoryContract = Depends(get_household_repository), + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> AcceptInviteServiceContract: return AcceptInviteService(household_repository) def get_decline_invite_service( - household_repository: HouseholdRepositoryContract = Depends(get_household_repository), + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> DeclineInviteServiceContract: return DeclineInviteService(household_repository) def get_remove_member_service( - household_repository: HouseholdRepositoryContract = Depends(get_household_repository), + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> RemoveMemberServiceContract: return RemoveMemberService(household_repository) # Handler dependencies (Commands) def get_create_household_handler( - service: CreateHouseholdServiceContract = Depends(get_create_household_service), + service: Annotated[CreateHouseholdServiceContract, Depends(get_create_household_service)], ) -> CreateHouseholdHandlerContract: return CreateHouseholdHandler(service) def get_invite_user_handler( - service: InviteUserServiceContract = Depends(get_invite_user_service), + service: Annotated[InviteUserServiceContract, Depends(get_invite_user_service)], ) -> InviteUserHandlerContract: return InviteUserHandler(service) def get_accept_invite_handler( - service: AcceptInviteServiceContract = Depends(get_accept_invite_service), + service: Annotated[AcceptInviteServiceContract, Depends(get_accept_invite_service)], ) -> AcceptInviteHandlerContract: return AcceptInviteHandler(service) def get_decline_invite_handler( - service: DeclineInviteServiceContract = Depends(get_decline_invite_service), + service: Annotated[DeclineInviteServiceContract, Depends(get_decline_invite_service)], ) -> DeclineInviteHandlerContract: return DeclineInviteHandler(service) def get_remove_member_handler( - service: RemoveMemberServiceContract = Depends(get_remove_member_service), + service: Annotated[RemoveMemberServiceContract, Depends(get_remove_member_service)], ) -> RemoveMemberHandlerContract: return RemoveMemberHandler(service) # Handler dependencies (Queries) def get_list_household_invites_handler( - repository: HouseholdRepositoryContract = Depends(get_household_repository), + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> ListHouseholdInvitesHandlerContract: return ListHouseholdInvitesHandler(repository) def get_list_user_pending_invites_handler( - repository: HouseholdRepositoryContract = Depends(get_household_repository), + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> ListUserPendingInvitesHandlerContract: return ListUserPendingInvitesHandler(repository) diff --git a/app/context/household/infrastructure/mappers/household_mapper.py b/app/context/household/infrastructure/mappers/household_mapper.py index 8e6dd85..be3ff8a 100644 --- a/app/context/household/infrastructure/mappers/household_mapper.py +++ b/app/context/household/infrastructure/mappers/household_mapper.py @@ -1,4 +1,3 @@ -from typing import Optional from app.context.household.domain.dto import HouseholdDTO from app.context.household.domain.exceptions import HouseholdMapperError @@ -12,7 +11,7 @@ class HouseholdMapper: @staticmethod - def to_dto(model: Optional[HouseholdModel]) -> Optional[HouseholdDTO]: + def to_dto(model: HouseholdModel | None) -> HouseholdDTO | None: """Convert database model to domain DTO""" if model is None: return None @@ -30,7 +29,7 @@ def to_dto(model: Optional[HouseholdModel]) -> Optional[HouseholdDTO]: ) from e @staticmethod - def to_dto_or_fail(model: Optional[HouseholdModel]) -> HouseholdDTO: + def to_dto_or_fail(model: HouseholdModel | None) -> HouseholdDTO: dto = HouseholdMapper.to_dto(model) if not dto: raise HouseholdMapperError("Error mapping HouseholdModel to DTO") diff --git a/app/context/household/infrastructure/mappers/household_member_mapper.py b/app/context/household/infrastructure/mappers/household_member_mapper.py index 8c410dc..afbac86 100644 --- a/app/context/household/infrastructure/mappers/household_member_mapper.py +++ b/app/context/household/infrastructure/mappers/household_member_mapper.py @@ -1,5 +1,3 @@ -from typing import Optional - from app.context.household.domain.dto import HouseholdMemberDTO from app.context.household.domain.exceptions import HouseholdMapperError from app.context.household.domain.value_objects import ( @@ -25,8 +23,8 @@ class HouseholdMemberMapper: @staticmethod def to_dto( model: HouseholdMemberModel, - household_model: Optional[HouseholdModel] = None, - user_model: Optional[UserModel] = None, + household_model: HouseholdModel | None = None, + user_model: UserModel | None = None, ) -> HouseholdMemberDTO: """Convert database model to domain DTO""" return HouseholdMemberDTO( @@ -36,19 +34,11 @@ def to_dto( 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 + 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 - ), + 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 @@ -60,9 +50,7 @@ def to_model(dto: HouseholdMemberDTO) -> HouseholdMemberModel: 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_by_user_id=(dto.invited_by_user_id.value if dto.invited_by_user_id else None), invited_at=dto.invited_at, ) @@ -72,4 +60,4 @@ def to_dto_or_fail(model: HouseholdMemberModel) -> HouseholdMemberDTO: try: return HouseholdMemberMapper.to_dto(model) except Exception as e: - raise HouseholdMapperError(f"Failed to map model to DTO: {str(e)}") + raise HouseholdMapperError(f"Failed to map model to DTO: {str(e)}") from Exception diff --git a/app/context/household/infrastructure/models/household_model.py b/app/context/household/infrastructure/models/household_model.py index 5f3b36c..b6fe11a 100644 --- a/app/context/household/infrastructure/models/household_model.py +++ b/app/context/household/infrastructure/models/household_model.py @@ -1,5 +1,4 @@ from datetime import UTC, datetime -from typing import Optional from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column @@ -34,16 +33,16 @@ class HouseholdMemberModel(BaseDBModel): ) role: Mapped[str] = mapped_column(String(20), nullable=False, default="participant") # NULL = invited (pending), NOT NULL = active member - joined_at: Mapped[Optional[datetime]] = mapped_column( + joined_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) - invited_by_user_id: Mapped[Optional[int]] = mapped_column( + invited_by_user_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("users.id", ondelete="RESTRICT"), nullable=True, default=None, ) - invited_at: Mapped[Optional[datetime]] = mapped_column( + invited_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=lambda: datetime.now(UTC) ) diff --git a/app/context/household/infrastructure/repositories/household_repository.py b/app/context/household/infrastructure/repositories/household_repository.py index 2d3708a..4b60f00 100644 --- a/app/context/household/infrastructure/repositories/household_repository.py +++ b/app/context/household/infrastructure/repositories/household_repository.py @@ -1,5 +1,4 @@ from datetime import UTC, datetime -from typing import List, Optional from sqlalchemy import and_, select from sqlalchemy.exc import IntegrityError @@ -32,9 +31,7 @@ class HouseholdRepository(HouseholdRepositoryContract): def __init__(self, db: AsyncSession): self._db = db - async def create_household( - self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID - ) -> HouseholdDTO: + async def create_household(self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID) -> HouseholdDTO: """Create a new household with the owner stored in the household table""" household_model = HouseholdMapper.to_model(household_dto) @@ -49,14 +46,12 @@ async def create_household( if "uq_households_owner_name" in str(e.orig): raise HouseholdNameAlreadyExistError( f"Household with name '{household_dto.name.value}' already exists for this user" - ) - raise Exception(e) + ) 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 - ) -> Optional[HouseholdDTO]: + 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( @@ -71,9 +66,7 @@ async def find_household_by_name( return HouseholdMapper.to_dto(model) if model else None - async def find_household_by_id( - self, household_id: HouseholdID - ) -> Optional[HouseholdDTO]: + async def find_household_by_id(self, household_id: HouseholdID) -> HouseholdDTO | None: """Find a household by ID""" stmt = select(HouseholdModel).where(HouseholdModel.id == household_id.value) @@ -94,9 +87,7 @@ async def create_member(self, member: HouseholdMemberDTO) -> HouseholdMemberDTO: return HouseholdMemberMapper.to_dto_or_fail(member_model) - async def find_member( - self, household_id: HouseholdID, user_id: HouseholdUserID - ) -> Optional[HouseholdMemberDTO]: + 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) @@ -115,9 +106,7 @@ async def find_member( return HouseholdMemberMapper.to_dto(model) if model else None - async def accept_invite( - self, household_id: HouseholdID, user_id: HouseholdUserID - ) -> HouseholdMemberDTO: + 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 = ( @@ -147,9 +136,7 @@ async def accept_invite( return HouseholdMemberMapper.to_dto_or_fail(member_model) - async def revoke_or_remove( - self, household_id: HouseholdID, user_id: HouseholdUserID - ) -> None: + 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 = ( @@ -172,14 +159,10 @@ async def revoke_or_remove( await self._db.commit() - async def list_user_households( - self, user_id: HouseholdUserID - ) -> List[HouseholdDTO]: + 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( - HouseholdModel.owner_user_id == user_id.value - ) + owner_stmt = select(HouseholdModel).where(HouseholdModel.owner_user_id == user_id.value) # Get households where user is active member member_stmt = ( @@ -209,9 +192,7 @@ async def list_user_households( return [HouseholdMapper.to_dto(h) for h in all_households.values()] - async def list_user_pending_invites( - self, user_id: HouseholdUserID - ) -> List[HouseholdDTO]: + 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) @@ -232,19 +213,13 @@ async def list_user_pending_invites( return [HouseholdMapper.to_dto_or_fail(h) for h in households] - async def list_user_pending_household_invites( - self, user_id: HouseholdUserID - ) -> List[HouseholdMemberDTO]: + 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 - ) + .join(HouseholdModel, HouseholdModel.id == HouseholdMemberModel.household_id) + .join(InviterUser, InviterUser.id == HouseholdMemberModel.invited_by_user_id) .where( and_( HouseholdMemberModel.user_id == user_id.value, @@ -259,16 +234,14 @@ async def list_user_pending_household_invites( # 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_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[HouseholdMemberDTO]: """List all pending invites for a household with household name and inviter username""" # Create alias for the inviter user InviterUser = aliased(UserModel) @@ -276,12 +249,8 @@ async def list_household_pending_invites( # 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 - ) + .join(HouseholdModel, HouseholdModel.id == HouseholdMemberModel.household_id) + .join(InviterUser, InviterUser.id == HouseholdMemberModel.invited_by_user_id) .where( and_( HouseholdMemberModel.household_id == household_id.value, @@ -297,16 +266,12 @@ async def list_household_pending_invites( # 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_dto = HouseholdMemberMapper.to_dto(member_model, household_model, inviter_model) member_list.append(member_dto) return member_list - async def user_has_access( - self, user_id: HouseholdUserID, household_id: HouseholdID - ) -> bool: + 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) diff --git a/app/context/household/interface/rest/__init__.py b/app/context/household/interface/rest/__init__.py index ae6e114..bc3b8e9 100644 --- a/app/context/household/interface/rest/__init__.py +++ b/app/context/household/interface/rest/__init__.py @@ -1 +1,3 @@ from .routes import household_routes + +__all__ = ["household_routes"] diff --git a/app/context/household/interface/rest/controllers/accept_invite_controller.py b/app/context/household/interface/rest/controllers/accept_invite_controller.py index 06cf7b1..a16a599 100644 --- a/app/context/household/interface/rest/controllers/accept_invite_controller.py +++ b/app/context/household/interface/rest/controllers/accept_invite_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.household.application.commands import AcceptInviteCommand @@ -13,8 +15,8 @@ @router.post("/{household_id}/invites/accept", status_code=200) async def accept_invite( household_id: int, - handler: AcceptInviteHandlerContract = Depends(get_accept_invite_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[AcceptInviteHandlerContract, Depends(get_accept_invite_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ) -> AcceptInviteResponse: """Accept a household invitation""" diff --git a/app/context/household/interface/rest/controllers/create_household_controller.py b/app/context/household/interface/rest/controllers/create_household_controller.py index 6a46ad5..b7d88d7 100644 --- a/app/context/household/interface/rest/controllers/create_household_controller.py +++ b/app/context/household/interface/rest/controllers/create_household_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.household.application.commands import CreateHouseholdCommand @@ -18,8 +20,8 @@ @router.post("/", status_code=201) async def create_household( request: CreateHouseholdRequest, - handler: CreateHouseholdHandlerContract = Depends(get_create_household_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[CreateHouseholdHandlerContract, Depends(get_create_household_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ) -> CreateHouseholdResponse: """Create a new household""" diff --git a/app/context/household/interface/rest/controllers/decline_invite_controller.py b/app/context/household/interface/rest/controllers/decline_invite_controller.py index 8a26fdb..d75461f 100644 --- a/app/context/household/interface/rest/controllers/decline_invite_controller.py +++ b/app/context/household/interface/rest/controllers/decline_invite_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.household.application.commands import DeclineInviteCommand @@ -13,8 +15,8 @@ @router.post("/{household_id}/invites/decline", status_code=200) async def decline_invite( household_id: int, - handler: DeclineInviteHandlerContract = Depends(get_decline_invite_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[DeclineInviteHandlerContract, Depends(get_decline_invite_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ) -> DeclineInviteResponse: """Decline a household invitation""" diff --git a/app/context/household/interface/rest/controllers/invite_user_controller.py b/app/context/household/interface/rest/controllers/invite_user_controller.py index dac1736..d1a4909 100644 --- a/app/context/household/interface/rest/controllers/invite_user_controller.py +++ b/app/context/household/interface/rest/controllers/invite_user_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.household.application.commands import InviteUserCommand @@ -17,8 +19,8 @@ async def invite_user( household_id: int, request: InviteUserRequest, - handler: InviteUserHandlerContract = Depends(get_invite_user_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[InviteUserHandlerContract, Depends(get_invite_user_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ) -> InviteUserResponse: """Invite a user to a household""" 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 index 03796f1..0c0bcae 100644 --- a/app/context/household/interface/rest/controllers/list_household_invites_controller.py +++ b/app/context/household/interface/rest/controllers/list_household_invites_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends from app.context.household.application.contracts import ( @@ -16,10 +18,10 @@ @router.get("/{household_id}/invites", status_code=200) async def list_household_invites( household_id: int, - handler: ListHouseholdInvitesHandlerContract = Depends( - get_list_household_invites_handler - ), - user_id: int = Depends(get_current_user_id), + handler: Annotated[ + ListHouseholdInvitesHandlerContract, Depends(get_list_household_invites_handler) + ], + user_id: Annotated[int, Depends(get_current_user_id)], ) -> list[HouseholdMemberResponse]: """List pending invitations for a household""" 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 index 2ef1ab8..d501605 100644 --- 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 @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends from app.context.household.application.contracts import ( @@ -15,10 +17,10 @@ @router.get("/invites/pending", status_code=200) async def list_user_pending_invites( - handler: ListUserPendingInvitesHandlerContract = Depends( - get_list_user_pending_invites_handler - ), - user_id: int = Depends(get_current_user_id), + handler: Annotated[ + ListUserPendingInvitesHandlerContract, Depends(get_list_user_pending_invites_handler) + ], + user_id: Annotated[int, Depends(get_current_user_id)], ) -> list[HouseholdMemberResponse]: """List all pending invitations for the authenticated user""" diff --git a/app/context/household/interface/rest/controllers/remove_member_controller.py b/app/context/household/interface/rest/controllers/remove_member_controller.py index 7b0f4ab..c617d2a 100644 --- a/app/context/household/interface/rest/controllers/remove_member_controller.py +++ b/app/context/household/interface/rest/controllers/remove_member_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.household.application.commands import RemoveMemberCommand @@ -14,8 +16,8 @@ async def remove_member( household_id: int, member_user_id: int, - handler: RemoveMemberHandlerContract = Depends(get_remove_member_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[RemoveMemberHandlerContract, Depends(get_remove_member_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ) -> RemoveMemberResponse: """Remove a member from a household""" diff --git a/app/context/household/interface/schemas/household_member_response.py b/app/context/household/interface/schemas/household_member_response.py index 4e37e95..ea1d9ae 100644 --- a/app/context/household/interface/schemas/household_member_response.py +++ b/app/context/household/interface/schemas/household_member_response.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from datetime import datetime -from typing import Optional @dataclass(frozen=True) @@ -11,8 +10,8 @@ class HouseholdMemberResponse: household_id: int user_id: int role: str - joined_at: Optional[datetime] - invited_by_user_id: Optional[int] - invited_at: Optional[datetime] - household_name: Optional[str] - inviter: Optional[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/user/application/dto/find_user_result.py b/app/context/user/application/dto/find_user_result.py index 5c2e6e3..d23e5b3 100644 --- a/app/context/user/application/dto/find_user_result.py +++ b/app/context/user/application/dto/find_user_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class FindUserErrorCode(str, Enum): @@ -17,11 +16,11 @@ class FindUserResult: """Result of find user operation""" # Success fields - populated when operation succeeds - user_id: Optional[int] = None - email: Optional[str] = None - username: Optional[str] = None - password: Optional[str] = None + 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: Optional[FindUserErrorCode] = None - error_message: Optional[str] = None + error_code: FindUserErrorCode | None = None + error_message: str | None = None diff --git a/app/context/user/application/handlers/__init__.py b/app/context/user/application/handlers/__init__.py index 130dd41..06e2251 100644 --- a/app/context/user/application/handlers/__init__.py +++ b/app/context/user/application/handlers/__init__.py @@ -1 +1,3 @@ from .find_user_handler import FindUserHandler + +__all__ = ["FindUserHandler"] diff --git a/app/context/user/application/queries/find_user_query.py b/app/context/user/application/queries/find_user_query.py index f3f881e..469fe4f 100644 --- a/app/context/user/application/queries/find_user_query.py +++ b/app/context/user/application/queries/find_user_query.py @@ -1,8 +1,7 @@ from dataclasses import dataclass -from typing import Optional @dataclass(frozen=True) class FindUserQuery: - user_id: Optional[int] = None - email: Optional[str] = None + user_id: int | None = None + email: str | None = None diff --git a/app/context/user/domain/contracts/infrastructure/user_repository_contract.py b/app/context/user/domain/contracts/infrastructure/user_repository_contract.py index 65d6eb0..443e1c9 100644 --- a/app/context/user/domain/contracts/infrastructure/user_repository_contract.py +++ b/app/context/user/domain/contracts/infrastructure/user_repository_contract.py @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.user.domain.dto import UserDTO from app.context.user.domain.value_objects import UserEmail, UserID @@ -10,8 +9,8 @@ class UserRepositoryContract(ABC): @abstractmethod async def find_user( - self, user_id: Optional[UserID] = None, email: Optional[UserEmail] = None - ) -> Optional[UserDTO]: + self, user_id: UserID | None = None, email: UserEmail | None = None + ) -> UserDTO | None: """ Find a user by ID or email. diff --git a/app/context/user/domain/dto/user_dto.py b/app/context/user/domain/dto/user_dto.py index efd2c87..800dc17 100644 --- a/app/context/user/domain/dto/user_dto.py +++ b/app/context/user/domain/dto/user_dto.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional from app.context.user.domain.value_objects import ( UserDeletedAt, @@ -17,8 +16,8 @@ class UserDTO: user_id: UserID email: UserEmail password: UserPassword - username: Optional[UserName] = None - deleted_at: Optional[UserDeletedAt] = None + username: UserName | None = None + deleted_at: UserDeletedAt | None = None @property def is_deleted(self) -> bool: diff --git a/app/context/user/infrastructure/dependencies.py b/app/context/user/infrastructure/dependencies.py index 1e49560..7ffd936 100644 --- a/app/context/user/infrastructure/dependencies.py +++ b/app/context/user/infrastructure/dependencies.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -8,7 +10,7 @@ from app.shared.infrastructure.database import get_db -def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepositoryContract: +def get_user_repository(db: Annotated[AsyncSession, Depends(get_db)]) -> UserRepositoryContract: """ Initialize user repository """ @@ -16,7 +18,7 @@ def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepositoryCon def get_find_user_query_handler( - user_repo: UserRepositoryContract = Depends(get_user_repository), + user_repo: Annotated[UserRepositoryContract, Depends(get_user_repository)], ) -> FindUserHandlerContract: """ Initialize FindUserHandler diff --git a/app/context/user/infrastructure/mappers/user_mapper.py b/app/context/user/infrastructure/mappers/user_mapper.py index 503eddf..d95c189 100644 --- a/app/context/user/infrastructure/mappers/user_mapper.py +++ b/app/context/user/infrastructure/mappers/user_mapper.py @@ -1,4 +1,3 @@ -from typing import Optional from app.context.user.domain.dto.user_dto import UserDTO from app.context.user.domain.exceptions import UserMapperError @@ -16,7 +15,7 @@ class UserMapper: """Mapper between UserModel (database) and UserDTO (domain)""" @staticmethod - def to_dto(model: Optional[UserModel]) -> Optional[UserDTO]: + def to_dto(model: UserModel | None) -> UserDTO | None: """ Convert database model to domain DTO. Uses from_trusted_source for performance optimization. diff --git a/app/context/user/infrastructure/models/user_model.py b/app/context/user/infrastructure/models/user_model.py index 5c3280e..6ee669d 100644 --- a/app/context/user/infrastructure/models/user_model.py +++ b/app/context/user/infrastructure/models/user_model.py @@ -1,5 +1,4 @@ from datetime import UTC, datetime -from typing import Optional from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column @@ -15,8 +14,8 @@ class UserModel(BaseDBModel): 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[Optional[str]] = mapped_column(String(100), nullable=True) - deleted_at: Mapped[Optional[datetime]] = mapped_column( + 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( diff --git a/app/context/user/infrastructure/repositories/__init__.py b/app/context/user/infrastructure/repositories/__init__.py index 73ba011..15c7389 100644 --- a/app/context/user/infrastructure/repositories/__init__.py +++ b/app/context/user/infrastructure/repositories/__init__.py @@ -1 +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 index f6de361..1160ab1 100644 --- a/app/context/user/infrastructure/repositories/user_repository.py +++ b/app/context/user/infrastructure/repositories/user_repository.py @@ -1,4 +1,3 @@ -from typing import Optional from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -17,8 +16,8 @@ def __init__(self, db: AsyncSession): self._db = db async def find_user( - self, user_id: Optional[UserID] = None, email: Optional[UserEmail] = None - ) -> Optional[UserDTO]: + 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. 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 index bcc5036..c43f73f 100644 --- 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 @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.user_account.application.dto.account_response_dto import ( AccountResponseDTO, @@ -13,5 +12,5 @@ class FindAccountByIdHandlerContract(ABC): @abstractmethod async def handle( self, query: FindAccountByIdQuery - ) -> Optional[AccountResponseDTO]: + ) -> AccountResponseDTO | None: pass diff --git a/app/context/user_account/application/dto/create_account_result.py b/app/context/user_account/application/dto/create_account_result.py index 547fa25..b44ed32 100644 --- a/app/context/user_account/application/dto/create_account_result.py +++ b/app/context/user_account/application/dto/create_account_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class CreateAccountErrorCode(str, Enum): @@ -16,10 +15,10 @@ 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 + account_id: int | None = None + account_name: str | None = None + account_balance: float | None = None # Error fields - populated when operation fails - error_code: Optional[CreateAccountErrorCode] = None - error_message: Optional[str] = None + 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 index 655d465..9cfadfb 100644 --- a/app/context/user_account/application/dto/delete_account_result.py +++ b/app/context/user_account/application/dto/delete_account_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class DeleteAccountErrorCode(str, Enum): @@ -18,5 +17,5 @@ class DeleteAccountResult: success: bool = False # Error fields - populated when operation fails - error_code: Optional[DeleteAccountErrorCode] = None - error_message: Optional[str] = None + error_code: DeleteAccountErrorCode | 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 index 3e21006..5a982ce 100644 --- a/app/context/user_account/application/dto/update_account_result.py +++ b/app/context/user_account/application/dto/update_account_result.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from enum import Enum -from typing import Optional class UpdateAccountErrorCode(str, Enum): @@ -17,10 +16,10 @@ class UpdateAccountResult: """Result of account update operation""" # Success fields - populated when operation succeeds - account_id: Optional[int] = None - account_name: Optional[str] = None - account_balance: Optional[float] = None + account_id: int | None = None + account_name: str | None = None + account_balance: float | None = None # Error fields - populated when operation fails - error_code: Optional[UpdateAccountErrorCode] = None - error_message: Optional[str] = None + error_code: UpdateAccountErrorCode | None = None + error_message: str | None = None 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 index 52925a2..ba68f67 100644 --- 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 @@ -1,4 +1,3 @@ -from typing import Optional from app.context.user_account.application.contracts.find_account_by_id_handler_contract import ( FindAccountByIdHandlerContract, @@ -22,7 +21,7 @@ class FindAccountByIdHandler(FindAccountByIdHandlerContract): def __init__(self, repository: UserAccountRepositoryContract): self._repository = repository - async def handle(self, query: FindAccountByIdQuery) -> Optional[AccountResponseDTO]: + async def handle(self, query: FindAccountByIdQuery) -> AccountResponseDTO | None: account = await self._repository.find_user_accounts( account_id=UserAccountID(query.account_id), user_id=UserAccountUserID(query.user_id), 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 index 6996a51..d2de3db 100644 --- 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 @@ -1,5 +1,4 @@ from abc import ABC, abstractmethod -from typing import Optional from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO from app.context.user_account.domain.value_objects import UserAccountUserID @@ -30,10 +29,10 @@ async def save_account(self, account: UserAccountDTO) -> UserAccountDTO: @abstractmethod async def find_account( self, - account_id: Optional[UserAccountID] = None, - user_id: Optional[UserAccountUserID] = None, - name: Optional[AccountName] = None, - ) -> Optional[UserAccountDTO]: + 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 @@ -51,10 +50,10 @@ async def find_account( async def find_user_accounts( self, user_id: UserAccountUserID, - account_id: Optional[UserAccountID] = None, - name: Optional[AccountName] = None, - only_active: Optional[bool] = True, - ) -> Optional[list[UserAccountDTO]]: + 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) @@ -74,8 +73,8 @@ async def find_user_account_by_id( self, user_id: UserAccountUserID, account_id: UserAccountID, - only_active: Optional[bool] = True, - ) -> Optional[UserAccountDTO]: + only_active: bool | None = True, + ) -> UserAccountDTO | None: pass @abstractmethod diff --git a/app/context/user_account/domain/dto/user_account_dto.py b/app/context/user_account/domain/dto/user_account_dto.py index b8dcca4..c31ce5e 100644 --- a/app/context/user_account/domain/dto/user_account_dto.py +++ b/app/context/user_account/domain/dto/user_account_dto.py @@ -1,5 +1,4 @@ from dataclasses import dataclass -from typing import Optional from app.context.user_account.domain.value_objects import ( AccountName, @@ -19,8 +18,8 @@ class UserAccountDTO: name: AccountName currency: UserAccountCurrency balance: UserAccountBalance - account_id: Optional[UserAccountID] = None - deleted_at: Optional[UserAccountDeletedAt] = None + account_id: UserAccountID | None = None + deleted_at: UserAccountDeletedAt | None = None @property def is_deleted(self) -> bool: diff --git a/app/context/user_account/infrastructure/dependencies.py b/app/context/user_account/infrastructure/dependencies.py index e00fc26..efa5adb 100644 --- a/app/context/user_account/infrastructure/dependencies.py +++ b/app/context/user_account/infrastructure/dependencies.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -29,7 +31,7 @@ def get_user_account_repository( - db: AsyncSession = Depends(get_db), + db: Annotated[AsyncSession, Depends(get_db)], ) -> UserAccountRepositoryContract: """UserAccountRepository dependency injection""" from app.context.user_account.infrastructure.repositories.user_account_repository import ( @@ -45,9 +47,9 @@ def get_user_account_repository( def get_create_account_service( - account_repository: UserAccountRepositoryContract = Depends( - get_user_account_repository - ), + account_repository: Annotated[ + UserAccountRepositoryContract, Depends(get_user_account_repository) + ], ) -> CreateAccountServiceContract: """CreateAccountService dependency injection""" from app.context.user_account.domain.services.create_account_service import ( @@ -58,7 +60,7 @@ def get_create_account_service( def get_create_account_handler( - service: CreateAccountServiceContract = Depends(get_create_account_service), + service: Annotated[CreateAccountServiceContract, Depends(get_create_account_service)], ) -> CreateAccountHandlerContract: """CreateAccountHandler dependency injection""" from app.context.user_account.application.handlers.create_account_handler import ( @@ -69,7 +71,7 @@ def get_create_account_handler( def get_update_account_service( - repository: UserAccountRepositoryContract = Depends(get_user_account_repository), + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], ) -> UpdateAccountServiceContract: """UpdateAccountService dependency injection""" from app.context.user_account.domain.services.update_account_service import ( @@ -80,7 +82,7 @@ def get_update_account_service( def get_update_account_handler( - service: UpdateAccountServiceContract = Depends(get_update_account_service), + service: Annotated[UpdateAccountServiceContract, Depends(get_update_account_service)], ) -> UpdateAccountHandlerContract: """UpdateAccountHandler dependency injection""" from app.context.user_account.application.handlers.update_account_handler import ( @@ -91,7 +93,7 @@ def get_update_account_handler( def get_delete_account_handler( - repository: UserAccountRepositoryContract = Depends(get_user_account_repository), + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], ) -> DeleteAccountHandlerContract: """DeleteAccountHandler dependency injection""" from app.context.user_account.application.handlers.delete_account_handler import ( @@ -107,7 +109,7 @@ def get_delete_account_handler( def get_find_account_by_id_handler( - repository: UserAccountRepositoryContract = Depends(get_user_account_repository), + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], ) -> FindAccountByIdHandlerContract: """FindAccountByIdHandler dependency injection""" from app.context.user_account.application.handlers.find_account_by_id_handler import ( @@ -118,7 +120,7 @@ def get_find_account_by_id_handler( def get_find_accounts_by_user_handler( - repository: UserAccountRepositoryContract = Depends(get_user_account_repository), + repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], ) -> FindAccountsByUserHandlerContract: """FindAccountsByUserHandler dependency injection""" from app.context.user_account.application.handlers.find_accounts_by_user_handler import ( diff --git a/app/context/user_account/infrastructure/mappers/user_account_mapper.py b/app/context/user_account/infrastructure/mappers/user_account_mapper.py index 7bcd01b..f5dece8 100644 --- a/app/context/user_account/infrastructure/mappers/user_account_mapper.py +++ b/app/context/user_account/infrastructure/mappers/user_account_mapper.py @@ -1,4 +1,3 @@ -from typing import Optional from app.context.user_account.domain.dto.user_account_dto import UserAccountDTO from app.context.user_account.domain.exceptions import UserAccountMapperError @@ -19,7 +18,7 @@ class UserAccountMapper: """Mapper for converting between UserAccountModel and UserAccountDTO""" @staticmethod - def to_dto(model: Optional[UserAccountModel]) -> Optional[UserAccountDTO]: + def to_dto(model: UserAccountModel | None) -> UserAccountDTO | None: """Convert database model to domain DTO""" return ( UserAccountDTO( diff --git a/app/context/user_account/infrastructure/models/user_account_model.py b/app/context/user_account/infrastructure/models/user_account_model.py index 9e1efcc..0c452b3 100644 --- a/app/context/user_account/infrastructure/models/user_account_model.py +++ b/app/context/user_account/infrastructure/models/user_account_model.py @@ -1,6 +1,5 @@ from datetime import datetime from decimal import Decimal -from typing import Optional from sqlalchemy import DECIMAL, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column @@ -18,6 +17,6 @@ class UserAccountModel(BaseDBModel): 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[Optional[datetime]] = mapped_column( + deleted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) diff --git a/app/context/user_account/infrastructure/repositories/user_account_repository.py b/app/context/user_account/infrastructure/repositories/user_account_repository.py index 96cfc31..7c29ede 100644 --- a/app/context/user_account/infrastructure/repositories/user_account_repository.py +++ b/app/context/user_account/infrastructure/repositories/user_account_repository.py @@ -1,4 +1,4 @@ -from typing import Any, Optional, cast +from typing import Any, cast from sqlalchemy import select, update from sqlalchemy.engine import CursorResult @@ -50,11 +50,11 @@ async def save_account(self, account: UserAccountDTO) -> UserAccountDTO: async def find_account( self, - account_id: Optional[UserAccountID] = None, - user_id: Optional[UserAccountUserID] = None, - name: Optional[AccountName] = None, - only_active: Optional[bool] = True, - ) -> Optional[UserAccountDTO]: + 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: @@ -78,10 +78,10 @@ async def find_account( async def find_user_accounts( self, user_id: UserAccountUserID, - account_id: Optional[UserAccountID] = None, - name: Optional[AccountName] = None, - only_active: Optional[bool] = True, - ) -> Optional[list[UserAccountDTO]]: + 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: @@ -104,8 +104,8 @@ async def find_user_account_by_id( self, user_id: UserAccountUserID, account_id: UserAccountID, - only_active: Optional[bool] = True, - ) -> Optional[UserAccountDTO]: + only_active: bool | None = True, + ) -> UserAccountDTO | None: stmt = select(UserAccountModel).where( UserAccountModel.id == account_id.value, UserAccountModel.user_id == user_id.value, diff --git a/app/context/user_account/interface/rest/__init__.py b/app/context/user_account/interface/rest/__init__.py index b886e76..b695afc 100644 --- a/app/context/user_account/interface/rest/__init__.py +++ b/app/context/user_account/interface/rest/__init__.py @@ -1 +1,3 @@ from .routes import user_account_routes + +__all__ = ["user_account_routes"] 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 index 1f066fb..24ec9b9 100644 --- a/app/context/user_account/interface/rest/controllers/create_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/create_account_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.user_account.application.commands import ( @@ -24,8 +26,8 @@ @router.post("", response_model=CreateAccountResponse, status_code=201) async def create_account( request: CreateAccountRequest, - handler: CreateAccountHandlerContract = Depends(get_create_account_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[CreateAccountHandlerContract, Depends(get_create_account_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Create a new user account""" command = CreateAccountCommand( 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 index 205c6b5..9a79489 100644 --- a/app/context/user_account/interface/rest/controllers/delete_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/delete_account_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.user_account.application.commands import ( @@ -20,8 +22,8 @@ @router.delete("/{account_id}", status_code=204) async def delete_account( account_id: int, - handler: DeleteAccountHandlerContract = Depends(get_delete_account_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[DeleteAccountHandlerContract, Depends(get_delete_account_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Delete a user account (soft delete)""" command = DeleteAccountCommand( 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 index 8b102c8..493bc38 100644 --- a/app/context/user_account/interface/rest/controllers/find_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.user_account.application.contracts.find_account_by_id_handler_contract import ( @@ -25,8 +27,8 @@ @router.get("/{account_id}", response_model=AccountResponse) async def get_account( account_id: int, - handler: FindAccountByIdHandlerContract = Depends(get_find_account_by_id_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[FindAccountByIdHandlerContract, Depends(get_find_account_by_id_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Get a specific user account by ID""" query = FindAccountByIdQuery( @@ -48,10 +50,10 @@ async def get_account( @router.get("", response_model=list[AccountResponse]) async def get_all_accounts( - handler: FindAccountsByUserHandlerContract = Depends( - get_find_accounts_by_user_handler - ), - user_id: int = Depends(get_current_user_id), + handler: Annotated[ + FindAccountsByUserHandlerContract, Depends(get_find_accounts_by_user_handler) + ], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Get all accounts for the authenticated user""" query = FindAccountsByUserQuery(user_id=user_id) 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 index 896c589..232f77b 100644 --- a/app/context/user_account/interface/rest/controllers/update_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/update_account_controller.py @@ -1,3 +1,5 @@ +from typing import Annotated + from fastapi import APIRouter, Depends, HTTPException from app.context.user_account.application.commands import ( @@ -25,8 +27,8 @@ async def update_account( account_id: int, request: UpdateAccountRequest, - handler: UpdateAccountHandlerContract = Depends(get_update_account_handler), - user_id: int = Depends(get_current_user_id), + handler: Annotated[UpdateAccountHandlerContract, Depends(get_update_account_handler)], + user_id: Annotated[int, Depends(get_current_user_id)], ): """Update a user account (full update - all fields required)""" command = UpdateAccountCommand( diff --git a/app/context/user_account/interface/schemas/create_account_response.py b/app/context/user_account/interface/schemas/create_account_response.py index 42ccd2f..aafe758 100644 --- a/app/context/user_account/interface/schemas/create_account_response.py +++ b/app/context/user_account/interface/schemas/create_account_response.py @@ -1,11 +1,10 @@ from dataclasses import dataclass -from typing import Optional @dataclass(frozen=True) class CreateAccountResponse: """Response schema for account creation""" - account_id: Optional[int] - account_name: Optional[str] - account_balance: Optional[float] + account_id: int | None + account_name: str | None + account_balance: float | None diff --git a/app/shared/domain/value_objects/shared_deleted_at.py b/app/shared/domain/value_objects/shared_deleted_at.py index 738ca16..dfe58a8 100644 --- a/app/shared/domain/value_objects/shared_deleted_at.py +++ b/app/shared/domain/value_objects/shared_deleted_at.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field from datetime import UTC, datetime -from typing import Optional, Self +from typing import Self @dataclass(frozen=True) @@ -42,7 +42,7 @@ def from_trusted_source(cls, value: datetime) -> Self: return cls(value=value, _validated=True) @classmethod - def from_optional(cls, value: Optional[datetime]) -> Optional[Self]: + def from_optional(cls, value: datetime | None) -> Self | None: """ Create DeletedAt from optional datetime. Returns None if value is None (entity is not deleted). diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index 36e333f..3ecea39 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -50,15 +50,15 @@ async def get_db(): # 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, ) from app.context.household.infrastructure.models.household_model import ( # noqa: F401, E402 - HouseholdModel, HouseholdMemberModel, + HouseholdModel, +) +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/middleware/session_auth_dependency.py b/app/shared/infrastructure/middleware/session_auth_dependency.py index bffc6d1..eebaef4 100644 --- a/app/shared/infrastructure/middleware/session_auth_dependency.py +++ b/app/shared/infrastructure/middleware/session_auth_dependency.py @@ -5,7 +5,7 @@ from HTTP-only cookies. Used to protect routes that require authentication. """ -from typing import Optional +from typing import Annotated from fastapi import Cookie, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession @@ -21,7 +21,7 @@ def get_session_repository_for_auth( - db: AsyncSession = Depends(get_db), + db: Annotated[AsyncSession, Depends(get_db)], ) -> SessionRepositoryContract: """ Factory function to create session repository for authentication. @@ -31,8 +31,8 @@ def get_session_repository_for_auth( async def get_current_user_id( - access_token: Optional[str] = Cookie(default=None), - session_repo: SessionRepositoryContract = Depends(get_session_repository_for_auth), + 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. @@ -77,19 +77,19 @@ async def get_current_user_id( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token format: {str(e)}", headers={"WWW-Authenticate": "Bearer"}, - ) - except Exception as e: + ) 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( - access_token: Optional[str] = Cookie(default=None), - session_repo: SessionRepositoryContract = Depends(get_session_repository_for_auth), -) -> Optional[int]: + 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). @@ -128,9 +128,9 @@ async def get_current_user_id_optional( status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid token format: {str(e)}", headers={"WWW-Authenticate": "Bearer"}, - ) - except Exception as e: + ) from ValueError + except Exception: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Authentication service unavailable", - ) + ) from Exception diff --git a/justfile b/justfile index 77d48f2..c68f02a 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,7 @@ run: uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 run-with-test: - DB_PORT=5434 DB_NAME=homecomp_test uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 + APP_ENV=test uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8080 migration-generate comment: alembic revision -m "{{comment}}" @@ -11,7 +11,7 @@ migrate: uv run alembic upgrade head migrate-test: - DB_PORT=5434 DB_NAME=homecomp_test uv run alembic upgrade head + APP_ENV=test uv run alembic upgrade head db-clear: uv run python scripts/db_clear.py @@ -36,4 +36,24 @@ test-unit-cov: uv run pytest -m unit --ignore=tests/integration --cov --cov-report=term --cov-report=html test-integration: - uv run pytest -m integration + APP_ENV=test uv run pytest -m integration + +lint: + uv run ruff check . + +lint-fix: + uv run ruff check --fix . + +format: + uv run ruff format . + +format-check: + uv run ruff format --check . + +check: + uv run ruff check . + uv run ruff format --check . + +fix: + uv run ruff check --fix . + uv run ruff format . diff --git a/migrations/versions/01770bb99438_create_household_tables.py b/migrations/versions/01770bb99438_create_household_tables.py index f5ee918..51f94da 100644 --- a/migrations/versions/01770bb99438_create_household_tables.py +++ b/migrations/versions/01770bb99438_create_household_tables.py @@ -6,16 +6,16 @@ """ -from typing import Sequence, Union +from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "01770bb99438" -down_revision: Union[str, Sequence[str], None] = "d96343c7a2a6" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "d96343c7a2a6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/migrations/versions/b8067948709f_create_session_table.py b/migrations/versions/b8067948709f_create_session_table.py index 52408c5..4426aec 100644 --- a/migrations/versions/b8067948709f_create_session_table.py +++ b/migrations/versions/b8067948709f_create_session_table.py @@ -6,16 +6,16 @@ """ -from typing import Sequence, Union +from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "b8067948709f" -down_revision: Union[str, Sequence[str], None] = "f8333e5b2bac" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "f8333e5b2bac" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/migrations/versions/d756346442c3_create_user_accounts_table.py b/migrations/versions/d756346442c3_create_user_accounts_table.py index 8cd397e..6efe814 100644 --- a/migrations/versions/d756346442c3_create_user_accounts_table.py +++ b/migrations/versions/d756346442c3_create_user_accounts_table.py @@ -6,16 +6,16 @@ """ -from typing import Sequence, Union +from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "d756346442c3" -down_revision: Union[str, Sequence[str], None] = "b8067948709f" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "b8067948709f" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py b/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py index 7ef8127..d2d49a8 100644 --- a/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py +++ b/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py @@ -5,17 +5,16 @@ Create Date: 2025-12-27 11:26:08.433758 """ -from typing import Sequence, Union +from collections.abc import Sequence -from alembic import op import sqlalchemy as sa - +from alembic import op # revision identifiers, used by Alembic. revision: str = 'd96343c7a2a6' -down_revision: Union[str, Sequence[str], None] = 'e19a954402db' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = 'e19a954402db' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/migrations/versions/e19a954402db_create_credit_cards_table.py b/migrations/versions/e19a954402db_create_credit_cards_table.py index ac4d870..775f5cf 100644 --- a/migrations/versions/e19a954402db_create_credit_cards_table.py +++ b/migrations/versions/e19a954402db_create_credit_cards_table.py @@ -6,16 +6,16 @@ """ -from typing import Sequence, Union +from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "e19a954402db" -down_revision: Union[str, Sequence[str], None] = "d756346442c3" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = "d756346442c3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/migrations/versions/f8333e5b2bac_create_user_table.py b/migrations/versions/f8333e5b2bac_create_user_table.py index 1c5eb9e..2c6f430 100644 --- a/migrations/versions/f8333e5b2bac_create_user_table.py +++ b/migrations/versions/f8333e5b2bac_create_user_table.py @@ -6,16 +6,16 @@ """ -from typing import Sequence, Union +from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. revision: str = "f8333e5b2bac" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None def upgrade() -> None: diff --git a/pyproject.toml b/pyproject.toml index 1361c38..765e9ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,3 +56,25 @@ exclude_lines = [ "@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/scripts/seeds/__init__.py b/scripts/seeds/__init__.py index 3d30519..abd1cf5 100644 --- a/scripts/seeds/__init__.py +++ b/scripts/seeds/__init__.py @@ -1,8 +1,8 @@ -from .seed_users import seed_users +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_household_members import seed_household_members -from .seed_credit_cards import seed_credit_cards +from .seed_users import seed_users __all__ = [ "seed_users", diff --git a/scripts/seeds/seed_credit_cards.py b/scripts/seeds/seed_credit_cards.py index cc443d4..60d1b5a 100644 --- a/scripts/seeds/seed_credit_cards.py +++ b/scripts/seeds/seed_credit_cards.py @@ -3,8 +3,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.context.credit_card.infrastructure.models import CreditCardModel -from app.context.user_account.infrastructure.models import UserAccountModel from app.context.user.infrastructure.models import UserModel +from app.context.user_account.infrastructure.models import UserAccountModel async def seed_credit_cards( diff --git a/scripts/seeds/seed_user_accounts.py b/scripts/seeds/seed_user_accounts.py index 576a81d..9e8bdcf 100644 --- a/scripts/seeds/seed_user_accounts.py +++ b/scripts/seeds/seed_user_accounts.py @@ -2,8 +2,8 @@ from sqlalchemy.ext.asyncio import AsyncSession -from app.context.user_account.infrastructure.models import UserAccountModel from app.context.user.infrastructure.models import UserModel +from app.context.user_account.infrastructure.models import UserAccountModel async def seed_user_accounts( diff --git a/tests/conftest.py b/tests/conftest.py index 01a200a..c5fbe93 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,16 +17,16 @@ # Import user_account context fixtures from tests.fixtures.user_account import ( - sample_user_id, + sample_account_dto, sample_account_id, + sample_account_model, sample_account_name, - sample_currency, sample_balance, - sample_account_dto, - sample_new_account_dto, + sample_currency, sample_deleted_account_dto, - sample_account_model, sample_deleted_account_model, + sample_new_account_dto, + sample_user_id, ) # Make fixtures available to pytest diff --git a/tests/fixtures/auth/repositories.py b/tests/fixtures/auth/repositories.py index f571160..464a45e 100644 --- a/tests/fixtures/auth/repositories.py +++ b/tests/fixtures/auth/repositories.py @@ -1,6 +1,5 @@ """Mock repositories for auth context testing.""" -from typing import Optional from unittest.mock import AsyncMock import pytest @@ -19,8 +18,8 @@ def __init__(self): self.update_session_mock = AsyncMock() async def getSession( - self, user_id: Optional[AuthUserID] = None, token: Optional[SessionToken] = None - ) -> Optional[SessionDTO]: + 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: diff --git a/tests/fixtures/credit_card/credit_card_fixtures.py b/tests/fixtures/credit_card/credit_card_fixtures.py index 687a688..0a9f26b 100644 --- a/tests/fixtures/credit_card/credit_card_fixtures.py +++ b/tests/fixtures/credit_card/credit_card_fixtures.py @@ -1,8 +1,9 @@ """Test fixtures for credit card context""" -import pytest +from datetime import datetime from decimal import Decimal -from datetime import UTC, datetime + +import pytest from app.context.credit_card.domain.dto import CreditCardDTO from app.context.credit_card.domain.value_objects import ( diff --git a/tests/fixtures/user_account/__init__.py b/tests/fixtures/user_account/__init__.py index 54f565e..3587885 100644 --- a/tests/fixtures/user_account/__init__.py +++ b/tests/fixtures/user_account/__init__.py @@ -1,23 +1,23 @@ """User account context test fixtures""" -from decimal import Decimal 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 ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, - UserAccountUserID, + UserAccountCurrency, UserAccountDeletedAt, + UserAccountID, + UserAccountUserID, ) from app.context.user_account.infrastructure.models.user_account_model import ( UserAccountModel, ) - # ────────────────────────────────────────────────────────────────────────────── # Domain Value Object Fixtures # ────────────────────────────────────────────────────────────────────────────── diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index abfb496..10fd17d 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -5,14 +5,13 @@ """ # 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 client fixtures -from tests.integration.fixtures.client import test_client - # Import user fixtures from tests.integration.fixtures.users import ( blocked_user, diff --git a/tests/integration/fixtures/__init__.py b/tests/integration/fixtures/__init__.py index 80fb0d0..69b9215 100644 --- a/tests/integration/fixtures/__init__.py +++ b/tests/integration/fixtures/__init__.py @@ -1,14 +1,13 @@ """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 client fixtures -from tests.integration.fixtures.client import test_client - # Import user fixtures from tests.integration.fixtures.users import ( blocked_user, diff --git a/tests/integration/fixtures/client.py b/tests/integration/fixtures/client.py index 6b46425..53d17ee 100644 --- a/tests/integration/fixtures/client.py +++ b/tests/integration/fixtures/client.py @@ -3,7 +3,7 @@ Provides fixtures for FastAPI test client with database dependency overrides. """ -from typing import AsyncGenerator +from collections.abc import AsyncGenerator import pytest_asyncio from httpx import ASGITransport, AsyncClient @@ -16,7 +16,7 @@ @pytest_asyncio.fixture(scope="function") async def test_client( test_db_session: AsyncSession, -) -> AsyncGenerator[AsyncClient, None]: +) -> AsyncGenerator[AsyncClient]: """Create a test client with overridden database dependency.""" async def override_get_db(): diff --git a/tests/integration/fixtures/database.py b/tests/integration/fixtures/database.py index 91febf8..1cedf9f 100644 --- a/tests/integration/fixtures/database.py +++ b/tests/integration/fixtures/database.py @@ -4,7 +4,7 @@ """ import os -from typing import AsyncGenerator +from collections.abc import AsyncGenerator import pytest_asyncio from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -41,7 +41,7 @@ async def test_engine(): @pytest_asyncio.fixture(scope="function") -async def test_db_session(test_engine) -> AsyncGenerator[AsyncSession, None]: +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 diff --git a/tests/integration/test_login_flow.py b/tests/integration/test_login_flow.py index aced3eb..1df4cef 100644 --- a/tests/integration/test_login_flow.py +++ b/tests/integration/test_login_flow.py @@ -223,8 +223,8 @@ async def test_concurrent_logins_different_users( ): """Test that multiple users can login concurrently without conflicts.""" # Arrange - Create a second user - from app.shared.domain.value_objects import SharedPassword 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!" diff --git a/tests/unit/context/auth/domain/auth_email_test.py b/tests/unit/context/auth/domain/auth_email_test.py index 9b9d6e2..b71cfba 100644 --- a/tests/unit/context/auth/domain/auth_email_test.py +++ b/tests/unit/context/auth/domain/auth_email_test.py @@ -1,5 +1,7 @@ """Unit tests for AuthEmail value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.auth.domain.value_objects import AuthEmail @@ -56,7 +58,7 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" email = AuthEmail("test@example.com") - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): email.value = "changed@example.com" def test_equality(self): diff --git a/tests/unit/context/auth/domain/auth_password_test.py b/tests/unit/context/auth/domain/auth_password_test.py index 292c851..1101ae0 100644 --- a/tests/unit/context/auth/domain/auth_password_test.py +++ b/tests/unit/context/auth/domain/auth_password_test.py @@ -1,5 +1,7 @@ """Unit tests for AuthPassword value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.auth.domain.value_objects import AuthPassword @@ -66,7 +68,7 @@ def test_keep_plain_creates_unhashed_password(self): def test_immutability(self): """Test that value object is immutable""" password = AuthPassword.from_plain_text("test123") - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): password.value = "changed" def test_verify_with_empty_password_returns_false(self): diff --git a/tests/unit/context/auth/domain/auth_user_id_test.py b/tests/unit/context/auth/domain/auth_user_id_test.py index cffeec2..9935409 100644 --- a/tests/unit/context/auth/domain/auth_user_id_test.py +++ b/tests/unit/context/auth/domain/auth_user_id_test.py @@ -1,5 +1,7 @@ """Unit tests for AuthUserID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.auth.domain.value_objects import AuthUserID @@ -29,7 +31,7 @@ def test_zero_user_id(self): def test_immutability(self): """Test that value object is immutable""" user_id = AuthUserID(123) - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): user_id.value = 456 def test_equality(self): diff --git a/tests/unit/context/auth/domain/failed_login_attempts_test.py b/tests/unit/context/auth/domain/failed_login_attempts_test.py index 2b839ed..2660813 100644 --- a/tests/unit/context/auth/domain/failed_login_attempts_test.py +++ b/tests/unit/context/auth/domain/failed_login_attempts_test.py @@ -1,5 +1,7 @@ """Unit tests for FailedLoginAttempts value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.auth.domain.value_objects import FailedLoginAttempts @@ -74,7 +76,7 @@ def test_reset_creates_zero_attempts(self): def test_immutability(self): """Test that value object is immutable""" attempts = FailedLoginAttempts(2) - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): attempts.value = 3 def test_equality(self): diff --git a/tests/unit/context/auth/domain/session_token_test.py b/tests/unit/context/auth/domain/session_token_test.py index e2ec522..68af1a8 100644 --- a/tests/unit/context/auth/domain/session_token_test.py +++ b/tests/unit/context/auth/domain/session_token_test.py @@ -1,5 +1,7 @@ """Unit tests for SessionToken value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.auth.domain.value_objects import SessionToken @@ -54,7 +56,7 @@ def test_direct_construction(self): def test_immutability(self): """Test that value object is immutable""" token = SessionToken.generate() - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): token.value = "modified-token" def test_equality(self): diff --git a/tests/unit/context/auth/domain/throttle_time_test.py b/tests/unit/context/auth/domain/throttle_time_test.py index 2dc3842..9580d36 100644 --- a/tests/unit/context/auth/domain/throttle_time_test.py +++ b/tests/unit/context/auth/domain/throttle_time_test.py @@ -1,5 +1,7 @@ """Unit tests for ThrottleTime value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.auth.domain.value_objects import FailedLoginAttempts @@ -56,7 +58,7 @@ def test_throttle_time_tuple(self): def test_immutability(self): """Test that value object is immutable""" throttle = ThrottleTime(value=2) - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): throttle.value = 4 def test_equality(self): 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 index 041f93d..4ec4e54 100644 --- 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 @@ -1,15 +1,20 @@ """Unit tests for CreateCreditCardHandler""" -import pytest 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.application.commands import CreateCreditCardCommand -from app.context.credit_card.application.dto import CreateCreditCardErrorCode 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, @@ -18,10 +23,6 @@ CreditCardName, CreditCardUserID, ) -from app.context.credit_card.domain.exceptions import ( - CreditCardNameAlreadyExistError, - CreditCardMapperError, -) @pytest.mark.unit 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 index 7aa6334..4b8d33d 100644 --- 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 @@ -1,13 +1,14 @@ """Unit tests for DeleteCreditCardHandler""" -import pytest 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.application.commands import DeleteCreditCardCommand -from app.context.credit_card.application.dto import DeleteCreditCardErrorCode from app.context.credit_card.domain.exceptions import CreditCardNotFoundError 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 index f03fbb7..cc63c50 100644 --- 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 @@ -1,9 +1,10 @@ """Unit tests for FindCreditCardByIdHandler""" -import pytest 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, ) 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 index 1f6631e..0338b8c 100644 --- 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 @@ -1,9 +1,10 @@ """Unit tests for FindCreditCardsByUserHandler""" -import pytest 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, ) 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 index 82f4fa3..23a08da 100644 --- 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 @@ -1,15 +1,21 @@ """Unit tests for UpdateCreditCardHandler""" -import pytest 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.application.commands import UpdateCreditCardCommand -from app.context.credit_card.application.dto import UpdateCreditCardErrorCode 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, @@ -19,11 +25,6 @@ CreditCardName, CreditCardUserID, ) -from app.context.credit_card.domain.exceptions import ( - CreditCardNotFoundError, - CreditCardNameAlreadyExistError, - CreditCardMapperError, -) @pytest.mark.unit diff --git a/tests/unit/context/credit_card/domain/card_limit_test.py b/tests/unit/context/credit_card/domain/card_limit_test.py index 7ad8cc9..e3ebce1 100644 --- a/tests/unit/context/credit_card/domain/card_limit_test.py +++ b/tests/unit/context/credit_card/domain/card_limit_test.py @@ -1,15 +1,17 @@ """Unit tests for CardLimit value object""" -import pytest +from dataclasses import FrozenInstanceError from decimal import Decimal -from app.context.credit_card.domain.value_objects import CardLimit +import pytest + from app.context.credit_card.domain.exceptions import ( + InvalidCardLimitFormatError, + InvalidCardLimitPrecisionError, InvalidCardLimitTypeError, InvalidCardLimitValueError, - InvalidCardLimitPrecisionError, - InvalidCardLimitFormatError, ) +from app.context.credit_card.domain.value_objects import CardLimit @pytest.mark.unit @@ -84,5 +86,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" limit = CardLimit(Decimal("1000.00")) - with pytest.raises(Exception): # FrozenInstanceError + 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 index ac25cd6..6ceed81 100644 --- a/tests/unit/context/credit_card/domain/card_used_test.py +++ b/tests/unit/context/credit_card/domain/card_used_test.py @@ -1,15 +1,17 @@ """Unit tests for CardUsed value object""" -import pytest +from dataclasses import FrozenInstanceError from decimal import Decimal -from app.context.credit_card.domain.value_objects import CardUsed +import pytest + from app.context.credit_card.domain.exceptions import ( + InvalidCardUsedFormatError, + InvalidCardUsedPrecisionError, InvalidCardUsedTypeError, InvalidCardUsedValueError, - InvalidCardUsedPrecisionError, - InvalidCardUsedFormatError, ) +from app.context.credit_card.domain.value_objects import CardUsed @pytest.mark.unit @@ -82,5 +84,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" used = CardUsed(Decimal("500.00")) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 90eb0f2..3480ea5 100644 --- 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 @@ -1,13 +1,14 @@ """Unit tests for CreateCreditCardService""" -import pytest 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.dto import CreditCardDTO from app.context.credit_card.domain.value_objects import ( CardLimit, CreditCardAccountID, 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 index 1dd2993..4045320 100644 --- 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 @@ -1,5 +1,7 @@ """Unit tests for CreditCardAccountID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.credit_card.domain.value_objects import CreditCardAccountID @@ -38,5 +40,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" account_id = CreditCardAccountID(1) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 5141504..dff5011 100644 --- a/tests/unit/context/credit_card/domain/credit_card_currency_test.py +++ b/tests/unit/context/credit_card/domain/credit_card_currency_test.py @@ -1,5 +1,7 @@ """Unit tests for CreditCardCurrency value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.credit_card.domain.value_objects import CreditCardCurrency @@ -65,5 +67,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" currency = CreditCardCurrency("USD") - with pytest.raises(Exception): # FrozenInstanceError + 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 index bcd1e47..5f38c68 100644 --- 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 @@ -1,8 +1,10 @@ """Unit tests for CreditCardDeletedAt value object""" -import pytest +from dataclasses import FrozenInstanceError from datetime import UTC, datetime, timedelta +import pytest + from app.context.credit_card.domain.value_objects import CreditCardDeletedAt @@ -61,5 +63,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" deleted_at = CreditCardDeletedAt.now() - with pytest.raises(Exception): # FrozenInstanceError + 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 index 7b61c23..0d1c698 100644 --- a/tests/unit/context/credit_card/domain/credit_card_dto_test.py +++ b/tests/unit/context/credit_card/domain/credit_card_dto_test.py @@ -1,8 +1,9 @@ """Unit tests for CreditCardDTO""" -import pytest +from dataclasses import FrozenInstanceError from decimal import Decimal -from datetime import UTC, datetime + +import pytest from app.context.credit_card.domain.dto import CreditCardDTO from app.context.credit_card.domain.value_objects import ( @@ -97,7 +98,7 @@ def test_immutability(self): limit=CardLimit(Decimal("1000.00")), ) - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): dto.name = CreditCardName("New Name") def test_zero_used_amount(self): 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 index cf117c4..2a11f46 100644 --- a/tests/unit/context/credit_card/domain/credit_card_id_test.py +++ b/tests/unit/context/credit_card/domain/credit_card_id_test.py @@ -1,12 +1,14 @@ """Unit tests for CreditCardID value object""" +from dataclasses import FrozenInstanceError + import pytest -from app.context.credit_card.domain.value_objects import CreditCardID from app.context.credit_card.domain.exceptions import ( InvalidCreditCardIdTypeError, InvalidCreditCardIdValueError, ) +from app.context.credit_card.domain.value_objects import CreditCardID @pytest.mark.unit @@ -48,5 +50,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" card_id = CreditCardID(1) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 339fde3..cc5cbca 100644 --- a/tests/unit/context/credit_card/domain/credit_card_name_test.py +++ b/tests/unit/context/credit_card/domain/credit_card_name_test.py @@ -1,12 +1,14 @@ """Unit tests for CreditCardName value object""" +from dataclasses import FrozenInstanceError + import pytest -from app.context.credit_card.domain.value_objects import CreditCardName from app.context.credit_card.domain.exceptions import ( - InvalidCreditCardNameTypeError, InvalidCreditCardNameLengthError, + InvalidCreditCardNameTypeError, ) +from app.context.credit_card.domain.value_objects import CreditCardName @pytest.mark.unit @@ -31,9 +33,7 @@ def test_maximum_length_name(self): def test_invalid_type_raises_error(self): """Test that invalid types raise InvalidCreditCardNameTypeError""" - with pytest.raises( - InvalidCreditCardNameTypeError, match="CreditCardName must be a string" - ): + with pytest.raises(InvalidCreditCardNameTypeError, match="CreditCardName must be a string"): CreditCardName(123) def test_too_short_name_raises_error(self): @@ -62,5 +62,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" name = CreditCardName("My Card") - with pytest.raises(Exception): # FrozenInstanceError + 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 index cf1da8e..bc4d51a 100644 --- 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 @@ -1,5 +1,7 @@ """Unit tests for CreditCardUserID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.credit_card.domain.value_objects import CreditCardUserID @@ -27,5 +29,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" user_id = CreditCardUserID(1) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 2c84fd9..d0611f7 100644 --- 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 @@ -1,13 +1,20 @@ """Unit tests for UpdateCreditCardService""" -import pytest 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.dto import CreditCardDTO from app.context.credit_card.domain.value_objects import ( CardLimit, CardUsed, @@ -17,12 +24,6 @@ CreditCardName, CreditCardUserID, ) -from app.context.credit_card.domain.exceptions import ( - CreditCardNotFoundError, - CreditCardUnauthorizedAccessError, - CreditCardNameAlreadyExistError, - CreditCardUsedExceedsLimitError, -) @pytest.mark.unit 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 index b18082b..5c6686c 100644 --- a/tests/unit/context/credit_card/infrastructure/credit_card_mapper_test.py +++ b/tests/unit/context/credit_card/infrastructure/credit_card_mapper_test.py @@ -1,14 +1,12 @@ """Unit tests for CreditCardMapper""" -import pytest -from decimal import Decimal from datetime import UTC, datetime +from decimal import Decimal + +import pytest -from app.context.credit_card.infrastructure.mappers.credit_card_mapper import ( - CreditCardMapper, -) -from app.context.credit_card.infrastructure.models import CreditCardModel 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, @@ -19,7 +17,10 @@ CreditCardName, CreditCardUserID, ) -from app.context.credit_card.domain.exceptions import CreditCardMapperError +from app.context.credit_card.infrastructure.mappers.credit_card_mapper import ( + CreditCardMapper, +) +from app.context.credit_card.infrastructure.models import CreditCardModel @pytest.mark.unit diff --git a/tests/unit/context/household/application/create_household_handler_test.py b/tests/unit/context/household/application/create_household_handler_test.py index a65f228..41e7f64 100644 --- a/tests/unit/context/household/application/create_household_handler_test.py +++ b/tests/unit/context/household/application/create_household_handler_test.py @@ -1,23 +1,24 @@ """Unit tests for CreateHouseholdHandler""" -import pytest 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.application.commands import CreateHouseholdCommand -from app.context.household.application.dto import CreateHouseholdErrorCode 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, ) -from app.context.household.domain.exceptions import ( - HouseholdMapperError, - HouseholdNameAlreadyExistError, -) @pytest.mark.unit diff --git a/tests/unit/context/household/application/invite_user_handler_test.py b/tests/unit/context/household/application/invite_user_handler_test.py index aea6205..e7b2697 100644 --- a/tests/unit/context/household/application/invite_user_handler_test.py +++ b/tests/unit/context/household/application/invite_user_handler_test.py @@ -1,27 +1,28 @@ """Unit tests for InviteUserHandler""" -import pytest 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.application.commands import InviteUserCommand -from app.context.household.application.dto import InviteUserErrorCode from app.context.household.domain.dto import HouseholdMemberDTO -from app.context.household.domain.value_objects import ( - HouseholdID, - HouseholdMemberID, - HouseholdRole, - HouseholdUserID, -) 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 diff --git a/tests/unit/context/household/domain/accept_invite_service_test.py b/tests/unit/context/household/domain/accept_invite_service_test.py index 9bd9b3f..01f76c8 100644 --- a/tests/unit/context/household/domain/accept_invite_service_test.py +++ b/tests/unit/context/household/domain/accept_invite_service_test.py @@ -1,20 +1,21 @@ """Unit tests for AcceptInviteService""" -import pytest 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.dto import HouseholdMemberDTO from app.context.household.domain.value_objects import ( HouseholdID, HouseholdMemberID, HouseholdRole, HouseholdUserID, ) -from app.context.household.domain.exceptions import NotInvitedError @pytest.mark.unit diff --git a/tests/unit/context/household/domain/create_household_service_test.py b/tests/unit/context/household/domain/create_household_service_test.py index 4632887..6e02cc9 100644 --- a/tests/unit/context/household/domain/create_household_service_test.py +++ b/tests/unit/context/household/domain/create_household_service_test.py @@ -1,18 +1,19 @@ """Unit tests for CreateHouseholdService""" -import pytest 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.dto import HouseholdDTO from app.context.household.domain.value_objects import ( HouseholdID, HouseholdName, HouseholdUserID, ) -from app.context.household.domain.exceptions import HouseholdNameAlreadyExistError @pytest.mark.unit diff --git a/tests/unit/context/household/domain/decline_invite_service_test.py b/tests/unit/context/household/domain/decline_invite_service_test.py index 14dd2b4..bd31030 100644 --- a/tests/unit/context/household/domain/decline_invite_service_test.py +++ b/tests/unit/context/household/domain/decline_invite_service_test.py @@ -1,20 +1,21 @@ """Unit tests for DeclineInviteService""" -import pytest 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.dto import HouseholdMemberDTO from app.context.household.domain.value_objects import ( HouseholdID, HouseholdMemberID, HouseholdRole, HouseholdUserID, ) -from app.context.household.domain.exceptions import NotInvitedError @pytest.mark.unit diff --git a/tests/unit/context/household/domain/household_id_test.py b/tests/unit/context/household/domain/household_id_test.py index 9de551a..1245206 100644 --- a/tests/unit/context/household/domain/household_id_test.py +++ b/tests/unit/context/household/domain/household_id_test.py @@ -1,5 +1,7 @@ """Unit tests for HouseholdID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.household.domain.value_objects import HouseholdID @@ -42,5 +44,5 @@ def test_float_raises_error(self): def test_immutability(self): """Test that value object is immutable""" household_id = HouseholdID(1) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 5ddaad1..991f9ca 100644 --- a/tests/unit/context/household/domain/household_member_id_test.py +++ b/tests/unit/context/household/domain/household_member_id_test.py @@ -1,5 +1,7 @@ """Unit tests for HouseholdMemberID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.household.domain.value_objects import HouseholdMemberID @@ -46,5 +48,5 @@ def test_float_raises_error(self): def test_immutability(self): """Test that value object is immutable""" member_id = HouseholdMemberID(1) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 0323cbf..886befc 100644 --- a/tests/unit/context/household/domain/household_name_test.py +++ b/tests/unit/context/household/domain/household_name_test.py @@ -1,5 +1,7 @@ """Unit tests for HouseholdName value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.household.domain.value_objects import HouseholdName @@ -66,5 +68,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" name = HouseholdName("Test") - with pytest.raises(Exception): # FrozenInstanceError + 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 index 1179e3a..bbfb005 100644 --- a/tests/unit/context/household/domain/household_role_test.py +++ b/tests/unit/context/household/domain/household_role_test.py @@ -1,5 +1,7 @@ """Unit tests for HouseholdRole value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.household.domain.value_objects import HouseholdRole @@ -56,5 +58,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" role = HouseholdRole("participant") - with pytest.raises(Exception): # FrozenInstanceError + 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 index ead182d..d66c197 100644 --- a/tests/unit/context/household/domain/household_user_id_test.py +++ b/tests/unit/context/household/domain/household_user_id_test.py @@ -1,5 +1,7 @@ """Unit tests for HouseholdUserID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.household.domain.value_objects import HouseholdUserID @@ -42,5 +44,5 @@ def test_float_raises_error(self): def test_immutability(self): """Test that value object is immutable""" user_id = HouseholdUserID(1) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 59447d9..304ec36 100644 --- a/tests/unit/context/household/domain/household_user_name_test.py +++ b/tests/unit/context/household/domain/household_user_name_test.py @@ -1,5 +1,7 @@ """Unit tests for HouseholdUserName value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.household.domain.value_objects import HouseholdUserName @@ -27,5 +29,5 @@ def test_numeric_username(self): def test_immutability(self): """Test that value object is immutable""" username = HouseholdUserName("test_user") - with pytest.raises(Exception): # FrozenInstanceError + 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 index 50f8414..9967b04 100644 --- a/tests/unit/context/household/domain/invite_user_service_test.py +++ b/tests/unit/context/household/domain/invite_user_service_test.py @@ -1,11 +1,17 @@ """Unit tests for InviteUserService""" -import pytest from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock -from app.context.household.domain.services.invite_user_service import InviteUserService +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, @@ -13,11 +19,6 @@ HouseholdRole, HouseholdUserID, ) -from app.context.household.domain.exceptions import ( - AlreadyActiveMemberError, - AlreadyInvitedError, - OnlyOwnerCanInviteError, -) @pytest.mark.unit diff --git a/tests/unit/context/household/domain/remove_member_service_test.py b/tests/unit/context/household/domain/remove_member_service_test.py index c3d0420..0a57a9e 100644 --- a/tests/unit/context/household/domain/remove_member_service_test.py +++ b/tests/unit/context/household/domain/remove_member_service_test.py @@ -1,13 +1,19 @@ """Unit tests for RemoveMemberService""" -import pytest 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.dto import HouseholdDTO, HouseholdMemberDTO from app.context.household.domain.value_objects import ( HouseholdID, HouseholdMemberID, @@ -15,11 +21,6 @@ HouseholdRole, HouseholdUserID, ) -from app.context.household.domain.exceptions import ( - CannotRemoveSelfError, - InviteNotFoundError, - OnlyOwnerCanRemoveMemberError, -) @pytest.mark.unit diff --git a/tests/unit/context/household/domain/revoke_invite_service_test.py b/tests/unit/context/household/domain/revoke_invite_service_test.py index dfcfb57..986223f 100644 --- a/tests/unit/context/household/domain/revoke_invite_service_test.py +++ b/tests/unit/context/household/domain/revoke_invite_service_test.py @@ -1,13 +1,18 @@ """Unit tests for RevokeInviteService""" -import pytest 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.dto import HouseholdDTO, HouseholdMemberDTO from app.context.household.domain.value_objects import ( HouseholdID, HouseholdMemberID, @@ -15,10 +20,6 @@ HouseholdRole, HouseholdUserID, ) -from app.context.household.domain.exceptions import ( - InviteNotFoundError, - OnlyOwnerCanRevokeError, -) @pytest.mark.unit diff --git a/tests/unit/context/household/infrastructure/household_mapper_test.py b/tests/unit/context/household/infrastructure/household_mapper_test.py index b047fc0..9edcd52 100644 --- a/tests/unit/context/household/infrastructure/household_mapper_test.py +++ b/tests/unit/context/household/infrastructure/household_mapper_test.py @@ -1,17 +1,18 @@ """Unit tests for HouseholdMapper""" -import pytest from datetime import UTC, datetime -from app.context.household.infrastructure.mappers.household_mapper import HouseholdMapper -from app.context.household.infrastructure.models import HouseholdModel +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.domain.exceptions import HouseholdMapperError +from app.context.household.infrastructure.mappers.household_mapper import HouseholdMapper +from app.context.household.infrastructure.models import HouseholdModel @pytest.mark.unit diff --git a/tests/unit/context/household/infrastructure/household_member_mapper_test.py b/tests/unit/context/household/infrastructure/household_member_mapper_test.py index b5632d5..d331f4f 100644 --- a/tests/unit/context/household/infrastructure/household_member_mapper_test.py +++ b/tests/unit/context/household/infrastructure/household_member_mapper_test.py @@ -1,16 +1,9 @@ """Unit tests for HouseholdMemberMapper""" -import pytest from datetime import UTC, datetime -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 +import pytest + from app.context.household.domain.dto import HouseholdMemberDTO from app.context.household.domain.value_objects import ( HouseholdID, @@ -20,7 +13,14 @@ HouseholdUserID, HouseholdUserName, ) -from app.context.household.domain.exceptions import HouseholdMapperError +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 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 index 4cde8d8..cc6c763 100644 --- a/tests/unit/context/user_account/application/create_account_handler_test.py +++ b/tests/unit/context/user_account/application/create_account_handler_test.py @@ -1,26 +1,27 @@ """Unit tests for CreateAccountHandler""" -import pytest 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.application.commands import CreateAccountCommand -from app.context.user_account.application.dto import CreateAccountErrorCode 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 ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, + UserAccountCurrency, + UserAccountID, UserAccountUserID, ) -from app.context.user_account.domain.exceptions import ( - UserAccountNameAlreadyExistError, - UserAccountMapperError, -) @pytest.mark.unit 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 index e730009..d86c88a 100644 --- a/tests/unit/context/user_account/application/delete_account_handler_test.py +++ b/tests/unit/context/user_account/application/delete_account_handler_test.py @@ -1,17 +1,13 @@ """Unit tests for DeleteAccountHandler""" -import pytest -from decimal import Decimal from unittest.mock import AsyncMock, MagicMock -from app.context.user_account.application.handlers.delete_account_handler import ( - DeleteAccountHandler, -) +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.domain.value_objects import ( - UserAccountID, - UserAccountUserID, +from app.context.user_account.application.handlers.delete_account_handler import ( + DeleteAccountHandler, ) 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 index dd35390..1a81e85 100644 --- 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 @@ -1,19 +1,20 @@ """Unit tests for FindAccountByIdHandler""" -import pytest from decimal import Decimal from unittest.mock import AsyncMock, MagicMock +import pytest + 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 ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, + UserAccountCurrency, + UserAccountID, UserAccountUserID, ) 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 index 0fbe803..77e2d89 100644 --- 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 @@ -1,19 +1,20 @@ """Unit tests for FindAccountsByUserHandler""" -import pytest from decimal import Decimal from unittest.mock import AsyncMock, MagicMock +import pytest + 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 ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, + UserAccountCurrency, + UserAccountID, UserAccountUserID, ) 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 index 6e5dbe2..fb8ebcd 100644 --- a/tests/unit/context/user_account/application/update_account_handler_test.py +++ b/tests/unit/context/user_account/application/update_account_handler_test.py @@ -1,27 +1,28 @@ """Unit tests for UpdateAccountHandler""" -import pytest 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.application.commands import UpdateAccountCommand -from app.context.user_account.application.dto import UpdateAccountErrorCode 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 ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, + UserAccountCurrency, + UserAccountID, UserAccountUserID, ) -from app.context.user_account.domain.exceptions import ( - UserAccountNotFoundError, - UserAccountNameAlreadyExistError, - UserAccountMapperError, -) @pytest.mark.unit diff --git a/tests/unit/context/user_account/domain/account_name_test.py b/tests/unit/context/user_account/domain/account_name_test.py index 1086e2e..645f8f4 100644 --- a/tests/unit/context/user_account/domain/account_name_test.py +++ b/tests/unit/context/user_account/domain/account_name_test.py @@ -1,5 +1,7 @@ """Unit tests for AccountName value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.user_account.domain.value_objects import AccountName @@ -54,5 +56,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" name = AccountName("Test") - with pytest.raises(Exception): # FrozenInstanceError + 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 index c73d87c..20f3502 100644 --- a/tests/unit/context/user_account/domain/create_account_service_test.py +++ b/tests/unit/context/user_account/domain/create_account_service_test.py @@ -1,18 +1,19 @@ """Unit tests for CreateAccountService""" -import pytest 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.dto import UserAccountDTO from app.context.user_account.domain.value_objects import ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, + UserAccountCurrency, + UserAccountID, UserAccountUserID, ) 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 index af3a399..55a8289 100644 --- a/tests/unit/context/user_account/domain/update_account_service_test.py +++ b/tests/unit/context/user_account/domain/update_account_service_test.py @@ -1,24 +1,25 @@ """Unit tests for UpdateAccountService""" -import pytest 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.dto import UserAccountDTO from app.context.user_account.domain.value_objects import ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, + UserAccountCurrency, + UserAccountID, UserAccountUserID, ) -from app.context.user_account.domain.exceptions import ( - UserAccountNotFoundError, - UserAccountNameAlreadyExistError, -) @pytest.mark.unit 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 index ea1ec80..0f02b3e 100644 --- a/tests/unit/context/user_account/domain/user_account_balance_test.py +++ b/tests/unit/context/user_account/domain/user_account_balance_test.py @@ -1,8 +1,10 @@ """Unit tests for UserAccountBalance value object""" -import pytest +from dataclasses import FrozenInstanceError from decimal import Decimal +import pytest + from app.context.user_account.domain.value_objects import UserAccountBalance @@ -48,5 +50,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" balance = UserAccountBalance(Decimal("100.00")) - with pytest.raises(Exception): # FrozenInstanceError + 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 index 0b0e386..5a95750 100644 --- a/tests/unit/context/user_account/domain/user_account_currency_test.py +++ b/tests/unit/context/user_account/domain/user_account_currency_test.py @@ -1,5 +1,7 @@ """Unit tests for UserAccountCurrency value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.user_account.domain.value_objects import UserAccountCurrency @@ -42,5 +44,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" currency = UserAccountCurrency("USD") - with pytest.raises(Exception): # FrozenInstanceError + 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 index 21c6308..4145505 100644 --- 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 @@ -1,7 +1,9 @@ """Unit tests for UserAccountDeletedAt value object""" +from dataclasses import FrozenInstanceError +from datetime import UTC, datetime + import pytest -from datetime import datetime, UTC from app.context.user_account.domain.value_objects import UserAccountDeletedAt @@ -40,5 +42,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" deleted_at = UserAccountDeletedAt.now() - with pytest.raises(Exception): # FrozenInstanceError + 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 index c6302fd..04aabff 100644 --- a/tests/unit/context/user_account/domain/user_account_dto_test.py +++ b/tests/unit/context/user_account/domain/user_account_dto_test.py @@ -1,17 +1,19 @@ """Unit tests for user_account domain DTOs""" -import pytest -from decimal import Decimal +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 ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, - UserAccountUserID, + UserAccountCurrency, UserAccountDeletedAt, + UserAccountID, + UserAccountUserID, ) @@ -21,8 +23,6 @@ class TestUserAccountDTO: def test_create_dto_with_all_fields(self): """Test creating DTO with all fields populated""" - from datetime import UTC - now = datetime.now(UTC) dto = UserAccountDTO( user_id=UserAccountUserID(1), name=AccountName("My Account"), @@ -36,7 +36,9 @@ def test_create_dto_with_all_fields(self): 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): @@ -104,7 +106,7 @@ def test_immutability(self): balance=UserAccountBalance(Decimal("100.50")), ) - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): dto.user_id = UserAccountUserID(2) def test_dto_with_negative_balance(self): 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 index 978dfff..89519d9 100644 --- a/tests/unit/context/user_account/domain/user_account_id_test.py +++ b/tests/unit/context/user_account/domain/user_account_id_test.py @@ -1,5 +1,7 @@ """Unit tests for UserAccountID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.user_account.domain.value_objects import UserAccountID @@ -38,5 +40,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" account_id = UserAccountID(1) - with pytest.raises(Exception): # FrozenInstanceError + 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 index b7d0f00..77857eb 100644 --- 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 @@ -1,5 +1,7 @@ """Unit tests for UserAccountUserID value object""" +from dataclasses import FrozenInstanceError + import pytest from app.context.user_account.domain.value_objects import UserAccountUserID @@ -22,5 +24,5 @@ def test_from_trusted_source_skips_validation(self): def test_immutability(self): """Test that value object is immutable""" user_id = UserAccountUserID(1) - with pytest.raises(Exception): # FrozenInstanceError + with pytest.raises(FrozenInstanceError): user_id.value = 2 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 index 40f181b..8847895 100644 --- a/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py +++ b/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py @@ -1,25 +1,26 @@ """Unit tests for user_account infrastructure mapper""" -import pytest -from decimal import Decimal from datetime import datetime +from decimal import Decimal + +import pytest -from app.context.user_account.infrastructure.mappers.user_account_mapper import ( - UserAccountMapper, -) -from app.context.user_account.infrastructure.models.user_account_model import ( - UserAccountModel, -) 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 ( - UserAccountID, AccountName, - UserAccountCurrency, UserAccountBalance, - UserAccountUserID, + 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, ) -from app.context.user_account.domain.exceptions import UserAccountMapperError @pytest.mark.unit From 61160be30bf9c589893059934a1890d9c7f6ff51 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 21:10:58 +0100 Subject: [PATCH 40/58] Adding ruf as dev dependency --- pyproject.toml | 1 + uv.lock | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 765e9ce..1c5cfa7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dev = [ "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov>=6.0.0", + "ruff>=0.14.10", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index 161b167..1c5e63c 100644 --- a/uv.lock +++ b/uv.lock @@ -360,6 +360,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "ruff" }, ] [package.metadata] @@ -381,6 +382,7 @@ dev = [ { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "ruff", specifier = ">=0.14.10" }, ] [[package]] @@ -674,6 +676,32 @@ 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 = "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" From 37c15713cf2c9e99f08280acf5c826e74abfba73 Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 21:16:55 +0100 Subject: [PATCH 41/58] Format fixes --- .../handlers/get_session_handler.py | 5 +- .../application/handlers/login_handler.py | 4 +- .../contracts/login_service_contract.py | 4 +- .../auth/domain/services/login_service.py | 4 +- .../value_objects/failed_login_attempts.py | 6 +- .../infrastructure/mappers/session_mapper.py | 4 +- .../infrastructure/models/session_model.py | 4 +- .../repositories/session_repository.py | 9 +-- .../rest/controllers/login_rest_controller.py | 8 +-- .../create_credit_card_handler_contract.py | 4 +- ...find_credit_card_by_id_handler_contract.py | 4 +- ...d_credit_cards_by_user_handler_contract.py | 4 +- .../update_credit_card_handler_contract.py | 4 +- .../find_credit_card_by_id_handler.py | 5 +- .../find_credit_cards_by_user_handler.py | 8 +-- .../handlers/update_credit_card_handler.py | 14 +--- .../credit_card_repository_contract.py | 4 +- .../domain/exceptions/exceptions.py | 3 + .../services/update_credit_card_service.py | 19 ++--- .../domain/value_objects/card_limit.py | 12 +--- .../domain/value_objects/card_used.py | 12 +--- .../domain/value_objects/credit_card_id.py | 8 +-- .../domain/value_objects/credit_card_name.py | 4 +- .../mappers/credit_card_mapper.py | 1 - .../models/credit_card_model.py | 12 +--- .../find_credit_card_controller.py | 8 +-- .../update_credit_card_controller.py | 4 +- .../schemas/create_credit_card_schema.py | 4 +- .../schemas/update_credit_card_schema.py | 5 +- ...list_household_invites_handler_contract.py | 4 +- ...t_user_pending_invites_handler_contract.py | 4 +- .../dto/household_member_response_dto.py | 14 +--- .../list_household_invites_handler.py | 10 +-- .../list_user_pending_invites_handler.py | 10 +-- .../create_household_service_contract.py | 4 +- .../household_repository_contract.py | 40 +++-------- .../domain/services/accept_invite_service.py | 4 +- .../services/create_household_service.py | 4 +- .../domain/services/invite_user_service.py | 12 +--- .../domain/services/remove_member_service.py | 8 +-- .../domain/value_objects/household_name.py | 4 +- .../domain/value_objects/household_role.py | 4 +- .../mappers/household_mapper.py | 9 +-- .../infrastructure/models/household_model.py | 20 ++---- .../list_household_invites_controller.py | 4 +- .../list_user_pending_invites_controller.py | 4 +- .../interface/schemas/invite_user_request.py | 4 +- .../user_repository_contract.py | 4 +- .../infrastructure/mappers/user_mapper.py | 5 +- .../user/infrastructure/models/user_model.py | 4 +- .../repositories/user_repository.py | 5 +- .../find_account_by_id_handler_contract.py | 4 +- .../handlers/find_account_by_id_handler.py | 1 - .../handlers/find_accounts_by_user_handler.py | 10 +-- .../user_account_repository_contract.py | 4 +- .../domain/services/update_account_service.py | 21 ++---- .../infrastructure/dependencies.py | 4 +- .../mappers/user_account_mapper.py | 1 - .../models/user_account_model.py | 8 +-- .../repositories/user_account_repository.py | 14 +--- .../controllers/find_account_controller.py | 4 +- .../schemas/create_account_schema.py | 4 +- .../domain/value_objects/shared_balance.py | 4 +- .../domain/value_objects/shared_currency.py | 8 +-- .../domain/value_objects/shared_date.py | 5 +- .../domain/value_objects/shared_deleted_at.py | 4 +- app/shared/infrastructure/database.py | 4 +- ...make_deleted_at_timezone_aware_in_user_.py | 13 ++-- .../e19a954402db_create_credit_cards_table.py | 4 +- scripts/seeds/seed_households.py | 4 +- scripts/seeds/seed_user_accounts.py | 8 +-- tests/fixtures/auth/services.py | 4 +- .../credit_card/credit_card_fixtures.py | 1 + tests/integration/fixtures/client.py | 4 +- tests/integration/fixtures/database.py | 4 +- tests/integration/test_login_flow.py | 44 +++--------- .../application/get_session_handler_test.py | 8 +-- .../auth/application/login_handler_test.py | 72 +++++-------------- .../context/auth/domain/login_service_test.py | 56 ++++----------- .../context/auth/domain/throttle_time_test.py | 4 +- .../create_credit_card_handler_test.py | 36 +++------- .../delete_credit_card_handler_test.py | 16 ++--- .../find_credit_card_by_id_handler_test.py | 24 ++----- .../find_credit_cards_by_user_handler_test.py | 32 +++------ .../update_credit_card_handler_test.py | 24 ++----- .../credit_card/domain/card_limit_test.py | 12 +--- .../credit_card/domain/card_used_test.py | 8 +-- .../domain/create_credit_card_service_test.py | 12 +--- .../credit_card/domain/credit_card_id_test.py | 12 +--- .../domain/update_credit_card_service_test.py | 32 +++------ .../create_household_handler_test.py | 12 +--- .../application/invite_user_handler_test.py | 65 +++++------------ .../domain/accept_invite_service_test.py | 16 ++--- .../domain/create_household_service_test.py | 20 ++---- .../domain/decline_invite_service_test.py | 20 ++---- .../domain/household_member_id_test.py | 8 +-- .../household/domain/household_name_test.py | 4 +- .../household/domain/household_role_test.py | 8 +-- .../domain/invite_user_service_test.py | 24 ++----- .../domain/remove_member_service_test.py | 24 ++----- .../domain/revoke_invite_service_test.py | 24 ++----- .../create_account_handler_test.py | 36 +++------- .../delete_account_handler_test.py | 2 +- .../find_account_by_id_handler_test.py | 4 +- .../find_accounts_by_user_handler_test.py | 4 +- .../update_account_handler_test.py | 32 +++------ .../domain/create_account_service_test.py | 16 ++--- .../domain/update_account_service_test.py | 24 ++----- .../user_account_mapper_test.py | 1 + 109 files changed, 313 insertions(+), 933 deletions(-) diff --git a/app/context/auth/application/handlers/get_session_handler.py b/app/context/auth/application/handlers/get_session_handler.py index a55755a..34a073e 100644 --- a/app/context/auth/application/handlers/get_session_handler.py +++ b/app/context/auth/application/handlers/get_session_handler.py @@ -1,4 +1,3 @@ - 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 @@ -22,9 +21,7 @@ async def handle(self, query: GetSessionQuery) -> GetSessionResultDTO | None: 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, + 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 index 6026b76..cbdd2d3 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -21,9 +21,7 @@ class LoginHandler(LoginHandlerContract): _user_handler: FindUserHandlerContract _login_service: LoginServiceContract - def __init__( - self, user_handler: FindUserHandlerContract, login_service: LoginServiceContract - ): + def __init__(self, user_handler: FindUserHandlerContract, login_service: LoginServiceContract): self._user_handler = user_handler self._login_service = login_service pass diff --git a/app/context/auth/domain/contracts/login_service_contract.py b/app/context/auth/domain/contracts/login_service_contract.py index da9b482..fa1cc56 100644 --- a/app/context/auth/domain/contracts/login_service_contract.py +++ b/app/context/auth/domain/contracts/login_service_contract.py @@ -6,9 +6,7 @@ class LoginServiceContract(ABC): @abstractmethod - async def handle( - self, user_password: AuthPassword, db_user: AuthUserDTO - ) -> SessionToken: + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> SessionToken: """ Handle user login. diff --git a/app/context/auth/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index 2f6fef8..952ae1c 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -23,9 +23,7 @@ class LoginService(LoginServiceContract): def __init__(self, session_repo: SessionRepositoryContract): self._session_repo = session_repo - async def handle( - self, user_password: AuthPassword, db_user: AuthUserDTO - ) -> SessionToken: + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> SessionToken: session = await self._session_repo.getSession(user_id=db_user.user_id) if session is None: diff --git a/app/context/auth/domain/value_objects/failed_login_attempts.py b/app/context/auth/domain/value_objects/failed_login_attempts.py index 4ff4f27..2d4de98 100644 --- a/app/context/auth/domain/value_objects/failed_login_attempts.py +++ b/app/context/auth/domain/value_objects/failed_login_attempts.py @@ -12,11 +12,7 @@ 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 - ) + return self._wait_attempts[self.value] if self.value < len(self._wait_attempts) else 4 @classmethod def reset(cls) -> Self: diff --git a/app/context/auth/infrastructure/mappers/session_mapper.py b/app/context/auth/infrastructure/mappers/session_mapper.py index 567c070..7ad9ecf 100644 --- a/app/context/auth/infrastructure/mappers/session_mapper.py +++ b/app/context/auth/infrastructure/mappers/session_mapper.py @@ -21,9 +21,7 @@ def toDTO(model: SessionModel | None) -> SessionDTO | None: 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, + blocked_until=BlockedTime(model.blocked_until) if model.blocked_until is not None else None, ) @staticmethod diff --git a/app/context/auth/infrastructure/models/session_model.py b/app/context/auth/infrastructure/models/session_model.py index 4e6ca33..610cc64 100644 --- a/app/context/auth/infrastructure/models/session_model.py +++ b/app/context/auth/infrastructure/models/session_model.py @@ -9,9 +9,7 @@ class SessionModel(BaseDBModel): __tablename__ = "sessions" - user_id: Mapped[int] = mapped_column( - Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True - ) + 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/session_repository.py b/app/context/auth/infrastructure/repositories/session_repository.py index 249d7c0..95e419b 100644 --- a/app/context/auth/infrastructure/repositories/session_repository.py +++ b/app/context/auth/infrastructure/repositories/session_repository.py @@ -1,4 +1,3 @@ - from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -32,9 +31,7 @@ async def createSession(self, session: SessionDTO) -> SessionDTO: 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, + blocked_until=session.blocked_until.value if session.blocked_until is not None else None, ) self._db.add(session_model) @@ -54,9 +51,7 @@ async def updateSession(self, session: SessionDTO) -> SessionDTO: .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, + blocked_until=session.blocked_until.value if session.blocked_until is not None else None, ) ) await self._db.execute(stmt) diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 6ea7bac..896def4 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -19,9 +19,7 @@ async def login( handler: Annotated[LoginHandlerContract, Depends(get_login_handler)], ): """User login endpoint""" - login_result = await handler.handle( - LoginCommand(email=str(request.email), password=request.password) - ) + 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: @@ -46,9 +44,7 @@ async def login( 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 - ) + raise HTTPException(status_code=429, detail=login_result.error_msg, headers=headers) # UNEXPECTED_ERROR or any other status raise HTTPException(status_code=500, detail=login_result.error_msg) 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 index dc1b48c..c672cf3 100644 --- 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 @@ -12,8 +12,6 @@ class CreateCreditCardHandlerContract(ABC): """Contract for create credit card command handler""" @abstractmethod - async def handle( - self, command: CreateCreditCardCommand - ) -> CreateCreditCardResult: + async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResult: """Handle the create 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 index 8211eb1..177e035 100644 --- 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 @@ -12,8 +12,6 @@ class FindCreditCardByIdHandlerContract(ABC): """Contract for find credit card by ID query handler""" @abstractmethod - async def handle( - self, query: FindCreditCardByIdQuery - ) -> CreditCardResponseDTO | None: + 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 index 7ed5438..38899c2 100644 --- 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 @@ -12,8 +12,6 @@ class FindCreditCardsByUserHandlerContract(ABC): """Contract for find credit cards by user query handler""" @abstractmethod - async def handle( - self, query: FindCreditCardsByUserQuery - ) -> list[CreditCardResponseDTO]: + 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 index ee9559d..61555e3 100644 --- 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 @@ -12,8 +12,6 @@ class UpdateCreditCardHandlerContract(ABC): """Contract for update credit card command handler""" @abstractmethod - async def handle( - self, command: UpdateCreditCardCommand - ) -> UpdateCreditCardResult: + async def handle(self, command: UpdateCreditCardCommand) -> UpdateCreditCardResult: """Handle the update credit card command""" pass 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 index a627372..b9d51e8 100644 --- 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 @@ -1,4 +1,3 @@ - from app.context.credit_card.application.contracts import ( FindCreditCardByIdHandlerContract, ) @@ -16,9 +15,7 @@ class FindCreditCardByIdHandler(FindCreditCardByIdHandlerContract): def __init__(self, repository: CreditCardRepositoryContract): self._repository = repository - async def handle( - self, query: FindCreditCardByIdQuery - ) -> CreditCardResponseDTO | None: + async def handle(self, query: FindCreditCardByIdQuery) -> CreditCardResponseDTO | None: """Execute the find credit card by ID query""" # Convert query primitives to value objects and find card for user 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 index 807c21a..7a50f7f 100644 --- 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 @@ -15,14 +15,10 @@ class FindCreditCardsByUserHandler(FindCreditCardsByUserHandlerContract): def __init__(self, repository: CreditCardRepositoryContract): self._repository = repository - async def handle( - self, query: FindCreditCardsByUserQuery - ) -> list[CreditCardResponseDTO]: + async def handle(self, query: FindCreditCardsByUserQuery) -> list[CreditCardResponseDTO]: """Execute the find credit cards by user query""" # Convert query primitive to value object - card_dtos = await self._repository.find_user_credit_cards( - user_id=CreditCardUserID(query.user_id) - ) + 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 index b2695ac..2852714 100644 --- a/app/context/credit_card/application/handlers/update_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/update_credit_card_handler.py @@ -38,17 +38,9 @@ async def handle(self, command: UpdateCreditCardCommand) -> UpdateCreditCardResu 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 - ) + 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( 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 index aab4ca7..9ff964e 100644 --- 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 @@ -109,9 +109,7 @@ async def update_credit_card(self, card: CreditCardDTO) -> CreditCardDTO: pass @abstractmethod - async def delete_credit_card( - self, card_id: CreditCardID, user_id: CreditCardUserID - ) -> bool: + 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 diff --git a/app/context/credit_card/domain/exceptions/exceptions.py b/app/context/credit_card/domain/exceptions/exceptions.py index 67ce8b7..66276bc 100644 --- a/app/context/credit_card/domain/exceptions/exceptions.py +++ b/app/context/credit_card/domain/exceptions/exceptions.py @@ -1,5 +1,6 @@ # Value Object Exceptions + class InvalidCardLimitTypeError(Exception): pass @@ -50,6 +51,7 @@ class InvalidCreditCardIdValueError(Exception): # Domain Service Exceptions + class CreditCardNotFoundError(Exception): pass @@ -68,6 +70,7 @@ class CreditCardUsedExceedsLimitError(Exception): # Repository Exceptions + class CreditCardCreationError(Exception): pass 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 index b2023de..8ca8304 100644 --- a/app/context/credit_card/domain/services/update_credit_card_service.py +++ b/app/context/credit_card/domain/services/update_credit_card_service.py @@ -1,4 +1,3 @@ - from app.context.credit_card.domain.contracts.infrastructure.credit_card_repository_contract import ( CreditCardRepositoryContract, ) @@ -42,27 +41,20 @@ async def update_credit_card( """Update an existing credit card with validation""" # Find the existing card - existing_card = await self._repository.find_credit_card( - card_id=credit_card_id - ) + existing_card = await self._repository.find_credit_card(card_id=credit_card_id) if not existing_card: - raise CreditCardNotFoundError( - f"Credit card with ID {credit_card_id.value} not found" - ) + raise CreditCardNotFoundError(f"Credit card with ID {credit_card_id.value} not found") # Verify ownership if existing_card.user_id.value != user_id.value: raise CreditCardUnauthorizedAccessError( - f"User {user_id.value} is not authorized to update credit card " - f"{credit_card_id.value}" + 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 - ) + duplicate_card = await self._repository.find_credit_card(user_id=user_id, name=name) if duplicate_card: raise CreditCardNameAlreadyExistError( f"Credit card with name '{name.value}' already exists for this user" @@ -76,8 +68,7 @@ async def update_credit_card( # Business rule: ensure used <= limit if updated_used.value > updated_limit.value: raise CreditCardUsedExceedsLimitError( - f"Used amount ({updated_used.value}) cannot exceed limit " - f"({updated_limit.value})" + f"Used amount ({updated_used.value}) cannot exceed limit ({updated_limit.value})" ) # Create updated DTO diff --git a/app/context/credit_card/domain/value_objects/card_limit.py b/app/context/credit_card/domain/value_objects/card_limit.py index 11e2d69..cae3a5f 100644 --- a/app/context/credit_card/domain/value_objects/card_limit.py +++ b/app/context/credit_card/domain/value_objects/card_limit.py @@ -19,19 +19,13 @@ class CardLimit: 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)}" - ) + 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}" - ) + 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}" - ) + raise InvalidCardLimitPrecisionError(f"CardLimit must have at most 2 decimal places, got {self.value}") @classmethod def from_float(cls, value: float) -> "CardLimit": diff --git a/app/context/credit_card/domain/value_objects/card_used.py b/app/context/credit_card/domain/value_objects/card_used.py index 6cf1fe2..6892749 100644 --- a/app/context/credit_card/domain/value_objects/card_used.py +++ b/app/context/credit_card/domain/value_objects/card_used.py @@ -19,19 +19,13 @@ class CardUsed: 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)}" - ) + 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}" - ) + 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}" - ) + raise InvalidCardUsedPrecisionError(f"CardUsed must have at most 2 decimal places, got {self.value}") @classmethod def from_float(cls, value: float) -> "CardUsed": 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 index 62b3cef..435c51c 100644 --- a/app/context/credit_card/domain/value_objects/credit_card_id.py +++ b/app/context/credit_card/domain/value_objects/credit_card_id.py @@ -15,13 +15,9 @@ class CreditCardID: 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)}" - ) + 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}" - ) + raise InvalidCreditCardIdValueError(f"CreditCardID must be positive, got {self.value}") @classmethod def from_trusted_source(cls, value: int) -> "CreditCardID": 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 index 195b426..1d95992 100644 --- a/app/context/credit_card/domain/value_objects/credit_card_name.py +++ b/app/context/credit_card/domain/value_objects/credit_card_name.py @@ -16,9 +16,7 @@ class CreditCardName: 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)}" - ) + 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)}" diff --git a/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py index 2a65066..283086e 100644 --- a/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py +++ b/app/context/credit_card/infrastructure/mappers/credit_card_mapper.py @@ -1,4 +1,3 @@ - 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 ( diff --git a/app/context/credit_card/infrastructure/models/credit_card_model.py b/app/context/credit_card/infrastructure/models/credit_card_model.py index ed38380..003dc65 100644 --- a/app/context/credit_card/infrastructure/models/credit_card_model.py +++ b/app/context/credit_card/infrastructure/models/credit_card_model.py @@ -11,16 +11,10 @@ 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 - ) + 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 - ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) diff --git a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py index eb57e36..ac3a7c9 100644 --- a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py +++ b/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py @@ -25,9 +25,7 @@ @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) - ], + handler: Annotated[FindCreditCardByIdHandlerContract, Depends(get_find_credit_card_by_id_handler)], user_id: Annotated[int, Depends(get_current_user_id)], ): """Get a credit card by ID""" @@ -57,9 +55,7 @@ async def get_credit_card( @router.get("", response_model=list[CreditCardResponse]) async def get_credit_cards( - handler: Annotated[ - FindCreditCardsByUserHandlerContract, Depends(get_find_credit_cards_by_user_handler) - ], + handler: Annotated[FindCreditCardsByUserHandlerContract, Depends(get_find_credit_cards_by_user_handler)], user_id: Annotated[int, Depends(get_current_user_id)], ): """Get all credit cards for the current user""" 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 index 3fac160..b11d929 100644 --- 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 @@ -50,6 +50,4 @@ async def update_credit_card( status_code = status_code_map.get(result.error_code, 500) raise HTTPException(status_code=status_code, detail=result.error_message) - return UpdateCreditCardResponse( - success=True, message="Credit card updated successfully" - ) + return UpdateCreditCardResponse(success=True, message="Credit card updated successfully") 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 index 9db1288..b4a9b27 100644 --- a/app/context/credit_card/interface/schemas/create_credit_card_schema.py +++ b/app/context/credit_card/interface/schemas/create_credit_card_schema.py @@ -6,9 +6,7 @@ class CreateCreditCardRequest(BaseModel): 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)" - ) + 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") 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 index f4f2c4d..0d3502b 100644 --- a/app/context/credit_card/interface/schemas/update_credit_card_schema.py +++ b/app/context/credit_card/interface/schemas/update_credit_card_schema.py @@ -1,12 +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" - ) + 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/household/application/contracts/list_household_invites_handler_contract.py b/app/context/household/application/contracts/list_household_invites_handler_contract.py index c5ff253..2947e64 100644 --- a/app/context/household/application/contracts/list_household_invites_handler_contract.py +++ b/app/context/household/application/contracts/list_household_invites_handler_contract.py @@ -8,8 +8,6 @@ class ListHouseholdInvitesHandlerContract(ABC): """Contract for list household invites query handler""" @abstractmethod - async def handle( - self, query: ListHouseholdInvitesQuery - ) -> list[HouseholdMemberResponseDTO]: + 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_pending_invites_handler_contract.py b/app/context/household/application/contracts/list_user_pending_invites_handler_contract.py index 69fc219..7866181 100644 --- 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 @@ -8,8 +8,6 @@ class ListUserPendingInvitesHandlerContract(ABC): """Contract for list user pending invites query handler""" @abstractmethod - async def handle( - self, query: ListUserPendingInvitesQuery - ) -> list[HouseholdMemberResponseDTO]: + async def handle(self, query: ListUserPendingInvitesQuery) -> list[HouseholdMemberResponseDTO]: """Execute the list user pending invites query""" pass diff --git a/app/context/household/application/dto/household_member_response_dto.py b/app/context/household/application/dto/household_member_response_dto.py index eaa2ef8..3bffa3f 100644 --- a/app/context/household/application/dto/household_member_response_dto.py +++ b/app/context/household/application/dto/household_member_response_dto.py @@ -27,16 +27,8 @@ def from_domain_dto(member_dto: HouseholdMemberDTO) -> "HouseholdMemberResponseD 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_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, + 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/handlers/list_household_invites_handler.py b/app/context/household/application/handlers/list_household_invites_handler.py index ced7ad8..1aa8acf 100644 --- a/app/context/household/application/handlers/list_household_invites_handler.py +++ b/app/context/household/application/handlers/list_household_invites_handler.py @@ -13,9 +13,7 @@ class ListHouseholdInvitesHandler(ListHouseholdInvitesHandlerContract): def __init__(self, repository: HouseholdRepositoryContract): self._repository = repository - async def handle( - self, query: ListHouseholdInvitesQuery - ) -> list[HouseholdMemberResponseDTO]: + async def handle(self, query: ListHouseholdInvitesQuery) -> list[HouseholdMemberResponseDTO]: """Execute the list household invites query""" members = await self._repository.list_household_pending_invites( @@ -23,8 +21,4 @@ async def handle( owner_id=HouseholdUserID(query.user_id), ) - return ( - [HouseholdMemberResponseDTO.from_domain_dto(member) for member in members] - if members - else [] - ) + return [HouseholdMemberResponseDTO.from_domain_dto(member) for member in members] if members else [] 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 index 0756ebe..27273cf 100644 --- a/app/context/household/application/handlers/list_user_pending_invites_handler.py +++ b/app/context/household/application/handlers/list_user_pending_invites_handler.py @@ -13,17 +13,11 @@ class ListUserPendingInvitesHandler(ListUserPendingInvitesHandlerContract): def __init__(self, repository: HouseholdRepositoryContract): self._repository = repository - async def handle( - self, query: ListUserPendingInvitesQuery - ) -> list[HouseholdMemberResponseDTO]: + async def handle(self, query: ListUserPendingInvitesQuery) -> list[HouseholdMemberResponseDTO]: """Execute the list user pending invites query""" 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 [] - ) + return [HouseholdMemberResponseDTO.from_domain_dto(member) for member in members] if members else [] diff --git a/app/context/household/domain/contracts/create_household_service_contract.py b/app/context/household/domain/contracts/create_household_service_contract.py index da09c15..588da30 100644 --- a/app/context/household/domain/contracts/create_household_service_contract.py +++ b/app/context/household/domain/contracts/create_household_service_contract.py @@ -6,8 +6,6 @@ class CreateHouseholdServiceContract(ABC): @abstractmethod - async def create_household( - self, name: HouseholdName, creator_user_id: HouseholdUserID - ) -> HouseholdDTO: + 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/household_repository_contract.py b/app/context/household/domain/contracts/household_repository_contract.py index 2e37836..b42b471 100644 --- a/app/context/household/domain/contracts/household_repository_contract.py +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -10,23 +10,17 @@ class HouseholdRepositoryContract(ABC): @abstractmethod - async def create_household( - self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID - ) -> HouseholdDTO: + async def create_household(self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID) -> 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: + 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: + async def find_household_by_id(self, household_id: HouseholdID) -> HouseholdDTO | None: """Find a household by ID""" pass @@ -37,37 +31,27 @@ async def create_member(self, member: HouseholdMemberDTO) -> HouseholdMemberDTO: pass @abstractmethod - async def find_member( - self, household_id: HouseholdID, user_id: HouseholdUserID - ) -> HouseholdMemberDTO | None: + 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: + 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: + 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]: + 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]: + 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 @@ -79,15 +63,11 @@ async def list_household_pending_invites( pass @abstractmethod - async def list_user_pending_household_invites( - self, user_id: HouseholdUserID - ) -> list[HouseholdMemberDTO]: + async def list_user_pending_household_invites(self, user_id: HouseholdUserID) -> list[HouseholdMemberDTO]: """List user pending invitation to households""" pass @abstractmethod - async def user_has_access( - self, user_id: HouseholdUserID, household_id: HouseholdID - ) -> bool: + 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/services/accept_invite_service.py b/app/context/household/domain/services/accept_invite_service.py index 4b6ba42..9e305b7 100644 --- a/app/context/household/domain/services/accept_invite_service.py +++ b/app/context/household/domain/services/accept_invite_service.py @@ -22,9 +22,7 @@ async def accept_invite( member = await self._household_repo.find_member(household_id, user_id) if not member or not member.is_invited: - raise NotInvitedError( - "No pending invite found for this household" - ) + 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 index fcff323..b16879b 100644 --- a/app/context/household/domain/services/create_household_service.py +++ b/app/context/household/domain/services/create_household_service.py @@ -10,9 +10,7 @@ class CreateHouseholdService(CreateHouseholdServiceContract): def __init__(self, household_repository: HouseholdRepositoryContract): self._household_repository = household_repository - async def create_household( - self, name: HouseholdName, creator_user_id: HouseholdUserID - ) -> HouseholdDTO: + async def create_household(self, name: HouseholdName, creator_user_id: HouseholdUserID) -> HouseholdDTO: """Create a new household with the creator as owner""" # Create new household DTO with owner diff --git a/app/context/household/domain/services/invite_user_service.py b/app/context/household/domain/services/invite_user_service.py index 92f7be4..ae7a98f 100644 --- a/app/context/household/domain/services/invite_user_service.py +++ b/app/context/household/domain/services/invite_user_service.py @@ -39,19 +39,13 @@ async def invite_user( if not household or household.owner_user_id.value != inviter_user_id.value: raise OnlyOwnerCanInviteError("Only the household owner can invite users") - existing_member = await self._household_repo.find_member( - household_id, invitee_user_id - ) + existing_member = await self._household_repo.find_member(household_id, invitee_user_id) if existing_member: if existing_member.is_active: - raise AlreadyActiveMemberError( - "User is already an active member of this household" - ) + raise AlreadyActiveMemberError("User is already an active member of this household") if existing_member.is_invited: - raise AlreadyInvitedError( - "User already has a pending invite to this household" - ) + raise AlreadyInvitedError("User already has a pending invite to this household") # 3. Create invite (household_member with joined_at=None) member_dto = HouseholdMemberDTO( diff --git a/app/context/household/domain/services/remove_member_service.py b/app/context/household/domain/services/remove_member_service.py index 0347375..bbe52b4 100644 --- a/app/context/household/domain/services/remove_member_service.py +++ b/app/context/household/domain/services/remove_member_service.py @@ -25,15 +25,11 @@ async def remove_member( # 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: - raise OnlyOwnerCanRemoveMemberError( - "Only the household owner can remove members" - ) + raise OnlyOwnerCanRemoveMemberError("Only the household owner can remove members") # Owner cannot remove themselves if remover_user_id.value == member_user_id.value: - raise CannotRemoveSelfError( - "Owner cannot remove themselves from the household" - ) + 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) diff --git a/app/context/household/domain/value_objects/household_name.py b/app/context/household/domain/value_objects/household_name.py index 058268d..ed7573e 100644 --- a/app/context/household/domain/value_objects/household_name.py +++ b/app/context/household/domain/value_objects/household_name.py @@ -12,9 +12,7 @@ def __post_init__(self): 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)}" - ) + raise ValueError(f"Household name cannot exceed 100 characters, got {len(self.value)}") @classmethod def from_trusted_source(cls, value: str) -> Self: diff --git a/app/context/household/domain/value_objects/household_role.py b/app/context/household/domain/value_objects/household_role.py index 74da14d..abf6a22 100644 --- a/app/context/household/domain/value_objects/household_role.py +++ b/app/context/household/domain/value_objects/household_role.py @@ -15,9 +15,7 @@ def __post_init__(self): 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}" - ) + raise ValueError(f"Invalid role: '{self.value}'. Must be one of {self.VALID_ROLES}") @classmethod def from_trusted_source(cls, value: str) -> Self: diff --git a/app/context/household/infrastructure/mappers/household_mapper.py b/app/context/household/infrastructure/mappers/household_mapper.py index be3ff8a..58aa1a0 100644 --- a/app/context/household/infrastructure/mappers/household_mapper.py +++ b/app/context/household/infrastructure/mappers/household_mapper.py @@ -1,4 +1,3 @@ - from app.context.household.domain.dto import HouseholdDTO from app.context.household.domain.exceptions import HouseholdMapperError from app.context.household.domain.value_objects import ( @@ -24,9 +23,7 @@ def to_dto(model: HouseholdModel | None) -> HouseholdDTO | None: created_at=model.created_at, ) except Exception as e: - raise HouseholdMapperError( - f"Error mapping HouseholdModel to DTO: {e}" - ) from e + raise HouseholdMapperError(f"Error mapping HouseholdModel to DTO: {e}") from e @staticmethod def to_dto_or_fail(model: HouseholdModel | None) -> HouseholdDTO: @@ -52,6 +49,4 @@ def to_model(dto: HouseholdDTO) -> HouseholdModel: return model except Exception as e: - raise HouseholdMapperError( - f"Error mapping HouseholdDTO to model: {e}" - ) from e + raise HouseholdMapperError(f"Error mapping HouseholdDTO to model: {e}") from e diff --git a/app/context/household/infrastructure/models/household_model.py b/app/context/household/infrastructure/models/household_model.py index b6fe11a..1083ae2 100644 --- a/app/context/household/infrastructure/models/household_model.py +++ b/app/context/household/infrastructure/models/household_model.py @@ -10,9 +10,7 @@ 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 - ) + 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), @@ -25,17 +23,11 @@ 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 - ) + 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 - ) + 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"), @@ -47,6 +39,4 @@ class HouseholdMemberModel(BaseDBModel): ) # Composite unique constraint - user can only have one record per household - __table_args__ = ( - UniqueConstraint("household_id", "user_id", name="uq_household_user"), - ) + __table_args__ = (UniqueConstraint("household_id", "user_id", name="uq_household_user"),) 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 index 0c0bcae..8b829b3 100644 --- a/app/context/household/interface/rest/controllers/list_household_invites_controller.py +++ b/app/context/household/interface/rest/controllers/list_household_invites_controller.py @@ -18,9 +18,7 @@ @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) - ], + handler: Annotated[ListHouseholdInvitesHandlerContract, Depends(get_list_household_invites_handler)], user_id: Annotated[int, Depends(get_current_user_id)], ) -> list[HouseholdMemberResponse]: """List pending invitations for a household""" 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 index d501605..8e06fd7 100644 --- 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 @@ -17,9 +17,7 @@ @router.get("/invites/pending", status_code=200) async def list_user_pending_invites( - handler: Annotated[ - ListUserPendingInvitesHandlerContract, Depends(get_list_user_pending_invites_handler) - ], + handler: Annotated[ListUserPendingInvitesHandlerContract, Depends(get_list_user_pending_invites_handler)], user_id: Annotated[int, Depends(get_current_user_id)], ) -> list[HouseholdMemberResponse]: """List all pending invitations for the authenticated user""" diff --git a/app/context/household/interface/schemas/invite_user_request.py b/app/context/household/interface/schemas/invite_user_request.py index 537e0cc..ea14835 100644 --- a/app/context/household/interface/schemas/invite_user_request.py +++ b/app/context/household/interface/schemas/invite_user_request.py @@ -4,9 +4,7 @@ class InviteUserRequest(BaseModel): model_config = ConfigDict(frozen=True) - invitee_user_id: int = Field( - ..., gt=0, description="User ID of the person being invited" - ) + 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/user/domain/contracts/infrastructure/user_repository_contract.py b/app/context/user/domain/contracts/infrastructure/user_repository_contract.py index 443e1c9..62a8b51 100644 --- a/app/context/user/domain/contracts/infrastructure/user_repository_contract.py +++ b/app/context/user/domain/contracts/infrastructure/user_repository_contract.py @@ -8,9 +8,7 @@ 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: + async def find_user(self, user_id: UserID | None = None, email: UserEmail | None = None) -> UserDTO | None: """ Find a user by ID or email. diff --git a/app/context/user/infrastructure/mappers/user_mapper.py b/app/context/user/infrastructure/mappers/user_mapper.py index d95c189..c18d7cf 100644 --- a/app/context/user/infrastructure/mappers/user_mapper.py +++ b/app/context/user/infrastructure/mappers/user_mapper.py @@ -1,4 +1,3 @@ - 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 ( @@ -25,9 +24,7 @@ def to_dto(model: UserModel | None) -> UserDTO | None: 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, + username=UserName.from_trusted_source(model.username) if model.username else None, deleted_at=UserDeletedAt.from_optional(model.deleted_at), ) if model diff --git a/app/context/user/infrastructure/models/user_model.py b/app/context/user/infrastructure/models/user_model.py index 6ee669d..316b47d 100644 --- a/app/context/user/infrastructure/models/user_model.py +++ b/app/context/user/infrastructure/models/user_model.py @@ -15,9 +15,7 @@ class UserModel(BaseDBModel): 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 - ) + 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 ) diff --git a/app/context/user/infrastructure/repositories/user_repository.py b/app/context/user/infrastructure/repositories/user_repository.py index 1160ab1..e0ffa88 100644 --- a/app/context/user/infrastructure/repositories/user_repository.py +++ b/app/context/user/infrastructure/repositories/user_repository.py @@ -1,4 +1,3 @@ - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,9 +14,7 @@ class UserRepository(UserRepositoryContract): def __init__(self, db: AsyncSession): self._db = db - async def find_user( - self, user_id: UserID | None = None, email: UserEmail | None = None - ) -> UserDTO | None: + 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. 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 index c43f73f..e11b416 100644 --- 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 @@ -10,7 +10,5 @@ class FindAccountByIdHandlerContract(ABC): @abstractmethod - async def handle( - self, query: FindAccountByIdQuery - ) -> AccountResponseDTO | None: + async def handle(self, query: FindAccountByIdQuery) -> AccountResponseDTO | None: pass 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 index ba68f67..d34870f 100644 --- 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 @@ -1,4 +1,3 @@ - from app.context.user_account.application.contracts.find_account_by_id_handler_contract import ( FindAccountByIdHandlerContract, ) 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 index 922cb2c..3217b4a 100644 --- 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 @@ -18,11 +18,5 @@ def __init__(self, repository: UserAccountRepositoryContract): self._repository = repository async def handle(self, query: FindAccountsByUserQuery) -> list[AccountResponseDTO]: - accounts = await self._repository.find_user_accounts( - user_id=UserAccountUserID(query.user_id) - ) - return ( - [AccountResponseDTO.from_domain_dto(acc) for acc in accounts] - if accounts is not None - else [] - ) + accounts = await self._repository.find_user_accounts(user_id=UserAccountUserID(query.user_id)) + return [AccountResponseDTO.from_domain_dto(acc) for acc in accounts] if accounts is not None else [] 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 index d2de3db..ab0c9b0 100644 --- 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 @@ -94,9 +94,7 @@ async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: pass @abstractmethod - async def delete_account( - self, account_id: UserAccountID, user_id: UserAccountUserID - ) -> bool: + 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 diff --git a/app/context/user_account/domain/services/update_account_service.py b/app/context/user_account/domain/services/update_account_service.py index 93ff514..b644fb2 100644 --- a/app/context/user_account/domain/services/update_account_service.py +++ b/app/context/user_account/domain/services/update_account_service.py @@ -30,29 +30,18 @@ async def update_account( currency: UserAccountCurrency, balance: UserAccountBalance, ) -> UserAccountDTO: - existing = await self._repository.find_user_account_by_id( - user_id=user_id, account_id=account_id - ) + existing = await self._repository.find_user_account_by_id(user_id=user_id, account_id=account_id) if not existing: - raise UserAccountNotFoundError( - f"Account with ID {account_id.value} not found for user {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 - ) + 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 - ): - raise UserAccountNameAlreadyExistError( - f"Account with name '{name.value}' already exists" - ) + if duplicate and any(acc.account_id and acc.account_id.value != account_id.value for acc in duplicate): + raise UserAccountNameAlreadyExistError(f"Account with name '{name.value}' already exists") # 3. Update account updated_dto = UserAccountDTO( diff --git a/app/context/user_account/infrastructure/dependencies.py b/app/context/user_account/infrastructure/dependencies.py index efa5adb..f0c4b1a 100644 --- a/app/context/user_account/infrastructure/dependencies.py +++ b/app/context/user_account/infrastructure/dependencies.py @@ -47,9 +47,7 @@ def get_user_account_repository( def get_create_account_service( - account_repository: Annotated[ - UserAccountRepositoryContract, Depends(get_user_account_repository) - ], + account_repository: Annotated[UserAccountRepositoryContract, Depends(get_user_account_repository)], ) -> CreateAccountServiceContract: """CreateAccountService dependency injection""" from app.context.user_account.domain.services.create_account_service import ( diff --git a/app/context/user_account/infrastructure/mappers/user_account_mapper.py b/app/context/user_account/infrastructure/mappers/user_account_mapper.py index f5dece8..e847087 100644 --- a/app/context/user_account/infrastructure/mappers/user_account_mapper.py +++ b/app/context/user_account/infrastructure/mappers/user_account_mapper.py @@ -1,4 +1,3 @@ - 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 ( diff --git a/app/context/user_account/infrastructure/models/user_account_model.py b/app/context/user_account/infrastructure/models/user_account_model.py index 0c452b3..5515164 100644 --- a/app/context/user_account/infrastructure/models/user_account_model.py +++ b/app/context/user_account/infrastructure/models/user_account_model.py @@ -11,12 +11,8 @@ 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 - ) + 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 - ) + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, default=None) diff --git a/app/context/user_account/infrastructure/repositories/user_account_repository.py b/app/context/user_account/infrastructure/repositories/user_account_repository.py index 7c29ede..4d264c0 100644 --- a/app/context/user_account/infrastructure/repositories/user_account_repository.py +++ b/app/context/user_account/infrastructure/repositories/user_account_repository.py @@ -94,11 +94,7 @@ async def find_user_accounts( 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 [] - ) + return [UserAccountMapper.to_dto_or_fail(model) for model in models] if models else [] async def find_user_account_by_id( self, @@ -136,17 +132,13 @@ async def update_account(self, account: UserAccountDTO) -> UserAccountDTO: 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" - ) + 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: + 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) 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 index 493bc38..a689256 100644 --- a/app/context/user_account/interface/rest/controllers/find_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -50,9 +50,7 @@ async def get_account( @router.get("", response_model=list[AccountResponse]) async def get_all_accounts( - handler: Annotated[ - FindAccountsByUserHandlerContract, Depends(get_find_accounts_by_user_handler) - ], + handler: Annotated[FindAccountsByUserHandlerContract, Depends(get_find_accounts_by_user_handler)], user_id: Annotated[int, Depends(get_current_user_id)], ): """Get all accounts for the authenticated user""" diff --git a/app/context/user_account/interface/schemas/create_account_schema.py b/app/context/user_account/interface/schemas/create_account_schema.py index 7f70fe7..53e4f88 100644 --- a/app/context/user_account/interface/schemas/create_account_schema.py +++ b/app/context/user_account/interface/schemas/create_account_schema.py @@ -5,9 +5,7 @@ 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)" - ) + currency: str = Field(..., min_length=3, max_length=3, description="Currency code (ISO 4217)") balance: float = Field(..., description="Initial account balance") @field_validator("currency") diff --git a/app/shared/domain/value_objects/shared_balance.py b/app/shared/domain/value_objects/shared_balance.py index e0a5a26..a411870 100644 --- a/app/shared/domain/value_objects/shared_balance.py +++ b/app/shared/domain/value_objects/shared_balance.py @@ -16,9 +16,7 @@ def __post_init__(self): 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}" - ) + raise ValueError(f"Balance cannot have more than 2 decimal places, got {self.value}") @classmethod def from_float(cls, value: float) -> Self: diff --git a/app/shared/domain/value_objects/shared_currency.py b/app/shared/domain/value_objects/shared_currency.py index dc19209..ec2c125 100644 --- a/app/shared/domain/value_objects/shared_currency.py +++ b/app/shared/domain/value_objects/shared_currency.py @@ -12,13 +12,9 @@ def __post_init__(self): 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)}" - ) + 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}" - ) + 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}") diff --git a/app/shared/domain/value_objects/shared_date.py b/app/shared/domain/value_objects/shared_date.py index 621077b..c068e38 100644 --- a/app/shared/domain/value_objects/shared_date.py +++ b/app/shared/domain/value_objects/shared_date.py @@ -12,10 +12,7 @@ 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." - ) + 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: diff --git a/app/shared/domain/value_objects/shared_deleted_at.py b/app/shared/domain/value_objects/shared_deleted_at.py index dfe58a8..564a959 100644 --- a/app/shared/domain/value_objects/shared_deleted_at.py +++ b/app/shared/domain/value_objects/shared_deleted_at.py @@ -21,9 +21,7 @@ def __post_init__(self): # Ensure timezone-aware comparison now = datetime.now(UTC) - value_utc = ( - self.value if self.value.tzinfo else self.value.replace(tzinfo=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") diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index 3ecea39..915e281 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -33,9 +33,7 @@ pool_recycle=3600, # Recycle connections after 1 hour ) -AsyncSessionLocal = async_sessionmaker( - async_engine, class_=AsyncSession, expire_on_commit=False -) +AsyncSessionLocal = async_sessionmaker(async_engine, class_=AsyncSession, expire_on_commit=False) Base = declarative_base() diff --git a/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py b/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py index d2d49a8..eb8d8c8 100644 --- a/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py +++ b/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py @@ -5,14 +5,15 @@ Create Date: 2025-12-27 11:26:08.433758 """ + from collections.abc import Sequence import sqlalchemy as sa from alembic import op # revision identifiers, used by Alembic. -revision: str = 'd96343c7a2a6' -down_revision: str | Sequence[str] | None = 'e19a954402db' +revision: str = "d96343c7a2a6" +down_revision: str | Sequence[str] | None = "e19a954402db" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -21,8 +22,8 @@ def upgrade() -> None: """Upgrade schema.""" # Change deleted_at from TIMESTAMP to TIMESTAMPTZ (timezone-aware) op.alter_column( - 'user_accounts', - 'deleted_at', + "user_accounts", + "deleted_at", type_=sa.DateTime(timezone=True), existing_type=sa.DateTime(), existing_nullable=True, @@ -33,8 +34,8 @@ def downgrade() -> None: """Downgrade schema.""" # Revert deleted_at from TIMESTAMPTZ back to TIMESTAMP op.alter_column( - 'user_accounts', - 'deleted_at', + "user_accounts", + "deleted_at", type_=sa.DateTime(), existing_type=sa.DateTime(timezone=True), existing_nullable=True, diff --git a/migrations/versions/e19a954402db_create_credit_cards_table.py b/migrations/versions/e19a954402db_create_credit_cards_table.py index 775f5cf..364049f 100644 --- a/migrations/versions/e19a954402db_create_credit_cards_table.py +++ b/migrations/versions/e19a954402db_create_credit_cards_table.py @@ -30,9 +30,7 @@ def upgrade() -> None: sa.Column("used", sa.DECIMAL(15, 2), nullable=False), sa.Column("deleted_at", sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint( - ["account_id"], ["user_accounts.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"]) diff --git a/scripts/seeds/seed_households.py b/scripts/seeds/seed_households.py index d95cd97..2e4fdd6 100644 --- a/scripts/seeds/seed_households.py +++ b/scripts/seeds/seed_households.py @@ -4,9 +4,7 @@ from app.context.user.infrastructure.models import UserModel -async def seed_households( - session: AsyncSession, users: dict[str, UserModel] -) -> dict[str, HouseholdModel]: +async def seed_households(session: AsyncSession, users: dict[str, UserModel]) -> dict[str, HouseholdModel]: """Seed households table with test data""" print(" → Seeding households...") diff --git a/scripts/seeds/seed_user_accounts.py b/scripts/seeds/seed_user_accounts.py index 9e8bdcf..a8f1ddf 100644 --- a/scripts/seeds/seed_user_accounts.py +++ b/scripts/seeds/seed_user_accounts.py @@ -6,9 +6,7 @@ from app.context.user_account.infrastructure.models import UserAccountModel -async def seed_user_accounts( - session: AsyncSession, users: dict[str, UserModel] -) -> dict[str, 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...") @@ -76,9 +74,7 @@ async def seed_user_accounts( 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 - ) + 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 diff --git a/tests/fixtures/auth/services.py b/tests/fixtures/auth/services.py index b8cde1e..b298d60 100644 --- a/tests/fixtures/auth/services.py +++ b/tests/fixtures/auth/services.py @@ -15,9 +15,7 @@ class MockLoginService(LoginServiceContract): def __init__(self): self.handle_mock = AsyncMock() - async def handle( - self, user_password: AuthPassword, db_user: AuthUserDTO - ) -> SessionToken: + async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> SessionToken: return await self.handle_mock(user_password=user_password, db_user=db_user) diff --git a/tests/fixtures/credit_card/credit_card_fixtures.py b/tests/fixtures/credit_card/credit_card_fixtures.py index 0a9f26b..8d08dbc 100644 --- a/tests/fixtures/credit_card/credit_card_fixtures.py +++ b/tests/fixtures/credit_card/credit_card_fixtures.py @@ -144,6 +144,7 @@ def maxed_out_credit_card_dto(): # Helper functions + def create_credit_card_dto( card_id: int = 1, user_id: int = 100, diff --git a/tests/integration/fixtures/client.py b/tests/integration/fixtures/client.py index 53d17ee..3d2a986 100644 --- a/tests/integration/fixtures/client.py +++ b/tests/integration/fixtures/client.py @@ -24,9 +24,7 @@ async def override_get_db(): app.dependency_overrides[get_db] = override_get_db - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: + 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 index 1cedf9f..d021168 100644 --- a/tests/integration/fixtures/database.py +++ b/tests/integration/fixtures/database.py @@ -43,9 +43,7 @@ async def test_engine(): @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_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) async with async_session_maker() as session: yield session diff --git a/tests/integration/test_login_flow.py b/tests/integration/test_login_flow.py index 1df4cef..ef4ec11 100644 --- a/tests/integration/test_login_flow.py +++ b/tests/integration/test_login_flow.py @@ -13,9 +13,7 @@ class TestLoginFlow: """Integration tests for login endpoint.""" - async def test_successful_login_returns_200_and_sets_cookie( - self, test_client: AsyncClient, test_user: dict - ): + 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 = { @@ -42,9 +40,7 @@ async def test_successful_login_returns_200_and_sets_cookie( 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 - ): + async def test_login_with_invalid_email_returns_401(self, test_client: AsyncClient): """Test login with non-existent email returns 401.""" # Arrange login_payload = { @@ -62,9 +58,7 @@ async def test_login_with_invalid_email_returns_401( # 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 - ): + 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 = { @@ -82,9 +76,7 @@ async def test_login_with_wrong_password_returns_401( # 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 - ): + 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 = { @@ -99,9 +91,7 @@ async def test_login_with_invalid_email_format_returns_422( 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 - ): + 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 = { @@ -116,9 +106,7 @@ async def test_login_with_missing_password_returns_422( assert response.status_code == 422 assert "detail" in response.json() - async def test_login_with_missing_email_returns_422( - self, test_client: AsyncClient - ): + async def test_login_with_missing_email_returns_422(self, test_client: AsyncClient): """Test login without email returns 422 (validation error).""" # Arrange login_payload = { @@ -133,9 +121,7 @@ async def test_login_with_missing_email_returns_422( assert response.status_code == 422 assert "detail" in response.json() - async def test_login_with_empty_payload_returns_422( - self, test_client: AsyncClient - ): + async def test_login_with_empty_payload_returns_422(self, test_client: AsyncClient): """Test login with empty payload returns 422.""" # Arrange login_payload = {} @@ -146,9 +132,7 @@ async def test_login_with_empty_payload_returns_422( # Assert assert response.status_code == 422 - async def test_multiple_successful_logins_same_user( - self, test_client: AsyncClient, test_user: dict - ): + 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 = { @@ -172,9 +156,7 @@ async def test_multiple_successful_logins_same_user( 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 - ): + 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. @@ -193,9 +175,7 @@ async def test_login_with_case_sensitive_email( 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 - ): + 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 = { @@ -218,9 +198,7 @@ async def test_login_endpoint_uses_post_method(self, test_client: AsyncClient): # 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 - ): + 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 diff --git a/tests/unit/context/auth/application/get_session_handler_test.py b/tests/unit/context/auth/application/get_session_handler_test.py index e93b5ad..0d5a838 100644 --- a/tests/unit/context/auth/application/get_session_handler_test.py +++ b/tests/unit/context/auth/application/get_session_handler_test.py @@ -119,9 +119,7 @@ async def test_handle_with_both_user_id_and_token(self, mock_session_repository) 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 - ): + 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 @@ -179,9 +177,7 @@ async def test_handle_with_empty_query(self, mock_session_repository): 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 - ): + 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 diff --git a/tests/unit/context/auth/application/login_handler_test.py b/tests/unit/context/auth/application/login_handler_test.py index 4c83838..debbab8 100644 --- a/tests/unit/context/auth/application/login_handler_test.py +++ b/tests/unit/context/auth/application/login_handler_test.py @@ -21,9 +21,7 @@ class TestLoginHandler: """Unit tests for LoginHandler.""" - async def test_handle_successful_login( - self, mock_find_user_handler, mock_login_service - ): + async def test_handle_successful_login(self, mock_find_user_handler, mock_login_service): """Test successful login returns token and user_id.""" # Arrange email = "test@example.com" @@ -32,9 +30,7 @@ async def test_handle_successful_login( 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_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) @@ -61,9 +57,7 @@ async def test_handle_successful_login( # 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 - ): + async def test_handle_user_not_found(self, mock_find_user_handler, mock_login_service): """Test that login fails when user is not found.""" # Arrange email = "nonexistent@example.com" @@ -88,9 +82,7 @@ async def test_handle_user_not_found( # 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 - ): + async def test_handle_invalid_credentials_exception(self, mock_find_user_handler, mock_login_service): """Test that InvalidCredentialsException is handled correctly.""" # Arrange email = "test@example.com" @@ -98,9 +90,7 @@ async def test_handle_invalid_credentials_exception( 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_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() @@ -121,9 +111,7 @@ async def test_handle_invalid_credentials_exception( # 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 - ): + async def test_handle_account_blocked_exception(self, mock_find_user_handler, mock_login_service): """Test that AccountBlockedException is handled correctly.""" # Arrange email = "blocked@example.com" @@ -132,13 +120,9 @@ async def test_handle_account_blocked_exception( 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_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 - ) + mock_login_service.handle_mock.side_effect = AccountBlockedException(blocked_until) handler = LoginHandler(mock_find_user_handler, mock_login_service) command = LoginCommand(email=email, password=password) @@ -157,9 +141,7 @@ async def test_handle_account_blocked_exception( # 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 - ): + async def test_handle_unexpected_error(self, mock_find_user_handler, mock_login_service): """Test that unexpected exceptions are handled gracefully.""" # Arrange email = "error@example.com" @@ -167,9 +149,7 @@ async def test_handle_unexpected_error( 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_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") @@ -190,9 +170,7 @@ async def test_handle_unexpected_error( # 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 - ): + async def test_handle_database_exception(self, mock_find_user_handler, mock_login_service): """Test that database-related exceptions are handled as unexpected errors.""" # Arrange email = "db-error@example.com" @@ -200,13 +178,9 @@ async def test_handle_database_exception( 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_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" - ) + mock_login_service.handle_mock.side_effect = Exception("Database connection lost") handler = LoginHandler(mock_find_user_handler, mock_login_service) command = LoginCommand(email=email, password=password) @@ -219,9 +193,7 @@ async def test_handle_database_exception( 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 - ): + async def test_handle_passes_correct_auth_user_dto(self, mock_find_user_handler, mock_login_service): """Test that handler correctly constructs AuthUserDTO from UserContextDTO.""" # Arrange email = "dto-test@example.com" @@ -229,9 +201,7 @@ async def test_handle_passes_correct_auth_user_dto( 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_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") @@ -254,9 +224,7 @@ async def test_handle_passes_correct_auth_user_dto( 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 - ): + async def test_handle_with_different_user_scenarios(self, mock_find_user_handler, mock_login_service): """Test login with various user scenarios.""" # Arrange test_cases = [ @@ -287,13 +255,9 @@ async def test_handle_with_different_user_scenarios( 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_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}" - ) + mock_login_service.handle_mock.return_value = SessionToken(f"token-{user_id}") handler = LoginHandler(mock_find_user_handler, mock_login_service) command = LoginCommand(email=email, password=password) diff --git a/tests/unit/context/auth/domain/login_service_test.py b/tests/unit/context/auth/domain/login_service_test.py index ac1936d..721cb6f 100644 --- a/tests/unit/context/auth/domain/login_service_test.py +++ b/tests/unit/context/auth/domain/login_service_test.py @@ -63,9 +63,7 @@ async def test_successful_login_no_existing_session(self, mock_session_repositor service = LoginService(mock_session_repository) # Act - with patch.object( - SessionToken, "generate", return_value=SessionToken("mocked-token") - ): + with patch.object(SessionToken, "generate", return_value=SessionToken("mocked-token")): result = await service.handle(user_password, db_user) # Assert @@ -74,9 +72,7 @@ async def test_successful_login_no_existing_session(self, mock_session_repositor # 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 - ) + 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() @@ -94,9 +90,7 @@ async def test_successful_login_no_existing_session(self, mock_session_repositor 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 - ): + async def test_successful_login_with_existing_session(self, mock_session_repository): """Test successful login when session already exists.""" # Arrange user_id = AuthUserID(99) @@ -122,9 +116,7 @@ async def test_successful_login_with_existing_session( service = LoginService(mock_session_repository) # Act - with patch.object( - SessionToken, "generate", return_value=SessionToken("new-token") - ): + with patch.object(SessionToken, "generate", return_value=SessionToken("new-token")): result = await service.handle(user_password, db_user) # Assert @@ -137,9 +129,7 @@ async def test_successful_login_with_existing_session( # 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 - ): + async def test_failed_login_wrong_password_first_attempt(self, mock_session_repository): """Test failed login increments attempt counter on first wrong password.""" # Arrange user_id = AuthUserID(10) @@ -218,9 +208,7 @@ async def test_failed_login_third_attempt_with_delay(self, mock_session_reposito 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 - ): + async def test_failed_login_max_attempts_blocks_account(self, mock_session_repository): """Test that reaching max attempts blocks the account.""" # Arrange user_id = AuthUserID(20) @@ -259,9 +247,7 @@ async def test_failed_login_max_attempts_blocks_account( # 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 - ): + async def test_login_blocked_account_raises_exception(self, mock_session_repository): """Test that login attempt on blocked account raises AccountBlockedException.""" # Arrange user_id = AuthUserID(25) @@ -325,9 +311,7 @@ async def test_login_expired_block_allows_login(self, mock_session_repository): service = LoginService(mock_session_repository) # Act - with patch.object( - SessionToken, "generate", return_value=SessionToken("unblock-token") - ): + with patch.object(SessionToken, "generate", return_value=SessionToken("unblock-token")): result = await service.handle(user_password, db_user) # Assert @@ -340,9 +324,7 @@ async def test_login_expired_block_allows_login(self, mock_session_repository): 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 - ): + async def test_successful_login_resets_failed_attempts(self, mock_session_repository): """Test that successful login resets failed attempts counter.""" # Arrange user_id = AuthUserID(35) @@ -368,9 +350,7 @@ async def test_successful_login_resets_failed_attempts( service = LoginService(mock_session_repository) # Act - with patch.object( - SessionToken, "generate", return_value=SessionToken("reset-token") - ): + with patch.object(SessionToken, "generate", return_value=SessionToken("reset-token")): result = await service.handle(user_password, db_user) # Assert @@ -382,9 +362,7 @@ async def test_successful_login_resets_failed_attempts( 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 - ): + async def test_password_verification_called_correctly(self, mock_session_repository): """Test that password verification is called with correct parameters.""" # Arrange user_id = AuthUserID(40) @@ -409,9 +387,7 @@ async def test_password_verification_called_correctly( service = LoginService(mock_session_repository) # Act - with patch.object( - SessionToken, "generate", return_value=SessionToken("verify-token") - ): + 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) @@ -445,9 +421,7 @@ async def test_session_token_generation(self, mock_session_repository): # Act generated_token = SessionToken("unique-secure-token-xyz") - with patch.object( - SessionToken, "generate", return_value=generated_token - ) as mock_generate: + 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 @@ -492,9 +466,7 @@ async def test_multiple_failed_attempts_sequence(self, mock_session_repository): await service.handle(user_password, db_user) # Verify attempt count increased - updated_session = mock_session_repository.update_session_mock.call_args[0][ - 0 - ] + 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 diff --git a/tests/unit/context/auth/domain/throttle_time_test.py b/tests/unit/context/auth/domain/throttle_time_test.py index 9580d36..9e638dd 100644 --- a/tests/unit/context/auth/domain/throttle_time_test.py +++ b/tests/unit/context/auth/domain/throttle_time_test.py @@ -75,9 +75,7 @@ def test_inequality(self): 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) - ] + 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] 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 index 4ec4e54..b505d7e 100644 --- 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 @@ -72,14 +72,10 @@ async def test_create_credit_card_success(self, handler, mock_service): 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 - ): + 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 - ) + command = CreateCreditCardCommand(user_id=1, account_id=10, name="My Card", currency="USD", limit=1000.00) card_dto = CreditCardDTO( user_id=CreditCardUserID(1), @@ -104,13 +100,9 @@ async def test_create_credit_card_without_id_returns_error( 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 - ) + 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") - ) + mock_service.create_credit_card = AsyncMock(side_effect=CreditCardNameAlreadyExistError("Duplicate name")) # Act result = await handler.handle(command) @@ -124,13 +116,9 @@ async def test_create_credit_card_duplicate_name_error(self, handler, mock_servi 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 - ) + 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") - ) + mock_service.create_credit_card = AsyncMock(side_effect=CreditCardMapperError("Mapping failed")) # Act result = await handler.handle(command) @@ -144,13 +132,9 @@ async def test_create_credit_card_mapper_error(self, handler, mock_service): 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 - ) + 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") - ) + mock_service.create_credit_card = AsyncMock(side_effect=Exception("Database error")) # Act result = await handler.handle(command) @@ -161,9 +145,7 @@ async def test_create_credit_card_unexpected_error(self, handler, mock_service): 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 - ): + 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( 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 index 4b8d33d..ac01906 100644 --- 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 @@ -44,9 +44,7 @@ async def test_delete_credit_card_success(self, handler, mock_repository): 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 - ): + 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) @@ -62,16 +60,12 @@ async def test_delete_credit_card_not_found_returns_error( assert result.success is False @pytest.mark.asyncio - async def test_delete_credit_card_not_found_exception( - self, handler, mock_repository - ): + 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") - ) + mock_repository.delete_credit_card = AsyncMock(side_effect=CreditCardNotFoundError("Card not found")) # Act result = await handler.handle(command) @@ -87,9 +81,7 @@ async def test_delete_credit_card_unexpected_error(self, handler, mock_repositor # Arrange command = DeleteCreditCardCommand(credit_card_id=1, user_id=100) - mock_repository.delete_credit_card = AsyncMock( - side_effect=Exception("Database error") - ) + mock_repository.delete_credit_card = AsyncMock(side_effect=Exception("Database error")) # Act result = await handler.handle(command) 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 index cc63c50..73bf1f0 100644 --- 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 @@ -50,16 +50,12 @@ def sample_card_dto(self): ) @pytest.mark.asyncio - async def test_find_credit_card_by_id_success( - self, handler, mock_repository, sample_card_dto - ): + 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 - ) + mock_repository.find_user_credit_card_by_id = AsyncMock(return_value=sample_card_dto) # Act result = await handler.handle(query) @@ -91,16 +87,12 @@ async def test_find_credit_card_by_id_not_found(self, handler, mock_repository): 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 - ): + 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 - ) + mock_repository.find_user_credit_card_by_id = AsyncMock(return_value=sample_card_dto) # Act await handler.handle(query) @@ -113,16 +105,12 @@ async def test_find_credit_card_by_id_converts_primitives( 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 - ): + 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 - ) + mock_repository.find_user_credit_card_by_id = AsyncMock(return_value=sample_card_dto) # Act result = await handler.handle(query) 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 index 0338b8c..1a8c214 100644 --- 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 @@ -61,16 +61,12 @@ def sample_card_dtos(self): ] @pytest.mark.asyncio - async def test_find_credit_cards_by_user_success( - self, handler, mock_repository, sample_card_dtos - ): + 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 - ) + mock_repository.find_user_credit_cards = AsyncMock(return_value=sample_card_dtos) # Act result = await handler.handle(query) @@ -99,9 +95,7 @@ async def test_find_credit_cards_by_user_empty_list(self, handler, mock_reposito 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 - ): + 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) @@ -116,16 +110,12 @@ async def test_find_credit_cards_by_user_none_returns_empty_list( 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 - ): + 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 - ) + mock_repository.find_user_credit_cards = AsyncMock(return_value=sample_card_dtos) # Act await handler.handle(query) @@ -136,16 +126,12 @@ async def test_find_credit_cards_by_user_converts_primitives( 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 - ): + 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 - ) + mock_repository.find_user_credit_cards = AsyncMock(return_value=sample_card_dtos) # Act result = await handler.handle(query) @@ -161,9 +147,7 @@ async def test_find_credit_cards_by_user_returns_application_dtos( assert isinstance(card.used, Decimal) @pytest.mark.asyncio - async def test_find_credit_cards_by_user_single_card( - self, handler, mock_repository - ): + 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) 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 index 23a08da..7b27c5e 100644 --- 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 @@ -153,9 +153,7 @@ async def test_update_credit_card_not_found_error(self, handler, mock_service): used=None, ) - mock_service.update_credit_card = AsyncMock( - side_effect=CreditCardNotFoundError("Card not found") - ) + mock_service.update_credit_card = AsyncMock(side_effect=CreditCardNotFoundError("Card not found")) # Act result = await handler.handle(command) @@ -178,9 +176,7 @@ async def test_update_credit_card_duplicate_name_error(self, handler, mock_servi used=None, ) - mock_service.update_credit_card = AsyncMock( - side_effect=CreditCardNameAlreadyExistError("Duplicate name") - ) + mock_service.update_credit_card = AsyncMock(side_effect=CreditCardNameAlreadyExistError("Duplicate name")) # Act result = await handler.handle(command) @@ -203,9 +199,7 @@ async def test_update_credit_card_mapper_error(self, handler, mock_service): used=None, ) - mock_service.update_credit_card = AsyncMock( - side_effect=CreditCardMapperError("Mapping failed") - ) + mock_service.update_credit_card = AsyncMock(side_effect=CreditCardMapperError("Mapping failed")) # Act result = await handler.handle(command) @@ -228,9 +222,7 @@ async def test_update_credit_card_unexpected_error(self, handler, mock_service): used=None, ) - mock_service.update_credit_card = AsyncMock( - side_effect=Exception("Database error") - ) + mock_service.update_credit_card = AsyncMock(side_effect=Exception("Database error")) # Act result = await handler.handle(command) @@ -241,9 +233,7 @@ async def test_update_credit_card_unexpected_error(self, handler, mock_service): 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 - ): + 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( @@ -280,9 +270,7 @@ async def test_update_credit_card_converts_primitives_to_value_objects( 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 - ): + 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( diff --git a/tests/unit/context/credit_card/domain/card_limit_test.py b/tests/unit/context/credit_card/domain/card_limit_test.py index e3ebce1..1feb27f 100644 --- a/tests/unit/context/credit_card/domain/card_limit_test.py +++ b/tests/unit/context/credit_card/domain/card_limit_test.py @@ -45,23 +45,17 @@ def test_from_float_rounds_to_two_decimals(self): def test_invalid_type_raises_error(self): """Test that invalid types raise InvalidCardLimitTypeError""" - with pytest.raises( - InvalidCardLimitTypeError, match="CardLimit must be a Decimal" - ): + 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" - ): + 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" - ): + with pytest.raises(InvalidCardLimitValueError, match="CardLimit must be positive"): CardLimit(Decimal("0.00")) def test_too_many_decimal_places_raises_error(self): diff --git a/tests/unit/context/credit_card/domain/card_used_test.py b/tests/unit/context/credit_card/domain/card_used_test.py index 6ceed81..82bc81f 100644 --- a/tests/unit/context/credit_card/domain/card_used_test.py +++ b/tests/unit/context/credit_card/domain/card_used_test.py @@ -50,16 +50,12 @@ def test_from_float_rounds_to_two_decimals(self): def test_invalid_type_raises_error(self): """Test that invalid types raise InvalidCardUsedTypeError""" - with pytest.raises( - InvalidCardUsedTypeError, match="CardUsed must be a Decimal" - ): + 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" - ): + with pytest.raises(InvalidCardUsedValueError, match="CardUsed must be non-negative"): CardUsed(Decimal("-50.00")) def test_too_many_decimal_places_raises_error(self): 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 index 3480ea5..75fdf33 100644 --- 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 @@ -78,9 +78,7 @@ async def test_create_credit_card_success(self, service, mock_repository): 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 - ): + 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"] @@ -138,14 +136,10 @@ async def test_create_credit_card_with_high_limit(self, service, mock_repository assert result.limit.value == Decimal("100000.00") @pytest.mark.asyncio - async def test_create_credit_card_propagates_repository_exceptions( - self, service, mock_repository - ): + 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") - ) + mock_repository.save_credit_card = AsyncMock(side_effect=Exception("Database error")) # Act & Assert with pytest.raises(Exception, match="Database error"): 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 index 2a11f46..bb7328f 100644 --- a/tests/unit/context/credit_card/domain/credit_card_id_test.py +++ b/tests/unit/context/credit_card/domain/credit_card_id_test.py @@ -22,23 +22,17 @@ def test_valid_id_creation(self): def test_invalid_type_raises_error(self): """Test that invalid types raise InvalidCreditCardIdTypeError""" - with pytest.raises( - InvalidCreditCardIdTypeError, match="CreditCardID must be an integer" - ): + 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" - ): + 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" - ): + with pytest.raises(InvalidCreditCardIdValueError, match="CreditCardID must be positive"): CreditCardID(0) def test_from_trusted_source_skips_validation(self): 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 index d0611f7..5a97c6a 100644 --- 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 @@ -55,17 +55,13 @@ def existing_card_dto(self): ) @pytest.mark.asyncio - async def test_update_credit_card_name_success( - self, service, mock_repository, existing_card_dto - ): + 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] - ) + mock_repository.find_credit_card = AsyncMock(side_effect=[existing_card_dto, None]) updated_dto = CreditCardDTO( credit_card_id=existing_card_dto.credit_card_id, @@ -92,9 +88,7 @@ async def test_update_credit_card_name_success( 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 - ): + 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")) @@ -124,9 +118,7 @@ async def test_update_credit_card_limit_success( 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 - ): + 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")) @@ -156,9 +148,7 @@ async def test_update_credit_card_used_success( assert result.used.value == Decimal("500.00") @pytest.mark.asyncio - async def test_update_credit_card_not_found_raises_error( - self, service, mock_repository - ): + 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) @@ -191,9 +181,7 @@ async def test_update_credit_card_unauthorized_access_raises_error( ) @pytest.mark.asyncio - async def test_update_credit_card_duplicate_name_raises_error( - self, service, mock_repository, existing_card_dto - ): + 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") @@ -243,9 +231,7 @@ async def test_update_credit_card_used_exceeds_limit_raises_error( ) @pytest.mark.asyncio - async def test_update_credit_card_limit_below_used_raises_error( - self, service, mock_repository - ): + 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( @@ -272,9 +258,7 @@ async def test_update_credit_card_limit_below_used_raises_error( ) @pytest.mark.asyncio - async def test_update_credit_card_same_name_no_duplicate_check( - self, service, mock_repository, existing_card_dto - ): + 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 diff --git a/tests/unit/context/household/application/create_household_handler_test.py b/tests/unit/context/household/application/create_household_handler_test.py index 41e7f64..ec1ec39 100644 --- a/tests/unit/context/household/application/create_household_handler_test.py +++ b/tests/unit/context/household/application/create_household_handler_test.py @@ -87,9 +87,7 @@ async def test_create_household_duplicate_name_error(self, handler, mock_service # Arrange command = CreateHouseholdCommand(user_id=1, name="Duplicate") - mock_service.create_household = AsyncMock( - side_effect=HouseholdNameAlreadyExistError("Duplicate name") - ) + mock_service.create_household = AsyncMock(side_effect=HouseholdNameAlreadyExistError("Duplicate name")) # Act result = await handler.handle(command) @@ -105,9 +103,7 @@ async def test_create_household_mapper_error(self, handler, mock_service): # Arrange command = CreateHouseholdCommand(user_id=1, name="Test") - mock_service.create_household = AsyncMock( - side_effect=HouseholdMapperError("Mapping failed") - ) + mock_service.create_household = AsyncMock(side_effect=HouseholdMapperError("Mapping failed")) # Act result = await handler.handle(command) @@ -134,9 +130,7 @@ async def test_create_household_unexpected_error(self, handler, mock_service): assert result.household_id is None @pytest.mark.asyncio - async def test_create_household_converts_primitives_to_value_objects( - self, handler, mock_service - ): + 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") diff --git a/tests/unit/context/household/application/invite_user_handler_test.py b/tests/unit/context/household/application/invite_user_handler_test.py index e7b2697..5062a91 100644 --- a/tests/unit/context/household/application/invite_user_handler_test.py +++ b/tests/unit/context/household/application/invite_user_handler_test.py @@ -44,9 +44,7 @@ def handler(self, mock_service): 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" - ) + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") member_dto = HouseholdMemberDTO( member_id=HouseholdMemberID(1), @@ -72,14 +70,10 @@ async def test_invite_user_success(self, handler, mock_service): mock_service.invite_user.assert_called_once() @pytest.mark.asyncio - async def test_invite_user_without_member_id_returns_error( - self, handler, mock_service - ): + 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" - ) + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") member_dto = HouseholdMemberDTO( member_id=None, # Missing ID @@ -105,13 +99,9 @@ async def test_invite_user_without_member_id_returns_error( 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" - ) + 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") - ) + mock_service.invite_user = AsyncMock(side_effect=OnlyOwnerCanInviteError("Only owner can invite")) # Act result = await handler.handle(command) @@ -125,58 +115,41 @@ async def test_invite_user_only_owner_can_invite_error(self, handler, mock_servi 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" - ) + 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") - ) + 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.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" - ) + 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") - ) + 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.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" - ) + 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") - ) + mock_service.invite_user = AsyncMock(side_effect=HouseholdMapperError("Mapping failed")) # Act result = await handler.handle(command) @@ -190,9 +163,7 @@ async def test_invite_user_mapper_error(self, handler, mock_service): 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" - ) + 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")) @@ -205,14 +176,10 @@ async def test_invite_user_unexpected_error(self, handler, mock_service): assert result.member_id is None @pytest.mark.asyncio - async def test_invite_user_converts_primitives_to_value_objects( - self, handler, mock_service - ): + 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" - ) + command = InviteUserCommand(inviter_user_id=1, household_id=10, invitee_user_id=2, role="participant") member_dto = HouseholdMemberDTO( member_id=HouseholdMemberID(1), diff --git a/tests/unit/context/household/domain/accept_invite_service_test.py b/tests/unit/context/household/domain/accept_invite_service_test.py index 01f76c8..9d37a8e 100644 --- a/tests/unit/context/household/domain/accept_invite_service_test.py +++ b/tests/unit/context/household/domain/accept_invite_service_test.py @@ -85,15 +85,11 @@ async def test_accept_invite_no_invite_raises_error(self, service, mock_reposito mock_repository.find_member = AsyncMock(return_value=None) # Act & Assert - with pytest.raises( - NotInvitedError, match="No pending invite found for this household" - ): + 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 - ): + 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) @@ -113,15 +109,11 @@ async def test_accept_invite_already_active_raises_error( mock_repository.find_member = AsyncMock(return_value=active_member) # Act & Assert - with pytest.raises( - NotInvitedError, match="No pending invite found for this household" - ): + 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 - ): + async def test_accept_invite_propagates_repository_exceptions(self, service, mock_repository): """Test that repository exceptions are propagated""" # Arrange user_id = HouseholdUserID(2) diff --git a/tests/unit/context/household/domain/create_household_service_test.py b/tests/unit/context/household/domain/create_household_service_test.py index 6e02cc9..8c1a2bc 100644 --- a/tests/unit/context/household/domain/create_household_service_test.py +++ b/tests/unit/context/household/domain/create_household_service_test.py @@ -65,9 +65,7 @@ async def test_create_household_success(self, service, mock_repository): assert call_args.kwargs["creator_user_id"] == creator_user_id @pytest.mark.asyncio - async def test_create_household_duplicate_name_raises_error( - self, service, mock_repository - ): + 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") @@ -78,20 +76,14 @@ async def test_create_household_duplicate_name_raises_error( ) # Act & Assert - with pytest.raises( - HouseholdNameAlreadyExistError, match="Household name already exists" - ): + 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 - ): + 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") - ) + mock_repository.create_household = AsyncMock(side_effect=Exception("Database error")) # Act & Assert with pytest.raises(Exception, match="Database error"): @@ -124,9 +116,7 @@ async def test_create_household_with_long_name(self, service, mock_repository): mock_repository.create_household.assert_called_once() @pytest.mark.asyncio - async def test_create_household_with_special_characters( - self, service, mock_repository - ): + 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") diff --git a/tests/unit/context/household/domain/decline_invite_service_test.py b/tests/unit/context/household/domain/decline_invite_service_test.py index bd31030..b185659 100644 --- a/tests/unit/context/household/domain/decline_invite_service_test.py +++ b/tests/unit/context/household/domain/decline_invite_service_test.py @@ -72,15 +72,11 @@ async def test_decline_invite_no_invite_raises_error(self, service, mock_reposit mock_repository.find_member = AsyncMock(return_value=None) # Act & Assert - with pytest.raises( - NotInvitedError, match="No pending invite found for this household" - ): + 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 - ): + 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) @@ -100,15 +96,11 @@ async def test_decline_invite_already_active_raises_error( mock_repository.find_member = AsyncMock(return_value=active_member) # Act & Assert - with pytest.raises( - NotInvitedError, match="No pending invite found for this household" - ): + 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 - ): + async def test_decline_invite_propagates_repository_exceptions(self, service, mock_repository): """Test that repository exceptions are propagated""" # Arrange user_id = HouseholdUserID(2) @@ -125,9 +117,7 @@ async def test_decline_invite_propagates_repository_exceptions( ) mock_repository.find_member = AsyncMock(return_value=pending_member) - mock_repository.revoke_or_remove = AsyncMock( - side_effect=Exception("Database error") - ) + mock_repository.revoke_or_remove = AsyncMock(side_effect=Exception("Database error")) # Act & Assert with pytest.raises(Exception, match="Database error"): diff --git a/tests/unit/context/household/domain/household_member_id_test.py b/tests/unit/context/household/domain/household_member_id_test.py index 991f9ca..6086c14 100644 --- a/tests/unit/context/household/domain/household_member_id_test.py +++ b/tests/unit/context/household/domain/household_member_id_test.py @@ -23,16 +23,12 @@ def test_large_id_creation(self): 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" - ): + 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" - ): + with pytest.raises(ValueError, match="Household member ID must be a positive integer"): HouseholdMemberID(-1) def test_non_integer_raises_error(self): diff --git a/tests/unit/context/household/domain/household_name_test.py b/tests/unit/context/household/domain/household_name_test.py index 886befc..d8f9cd4 100644 --- a/tests/unit/context/household/domain/household_name_test.py +++ b/tests/unit/context/household/domain/household_name_test.py @@ -49,9 +49,7 @@ def test_tab_only_raises_error(self): 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" - ): + with pytest.raises(ValueError, match="Household name cannot exceed 100 characters"): HouseholdName("H" * 101) def test_from_trusted_source_skips_validation(self): diff --git a/tests/unit/context/household/domain/household_role_test.py b/tests/unit/context/household/domain/household_role_test.py index bbfb005..da49e4d 100644 --- a/tests/unit/context/household/domain/household_role_test.py +++ b/tests/unit/context/household/domain/household_role_test.py @@ -23,9 +23,7 @@ def test_empty_string_raises_error(self): 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" - ): + with pytest.raises(ValueError, match="Invalid role: 'admin'. Must be one of"): HouseholdRole("admin") def test_owner_role_raises_error(self): @@ -35,9 +33,7 @@ def test_owner_role_raises_error(self): 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" - ): + with pytest.raises(ValueError, match="Invalid role: 'Participant'. Must be one of"): HouseholdRole("Participant") def test_valid_roles_constant(self): diff --git a/tests/unit/context/household/domain/invite_user_service_test.py b/tests/unit/context/household/domain/invite_user_service_test.py index 9967b04..c18d40c 100644 --- a/tests/unit/context/household/domain/invite_user_service_test.py +++ b/tests/unit/context/household/domain/invite_user_service_test.py @@ -100,9 +100,7 @@ async def test_invite_user_non_owner_raises_error(self, service, mock_repository 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" - ): + 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, @@ -111,9 +109,7 @@ async def test_invite_user_non_owner_raises_error(self, service, mock_repository ) @pytest.mark.asyncio - async def test_invite_user_household_not_found_raises_error( - self, service, mock_repository - ): + 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) @@ -124,9 +120,7 @@ async def test_invite_user_household_not_found_raises_error( mock_repository.find_household_by_id = AsyncMock(return_value=None) # Act & Assert - with pytest.raises( - OnlyOwnerCanInviteError, match="Only the household owner can invite users" - ): + 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, @@ -135,9 +129,7 @@ async def test_invite_user_household_not_found_raises_error( ) @pytest.mark.asyncio - async def test_invite_user_already_active_raises_error( - self, service, mock_repository - ): + 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) @@ -178,9 +170,7 @@ async def test_invite_user_already_active_raises_error( ) @pytest.mark.asyncio - async def test_invite_user_already_invited_raises_error( - self, service, mock_repository - ): + 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) @@ -221,9 +211,7 @@ async def test_invite_user_already_invited_raises_error( ) @pytest.mark.asyncio - async def test_invite_user_creates_member_with_correct_data( - self, service, mock_repository - ): + 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) diff --git a/tests/unit/context/household/domain/remove_member_service_test.py b/tests/unit/context/household/domain/remove_member_service_test.py index 0a57a9e..0920d80 100644 --- a/tests/unit/context/household/domain/remove_member_service_test.py +++ b/tests/unit/context/household/domain/remove_member_service_test.py @@ -109,9 +109,7 @@ async def test_remove_member_non_owner_raises_error(self, service, mock_reposito ) @pytest.mark.asyncio - async def test_remove_member_household_not_found_raises_error( - self, service, mock_repository - ): + 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) @@ -132,9 +130,7 @@ async def test_remove_member_household_not_found_raises_error( ) @pytest.mark.asyncio - async def test_remove_member_cannot_remove_self_raises_error( - self, service, mock_repository - ): + 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) @@ -177,9 +173,7 @@ async def test_remove_member_not_found_raises_error(self, service, mock_reposito mock_repository.find_member = AsyncMock(return_value=None) # Act & Assert - with pytest.raises( - InviteNotFoundError, match="No active member found with this user ID" - ): + 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, @@ -215,9 +209,7 @@ async def test_remove_member_not_active_raises_error(self, service, mock_reposit mock_repository.find_member = AsyncMock(return_value=pending_member) # Act & Assert - with pytest.raises( - InviteNotFoundError, match="No active member found with this user ID" - ): + 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, @@ -225,9 +217,7 @@ async def test_remove_member_not_active_raises_error(self, service, mock_reposit ) @pytest.mark.asyncio - async def test_remove_member_propagates_repository_exceptions( - self, service, mock_repository - ): + async def test_remove_member_propagates_repository_exceptions(self, service, mock_repository): """Test that repository exceptions are propagated""" # Arrange owner_id = HouseholdUserID(1) @@ -252,9 +242,7 @@ async def test_remove_member_propagates_repository_exceptions( 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") - ) + mock_repository.revoke_or_remove = AsyncMock(side_effect=Exception("Database error")) # Act & Assert with pytest.raises(Exception, match="Database error"): diff --git a/tests/unit/context/household/domain/revoke_invite_service_test.py b/tests/unit/context/household/domain/revoke_invite_service_test.py index 986223f..da7f0e1 100644 --- a/tests/unit/context/household/domain/revoke_invite_service_test.py +++ b/tests/unit/context/household/domain/revoke_invite_service_test.py @@ -108,9 +108,7 @@ async def test_revoke_invite_non_owner_raises_error(self, service, mock_reposito ) @pytest.mark.asyncio - async def test_revoke_invite_household_not_found_raises_error( - self, service, mock_repository - ): + 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) @@ -148,9 +146,7 @@ async def test_revoke_invite_not_found_raises_error(self, service, mock_reposito mock_repository.find_member = AsyncMock(return_value=None) # Act & Assert - with pytest.raises( - InviteNotFoundError, match="No pending invite found for this user" - ): + 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, @@ -158,9 +154,7 @@ async def test_revoke_invite_not_found_raises_error(self, service, mock_reposito ) @pytest.mark.asyncio - async def test_revoke_invite_already_active_raises_error( - self, service, mock_repository - ): + 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) @@ -188,9 +182,7 @@ async def test_revoke_invite_already_active_raises_error( mock_repository.find_member = AsyncMock(return_value=active_member) # Act & Assert - with pytest.raises( - InviteNotFoundError, match="No pending invite found for this user" - ): + 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, @@ -198,9 +190,7 @@ async def test_revoke_invite_already_active_raises_error( ) @pytest.mark.asyncio - async def test_revoke_invite_propagates_repository_exceptions( - self, service, mock_repository - ): + async def test_revoke_invite_propagates_repository_exceptions(self, service, mock_repository): """Test that repository exceptions are propagated""" # Arrange owner_id = HouseholdUserID(1) @@ -225,9 +215,7 @@ async def test_revoke_invite_propagates_repository_exceptions( 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") - ) + mock_repository.revoke_or_remove = AsyncMock(side_effect=Exception("Database error")) # Act & Assert with pytest.raises(Exception, match="Database error"): 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 index cc6c763..de8429d 100644 --- a/tests/unit/context/user_account/application/create_account_handler_test.py +++ b/tests/unit/context/user_account/application/create_account_handler_test.py @@ -43,9 +43,7 @@ def handler(self, mock_service): 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 - ) + command = CreateAccountCommand(user_id=1, name="My Account", currency="USD", balance=100.50) account_dto = UserAccountDTO( user_id=UserAccountUserID(1), @@ -71,9 +69,7 @@ async def test_create_account_success(self, handler, mock_service): 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 - ) + command = CreateAccountCommand(user_id=1, name="My Account", currency="USD", balance=100.00) account_dto = UserAccountDTO( user_id=UserAccountUserID(1), @@ -97,13 +93,9 @@ async def test_create_account_without_id_returns_error(self, handler, mock_servi 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 - ) + command = CreateAccountCommand(user_id=1, name="Duplicate", currency="USD", balance=100.00) - mock_service.create_account = AsyncMock( - side_effect=UserAccountNameAlreadyExistError("Duplicate name") - ) + mock_service.create_account = AsyncMock(side_effect=UserAccountNameAlreadyExistError("Duplicate name")) # Act result = await handler.handle(command) @@ -117,13 +109,9 @@ async def test_create_account_duplicate_name_error(self, handler, mock_service): 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 - ) + command = CreateAccountCommand(user_id=1, name="Test", currency="USD", balance=100.00) - mock_service.create_account = AsyncMock( - side_effect=UserAccountMapperError("Mapping failed") - ) + mock_service.create_account = AsyncMock(side_effect=UserAccountMapperError("Mapping failed")) # Act result = await handler.handle(command) @@ -137,9 +125,7 @@ async def test_create_account_mapper_error(self, handler, mock_service): 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 - ) + command = CreateAccountCommand(user_id=1, name="Test", currency="USD", balance=100.00) mock_service.create_account = AsyncMock(side_effect=Exception("Database error")) @@ -152,14 +138,10 @@ async def test_create_account_unexpected_error(self, handler, mock_service): assert result.account_id is None @pytest.mark.asyncio - async def test_create_account_converts_primitives_to_value_objects( - self, handler, mock_service - ): + 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 - ) + command = CreateAccountCommand(user_id=1, name="Test", currency="USD", balance=100.50) account_dto = UserAccountDTO( user_id=UserAccountUserID(1), 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 index d86c88a..0aaa306 100644 --- a/tests/unit/context/user_account/application/delete_account_handler_test.py +++ b/tests/unit/context/user_account/application/delete_account_handler_test.py @@ -55,7 +55,7 @@ async def test_delete_account_not_found(self, handler, mock_repository): 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 + 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): 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 index 1a81e85..8d98ab1 100644 --- 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 @@ -75,9 +75,7 @@ async def test_find_account_by_id_not_found(self, handler, mock_repository): assert result is None @pytest.mark.asyncio - async def test_find_account_by_id_calls_repository_with_correct_params( - self, handler, mock_repository - ): + 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) 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 index 77e2d89..8a8801f 100644 --- 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 @@ -83,9 +83,7 @@ async def test_find_accounts_by_user_empty_list(self, handler, mock_repository): assert result == [] @pytest.mark.asyncio - async def test_find_accounts_by_user_none_returns_empty_list( - self, handler, mock_repository - ): + 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) 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 index fb8ebcd..4345faa 100644 --- a/tests/unit/context/user_account/application/update_account_handler_test.py +++ b/tests/unit/context/user_account/application/update_account_handler_test.py @@ -44,9 +44,7 @@ def handler(self, mock_service): 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 - ) + command = UpdateAccountCommand(account_id=10, user_id=1, name="Updated", currency="EUR", balance=200.00) updated_dto = UserAccountDTO( user_id=UserAccountUserID(1), @@ -71,13 +69,9 @@ async def test_update_account_success(self, handler, mock_service): 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 - ) + 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") - ) + mock_service.update_account = AsyncMock(side_effect=UserAccountNotFoundError("Not found")) # Act result = await handler.handle(command) @@ -90,13 +84,9 @@ async def test_update_account_not_found(self, handler, mock_service): 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 - ) + command = UpdateAccountCommand(account_id=10, user_id=1, name="Duplicate", currency="USD", balance=100.00) - mock_service.update_account = AsyncMock( - side_effect=UserAccountNameAlreadyExistError("Duplicate") - ) + mock_service.update_account = AsyncMock(side_effect=UserAccountNameAlreadyExistError("Duplicate")) # Act result = await handler.handle(command) @@ -109,13 +99,9 @@ async def test_update_account_duplicate_name(self, handler, mock_service): 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 - ) + 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") - ) + mock_service.update_account = AsyncMock(side_effect=UserAccountMapperError("Mapping failed")) # Act result = await handler.handle(command) @@ -128,9 +114,7 @@ async def test_update_account_mapper_error(self, handler, mock_service): 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 - ) + 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")) 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 index 20f3502..94e78b5 100644 --- a/tests/unit/context/user_account/domain/create_account_service_test.py +++ b/tests/unit/context/user_account/domain/create_account_service_test.py @@ -53,9 +53,7 @@ async def test_create_account_success(self, service, mock_repository): 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 - ) + result = await service.create_account(user_id=user_id, name=name, currency=currency, balance=balance) # Assert assert result == expected_dto @@ -88,23 +86,17 @@ async def test_create_account_with_zero_balance(self, service, mock_repository): 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 - ) + 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 - ): + 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") - ) + mock_repository.save_account = AsyncMock(side_effect=Exception("Database error")) # Act & Assert with pytest.raises(Exception, match="Database error"): 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 index 55a8289..efac3b6 100644 --- a/tests/unit/context/user_account/domain/update_account_service_test.py +++ b/tests/unit/context/user_account/domain/update_account_service_test.py @@ -78,9 +78,7 @@ async def test_update_account_success(self, service, mock_repository): # 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.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 @@ -93,9 +91,7 @@ async def test_update_account_not_found(self, service, mock_repository): mock_repository.find_user_account_by_id = AsyncMock(return_value=None) # Act & Assert - with pytest.raises( - UserAccountNotFoundError, match="Account with ID 999 not found" - ): + with pytest.raises(UserAccountNotFoundError, match="Account with ID 999 not found"): await service.update_account( account_id=account_id, user_id=user_id, @@ -194,9 +190,7 @@ async def test_update_account_duplicate_name(self, service, mock_repository): mock_repository.update_account.assert_not_called() @pytest.mark.asyncio - async def test_update_account_checks_inactive_accounts_for_duplicates( - self, service, mock_repository - ): + 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) @@ -233,14 +227,10 @@ async def test_update_account_checks_inactive_accounts_for_duplicates( ) # 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 - ) + 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 - ): + 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) @@ -273,9 +263,7 @@ async def test_update_account_allows_same_account_name( ) 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.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 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 index 8847895..0ad8160 100644 --- a/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py +++ b/tests/unit/context/user_account/infrastructure/user_account_mapper_test.py @@ -176,6 +176,7 @@ 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), From 21b73a7e477db4f67f8c376706ed97101f79f55d Mon Sep 17 00:00:00 2001 From: polivera Date: Mon, 29 Dec 2025 21:18:40 +0100 Subject: [PATCH 42/58] run integration only if lint and unit pass --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4781e60..6e73dbb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -84,6 +84,8 @@ jobs: 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 && From dd08f7c42682f7f7a6d91c3b347255e2526d7070 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 01:41:16 +0100 Subject: [PATCH 43/58] Trying to finish household functionality --- .../application/commands/__init__.py | 4 + .../commands/delete_household_command.py | 9 ++ .../commands/update_household_command.py | 10 ++ .../application/contracts/__init__.py | 8 ++ .../delete_household_handler_contract.py | 13 +++ .../get_household_handler_contract.py | 13 +++ .../list_user_households_handler_contract.py | 13 +++ .../update_household_handler_contract.py | 13 +++ .../household/application/dto/__init__.py | 17 +++ .../dto/delete_household_result.py | 19 ++++ .../application/dto/get_household_result.py | 25 +++++ .../dto/list_user_households_result.py | 30 +++++ .../dto/update_household_result.py | 26 +++++ .../application/handlers/__init__.py | 8 ++ .../handlers/delete_household_handler.py | 36 ++++++ .../handlers/get_household_handler.py | 49 ++++++++ .../handlers/list_user_households_handler.py | 44 ++++++++ .../handlers/update_household_handler.py | 68 ++++++++++++ .../household/application/queries/__init__.py | 9 +- .../queries/get_household_query.py | 9 ++ .../queries/list_user_households_query.py | 8 ++ .../household/domain/contracts/__init__.py | 2 + .../household_repository_contract.py | 12 +- .../update_household_service_contract.py | 18 +++ .../household/domain/exceptions/__init__.py | 4 + .../household/domain/exceptions/exceptions.py | 8 ++ .../household/domain/services/__init__.py | 2 + .../services/create_household_service.py | 4 +- .../services/update_household_service.py | 53 +++++++++ .../domain/value_objects/__init__.py | 2 + .../domain/value_objects/deleted_at.py | 10 ++ .../domain/value_objects/household_user_id.py | 4 + .../household/infrastructure/dependencies.py | 40 +++++++ .../infrastructure/models/household_model.py | 5 + .../repositories/household_repository.py | 92 ++++++++++++--- .../interface/rest/controllers/__init__.py | 8 ++ .../delete_household_controller.py | 39 +++++++ .../controllers/get_household_controller.py | 42 +++++++ .../list_user_households_controller.py | 41 +++++++ .../update_household_controller.py | 49 ++++++++ .../household/interface/rest/routes.py | 8 ++ .../household/interface/schemas/__init__.py | 6 + .../interface/schemas/household_response.py | 11 ++ .../schemas/list_households_response.py | 10 ++ .../schemas/update_household_request.py | 8 ++ justfile | 81 ++++++++++---- .../01770bb99438_create_household_tables.py | 5 + requests/household_delete.sh | 11 ++ requests/household_get.sh | 11 ++ requests/household_list.sh | 6 + requests/household_update.sh | 13 +++ scripts/seeds/seed_household_members.py | 105 +++++++++++++++--- scripts/seeds/seed_households.py | 22 ++++ .../domain/create_household_service_test.py | 1 - 54 files changed, 1111 insertions(+), 53 deletions(-) create mode 100644 app/context/household/application/commands/delete_household_command.py create mode 100644 app/context/household/application/commands/update_household_command.py create mode 100644 app/context/household/application/contracts/delete_household_handler_contract.py create mode 100644 app/context/household/application/contracts/get_household_handler_contract.py create mode 100644 app/context/household/application/contracts/list_user_households_handler_contract.py create mode 100644 app/context/household/application/contracts/update_household_handler_contract.py create mode 100644 app/context/household/application/dto/delete_household_result.py create mode 100644 app/context/household/application/dto/get_household_result.py create mode 100644 app/context/household/application/dto/list_user_households_result.py create mode 100644 app/context/household/application/dto/update_household_result.py create mode 100644 app/context/household/application/handlers/delete_household_handler.py create mode 100644 app/context/household/application/handlers/get_household_handler.py create mode 100644 app/context/household/application/handlers/list_user_households_handler.py create mode 100644 app/context/household/application/handlers/update_household_handler.py create mode 100644 app/context/household/application/queries/get_household_query.py create mode 100644 app/context/household/application/queries/list_user_households_query.py create mode 100644 app/context/household/domain/contracts/update_household_service_contract.py create mode 100644 app/context/household/domain/services/update_household_service.py create mode 100644 app/context/household/domain/value_objects/deleted_at.py create mode 100644 app/context/household/interface/rest/controllers/delete_household_controller.py create mode 100644 app/context/household/interface/rest/controllers/get_household_controller.py create mode 100644 app/context/household/interface/rest/controllers/list_user_households_controller.py create mode 100644 app/context/household/interface/rest/controllers/update_household_controller.py create mode 100644 app/context/household/interface/schemas/household_response.py create mode 100644 app/context/household/interface/schemas/list_households_response.py create mode 100644 app/context/household/interface/schemas/update_household_request.py create mode 100755 requests/household_delete.sh create mode 100755 requests/household_get.sh create mode 100755 requests/household_list.sh create mode 100755 requests/household_update.sh diff --git a/app/context/household/application/commands/__init__.py b/app/context/household/application/commands/__init__.py index 08fc5a5..91c9398 100644 --- a/app/context/household/application/commands/__init__.py +++ b/app/context/household/application/commands/__init__.py @@ -1,8 +1,10 @@ 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", @@ -10,4 +12,6 @@ "AcceptInviteCommand", "DeclineInviteCommand", "RemoveMemberCommand", + "UpdateHouseholdCommand", + "DeleteHouseholdCommand", ] 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/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 index f2bc822..937d367 100644 --- a/app/context/household/application/contracts/__init__.py +++ b/app/context/household/application/contracts/__init__.py @@ -1,14 +1,18 @@ 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", @@ -16,6 +20,10 @@ "AcceptInviteHandlerContract", "DeclineInviteHandlerContract", "RemoveMemberHandlerContract", + "GetHouseholdHandlerContract", "ListHouseholdInvitesHandlerContract", + "ListUserHouseholdsHandlerContract", "ListUserPendingInvitesHandlerContract", + "UpdateHouseholdHandlerContract", + "DeleteHouseholdHandlerContract", ] 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/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/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 index 6cb4836..73ffef6 100644 --- a/app/context/household/application/dto/__init__.py +++ b/app/context/household/application/dto/__init__.py @@ -1,9 +1,17 @@ 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", @@ -16,5 +24,14 @@ "DeclineInviteResult", "RemoveMemberErrorCode", "RemoveMemberResult", + "GetHouseholdErrorCode", + "GetHouseholdResult", + "ListUserHouseholdsErrorCode", + "ListUserHouseholdsResult", + "HouseholdSummary", + "UpdateHouseholdErrorCode", + "UpdateHouseholdResult", + "DeleteHouseholdErrorCode", + "DeleteHouseholdResult", "HouseholdMemberResponseDTO", ] 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/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/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 index eb731e4..9c261a2 100644 --- a/app/context/household/application/handlers/__init__.py +++ b/app/context/household/application/handlers/__init__.py @@ -1,10 +1,14 @@ 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", @@ -12,6 +16,10 @@ "AcceptInviteHandler", "DeclineInviteHandler", "RemoveMemberHandler", + "GetHouseholdHandler", "ListHouseholdInvitesHandler", + "ListUserHouseholdsHandler", "ListUserPendingInvitesHandler", + "UpdateHouseholdHandler", + "DeleteHouseholdHandler", ] 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..5565d53 --- /dev/null +++ b/app/context/household/application/handlers/delete_household_handler.py @@ -0,0 +1,36 @@ +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 + + +class DeleteHouseholdHandler(DeleteHouseholdHandlerContract): + """Handler for delete household command""" + + def __init__(self, repository: HouseholdRepositoryContract): + self._repository = repository + + async def handle(self, command: DeleteHouseholdCommand) -> DeleteHouseholdResult: + """Execute the delete household command""" + + 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: + return DeleteHouseholdResult( + error_code=DeleteHouseholdErrorCode.NOT_FOUND, + error_message="Household not found or you are not the owner", + ) + + return DeleteHouseholdResult(success=True) + + except Exception: + 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..6081be1 --- /dev/null +++ b/app/context/household/application/handlers/get_household_handler.py @@ -0,0 +1,49 @@ +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 + + +class GetHouseholdHandler(GetHouseholdHandlerContract): + """Handler for get household query""" + + def __init__(self, repository: HouseholdRepositoryContract): + self._repository = repository + + async def handle(self, query: GetHouseholdQuery) -> GetHouseholdResult: + """Execute the get household query""" + 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: + 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: + 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: + return GetHouseholdResult( + error_code=GetHouseholdErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error", + ) 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..89ba514 --- /dev/null +++ b/app/context/household/application/handlers/list_user_households_handler.py @@ -0,0 +1,44 @@ +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 + + +class ListUserHouseholdsHandler(ListUserHouseholdsHandlerContract): + """Handler for list user households query""" + + def __init__(self, repository: HouseholdRepositoryContract): + self._repository = repository + + async def handle(self, query: ListUserHouseholdsQuery) -> ListUserHouseholdsResult: + """Execute the list user households query""" + 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: + return ListUserHouseholdsResult( + error_code=ListUserHouseholdsErrorCode.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..29fc56f --- /dev/null +++ b/app/context/household/application/handlers/update_household_handler.py @@ -0,0 +1,68 @@ +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 + + +class UpdateHouseholdHandler(UpdateHouseholdHandlerContract): + """Handler for update household command""" + + def __init__(self, service: UpdateHouseholdServiceContract): + self._service = service + + async def handle(self, command: UpdateHouseholdCommand) -> UpdateHouseholdResult: + """Execute the update household command""" + + 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: + 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: + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.NOT_FOUND, + error_message="Household not found", + ) + except OnlyOwnerCanUpdateError: + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.NOT_OWNER, + error_message="Only the household owner can update the household", + ) + except HouseholdNameAlreadyExistError: + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.NAME_ALREADY_EXISTS, + error_message="Household name already exists", + ) + except HouseholdMapperError: + return UpdateHouseholdResult( + error_code=UpdateHouseholdErrorCode.MAPPER_ERROR, + error_message="Error mapping model to dto", + ) + except Exception: + 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 index c7e0ec6..4c683b6 100644 --- a/app/context/household/application/queries/__init__.py +++ b/app/context/household/application/queries/__init__.py @@ -1,4 +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__ = ["ListHouseholdInvitesQuery", "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_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/domain/contracts/__init__.py b/app/context/household/domain/contracts/__init__.py index f58703c..f2d85f0 100644 --- a/app/context/household/domain/contracts/__init__.py +++ b/app/context/household/domain/contracts/__init__.py @@ -5,6 +5,7 @@ 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", @@ -14,4 +15,5 @@ "InviteUserServiceContract", "RemoveMemberServiceContract", "RevokeInviteServiceContract", + "UpdateHouseholdServiceContract", ] diff --git a/app/context/household/domain/contracts/household_repository_contract.py b/app/context/household/domain/contracts/household_repository_contract.py index b42b471..77dc104 100644 --- a/app/context/household/domain/contracts/household_repository_contract.py +++ b/app/context/household/domain/contracts/household_repository_contract.py @@ -10,7 +10,7 @@ class HouseholdRepositoryContract(ABC): @abstractmethod - async def create_household(self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID) -> HouseholdDTO: + async def create_household(self, household_dto: HouseholdDTO) -> HouseholdDTO: """Create a new household and add the creator as first member""" pass @@ -67,6 +67,16 @@ async def list_user_pending_household_invites(self, user_id: HouseholdUserID) -> """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""" 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/exceptions/__init__.py b/app/context/household/domain/exceptions/__init__.py index d9d6924..7797ead 100644 --- a/app/context/household/domain/exceptions/__init__.py +++ b/app/context/household/domain/exceptions/__init__.py @@ -7,9 +7,11 @@ HouseholdNotFoundError, InviteNotFoundError, NotInvitedError, + OnlyOwnerCanDeleteError, OnlyOwnerCanInviteError, OnlyOwnerCanRemoveMemberError, OnlyOwnerCanRevokeError, + OnlyOwnerCanUpdateError, UserNotFoundError, ) @@ -22,8 +24,10 @@ "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 index 6121228..c485ca1 100644 --- a/app/context/household/domain/exceptions/exceptions.py +++ b/app/context/household/domain/exceptions/exceptions.py @@ -45,3 +45,11 @@ class OnlyOwnerCanRemoveMemberError(Exception): 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 index 3f26aee..24b3858 100644 --- a/app/context/household/domain/services/__init__.py +++ b/app/context/household/domain/services/__init__.py @@ -4,6 +4,7 @@ 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", @@ -12,4 +13,5 @@ "InviteUserService", "RemoveMemberService", "RevokeInviteService", + "UpdateHouseholdService", ] diff --git a/app/context/household/domain/services/create_household_service.py b/app/context/household/domain/services/create_household_service.py index b16879b..3d29f58 100644 --- a/app/context/household/domain/services/create_household_service.py +++ b/app/context/household/domain/services/create_household_service.py @@ -21,8 +21,6 @@ async def create_household(self, name: HouseholdName, creator_user_id: Household ) # Save to repository - will raise HouseholdNameAlreadyExistError if duplicate - created_household = await self._household_repository.create_household( - household_dto=household_dto, creator_user_id=creator_user_id - ) + created_household = await self._household_repository.create_household(household_dto=household_dto) return created_household 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..55af5f3 --- /dev/null +++ b/app/context/household/domain/services/update_household_service.py @@ -0,0 +1,53 @@ +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 + + +class UpdateHouseholdService(UpdateHouseholdServiceContract): + """Service for updating households""" + + def __init__(self, repository: HouseholdRepositoryContract): + self._repository = repository + + async def update_household( + self, + household_id: HouseholdID, + user_id: HouseholdUserID, + name: HouseholdName, + ) -> HouseholdDTO: + """Update household name (owner only)""" + + # 1. Find existing household + existing = await self._repository.find_household_by_id(household_id) + + if not existing: + 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: + 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: + 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 index ea5f204..66a4d27 100644 --- a/app/context/household/domain/value_objects/__init__.py +++ b/app/context/household/domain/value_objects/__init__.py @@ -1,3 +1,4 @@ +from .deleted_at import HouseholdDeletedAt from .household_id import HouseholdID from .household_member_id import HouseholdMemberID from .household_name import HouseholdName @@ -6,6 +7,7 @@ from .household_user_name import HouseholdUserName __all__ = [ + "HouseholdDeletedAt", "HouseholdID", "HouseholdMemberID", "HouseholdName", 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_user_id.py b/app/context/household/domain/value_objects/household_user_id.py index a072b70..4af13d0 100644 --- a/app/context/household/domain/value_objects/household_user_id.py +++ b/app/context/household/domain/value_objects/household_user_id.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Self @dataclass(frozen=True) @@ -10,3 +11,6 @@ def __post_init__(self): 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/infrastructure/dependencies.py b/app/context/household/infrastructure/dependencies.py index 6d7bd12..84bf8f7 100644 --- a/app/context/household/infrastructure/dependencies.py +++ b/app/context/household/infrastructure/dependencies.py @@ -7,19 +7,27 @@ 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, @@ -28,6 +36,7 @@ HouseholdRepositoryContract, InviteUserServiceContract, RemoveMemberServiceContract, + UpdateHouseholdServiceContract, ) from app.context.household.domain.services import ( AcceptInviteService, @@ -35,6 +44,7 @@ DeclineInviteService, InviteUserService, RemoveMemberService, + UpdateHouseholdService, ) from app.context.household.infrastructure.repositories import HouseholdRepository from app.shared.infrastructure.database import get_db @@ -78,6 +88,12 @@ def get_remove_member_service( return RemoveMemberService(household_repository) +def get_update_household_service( + household_repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], +) -> UpdateHouseholdServiceContract: + return UpdateHouseholdService(household_repository) + + # Handler dependencies (Commands) def get_create_household_handler( service: Annotated[CreateHouseholdServiceContract, Depends(get_create_household_service)], @@ -109,6 +125,18 @@ def get_remove_member_handler( return RemoveMemberHandler(service) +def get_update_household_handler( + service: Annotated[UpdateHouseholdServiceContract, Depends(get_update_household_service)], +) -> UpdateHouseholdHandlerContract: + return UpdateHouseholdHandler(service) + + +def get_delete_household_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], +) -> DeleteHouseholdHandlerContract: + return DeleteHouseholdHandler(repository) + + # Handler dependencies (Queries) def get_list_household_invites_handler( repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], @@ -120,3 +148,15 @@ def get_list_user_pending_invites_handler( repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], ) -> ListUserPendingInvitesHandlerContract: return ListUserPendingInvitesHandler(repository) + + +def get_get_household_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], +) -> GetHouseholdHandlerContract: + return GetHouseholdHandler(repository) + + +def get_list_user_households_handler( + repository: Annotated[HouseholdRepositoryContract, Depends(get_household_repository)], +) -> ListUserHouseholdsHandlerContract: + return ListUserHouseholdsHandler(repository) diff --git a/app/context/household/infrastructure/models/household_model.py b/app/context/household/infrastructure/models/household_model.py index 1083ae2..55d2091 100644 --- a/app/context/household/infrastructure/models/household_model.py +++ b/app/context/household/infrastructure/models/household_model.py @@ -17,6 +17,11 @@ class HouseholdModel(BaseDBModel): nullable=False, default=lambda: datetime.now(UTC), ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), + nullable=True, + default=None, + ) class HouseholdMemberModel(BaseDBModel): diff --git a/app/context/household/infrastructure/repositories/household_repository.py b/app/context/household/infrastructure/repositories/household_repository.py index 4b60f00..1c599c9 100644 --- a/app/context/household/infrastructure/repositories/household_repository.py +++ b/app/context/household/infrastructure/repositories/household_repository.py @@ -1,6 +1,8 @@ from datetime import UTC, datetime +from typing import Any, cast -from sqlalchemy import and_, select +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 @@ -9,6 +11,7 @@ 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 ( @@ -31,7 +34,7 @@ class HouseholdRepository(HouseholdRepositoryContract): def __init__(self, db: AsyncSession): self._db = db - async def create_household(self, household_dto: HouseholdDTO, creator_user_id: HouseholdUserID) -> HouseholdDTO: + 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) @@ -58,6 +61,7 @@ async def find_household_by_name(self, name: HouseholdName, user_id: HouseholdUs and_( HouseholdModel.owner_user_id == user_id.value, HouseholdModel.name == name.value, + HouseholdModel.deleted_at.is_(None), ) ) @@ -69,7 +73,12 @@ async def find_household_by_name(self, name: HouseholdName, user_id: HouseholdUs async def find_household_by_id(self, household_id: HouseholdID) -> HouseholdDTO | None: """Find a household by ID""" - stmt = select(HouseholdModel).where(HouseholdModel.id == household_id.value) + 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() @@ -162,7 +171,12 @@ async def revoke_or_remove(self, household_id: HouseholdID, user_id: HouseholdUs 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(HouseholdModel.owner_user_id == user_id.value) + 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 = ( @@ -175,22 +189,18 @@ async def list_user_households(self, user_id: HouseholdUserID) -> list[Household and_( HouseholdMemberModel.user_id == user_id.value, HouseholdMemberModel.joined_at.isnot(None), + HouseholdModel.deleted_at.is_(None), ) ) ) - # Execute both queries - owner_result = await self._db.execute(owner_stmt) - member_result = await self._db.execute(member_stmt) - - owner_households = owner_result.scalars().all() - member_households = member_result.scalars().all() + # Combine with UNION (automatically deduplicates) + combined_stmt = union(owner_stmt, member_stmt) - # Combine and dedupe (in case owner is also a member) - all_households = {h.id: h for h in owner_households} - all_households.update({h.id: h for h in member_households}) + result = await self._db.execute(combined_stmt) + households = result.scalars().all() - return [HouseholdMapper.to_dto(h) for h in all_households.values()] + 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""" @@ -204,6 +214,7 @@ async def list_user_pending_invites(self, user_id: HouseholdUserID) -> list[Hous and_( HouseholdMemberModel.user_id == user_id.value, HouseholdMemberModel.joined_at.is_(None), + HouseholdModel.deleted_at.is_(None), ) ) ) @@ -224,6 +235,7 @@ async def list_user_pending_household_invites(self, user_id: HouseholdUserID) -> and_( HouseholdMemberModel.user_id == user_id.value, HouseholdMemberModel.joined_at.is_(None), + HouseholdModel.deleted_at.is_(None), ) ) ) @@ -256,6 +268,7 @@ async def list_household_pending_invites( HouseholdMemberModel.household_id == household_id.value, HouseholdModel.owner_user_id == owner_id.value, HouseholdMemberModel.joined_at.is_(None), + HouseholdModel.deleted_at.is_(None), ) ) ) @@ -271,6 +284,57 @@ async def list_household_pending_invites( 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 diff --git a/app/context/household/interface/rest/controllers/__init__.py b/app/context/household/interface/rest/controllers/__init__.py index 9615844..b7b4d47 100644 --- a/app/context/household/interface/rest/controllers/__init__.py +++ b/app/context/household/interface/rest/controllers/__init__.py @@ -1,12 +1,16 @@ 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", @@ -14,6 +18,10 @@ "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/delete_household_controller.py b/app/context/household/interface/rest/controllers/delete_household_controller.py new file mode 100644 index 0000000..313d4c7 --- /dev/null +++ b/app/context/household/interface/rest/controllers/delete_household_controller.py @@ -0,0 +1,39 @@ +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.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)], +): + """Soft delete household (owner only)""" + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return 204 No Content on success + 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..43e3a42 --- /dev/null +++ b/app/context/household/interface/rest/controllers/get_household_controller.py @@ -0,0 +1,42 @@ +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.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)], +): + """Get household by ID (owner or active member only)""" + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + 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/list_user_households_controller.py b/app/context/household/interface/rest/controllers/list_user_households_controller.py new file mode 100644 index 0000000..007a451 --- /dev/null +++ b/app/context/household/interface/rest/controllers/list_user_households_controller.py @@ -0,0 +1,41 @@ +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.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)], +): + """List all households for the authenticated user""" + query = ListUserHouseholdsQuery(user_id=user_id) + + result = await handler.handle(query) + + # Check for errors + if result.error_code: + 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 []) + ] + + return ListHouseholdsResponse(households=households) 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..9f98271 --- /dev/null +++ b/app/context/household/interface/rest/controllers/update_household_controller.py @@ -0,0 +1,49 @@ +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.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)], +): + """Update household name (owner only)""" + 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + # Return success response + 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 index 0eef2c7..1cb1aeb 100644 --- a/app/context/household/interface/rest/routes.py +++ b/app/context/household/interface/rest/routes.py @@ -4,16 +4,24 @@ 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) diff --git a/app/context/household/interface/schemas/__init__.py b/app/context/household/interface/schemas/__init__.py index 54ca64c..206f078 100644 --- a/app/context/household/interface/schemas/__init__.py +++ b/app/context/household/interface/schemas/__init__.py @@ -3,9 +3,12 @@ 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", @@ -16,4 +19,7 @@ "DeclineInviteResponse", "RemoveMemberResponse", "HouseholdMemberResponse", + "HouseholdResponse", + "ListHouseholdsResponse", + "UpdateHouseholdRequest", ] 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/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/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/justfile b/justfile index c68f02a..95a50ee 100644 --- a/justfile +++ b/justfile @@ -1,59 +1,102 @@ -run: +# 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 -run-with-test: +# 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 -migration-generate comment: +# ============================================================================ +# 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: +# Seed database with initial data +db-seed: uv run python scripts/seed.py +# Clear and reseed database db-reset: just db-clear - just seed + just db-seed -pgcli: - pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:$DB_PORT/$DB_NAME +# ============================================================================ +# Testing +# ============================================================================ -pgcli-test: - pgcli postgresql://$DB_USER:$DB_PASS@$DB_HOST:5433/homecomp_test - -test-unit: +# Run unit tests +test: uv run pytest -m unit --ignore=tests/integration -test-unit-cov: +# 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 . -lint-fix: - uv run ruff check --fix . - -format: - uv run ruff format . - -format-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/versions/01770bb99438_create_household_tables.py b/migrations/versions/01770bb99438_create_household_tables.py index 51f94da..a1842c2 100644 --- a/migrations/versions/01770bb99438_create_household_tables.py +++ b/migrations/versions/01770bb99438_create_household_tables.py @@ -32,6 +32,11 @@ def upgrade() -> None: 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"], 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_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_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/scripts/seeds/seed_household_members.py b/scripts/seeds/seed_household_members.py index 43aa7a0..51f8680 100644 --- a/scripts/seeds/seed_household_members.py +++ b/scripts/seeds/seed_household_members.py @@ -23,15 +23,15 @@ async def seed_household_members( # Doe Family members { "household_id": households["Doe Family"].id, - "user_id": users["john.doe@example.com"].id, - "role": "owner", + "user_id": users["jane.smith@example.com"].id, + "role": "participant", "joined_at": now, - "invited_by_user_id": None, # Owner joined automatically - "invited_at": None, + "invited_by_user_id": users["john.doe@example.com"].id, + "invited_at": now, }, { "household_id": households["Doe Family"].id, - "user_id": users["jane.smith@example.com"].id, + "user_id": users["alice.williams@example.com"].id, "role": "participant", "joined_at": now, "invited_by_user_id": users["john.doe@example.com"].id, @@ -41,22 +41,22 @@ async def seed_household_members( "household_id": households["Doe Family"].id, "user_id": users["charlie.brown@example.com"].id, "role": "participant", - "joined_at": None, # Invited but not joined yet + "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["jane.smith@example.com"].id, - "role": "owner", + "user_id": users["bob.johnson@example.com"].id, + "role": "participant", "joined_at": now, - "invited_by_user_id": None, - "invited_at": None, + "invited_by_user_id": users["jane.smith@example.com"].id, + "invited_at": now, }, { "household_id": households["Smith Household"].id, - "user_id": users["bob.johnson@example.com"].id, + "user_id": users["john.doe@example.com"].id, "role": "participant", "joined_at": now, "invited_by_user_id": users["jane.smith@example.com"].id, @@ -65,11 +65,88 @@ async def seed_household_members( # 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": "owner", + "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": None, - "invited_at": None, + "invited_by_user_id": users["alice.williams@example.com"].id, + "invited_at": now, }, ] diff --git a/scripts/seeds/seed_households.py b/scripts/seeds/seed_households.py index 2e4fdd6..e259173 100644 --- a/scripts/seeds/seed_households.py +++ b/scripts/seeds/seed_households.py @@ -9,6 +9,7 @@ async def seed_households(session: AsyncSession, users: dict[str, UserModel]) -> print(" → Seeding households...") households_data = [ + # Original households { "owner_user_id": users["john.doe@example.com"].id, "name": "Doe Family", @@ -21,6 +22,27 @@ async def seed_households(session: AsyncSession, users: dict[str, UserModel]) -> "owner_user_id": users["alice.williams@example.com"].id, "name": "Williams Home", }, + # Additional households for better testing + { + "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] diff --git a/tests/unit/context/household/domain/create_household_service_test.py b/tests/unit/context/household/domain/create_household_service_test.py index 8c1a2bc..8c704fe 100644 --- a/tests/unit/context/household/domain/create_household_service_test.py +++ b/tests/unit/context/household/domain/create_household_service_test.py @@ -62,7 +62,6 @@ async def test_create_household_success(self, service, mock_repository): 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 - assert call_args.kwargs["creator_user_id"] == creator_user_id @pytest.mark.asyncio async def test_create_household_duplicate_name_raises_error(self, service, mock_repository): From 2411d6d8f15f0d03bdbf9b70829931d66064a871 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 14:53:05 +0100 Subject: [PATCH 44/58] Adding logging with loki - Also some code quality change on user_account and credit_card context --- .claude/rules/logging.md | 555 ++++++++++++++++++ .../application/handlers/login_handler.py | 26 +- .../auth/domain/services/login_service.py | 35 +- .../auth/infrastructure/dependencies.py | 8 +- .../rest/controllers/login_rest_controller.py | 26 +- .../interface/rest/controllers/__init__.py | 13 + .../create_credit_card_controller.py | 2 +- .../delete_credit_card_controller.py | 2 +- ...y => find_credit_card_by_id_controller.py} | 33 +- .../find_credit_cards_by_user_controller.py | 41 ++ .../update_credit_card_controller.py | 2 +- .../credit_card/interface/rest/routes.py | 20 +- .../find_account_by_id_handler_contract.py | 6 +- .../find_accounts_by_user_handler_contract.py | 6 +- .../user_account/application/dto/__init__.py | 9 + .../application/dto/account_response_dto.py | 1 + .../dto/find_multiple_accounts_result.py | 22 + .../dto/find_single_account_result.py | 23 + .../handlers/find_account_by_id_handler.py | 13 +- .../handlers/find_accounts_by_user_handler.py | 24 +- .../controllers/find_account_controller.py | 46 +- .../controllers/update_account_controller.py | 3 + app/main.py | 8 + app/shared/domain/contracts/__init__.py | 3 + .../domain/contracts/logger_contract.py | 36 ++ .../infrastructure/dependencies/__init__.py | 3 + .../dependencies/logger_dependency.py | 19 + app/shared/infrastructure/logging/__init__.py | 4 + app/shared/infrastructure/logging/config.py | 78 +++ .../infrastructure/logging/null_logger.py | 34 ++ .../logging/structlog_logger.py | 43 ++ docker-compose.yml | 52 ++ .../provisioning/datasources/datasources.yaml | 11 + docker/loki/loki-config.yaml | 55 ++ docker/promtail/promtail-config.yaml | 47 ++ pyproject.toml | 2 + scripts/seeds/seed_households.py | 2 - tests/conftest.py | 5 + tests/fixtures/shared/__init__.py | 5 + tests/fixtures/shared/logger.py | 11 + .../auth/application/login_handler_test.py | 32 +- .../context/auth/domain/login_service_test.py | 44 +- .../find_account_by_id_handler_test.py | 47 +- .../find_accounts_by_user_handler_test.py | 43 +- uv.lock | 100 ++++ 45 files changed, 1463 insertions(+), 137 deletions(-) create mode 100644 .claude/rules/logging.md rename app/context/credit_card/interface/rest/controllers/{find_credit_card_controller.py => find_credit_card_by_id_controller.py} (57%) create mode 100644 app/context/credit_card/interface/rest/controllers/find_credit_cards_by_user_controller.py create mode 100644 app/context/user_account/application/dto/find_multiple_accounts_result.py create mode 100644 app/context/user_account/application/dto/find_single_account_result.py create mode 100644 app/shared/domain/contracts/__init__.py create mode 100644 app/shared/domain/contracts/logger_contract.py create mode 100644 app/shared/infrastructure/dependencies/__init__.py create mode 100644 app/shared/infrastructure/dependencies/logger_dependency.py create mode 100644 app/shared/infrastructure/logging/__init__.py create mode 100644 app/shared/infrastructure/logging/config.py create mode 100644 app/shared/infrastructure/logging/null_logger.py create mode 100644 app/shared/infrastructure/logging/structlog_logger.py create mode 100644 docker/grafana/provisioning/datasources/datasources.yaml create mode 100644 docker/loki/loki-config.yaml create mode 100644 docker/promtail/promtail-config.yaml create mode 100644 tests/fixtures/shared/__init__.py create mode 100644 tests/fixtures/shared/logger.py diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md new file mode 100644 index 0000000..f6d6eec --- /dev/null +++ b/.claude/rules/logging.md @@ -0,0 +1,555 @@ +# 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 + +### 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/app/context/auth/application/handlers/login_handler.py b/app/context/auth/application/handlers/login_handler.py index cbdd2d3..6a8b05f 100644 --- a/app/context/auth/application/handlers/login_handler.py +++ b/app/context/auth/application/handlers/login_handler.py @@ -15,26 +15,37 @@ 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): + def __init__( + self, + user_handler: FindUserHandlerContract, + login_service: LoginServiceContract, + logger: LoggerContract, + ): self._user_handler = user_handler self._login_service = login_service - pass + 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", @@ -50,23 +61,32 @@ async def handle(self, command: LoginCommand) -> LoginHandlerResultDTO: ), ) + 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: + 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/domain/services/login_service.py b/app/context/auth/domain/services/login_service.py index 952ae1c..badb66e 100644 --- a/app/context/auth/domain/services/login_service.py +++ b/app/context/auth/domain/services/login_service.py @@ -15,18 +15,24 @@ 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): + 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( @@ -38,6 +44,11 @@ async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> Ses ) 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) @@ -45,10 +56,21 @@ async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> Ses # 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( @@ -59,14 +81,21 @@ async def handle(self, user_password: AuthPassword, db_user: AuthUserDTO) -> Ses ) ) - # Seep to avoid brute force attempts - await asyncio.sleep(new_attempts.getAttemptDelay()) + # 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, diff --git a/app/context/auth/infrastructure/dependencies.py b/app/context/auth/infrastructure/dependencies.py index f2f2026..2dbfbb4 100644 --- a/app/context/auth/infrastructure/dependencies.py +++ b/app/context/auth/infrastructure/dependencies.py @@ -23,7 +23,9 @@ 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( @@ -34,11 +36,12 @@ def get_session_repository( 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) + return LoginService(session_repo, logger) def get_session_handler( @@ -50,8 +53,9 @@ def get_session_handler( 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) + return LoginHandler(user_query_handler, login_service, logger) diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 896def4..53266fa 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -8,8 +8,10 @@ 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.infrastructure.dependencies import get_logger -router = APIRouter(prefix="/login", tags=["login"]) +router = APIRouter(prefix="/login") @router.post("", response_model=LoginResponse) @@ -17,34 +19,48 @@ 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, # Prevents JavaScript access (XSS protection) - secure=(getenv("APP_ENV", "dev") == "prod"), # Only send over HTTPS - samesite="lax", # CSRF protection - max_age=3600, # 1 hour expiration (adjust as needed) + httponly=True, + # FIX: Error prone + secure=(getenv("APP_ENV", "dev") == "prod"), + 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/credit_card/interface/rest/controllers/__init__.py b/app/context/credit_card/interface/rest/controllers/__init__.py index e69de29..0fb6f51 100644 --- a/app/context/credit_card/interface/rest/controllers/__init__.py +++ 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 index 9e167a1..492518f 100644 --- 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 @@ -19,7 +19,7 @@ ) from app.shared.infrastructure.middleware import get_current_user_id -router = APIRouter(prefix="/cards", tags=["credit-cards"]) +router = APIRouter(prefix="/cards") @router.post("", response_model=CreateCreditCardResponse, status_code=201) 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 index d22957f..09af71c 100644 --- 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 @@ -12,7 +12,7 @@ ) from app.shared.infrastructure.middleware import get_current_user_id -router = APIRouter(prefix="/cards", tags=["credit-cards"]) +router = APIRouter(prefix="/cards") @router.delete("/{credit_card_id}", status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py b/app/context/credit_card/interface/rest/controllers/find_credit_card_by_id_controller.py similarity index 57% rename from app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py rename to app/context/credit_card/interface/rest/controllers/find_credit_card_by_id_controller.py index ac3a7c9..1f9270a 100644 --- a/app/context/credit_card/interface/rest/controllers/find_credit_card_controller.py +++ b/app/context/credit_card/interface/rest/controllers/find_credit_card_by_id_controller.py @@ -4,22 +4,17 @@ from app.context.credit_card.application.contracts import ( FindCreditCardByIdHandlerContract, - FindCreditCardsByUserHandlerContract, -) -from app.context.credit_card.application.queries import ( - FindCreditCardByIdQuery, - FindCreditCardsByUserQuery, ) +from app.context.credit_card.application.queries import FindCreditCardByIdQuery from app.context.credit_card.infrastructure.dependencies import ( get_find_credit_card_by_id_handler, - get_find_credit_cards_by_user_handler, ) from app.context.credit_card.interface.schemas.credit_card_response import ( CreditCardResponse, ) from app.shared.infrastructure.middleware import get_current_user_id -router = APIRouter(prefix="/cards", tags=["credit-cards"]) +router = APIRouter(prefix="/cards") @router.get("/{credit_card_id}", response_model=CreditCardResponse) @@ -51,27 +46,3 @@ async def get_credit_card( limit=result.limit, used=result.used, ) - - -@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)], -): - """Get all credit cards for the current user""" - query = FindCreditCardsByUserQuery(user_id=user_id) - - results = await handler.handle(query) - - 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/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..f722049 --- /dev/null +++ b/app/context/credit_card/interface/rest/controllers/find_credit_cards_by_user_controller.py @@ -0,0 +1,41 @@ +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.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)], +): + """Get all credit cards for the current user""" + query = FindCreditCardsByUserQuery(user_id=user_id) + + results = await handler.handle(query) + + 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 index b11d929..c5d1e28 100644 --- 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 @@ -18,7 +18,7 @@ ) from app.shared.infrastructure.middleware import get_current_user_id -router = APIRouter(prefix="/cards", tags=["credit-cards"]) +router = APIRouter(prefix="/cards") @router.put("/{credit_card_id}", response_model=UpdateCreditCardResponse) diff --git a/app/context/credit_card/interface/rest/routes.py b/app/context/credit_card/interface/rest/routes.py index 83a596a..dbdf678 100644 --- a/app/context/credit_card/interface/rest/routes.py +++ b/app/context/credit_card/interface/rest/routes.py @@ -1,22 +1,18 @@ from fastapi import APIRouter -from app.context.credit_card.interface.rest.controllers.create_credit_card_controller import ( - router as create_router, -) -from app.context.credit_card.interface.rest.controllers.delete_credit_card_controller import ( - router as delete_router, -) -from app.context.credit_card.interface.rest.controllers.find_credit_card_controller import ( - router as find_router, -) -from app.context.credit_card.interface.rest.controllers.update_credit_card_controller import ( - router as update_router, +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_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/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 index e11b416..caa51cb 100644 --- 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 @@ -1,8 +1,6 @@ from abc import ABC, abstractmethod -from app.context.user_account.application.dto.account_response_dto import ( - AccountResponseDTO, -) +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, ) @@ -10,5 +8,5 @@ class FindAccountByIdHandlerContract(ABC): @abstractmethod - async def handle(self, query: FindAccountByIdQuery) -> AccountResponseDTO | None: + 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 index 8e26f79..99deb1e 100644 --- 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 @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod -from app.context.user_account.application.dto.account_response_dto import ( - AccountResponseDTO, +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, @@ -10,5 +10,5 @@ class FindAccountsByUserHandlerContract(ABC): @abstractmethod - async def handle(self, query: FindAccountsByUserQuery) -> list[AccountResponseDTO]: + async def handle(self, query: FindAccountsByUserQuery) -> FindMultipleAccountsResult: pass diff --git a/app/context/user_account/application/dto/__init__.py b/app/context/user_account/application/dto/__init__.py index 047050a..ba1fff8 100644 --- a/app/context/user_account/application/dto/__init__.py +++ b/app/context/user_account/application/dto/__init__.py @@ -1,6 +1,11 @@ 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__ = [ @@ -9,6 +14,10 @@ "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 index f4444de..238c2c7 100644 --- a/app/context/user_account/application/dto/account_response_dto.py +++ b/app/context/user_account/application/dto/account_response_dto.py @@ -17,6 +17,7 @@ class AccountResponseDTO: @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, 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/handlers/find_account_by_id_handler.py b/app/context/user_account/application/handlers/find_account_by_id_handler.py index d34870f..33dfba6 100644 --- 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 @@ -4,6 +4,10 @@ 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, ) @@ -20,10 +24,15 @@ class FindAccountByIdHandler(FindAccountByIdHandlerContract): def __init__(self, repository: UserAccountRepositoryContract): self._repository = repository - async def handle(self, query: FindAccountByIdQuery) -> AccountResponseDTO | None: + 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), ) - return AccountResponseDTO.from_domain_dto(account) if account else None + 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 index 3217b4a..2ba20b8 100644 --- 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 @@ -1,8 +1,10 @@ from app.context.user_account.application.contracts.find_accounts_by_user_handler_contract import ( FindAccountsByUserHandlerContract, ) -from app.context.user_account.application.dto.account_response_dto import ( - AccountResponseDTO, +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, @@ -17,6 +19,18 @@ class FindAccountsByUserHandler(FindAccountsByUserHandlerContract): def __init__(self, repository: UserAccountRepositoryContract): self._repository = repository - async def handle(self, query: FindAccountsByUserQuery) -> list[AccountResponseDTO]: - accounts = await self._repository.find_user_accounts(user_id=UserAccountUserID(query.user_id)) - return [AccountResponseDTO.from_domain_dto(acc) for acc in accounts] if accounts is not None else [] + async def handle(self, query: FindAccountsByUserQuery) -> FindMultipleAccountsResult: + try: + accounts = await self._repository.find_user_accounts(user_id=UserAccountUserID(query.user_id)) + + if accounts is None: + return FindMultipleAccountsResult(accounts=[]) + + account_dtos = [AccountResponseDTO.from_domain_dto(acc) for acc in accounts] + return FindMultipleAccountsResult(accounts=account_dtos) + + except Exception: + return FindMultipleAccountsResult( + error_code=FindMultipleAccountsErrorCode.UNEXPECTED_ERROR, + error_message="Unexpected error while finding accounts", + ) 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 index a689256..d9873d9 100644 --- a/app/context/user_account/interface/rest/controllers/find_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -8,6 +8,10 @@ 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, ) @@ -37,14 +41,26 @@ async def get_account( ) result = await handler.handle(query) - if not result: - raise HTTPException(status_code=404, detail="Account not found") + # 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) + raise HTTPException(status_code=status_code, detail=result.error_message) + + if not result.account: + raise HTTPException(status_code=500, detail="error in response data") + + # Return success response return AccountResponse( - account_id=result.account_id, - name=result.name, - currency=result.currency, - balance=result.balance, + account_id=result.account.account_id, + name=result.account.name, + currency=result.account.currency, + balance=result.account.balance, ) @@ -55,8 +71,22 @@ async def get_all_accounts( ): """Get all accounts for the authenticated user""" query = FindAccountsByUserQuery(user_id=user_id) - results = await handler.handle(query) + 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) + 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 success response return [ AccountResponse( account_id=r.account_id, @@ -64,5 +94,5 @@ async def get_all_accounts( currency=r.currency, balance=r.balance, ) - for r in results + 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 index 232f77b..76999b2 100644 --- a/app/context/user_account/interface/rest/controllers/update_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/update_account_controller.py @@ -53,6 +53,9 @@ async def update_account( status_code = status_code_map.get(result.error_code, 500) 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: + raise HTTPException(status_code=500, detail="error on required response fields") + # Return success response return UpdateAccountResponse( account_id=result.account_id, diff --git a/app/main.py b/app/main.py index 6311ba5..08659a2 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,17 @@ +from os import getenv + 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.household.interface.rest import household_routes from app.context.user_account.interface.rest import user_account_routes +from app.shared.infrastructure.logging.config import configure_structlog + +# Configure structlog (skip in test environment) +if getenv("APP_ENV") != "test": + use_json = getenv("APP_ENV") == "production" + configure_structlog(use_json=use_json) app = FastAPI( title="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/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..d162e38 --- /dev/null +++ b/app/shared/infrastructure/dependencies/logger_dependency.py @@ -0,0 +1,19 @@ +from os import getenv + +from app.shared.domain.contracts.logger_contract import LoggerContract +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 getenv("APP_ENV") == "test": + 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..7fc3dec --- /dev/null +++ b/app/shared/infrastructure/logging/config.py @@ -0,0 +1,78 @@ +import logging +import os +import sys + +import structlog + + +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 (raw structured data, no formatting) + if os.getenv("APP_ENV") != "prod": + try: + import logging_loki + + loki_handler = logging_loki.LokiHandler( + url="http://localhost:3100/loki/api/v1/push", + tags={"app": "homecomp-api", "env": "dev"}, + version="1", + ) + # Configure Loki handler to receive structured data + loki_handler.setFormatter( + structlog.stdlib.ProcessorFormatter( + processor=structlog.processors.JSONRenderer(), + 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/docker-compose.yml b/docker-compose.yml index abb9b13..3426a57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,7 +49,59 @@ services: timeout: 5s retries: 5 + loki: + image: grafana/loki:2.9.3 + 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 + + promtail: + image: grafana/promtail:2.9.3 + 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 + + grafana: + image: grafana/grafana:10.2.3 + 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 + 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 + volumes: postgres_data: postgres_test_data: valkey_data: + loki_data: + grafana_data: 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..4e22390 --- /dev/null +++ b/docker/loki/loki-config.yaml @@ -0,0 +1,55 @@ +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 + +table_manager: + retention_deletes_enabled: true + retention_period: 744h # 31 days + +compactor: + working_directory: /loki/compactor + compaction_interval: 10m + retention_enabled: true + retention_delete_delay: 2h + retention_delete_worker_count: 150 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/pyproject.toml b/pyproject.toml index 1c5cfa7..c0b8545 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "psycopg2-binary>=2.9.11", "pydantic[email]>=2.12.5", "sqlalchemy>=2.0.45", + "structlog>=25.5.0", "uvicorn>=0.38.0", ] @@ -22,6 +23,7 @@ dev = [ "pytest>=9.0.2", "pytest-asyncio>=1.3.0", "pytest-cov>=6.0.0", + "python-logging-loki>=0.3.1", "ruff>=0.14.10", ] diff --git a/scripts/seeds/seed_households.py b/scripts/seeds/seed_households.py index e259173..416792f 100644 --- a/scripts/seeds/seed_households.py +++ b/scripts/seeds/seed_households.py @@ -9,7 +9,6 @@ async def seed_households(session: AsyncSession, users: dict[str, UserModel]) -> print(" → Seeding households...") households_data = [ - # Original households { "owner_user_id": users["john.doe@example.com"].id, "name": "Doe Family", @@ -22,7 +21,6 @@ async def seed_households(session: AsyncSession, users: dict[str, UserModel]) -> "owner_user_id": users["alice.williams@example.com"].id, "name": "Williams Home", }, - # Additional households for better testing { "owner_user_id": users["bob.johnson@example.com"].id, "name": "Johnson's Place", diff --git a/tests/conftest.py b/tests/conftest.py index c5fbe93..3c2f20d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,9 @@ Add global fixtures here if they're truly shared across all contexts. """ +# Import shared fixtures +from tests.fixtures.shared import mock_logger + # Import auth context fixtures from tests.fixtures.auth import ( MockLoginService, @@ -31,6 +34,8 @@ # Make fixtures available to pytest __all__ = [ + # Shared fixtures + "mock_logger", # Auth fixtures "MockSessionRepository", "mock_session_repository", 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/unit/context/auth/application/login_handler_test.py b/tests/unit/context/auth/application/login_handler_test.py index debbab8..1957a31 100644 --- a/tests/unit/context/auth/application/login_handler_test.py +++ b/tests/unit/context/auth/application/login_handler_test.py @@ -21,7 +21,7 @@ class TestLoginHandler: """Unit tests for LoginHandler.""" - async def test_handle_successful_login(self, mock_find_user_handler, mock_login_service): + 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" @@ -34,7 +34,7 @@ async def test_handle_successful_login(self, mock_find_user_handler, mock_login_ 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) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act @@ -57,7 +57,7 @@ async def test_handle_successful_login(self, mock_find_user_handler, mock_login_ # 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): + 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" @@ -65,7 +65,7 @@ async def test_handle_user_not_found(self, mock_find_user_handler, mock_login_se mock_find_user_handler.handle_mock.return_value = None - handler = LoginHandler(mock_find_user_handler, mock_login_service) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act @@ -82,7 +82,7 @@ async def test_handle_user_not_found(self, mock_find_user_handler, mock_login_se # 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): + 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" @@ -94,7 +94,7 @@ async def test_handle_invalid_credentials_exception(self, mock_find_user_handler 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) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act @@ -111,7 +111,7 @@ async def test_handle_invalid_credentials_exception(self, mock_find_user_handler # 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): + 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" @@ -124,7 +124,7 @@ async def test_handle_account_blocked_exception(self, mock_find_user_handler, mo 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) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act @@ -141,7 +141,7 @@ async def test_handle_account_blocked_exception(self, mock_find_user_handler, mo # 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): + 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" @@ -153,7 +153,7 @@ async def test_handle_unexpected_error(self, mock_find_user_handler, mock_login_ 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) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act @@ -170,7 +170,7 @@ async def test_handle_unexpected_error(self, mock_find_user_handler, mock_login_ # 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): + 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" @@ -182,7 +182,7 @@ async def test_handle_database_exception(self, mock_find_user_handler, mock_logi 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) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act @@ -193,7 +193,7 @@ async def test_handle_database_exception(self, mock_find_user_handler, mock_logi 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): + 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" @@ -205,7 +205,7 @@ async def test_handle_passes_correct_auth_user_dto(self, mock_find_user_handler, 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) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act @@ -224,7 +224,7 @@ async def test_handle_passes_correct_auth_user_dto(self, mock_find_user_handler, 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): + 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 = [ @@ -259,7 +259,7 @@ async def test_handle_with_different_user_scenarios(self, mock_find_user_handler 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) + handler = LoginHandler(mock_find_user_handler, mock_login_service, mock_logger) command = LoginCommand(email=email, password=password) # Act diff --git a/tests/unit/context/auth/domain/login_service_test.py b/tests/unit/context/auth/domain/login_service_test.py index 721cb6f..c7d3af6 100644 --- a/tests/unit/context/auth/domain/login_service_test.py +++ b/tests/unit/context/auth/domain/login_service_test.py @@ -26,7 +26,7 @@ class TestLoginService: """Unit tests for LoginService domain service.""" - async def test_successful_login_no_existing_session(self, mock_session_repository): + 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) @@ -60,7 +60,7 @@ async def test_successful_login_no_existing_session(self, mock_session_repositor blocked_until=None, ) - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act with patch.object(SessionToken, "generate", return_value=SessionToken("mocked-token")): @@ -90,7 +90,7 @@ async def test_successful_login_no_existing_session(self, mock_session_repositor 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): + 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) @@ -113,7 +113,7 @@ async def test_successful_login_with_existing_session(self, mock_session_reposit ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act with patch.object(SessionToken, "generate", return_value=SessionToken("new-token")): @@ -129,7 +129,7 @@ async def test_successful_login_with_existing_session(self, mock_session_reposit # 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): + 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) @@ -153,7 +153,7 @@ async def test_failed_login_wrong_password_first_attempt(self, mock_session_repo ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act & Assert with pytest.raises(InvalidCredentialsException): @@ -170,7 +170,7 @@ async def test_failed_login_wrong_password_first_attempt(self, mock_session_repo 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): + 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) @@ -194,7 +194,7 @@ async def test_failed_login_third_attempt_with_delay(self, mock_session_reposito ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act & Assert with pytest.raises(InvalidCredentialsException): @@ -208,7 +208,7 @@ async def test_failed_login_third_attempt_with_delay(self, mock_session_reposito 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): + 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) @@ -232,7 +232,7 @@ async def test_failed_login_max_attempts_blocks_account(self, mock_session_repos ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act & Assert with pytest.raises(InvalidCredentialsException): @@ -247,7 +247,7 @@ async def test_failed_login_max_attempts_blocks_account(self, mock_session_repos # 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): + 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) @@ -271,7 +271,7 @@ async def test_login_blocked_account_raises_exception(self, mock_session_reposit ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act & Assert with pytest.raises(AccountBlockedException) as exc_info: @@ -284,7 +284,7 @@ async def test_login_blocked_account_raises_exception(self, mock_session_reposit # 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): + 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) @@ -308,7 +308,7 @@ async def test_login_expired_block_allows_login(self, mock_session_repository): ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act with patch.object(SessionToken, "generate", return_value=SessionToken("unblock-token")): @@ -324,7 +324,7 @@ async def test_login_expired_block_allows_login(self, mock_session_repository): 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): + 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) @@ -347,7 +347,7 @@ async def test_successful_login_resets_failed_attempts(self, mock_session_reposi ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act with patch.object(SessionToken, "generate", return_value=SessionToken("reset-token")): @@ -362,7 +362,7 @@ async def test_successful_login_resets_failed_attempts(self, mock_session_reposi 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): + 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) @@ -384,7 +384,7 @@ async def test_password_verification_called_correctly(self, mock_session_reposit ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act with patch.object(SessionToken, "generate", return_value=SessionToken("verify-token")): @@ -395,7 +395,7 @@ async def test_password_verification_called_correctly(self, mock_session_reposit # 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): + 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) @@ -417,7 +417,7 @@ async def test_session_token_generation(self, mock_session_repository): ) mock_session_repository.get_session_mock.return_value = existing_session - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Act generated_token = SessionToken("unique-secure-token-xyz") @@ -428,7 +428,7 @@ async def test_session_token_generation(self, mock_session_repository): mock_generate.assert_called_once() assert result == generated_token - async def test_multiple_failed_attempts_sequence(self, mock_session_repository): + 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) @@ -442,7 +442,7 @@ async def test_multiple_failed_attempts_sequence(self, mock_session_repository): password=hashed_password, ) - service = LoginService(mock_session_repository) + service = LoginService(mock_session_repository, mock_logger) # Test attempts 1-4 for attempt in range(4): 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 index 8d98ab1..e92826a 100644 --- 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 @@ -5,6 +5,9 @@ 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, ) @@ -48,38 +51,47 @@ async def test_find_account_by_id_success(self, handler, mock_repository): account_id=UserAccountID(10), ) - mock_repository.find_user_accounts = AsyncMock(return_value=account_dto) + # 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.account_id == 10 - assert result.user_id == 1 - assert result.name == "My Account" - assert result.currency == "USD" - assert result.balance == Decimal("100.00") + 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 None""" + """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 None + 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) - mock_repository.find_user_accounts = AsyncMock(return_value=None) + # Return empty list to test repository call without affecting test focus + mock_repository.find_user_accounts = AsyncMock(return_value=[]) # Act await handler.handle(query) @@ -90,3 +102,20 @@ async def test_find_account_by_id_calls_repository_with_correct_params(self, han 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 index 8a8801f..6d79591 100644 --- 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 @@ -5,6 +5,7 @@ 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, ) @@ -63,11 +64,15 @@ async def test_find_accounts_by_user_success(self, handler, mock_repository): result = await handler.handle(query) # Assert - assert len(result) == 2 - assert result[0].account_id == 10 - assert result[0].name == "Account 1" - assert result[1].account_id == 11 - assert result[1].name == "Account 2" + 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): @@ -80,7 +85,11 @@ async def test_find_accounts_by_user_empty_list(self, handler, mock_repository): result = await handler.handle(query) # Assert - assert result == [] + 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): @@ -93,4 +102,24 @@ async def test_find_accounts_by_user_none_returns_empty_list(self, handler, mock result = await handler.handle(query) # Assert - assert result == [] + 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/uv.lock b/uv.lock index 1c5e63c..0ee0b04 100644 --- a/uv.lock +++ b/uv.lock @@ -179,6 +179,47 @@ wheels = [ { 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" @@ -351,6 +392,7 @@ dependencies = [ { name = "psycopg2-binary" }, { name = "pydantic", extra = ["email"] }, { name = "sqlalchemy" }, + { name = "structlog" }, { name = "uvicorn" }, ] @@ -360,6 +402,7 @@ dev = [ { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, + { name = "python-logging-loki" }, { name = "ruff" }, ] @@ -373,6 +416,7 @@ requires-dist = [ { 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" }, ] @@ -382,6 +426,7 @@ dev = [ { 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" }, ] @@ -676,6 +721,43 @@ 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" @@ -743,6 +825,15 @@ 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" @@ -764,6 +855,15 @@ 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" From 52a83e5894ef897024d707b10163b61e18c562d7 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 14:55:11 +0100 Subject: [PATCH 45/58] Fix linting --- tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3c2f20d..3143f62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,8 +5,6 @@ """ # Import shared fixtures -from tests.fixtures.shared import mock_logger - # Import auth context fixtures from tests.fixtures.auth import ( MockLoginService, @@ -14,6 +12,7 @@ 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 From b0c3beb4756aae110371b84b314722fca235c287 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 17:34:16 +0100 Subject: [PATCH 46/58] Adding logs to user_account context --- .../handlers/create_account_handler.py | 17 +++++++- .../handlers/delete_account_handler.py | 12 +++++- .../handlers/find_account_by_id_handler.py | 4 +- .../handlers/find_accounts_by_user_handler.py | 26 +++++++++++- .../handlers/update_account_handler.py | 17 +++++++- .../domain/services/create_account_service.py | 16 ++++++- .../domain/services/update_account_service.py | 26 +++++++++++- .../infrastructure/dependencies.py | 23 ++++++---- .../controllers/create_account_controller.py | 29 +++++++++++++ .../controllers/delete_account_controller.py | 23 ++++++++++ .../controllers/find_account_controller.py | 29 ++++++++++++- .../controllers/update_account_controller.py | 42 +++++++++++++++++++ .../create_account_handler_test.py | 5 ++- .../delete_account_handler_test.py | 5 ++- .../find_account_by_id_handler_test.py | 5 ++- .../find_accounts_by_user_handler_test.py | 5 ++- .../update_account_handler_test.py | 5 ++- .../domain/create_account_service_test.py | 5 ++- .../domain/update_account_service_test.py | 5 ++- 19 files changed, 263 insertions(+), 36 deletions(-) diff --git a/app/context/user_account/application/handlers/create_account_handler.py b/app/context/user_account/application/handlers/create_account_handler.py index f61c827..379e537 100644 --- a/app/context/user_account/application/handlers/create_account_handler.py +++ b/app/context/user_account/application/handlers/create_account_handler.py @@ -21,13 +21,15 @@ UserAccountCurrency, UserAccountUserID, ) +from app.shared.domain.contracts import LoggerContract class CreateAccountHandler(CreateAccountHandlerContract): """Handler for create account command""" - def __init__(self, service: CreateAccountServiceContract): + 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""" @@ -41,6 +43,11 @@ async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: ) 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", @@ -61,7 +68,13 @@ async def handle(self, command: CreateAccountCommand) -> CreateAccountResult: error_code=CreateAccountErrorCode.MAPPER_ERROR, error_message="Error mapping model to dto", ) - except Exception: + 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 index 7d31330..c54ab07 100644 --- a/app/context/user_account/application/handlers/delete_account_handler.py +++ b/app/context/user_account/application/handlers/delete_account_handler.py @@ -15,11 +15,13 @@ UserAccountID, UserAccountUserID, ) +from app.shared.domain.contracts import LoggerContract class DeleteAccountHandler(DeleteAccountHandlerContract): - def __init__(self, repository: UserAccountRepositoryContract): + 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""" @@ -37,7 +39,13 @@ async def handle(self, command: DeleteAccountCommand) -> DeleteAccountResult: ) return DeleteAccountResult(success=True) - except Exception: + 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 index 33dfba6..1b4ba3e 100644 --- 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 @@ -18,11 +18,13 @@ UserAccountID, UserAccountUserID, ) +from app.shared.domain.contracts import LoggerContract class FindAccountByIdHandler(FindAccountByIdHandlerContract): - def __init__(self, repository: UserAccountRepositoryContract): + 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( 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 index 2ba20b8..4dcc055 100644 --- 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 @@ -13,23 +13,45 @@ 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): + 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: + 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 index 43eea16..a9d280f 100644 --- a/app/context/user_account/application/handlers/update_account_handler.py +++ b/app/context/user_account/application/handlers/update_account_handler.py @@ -23,11 +23,13 @@ UserAccountID, UserAccountUserID, ) +from app.shared.domain.contracts import LoggerContract class UpdateAccountHandler(UpdateAccountHandlerContract): - def __init__(self, service: UpdateAccountServiceContract): + 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""" @@ -42,6 +44,11 @@ async def handle(self, command: UpdateAccountCommand) -> UpdateAccountResult: ) 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", @@ -67,7 +74,13 @@ async def handle(self, command: UpdateAccountCommand) -> UpdateAccountResult: error_code=UpdateAccountErrorCode.MAPPER_ERROR, error_message="Error mapping model to dto", ) - except Exception: + 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/domain/services/create_account_service.py b/app/context/user_account/domain/services/create_account_service.py index f42fc5d..9a8f15d 100644 --- a/app/context/user_account/domain/services/create_account_service.py +++ b/app/context/user_account/domain/services/create_account_service.py @@ -11,13 +11,15 @@ 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): + def __init__(self, account_repository: UserAccountRepositoryContract, logger: LoggerContract): self._account_repository = account_repository + self._logger = logger async def create_account( self, @@ -37,4 +39,14 @@ async def create_account( ) # Save and return the new account ID - return await self._account_repository.save_account(account_dto) + 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 index b644fb2..dfefebd 100644 --- a/app/context/user_account/domain/services/update_account_service.py +++ b/app/context/user_account/domain/services/update_account_service.py @@ -16,11 +16,13 @@ UserAccountID, UserAccountUserID, ) +from app.shared.domain.contracts import LoggerContract class UpdateAccountService(UpdateAccountServiceContract): - def __init__(self, repository: UserAccountRepositoryContract): + def __init__(self, repository: UserAccountRepositoryContract, logger: LoggerContract): self._repository = repository + self._logger = logger async def update_account( self, @@ -33,6 +35,11 @@ async def update_account( 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 @@ -41,6 +48,12 @@ async def update_account( 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 @@ -51,4 +64,13 @@ async def update_account( currency=currency, balance=balance, ) - return await self._repository.update_account(updated_dto) + 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/infrastructure/dependencies.py b/app/context/user_account/infrastructure/dependencies.py index f0c4b1a..5f64f05 100644 --- a/app/context/user_account/infrastructure/dependencies.py +++ b/app/context/user_account/infrastructure/dependencies.py @@ -27,7 +27,9 @@ 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( @@ -48,57 +50,62 @@ def get_user_account_repository( 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) + 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) + 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) + 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) + 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) + return DeleteAccountHandler(repository, logger) # ───────────────────────────────────────────────────────────────── @@ -108,21 +115,23 @@ def get_delete_account_handler( 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) + 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) + return FindAccountsByUserHandler(repository, logger) 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 index 24ec9b9..ef88ab0 100644 --- a/app/context/user_account/interface/rest/controllers/create_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/create_account_controller.py @@ -18,6 +18,8 @@ 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"]) @@ -28,8 +30,11 @@ 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, @@ -48,9 +53,33 @@ async def create_account( } 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, 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 index 9a79489..177eb5c 100644 --- a/app/context/user_account/interface/rest/controllers/delete_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/delete_account_controller.py @@ -14,6 +14,8 @@ 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"]) @@ -24,8 +26,11 @@ 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, @@ -41,7 +46,25 @@ async def delete_account( } 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 index d9873d9..86ee69f 100644 --- a/app/context/user_account/interface/rest/controllers/find_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/find_account_controller.py @@ -23,6 +23,8 @@ 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"]) @@ -33,6 +35,7 @@ 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( @@ -50,12 +53,26 @@ async def get_account( } 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 success response return AccountResponse( account_id=result.account.account_id, name=result.account.name, @@ -68,6 +85,7 @@ async def get_account( 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) @@ -80,13 +98,20 @@ async def get_all_accounts( } 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 success response return [ AccountResponse( account_id=r.account_id, 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 index 76999b2..e277b40 100644 --- a/app/context/user_account/interface/rest/controllers/update_account_controller.py +++ b/app/context/user_account/interface/rest/controllers/update_account_controller.py @@ -18,6 +18,8 @@ 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"]) @@ -29,8 +31,11 @@ async def update_account( 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, @@ -51,12 +56,49 @@ async def update_account( } 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, 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 index de8429d..a246eb5 100644 --- a/tests/unit/context/user_account/application/create_account_handler_test.py +++ b/tests/unit/context/user_account/application/create_account_handler_test.py @@ -22,6 +22,7 @@ UserAccountID, UserAccountUserID, ) +from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit @@ -35,9 +36,9 @@ def mock_service(self): return MagicMock() @pytest.fixture - def handler(self, mock_service): + def handler(self, mock_service, mock_logger): """Create handler with mocked service""" - return CreateAccountHandler(mock_service) + return CreateAccountHandler(mock_service, mock_logger) @pytest.mark.asyncio async def test_create_account_success(self, handler, mock_service): 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 index 0aaa306..ccc18c7 100644 --- a/tests/unit/context/user_account/application/delete_account_handler_test.py +++ b/tests/unit/context/user_account/application/delete_account_handler_test.py @@ -9,6 +9,7 @@ from app.context.user_account.application.handlers.delete_account_handler import ( DeleteAccountHandler, ) +from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit @@ -22,9 +23,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def handler(self, mock_repository): + def handler(self, mock_repository, mock_logger): """Create handler with mocked repository""" - return DeleteAccountHandler(mock_repository) + return DeleteAccountHandler(mock_repository, mock_logger) @pytest.mark.asyncio async def test_delete_account_success(self, handler, mock_repository): 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 index e92826a..2c44091 100644 --- 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 @@ -20,6 +20,7 @@ UserAccountID, UserAccountUserID, ) +from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit @@ -33,9 +34,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def handler(self, mock_repository): + def handler(self, mock_repository, mock_logger): """Create handler with mocked repository""" - return FindAccountByIdHandler(mock_repository) + return FindAccountByIdHandler(mock_repository, mock_logger) @pytest.mark.asyncio async def test_find_account_by_id_success(self, handler, mock_repository): 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 index 6d79591..a220fc6 100644 --- 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 @@ -18,6 +18,7 @@ UserAccountID, UserAccountUserID, ) +from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit @@ -31,9 +32,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def handler(self, mock_repository): + def handler(self, mock_repository, mock_logger): """Create handler with mocked repository""" - return FindAccountsByUserHandler(mock_repository) + return FindAccountsByUserHandler(mock_repository, mock_logger) @pytest.mark.asyncio async def test_find_accounts_by_user_success(self, handler, mock_repository): 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 index 4345faa..a60b6e5 100644 --- a/tests/unit/context/user_account/application/update_account_handler_test.py +++ b/tests/unit/context/user_account/application/update_account_handler_test.py @@ -23,6 +23,7 @@ UserAccountID, UserAccountUserID, ) +from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit @@ -36,9 +37,9 @@ def mock_service(self): return MagicMock() @pytest.fixture - def handler(self, mock_service): + def handler(self, mock_service, mock_logger): """Create handler with mocked service""" - return UpdateAccountHandler(mock_service) + return UpdateAccountHandler(mock_service, mock_logger) @pytest.mark.asyncio async def test_update_account_success(self, handler, mock_service): 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 index 94e78b5..dab7ab2 100644 --- a/tests/unit/context/user_account/domain/create_account_service_test.py +++ b/tests/unit/context/user_account/domain/create_account_service_test.py @@ -16,6 +16,7 @@ UserAccountID, UserAccountUserID, ) +from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit @@ -29,9 +30,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): + def service(self, mock_repository, mock_logger): """Create service with mocked repository""" - return CreateAccountService(mock_repository) + return CreateAccountService(mock_repository, mock_logger) @pytest.mark.asyncio async def test_create_account_success(self, service, mock_repository): 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 index efac3b6..82fc6af 100644 --- a/tests/unit/context/user_account/domain/update_account_service_test.py +++ b/tests/unit/context/user_account/domain/update_account_service_test.py @@ -20,6 +20,7 @@ UserAccountID, UserAccountUserID, ) +from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit @@ -33,9 +34,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): + def service(self, mock_repository, mock_logger): """Create service with mocked repository""" - return UpdateAccountService(mock_repository) + return UpdateAccountService(mock_repository, mock_logger) @pytest.mark.asyncio async def test_update_account_success(self, service, mock_repository): From f1a134b043671ce6018a45a1b750b22dd60aceaf Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 18:54:16 +0100 Subject: [PATCH 47/58] Adding logs to credit cards --- .claude/rules/logging.md | 72 ++ .../handlers/create_credit_card_handler.py | 25 +- .../handlers/delete_credit_card_handler.py | 26 +- .../find_credit_card_by_id_handler.py | 15 +- .../find_credit_cards_by_user_handler.py | 6 +- .../handlers/update_credit_card_handler.py | 32 +- .../services/create_credit_card_service.py | 13 +- .../services/update_credit_card_service.py | 37 +- .../infrastructure/dependencies.py | 23 +- .../create_credit_card_controller.py | 14 + .../delete_credit_card_controller.py | 13 + .../find_credit_card_by_id_controller.py | 8 + .../find_credit_cards_by_user_controller.py | 7 + .../update_credit_card_controller.py | 15 + app/shared/infrastructure/logging/config.py | 14 +- docker-compose.yml | 1 + .../grafana/dashboards/homecomp-api-logs.json | 954 ++++++++++++++++++ .../provisioning/dashboards/dashboards.yaml | 12 + .../create_credit_card_handler_test.py | 11 +- .../delete_credit_card_handler_test.py | 11 +- .../find_credit_card_by_id_handler_test.py | 11 +- .../find_credit_cards_by_user_handler_test.py | 11 +- .../update_credit_card_handler_test.py | 11 +- .../domain/create_credit_card_service_test.py | 11 +- .../domain/update_credit_card_service_test.py | 11 +- 25 files changed, 1323 insertions(+), 41 deletions(-) create mode 100644 docker/grafana/dashboards/homecomp-api-logs.json create mode 100644 docker/grafana/provisioning/dashboards/dashboards.yaml diff --git a/.claude/rules/logging.md b/.claude/rules/logging.md index f6d6eec..e5a6903 100644 --- a/.claude/rules/logging.md +++ b/.claude/rules/logging.md @@ -124,6 +124,78 @@ 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 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 index e9800d7..e6ae38d 100644 --- a/app/context/credit_card/application/handlers/create_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/create_credit_card_handler.py @@ -20,17 +20,26 @@ CreditCardName, CreditCardUserID, ) +from app.shared.domain.contracts import LoggerContract class CreateCreditCardHandler(CreateCreditCardHandlerContract): """Handler for create credit card command""" - def __init__(self, service: CreateCreditCardServiceContract): + 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( @@ -43,6 +52,11 @@ async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResu # 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", @@ -53,18 +67,25 @@ async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResu # 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: + 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 index 83faf33..db619a8 100644 --- a/app/context/credit_card/application/handlers/delete_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/delete_credit_card_handler.py @@ -11,17 +11,25 @@ ) 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): + 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( @@ -30,6 +38,11 @@ async def handle(self, command: DeleteCreditCardCommand) -> DeleteCreditCardResu ) 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", @@ -39,13 +52,22 @@ async def handle(self, command: DeleteCreditCardCommand) -> DeleteCreditCardResu # 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: + 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 index b9d51e8..cae342b 100644 --- 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 @@ -7,17 +7,25 @@ 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): + 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 @@ -27,6 +35,11 @@ async def handle(self, query: FindCreditCardByIdQuery) -> CreditCardResponseDTO ) 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 index 7a50f7f..bbfb4ab 100644 --- 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 @@ -7,17 +7,21 @@ 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): + 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)) 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 index 2852714..d1f703a 100644 --- a/app/context/credit_card/application/handlers/update_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/update_credit_card_handler.py @@ -22,17 +22,26 @@ CreditCardName, CreditCardUserID, ) +from app.shared.domain.contracts import LoggerContract class UpdateCreditCardHandler(UpdateCreditCardHandlerContract): """Handler for update credit card command""" - def __init__(self, service: UpdateCreditCardServiceContract): + 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) @@ -60,23 +69,42 @@ async def handle(self, command: UpdateCreditCardCommand) -> UpdateCreditCardResu # 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: + 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/domain/services/create_credit_card_service.py b/app/context/credit_card/domain/services/create_credit_card_service.py index 63610ce..ad2b26e 100644 --- a/app/context/credit_card/domain/services/create_credit_card_service.py +++ b/app/context/credit_card/domain/services/create_credit_card_service.py @@ -14,13 +14,15 @@ 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): + def __init__(self, card_repository: CreditCardRepositoryContract, logger: LoggerContract): self._card_repository = card_repository + self._logger = logger async def create_credit_card( self, @@ -32,6 +34,15 @@ async def create_credit_card( ) -> 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, 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 index 8ca8304..4aa5c82 100644 --- a/app/context/credit_card/domain/services/update_credit_card_service.py +++ b/app/context/credit_card/domain/services/update_credit_card_service.py @@ -21,13 +21,15 @@ 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): + def __init__(self, repository: CreditCardRepositoryContract, logger: LoggerContract): self._repository = repository + self._logger = logger async def update_credit_card( self, @@ -40,14 +42,32 @@ async def update_credit_card( ) -> 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}" ) @@ -56,6 +76,14 @@ async def update_credit_card( 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" ) @@ -67,6 +95,13 @@ async def update_credit_card( # 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})" ) diff --git a/app/context/credit_card/infrastructure/dependencies.py b/app/context/credit_card/infrastructure/dependencies.py index cd083a4..091400c 100644 --- a/app/context/credit_card/infrastructure/dependencies.py +++ b/app/context/credit_card/infrastructure/dependencies.py @@ -27,7 +27,9 @@ 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 @@ -52,57 +54,62 @@ def get_credit_card_repository( 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) + 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) + 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) + 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) + 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) + return DeleteCreditCardHandler(repository, logger) # ───────────────────────────────────────────────────────────────── @@ -112,21 +119,23 @@ def get_delete_credit_card_handler( 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) + 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) + return FindCreditCardsByUserHandler(repository, logger) 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 index 492518f..1875e27 100644 --- 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 @@ -17,6 +17,8 @@ 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") @@ -27,8 +29,11 @@ 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, @@ -47,14 +52,23 @@ async def create_credit_card( 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, 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 index 09af71c..24614bb 100644 --- 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 @@ -10,6 +10,8 @@ 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") @@ -20,8 +22,11 @@ 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, @@ -36,6 +41,14 @@ async def delete_credit_card( 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 index 1f9270a..82facf2 100644 --- 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 @@ -12,6 +12,8 @@ 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") @@ -22,8 +24,11 @@ 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, @@ -32,11 +37,14 @@ async def get_credit_card( 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, 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 index f722049..9567ae8 100644 --- 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 @@ -12,6 +12,8 @@ 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") @@ -21,12 +23,17 @@ 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, 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 index c5d1e28..8f01371 100644 --- 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 @@ -16,6 +16,8 @@ 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") @@ -27,8 +29,11 @@ async def update_credit_card( 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, @@ -48,6 +53,16 @@ async def update_credit_card( 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/shared/infrastructure/logging/config.py b/app/shared/infrastructure/logging/config.py index 7fc3dec..2a61310 100644 --- a/app/shared/infrastructure/logging/config.py +++ b/app/shared/infrastructure/logging/config.py @@ -55,20 +55,28 @@ def configure_structlog(use_json: bool = False) -> None: ) logging.root.addHandler(console_handler) - # Add Loki handler in development (raw structured data, no formatting) + # Add Loki handler in development (sends structured data directly) if os.getenv("APP_ENV") != "prod": 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", ) - # Configure Loki handler to receive structured data + # Use ProcessorFormatter with custom JSON processor loki_handler.setFormatter( structlog.stdlib.ProcessorFormatter( - processor=structlog.processors.JSONRenderer(), + processor=json_message_processor, foreign_pre_chain=shared_processors, ) ) diff --git a/docker-compose.yml b/docker-compose.yml index 3426a57..6b41eee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,6 +90,7 @@ services: volumes: - grafana_data:/var/lib/grafana - ./docker/grafana/provisioning:/etc/grafana/provisioning + - ./docker/grafana/dashboards:/etc/grafana/dashboards restart: unless-stopped depends_on: - loki 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/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 index b505d7e..f087ba0 100644 --- 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 @@ -36,9 +36,14 @@ def mock_service(self): return MagicMock() @pytest.fixture - def handler(self, mock_service): - """Create handler with mocked service""" - return CreateCreditCardHandler(mock_service) + 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): 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 index ac01906..06566af 100644 --- 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 @@ -23,9 +23,14 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def handler(self, mock_repository): - """Create handler with mocked repository""" - return DeleteCreditCardHandler(mock_repository) + 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): 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 index 73bf1f0..5008d5b 100644 --- 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 @@ -32,9 +32,14 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def handler(self, mock_repository): - """Create handler with mocked repository""" - return FindCreditCardByIdHandler(mock_repository) + 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): 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 index 1a8c214..6e71a3f 100644 --- 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 @@ -32,9 +32,14 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def handler(self, mock_repository): - """Create handler with mocked repository""" - return FindCreditCardsByUserHandler(mock_repository) + 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): 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 index 7b27c5e..edfd31c 100644 --- 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 @@ -38,9 +38,14 @@ def mock_service(self): return MagicMock() @pytest.fixture - def handler(self, mock_service): - """Create handler with mocked service""" - return UpdateCreditCardHandler(mock_service) + 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): 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 index 75fdf33..c6ff5fb 100644 --- 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 @@ -30,9 +30,14 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return CreateCreditCardService(mock_repository) + 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): 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 index 5a97c6a..f9d7e89 100644 --- 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 @@ -37,9 +37,14 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return UpdateCreditCardService(mock_repository) + 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): From db20897e84a47c2045b5ce7b7e4e9c35c4590b61 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 19:38:00 +0100 Subject: [PATCH 48/58] Adding logs to household context --- .../handlers/create_credit_card_handler.py | 8 +-- .../handlers/delete_credit_card_handler.py | 4 +- .../handlers/update_credit_card_handler.py | 4 +- .../services/update_credit_card_service.py | 8 +-- .../create_credit_card_controller.py | 8 ++- .../delete_credit_card_controller.py | 7 ++- .../update_credit_card_controller.py | 7 ++- .../handlers/accept_invite_handler.py | 16 ++++- .../handlers/create_household_handler.py | 12 +++- .../handlers/decline_invite_handler.py | 12 +++- .../handlers/delete_household_handler.py | 18 +++++- .../handlers/get_household_handler.py | 16 ++++- .../handlers/invite_user_handler.py | 35 ++++++++++- .../list_household_invites_handler.py | 10 ++- .../handlers/list_user_households_handler.py | 10 ++- .../list_user_pending_invites_handler.py | 6 +- .../handlers/remove_member_handler.py | 25 +++++++- .../handlers/update_household_handler.py | 27 +++++++- .../domain/services/accept_invite_service.py | 7 ++- .../services/create_household_service.py | 6 +- .../domain/services/decline_invite_service.py | 7 ++- .../domain/services/invite_user_service.py | 26 ++++++++ .../domain/services/remove_member_service.py | 22 ++++++- .../domain/services/revoke_invite_service.py | 26 +++++++- .../services/update_household_service.py | 15 ++++- .../household/infrastructure/dependencies.py | 62 ++++++++++++++----- .../controllers/accept_invite_controller.py | 8 +++ .../create_household_controller.py | 8 +++ .../controllers/decline_invite_controller.py | 7 +++ .../delete_household_controller.py | 8 +++ .../controllers/get_household_controller.py | 8 +++ .../controllers/invite_user_controller.py | 13 ++++ .../list_household_invites_controller.py | 6 ++ .../list_user_households_controller.py | 8 +++ .../list_user_pending_invites_controller.py | 6 ++ .../controllers/remove_member_controller.py | 12 ++++ .../update_household_controller.py | 8 +++ app/shared/infrastructure/logging/config.py | 1 + .../create_household_handler_test.py | 6 +- .../application/invite_user_handler_test.py | 6 +- .../domain/accept_invite_service_test.py | 6 +- .../domain/create_household_service_test.py | 6 +- .../domain/decline_invite_service_test.py | 6 +- .../domain/invite_user_service_test.py | 6 +- .../domain/remove_member_service_test.py | 6 +- .../domain/revoke_invite_service_test.py | 6 +- .../create_account_handler_test.py | 1 - .../delete_account_handler_test.py | 1 - .../find_account_by_id_handler_test.py | 1 - .../find_accounts_by_user_handler_test.py | 1 - .../update_account_handler_test.py | 1 - .../domain/create_account_service_test.py | 1 - .../domain/update_account_service_test.py | 1 - 53 files changed, 456 insertions(+), 96 deletions(-) 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 index e6ae38d..75c27ce 100644 --- a/app/context/credit_card/application/handlers/create_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/create_credit_card_handler.py @@ -67,9 +67,7 @@ async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResu # 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 - ) + 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", @@ -83,9 +81,7 @@ async def handle(self, command: CreateCreditCardCommand) -> CreateCreditCardResu # 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) - ) + 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 index db619a8..cdc04dd 100644 --- a/app/context/credit_card/application/handlers/delete_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/delete_credit_card_handler.py @@ -52,9 +52,7 @@ async def handle(self, command: DeleteCreditCardCommand) -> DeleteCreditCardResu # 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 - ) + 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", 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 index d1f703a..c255d81 100644 --- a/app/context/credit_card/application/handlers/update_credit_card_handler.py +++ b/app/context/credit_card/application/handlers/update_credit_card_handler.py @@ -69,9 +69,7 @@ async def handle(self, command: UpdateCreditCardCommand) -> UpdateCreditCardResu # 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 - ) + 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", 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 index 4aa5c82..ec6d261 100644 --- a/app/context/credit_card/domain/services/update_credit_card_service.py +++ b/app/context/credit_card/domain/services/update_credit_card_service.py @@ -55,9 +55,7 @@ async def update_credit_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 - ) + 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 @@ -80,9 +78,7 @@ async def update_credit_card( "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, + 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" 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 index 1875e27..f272237 100644 --- 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 @@ -56,7 +56,9 @@ async def create_credit_card( 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) + 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) @@ -67,7 +69,9 @@ async def create_credit_card( 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) + 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, 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 index 24614bb..5120c21 100644 --- 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 @@ -45,7 +45,12 @@ async def delete_credit_card( 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) + 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) 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 index 8f01371..c26af55 100644 --- 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 @@ -59,7 +59,12 @@ async def update_credit_card( 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) + 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) diff --git a/app/context/household/application/handlers/accept_invite_handler.py b/app/context/household/application/handlers/accept_invite_handler.py index 9662854..fe84dff 100644 --- a/app/context/household/application/handlers/accept_invite_handler.py +++ b/app/context/household/application/handlers/accept_invite_handler.py @@ -10,17 +10,21 @@ 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): + 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), @@ -28,6 +32,11 @@ async def handle(self, command: AcceptInviteCommand) -> AcceptInviteResult: ) 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", @@ -41,16 +50,19 @@ async def handle(self, command: AcceptInviteCommand) -> AcceptInviteResult: ) 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: + 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 index 4da9fb6..65909ad 100644 --- a/app/context/household/application/handlers/create_household_handler.py +++ b/app/context/household/application/handlers/create_household_handler.py @@ -10,17 +10,21 @@ 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): + 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), @@ -28,6 +32,7 @@ async def handle(self, command: CreateHouseholdCommand) -> CreateHouseholdResult ) 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", @@ -39,16 +44,19 @@ async def handle(self, command: CreateHouseholdCommand) -> CreateHouseholdResult ) 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: + 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 index 1f7543c..d9a7f5f 100644 --- a/app/context/household/application/handlers/decline_invite_handler.py +++ b/app/context/household/application/handlers/decline_invite_handler.py @@ -7,17 +7,23 @@ 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): + 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), @@ -27,11 +33,13 @@ async def handle(self, command: DeclineInviteCommand) -> DeclineInviteResult: 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: + 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 index 5565d53..6a60a7b 100644 --- a/app/context/household/application/handlers/delete_household_handler.py +++ b/app/context/household/application/handlers/delete_household_handler.py @@ -3,17 +3,25 @@ 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): + 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( @@ -22,6 +30,11 @@ async def handle(self, command: DeleteHouseholdCommand) -> DeleteHouseholdResult ) 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", @@ -29,7 +42,8 @@ async def handle(self, command: DeleteHouseholdCommand) -> DeleteHouseholdResult return DeleteHouseholdResult(success=True) - except Exception: + 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 index 6081be1..5045cb9 100644 --- a/app/context/household/application/handlers/get_household_handler.py +++ b/app/context/household/application/handlers/get_household_handler.py @@ -3,16 +3,21 @@ 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): + 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) @@ -21,6 +26,11 @@ async def handle(self, query: GetHouseholdQuery) -> GetHouseholdResult: # 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", @@ -29,6 +39,7 @@ async def handle(self, query: GetHouseholdQuery) -> GetHouseholdResult: # 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", @@ -42,7 +53,8 @@ async def handle(self, query: GetHouseholdQuery) -> GetHouseholdResult: created_at=household.created_at.isoformat() if household.created_at else None, ) - except Exception: + 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 index 240225d..c026dd3 100644 --- a/app/context/household/application/handlers/invite_user_handler.py +++ b/app/context/household/application/handlers/invite_user_handler.py @@ -13,17 +13,26 @@ HouseholdRole, HouseholdUserID, ) +from app.shared.domain.contracts import LoggerContract class InviteUserHandler(InviteUserHandlerContract): """Handler for invite user command""" - def __init__(self, service: InviteUserServiceContract): + 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), @@ -33,6 +42,11 @@ async def handle(self, command: InviteUserCommand) -> InviteUserResult: ) 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", @@ -46,26 +60,43 @@ async def handle(self, command: InviteUserCommand) -> InviteUserResult: ) 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: + 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 index 1aa8acf..9ffd146 100644 --- a/app/context/household/application/handlers/list_household_invites_handler.py +++ b/app/context/household/application/handlers/list_household_invites_handler.py @@ -5,17 +5,25 @@ 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): + 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), diff --git a/app/context/household/application/handlers/list_user_households_handler.py b/app/context/household/application/handlers/list_user_households_handler.py index 89ba514..0a3e361 100644 --- a/app/context/household/application/handlers/list_user_households_handler.py +++ b/app/context/household/application/handlers/list_user_households_handler.py @@ -7,16 +7,21 @@ 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): + 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) @@ -37,7 +42,8 @@ async def handle(self, query: ListUserHouseholdsQuery) -> ListUserHouseholdsResu return ListUserHouseholdsResult(households=summaries) - except Exception: + 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 index 27273cf..7634f70 100644 --- a/app/context/household/application/handlers/list_user_pending_invites_handler.py +++ b/app/context/household/application/handlers/list_user_pending_invites_handler.py @@ -5,17 +5,21 @@ 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): + 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), ) diff --git a/app/context/household/application/handlers/remove_member_handler.py b/app/context/household/application/handlers/remove_member_handler.py index 94ffcc1..0b2174c 100644 --- a/app/context/household/application/handlers/remove_member_handler.py +++ b/app/context/household/application/handlers/remove_member_handler.py @@ -11,17 +11,26 @@ 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): + 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), @@ -32,21 +41,33 @@ async def handle(self, command: RemoveMemberCommand) -> RemoveMemberResult: 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: + 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 index 29fc56f..cc7070c 100644 --- a/app/context/household/application/handlers/update_household_handler.py +++ b/app/context/household/application/handlers/update_household_handler.py @@ -9,17 +9,26 @@ 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): + 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( @@ -29,6 +38,11 @@ async def handle(self, command: UpdateHouseholdCommand) -> UpdateHouseholdResult ) 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", @@ -42,26 +56,35 @@ async def handle(self, command: UpdateHouseholdCommand) -> UpdateHouseholdResult ) 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: + 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/domain/services/accept_invite_service.py b/app/context/household/domain/services/accept_invite_service.py index 9e305b7..2fcec30 100644 --- a/app/context/household/domain/services/accept_invite_service.py +++ b/app/context/household/domain/services/accept_invite_service.py @@ -5,11 +5,13 @@ 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): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): self._household_repo = household_repo + self._logger = logger async def accept_invite( self, @@ -18,10 +20,13 @@ async def accept_invite( ) -> 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) diff --git a/app/context/household/domain/services/create_household_service.py b/app/context/household/domain/services/create_household_service.py index 3d29f58..1f21903 100644 --- a/app/context/household/domain/services/create_household_service.py +++ b/app/context/household/domain/services/create_household_service.py @@ -4,15 +4,19 @@ ) 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): + 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, diff --git a/app/context/household/domain/services/decline_invite_service.py b/app/context/household/domain/services/decline_invite_service.py index 6d51520..cc0def6 100644 --- a/app/context/household/domain/services/decline_invite_service.py +++ b/app/context/household/domain/services/decline_invite_service.py @@ -4,11 +4,13 @@ ) 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): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): self._household_repo = household_repo + self._logger = logger async def decline_invite( self, @@ -17,10 +19,13 @@ async def decline_invite( ) -> 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 index ae7a98f..cd04922 100644 --- a/app/context/household/domain/services/invite_user_service.py +++ b/app/context/household/domain/services/invite_user_service.py @@ -15,14 +15,17 @@ 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, @@ -35,16 +38,39 @@ async def invite_user( 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) diff --git a/app/context/household/domain/services/remove_member_service.py b/app/context/household/domain/services/remove_member_service.py index bbe52b4..aec78af 100644 --- a/app/context/household/domain/services/remove_member_service.py +++ b/app/context/household/domain/services/remove_member_service.py @@ -8,11 +8,13 @@ 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): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): self._household_repo = household_repo + self._logger = logger async def remove_member( self, @@ -22,19 +24,37 @@ async def remove_member( ) -> 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 index 4f5c828..fad642c 100644 --- a/app/context/household/domain/services/revoke_invite_service.py +++ b/app/context/household/domain/services/revoke_invite_service.py @@ -7,11 +7,13 @@ 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): + def __init__(self, household_repo: HouseholdRepositoryContract, logger: LoggerContract): self._household_repo = household_repo + self._logger = logger async def revoke_invite( self, @@ -21,15 +23,37 @@ async def revoke_invite( ) -> 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 index 55af5f3..ec438ca 100644 --- a/app/context/household/domain/services/update_household_service.py +++ b/app/context/household/domain/services/update_household_service.py @@ -9,13 +9,15 @@ 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): + def __init__(self, repository: HouseholdRepositoryContract, logger: LoggerContract): self._repository = repository + self._logger = logger async def update_household( self, @@ -25,20 +27,31 @@ async def update_household( ) -> 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 diff --git a/app/context/household/infrastructure/dependencies.py b/app/context/household/infrastructure/dependencies.py index 84bf8f7..a9fe201 100644 --- a/app/context/household/infrastructure/dependencies.py +++ b/app/context/household/infrastructure/dependencies.py @@ -36,6 +36,7 @@ HouseholdRepositoryContract, InviteUserServiceContract, RemoveMemberServiceContract, + RevokeInviteServiceContract, UpdateHouseholdServiceContract, ) from app.context.household.domain.services import ( @@ -44,10 +45,13 @@ 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 @@ -60,103 +64,127 @@ def get_household_repository( # 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + 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) + return ListUserHouseholdsHandler(repository, logger) diff --git a/app/context/household/interface/rest/controllers/accept_invite_controller.py b/app/context/household/interface/rest/controllers/accept_invite_controller.py index a16a599..3758ace 100644 --- a/app/context/household/interface/rest/controllers/accept_invite_controller.py +++ b/app/context/household/interface/rest/controllers/accept_invite_controller.py @@ -7,6 +7,8 @@ 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() @@ -17,9 +19,12 @@ 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, @@ -36,11 +41,14 @@ async def accept_invite( } 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, diff --git a/app/context/household/interface/rest/controllers/create_household_controller.py b/app/context/household/interface/rest/controllers/create_household_controller.py index b7d88d7..1bf1bf1 100644 --- a/app/context/household/interface/rest/controllers/create_household_controller.py +++ b/app/context/household/interface/rest/controllers/create_household_controller.py @@ -12,6 +12,8 @@ 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() @@ -22,9 +24,12 @@ 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, @@ -42,11 +47,14 @@ async def create_household( } 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 index d75461f..0d22b6e 100644 --- a/app/context/household/interface/rest/controllers/decline_invite_controller.py +++ b/app/context/household/interface/rest/controllers/decline_invite_controller.py @@ -7,6 +7,8 @@ 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() @@ -17,9 +19,12 @@ 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, @@ -35,8 +40,10 @@ async def decline_invite( } 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 index 313d4c7..22d470d 100644 --- a/app/context/household/interface/rest/controllers/delete_household_controller.py +++ b/app/context/household/interface/rest/controllers/delete_household_controller.py @@ -6,6 +6,8 @@ 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() @@ -16,8 +18,12 @@ 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, @@ -33,7 +39,9 @@ async def delete_household( 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 index 43e3a42..58d198f 100644 --- a/app/context/household/interface/rest/controllers/get_household_controller.py +++ b/app/context/household/interface/rest/controllers/get_household_controller.py @@ -7,6 +7,8 @@ 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() @@ -17,8 +19,12 @@ 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) @@ -31,9 +37,11 @@ async def get_household( 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, diff --git a/app/context/household/interface/rest/controllers/invite_user_controller.py b/app/context/household/interface/rest/controllers/invite_user_controller.py index d1a4909..693a750 100644 --- a/app/context/household/interface/rest/controllers/invite_user_controller.py +++ b/app/context/household/interface/rest/controllers/invite_user_controller.py @@ -10,6 +10,8 @@ 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() @@ -21,9 +23,17 @@ async def invite_user( 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, @@ -44,11 +54,14 @@ async def invite_user( } 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, 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 index 8b829b3..6c93e71 100644 --- a/app/context/household/interface/rest/controllers/list_household_invites_controller.py +++ b/app/context/household/interface/rest/controllers/list_household_invites_controller.py @@ -10,6 +10,8 @@ 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() @@ -20,13 +22,17 @@ 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, 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 index 007a451..89c401c 100644 --- a/app/context/household/interface/rest/controllers/list_user_households_controller.py +++ b/app/context/household/interface/rest/controllers/list_user_households_controller.py @@ -8,6 +8,8 @@ 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() @@ -17,14 +19,19 @@ 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 @@ -38,4 +45,5 @@ async def list_user_households( 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 index 8e06fd7..afb6565 100644 --- 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 @@ -10,6 +10,8 @@ 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() @@ -19,13 +21,17 @@ 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, diff --git a/app/context/household/interface/rest/controllers/remove_member_controller.py b/app/context/household/interface/rest/controllers/remove_member_controller.py index c617d2a..2d3cb9b 100644 --- a/app/context/household/interface/rest/controllers/remove_member_controller.py +++ b/app/context/household/interface/rest/controllers/remove_member_controller.py @@ -7,6 +7,8 @@ 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() @@ -18,9 +20,17 @@ async def remove_member( 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, @@ -39,8 +49,10 @@ async def remove_member( } 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 index 9f98271..2e8c7eb 100644 --- a/app/context/household/interface/rest/controllers/update_household_controller.py +++ b/app/context/household/interface/rest/controllers/update_household_controller.py @@ -7,6 +7,8 @@ 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() @@ -18,8 +20,12 @@ async def update_household( 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, @@ -38,9 +44,11 @@ async def update_household( 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, diff --git a/app/shared/infrastructure/logging/config.py b/app/shared/infrastructure/logging/config.py index 2a61310..3473953 100644 --- a/app/shared/infrastructure/logging/config.py +++ b/app/shared/infrastructure/logging/config.py @@ -65,6 +65,7 @@ def configure_structlog(use_json: bool = False) -> None: 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) diff --git a/tests/unit/context/household/application/create_household_handler_test.py b/tests/unit/context/household/application/create_household_handler_test.py index ec1ec39..0e9f278 100644 --- a/tests/unit/context/household/application/create_household_handler_test.py +++ b/tests/unit/context/household/application/create_household_handler_test.py @@ -32,9 +32,9 @@ def mock_service(self): return MagicMock() @pytest.fixture - def handler(self, mock_service): - """Create handler with mocked service""" - return CreateHouseholdHandler(mock_service) + 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): diff --git a/tests/unit/context/household/application/invite_user_handler_test.py b/tests/unit/context/household/application/invite_user_handler_test.py index 5062a91..dd0d0c1 100644 --- a/tests/unit/context/household/application/invite_user_handler_test.py +++ b/tests/unit/context/household/application/invite_user_handler_test.py @@ -36,9 +36,9 @@ def mock_service(self): return MagicMock() @pytest.fixture - def handler(self, mock_service): - """Create handler with mocked service""" - return InviteUserHandler(mock_service) + 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): diff --git a/tests/unit/context/household/domain/accept_invite_service_test.py b/tests/unit/context/household/domain/accept_invite_service_test.py index 9d37a8e..87155d5 100644 --- a/tests/unit/context/household/domain/accept_invite_service_test.py +++ b/tests/unit/context/household/domain/accept_invite_service_test.py @@ -29,9 +29,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return AcceptInviteService(mock_repository) + 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): diff --git a/tests/unit/context/household/domain/create_household_service_test.py b/tests/unit/context/household/domain/create_household_service_test.py index 8c704fe..47f0263 100644 --- a/tests/unit/context/household/domain/create_household_service_test.py +++ b/tests/unit/context/household/domain/create_household_service_test.py @@ -27,9 +27,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return CreateHouseholdService(mock_repository) + 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): diff --git a/tests/unit/context/household/domain/decline_invite_service_test.py b/tests/unit/context/household/domain/decline_invite_service_test.py index b185659..af7a5c9 100644 --- a/tests/unit/context/household/domain/decline_invite_service_test.py +++ b/tests/unit/context/household/domain/decline_invite_service_test.py @@ -29,9 +29,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return DeclineInviteService(mock_repository) + 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): diff --git a/tests/unit/context/household/domain/invite_user_service_test.py b/tests/unit/context/household/domain/invite_user_service_test.py index c18d40c..7f07309 100644 --- a/tests/unit/context/household/domain/invite_user_service_test.py +++ b/tests/unit/context/household/domain/invite_user_service_test.py @@ -32,9 +32,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return InviteUserService(mock_repository) + 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): diff --git a/tests/unit/context/household/domain/remove_member_service_test.py b/tests/unit/context/household/domain/remove_member_service_test.py index 0920d80..3b66ea6 100644 --- a/tests/unit/context/household/domain/remove_member_service_test.py +++ b/tests/unit/context/household/domain/remove_member_service_test.py @@ -34,9 +34,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return RemoveMemberService(mock_repository) + 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): diff --git a/tests/unit/context/household/domain/revoke_invite_service_test.py b/tests/unit/context/household/domain/revoke_invite_service_test.py index da7f0e1..d1d7a76 100644 --- a/tests/unit/context/household/domain/revoke_invite_service_test.py +++ b/tests/unit/context/household/domain/revoke_invite_service_test.py @@ -33,9 +33,9 @@ def mock_repository(self): return MagicMock() @pytest.fixture - def service(self, mock_repository): - """Create service with mocked repository""" - return RevokeInviteService(mock_repository) + 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): 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 index a246eb5..c7bcb58 100644 --- a/tests/unit/context/user_account/application/create_account_handler_test.py +++ b/tests/unit/context/user_account/application/create_account_handler_test.py @@ -22,7 +22,6 @@ UserAccountID, UserAccountUserID, ) -from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit 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 index ccc18c7..dc88d69 100644 --- a/tests/unit/context/user_account/application/delete_account_handler_test.py +++ b/tests/unit/context/user_account/application/delete_account_handler_test.py @@ -9,7 +9,6 @@ from app.context.user_account.application.handlers.delete_account_handler import ( DeleteAccountHandler, ) -from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit 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 index 2c44091..1be6585 100644 --- 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 @@ -20,7 +20,6 @@ UserAccountID, UserAccountUserID, ) -from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit 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 index a220fc6..960cb49 100644 --- 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 @@ -18,7 +18,6 @@ UserAccountID, UserAccountUserID, ) -from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit 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 index a60b6e5..85f4872 100644 --- a/tests/unit/context/user_account/application/update_account_handler_test.py +++ b/tests/unit/context/user_account/application/update_account_handler_test.py @@ -23,7 +23,6 @@ UserAccountID, UserAccountUserID, ) -from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit 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 index dab7ab2..4bc230b 100644 --- a/tests/unit/context/user_account/domain/create_account_service_test.py +++ b/tests/unit/context/user_account/domain/create_account_service_test.py @@ -16,7 +16,6 @@ UserAccountID, UserAccountUserID, ) -from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit 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 index 82fc6af..95c78e8 100644 --- a/tests/unit/context/user_account/domain/update_account_service_test.py +++ b/tests/unit/context/user_account/domain/update_account_service_test.py @@ -20,7 +20,6 @@ UserAccountID, UserAccountUserID, ) -from tests.fixtures.shared.logger import mock_logger @pytest.mark.unit From e437004f2c79365bfe1afa5b49638943ad37e8a5 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 19:51:06 +0100 Subject: [PATCH 49/58] Update loki and grafana versions --- docker-compose.yml | 6 +++--- docker/loki/loki-config.yaml | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 6b41eee..5bc630b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: retries: 5 loki: - image: grafana/loki:2.9.3 + image: grafana/loki:3.2.0 container_name: homecomp-loki ports: - "3100:3100" @@ -66,7 +66,7 @@ services: retries: 5 promtail: - image: grafana/promtail:2.9.3 + image: grafana/promtail:3.2.0 container_name: homecomp-promtail volumes: - ./docker/promtail/promtail-config.yaml:/etc/promtail/config.yaml @@ -79,7 +79,7 @@ services: - loki grafana: - image: grafana/grafana:10.2.3 + image: grafana/grafana:11.4.0 container_name: homecomp-grafana ports: - "3000:3000" diff --git a/docker/loki/loki-config.yaml b/docker/loki/loki-config.yaml index 4e22390..eb10d22 100644 --- a/docker/loki/loki-config.yaml +++ b/docker/loki/loki-config.yaml @@ -43,13 +43,10 @@ limits_config: per_stream_rate_limit: 5MB per_stream_rate_limit_burst: 15MB -table_manager: - retention_deletes_enabled: true - retention_period: 744h # 31 days - compactor: working_directory: /loki/compactor compaction_interval: 10m retention_enabled: true retention_delete_delay: 2h retention_delete_worker_count: 150 + delete_request_store: filesystem From 323d48d3ceed50f06edd3f9ed3750ec311ee7676 Mon Sep 17 00:00:00 2001 From: polivera Date: Tue, 30 Dec 2025 20:10:16 +0100 Subject: [PATCH 50/58] Normalize environment check --- .../rest/controllers/login_rest_controller.py | 5 ++- app/main.py | 9 +++-- app/shared/domain/value_objects/__init__.py | 2 ++ .../domain/value_objects/shared_app_env.py | 35 +++++++++++++++++++ app/shared/infrastructure/database.py | 10 +++--- .../dependencies/logger_dependency.py | 5 ++- app/shared/infrastructure/logging/config.py | 5 +-- 7 files changed, 53 insertions(+), 18 deletions(-) create mode 100644 app/shared/domain/value_objects/shared_app_env.py diff --git a/app/context/auth/interface/rest/controllers/login_rest_controller.py b/app/context/auth/interface/rest/controllers/login_rest_controller.py index 53266fa..0eae356 100644 --- a/app/context/auth/interface/rest/controllers/login_rest_controller.py +++ b/app/context/auth/interface/rest/controllers/login_rest_controller.py @@ -1,4 +1,3 @@ -from os import getenv from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Response @@ -9,6 +8,7 @@ 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") @@ -38,8 +38,7 @@ async def login( key="access_token", value=login_result.token, httponly=True, - # FIX: Error prone - secure=(getenv("APP_ENV", "dev") == "prod"), + secure=SharedAppEnv.isProd(), samesite="lax", max_age=3600, ) diff --git a/app/main.py b/app/main.py index 08659a2..da17271 100644 --- a/app/main.py +++ b/app/main.py @@ -1,17 +1,16 @@ -from os import getenv - 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.household.interface.rest import household_routes 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 getenv("APP_ENV") != "test": - use_json = getenv("APP_ENV") == "production" - configure_structlog(use_json=use_json) +# if "APP_ENV") != "test": +if not SharedAppEnv.isTest(): + configure_structlog(use_json=SharedAppEnv.isProd()) app = FastAPI( title="Homecomp API", diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py index 57efa03..51e4a21 100644 --- a/app/shared/domain/value_objects/__init__.py +++ b/app/shared/domain/value_objects/__init__.py @@ -1,4 +1,5 @@ from .shared_account_id import SharedAccountID +from .shared_app_env import SharedAppEnv from .shared_balance import SharedBalance from .shared_currency import SharedCurrency from .shared_date import SharedDateTime @@ -18,4 +19,5 @@ "SharedUserID", "SharedDateTime", "SharedUsername", + "SharedAppEnv", ] 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/infrastructure/database.py b/app/shared/infrastructure/database.py index 915e281..7e60259 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -3,14 +3,16 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.orm import declarative_base -# Database configuration - use TEST_ prefixed variables when APP_ENV=test +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 getenv("APP_ENV") == "test": +if SharedAppEnv.isTest(): DB_HOST = getenv("TEST_DB_HOST") DB_PORT = getenv("TEST_DB_PORT") DB_USER = getenv("TEST_DB_USER") @@ -22,11 +24,9 @@ f"postgresql+asyncpg://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}", ) -echo_queries = getenv("APP_ENV", "prod") == "debug" - async_engine = create_async_engine( DATABASE_URL, - echo=echo_queries, # Log SQL queries (disable in production) + 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 diff --git a/app/shared/infrastructure/dependencies/logger_dependency.py b/app/shared/infrastructure/dependencies/logger_dependency.py index d162e38..3a4aef3 100644 --- a/app/shared/infrastructure/dependencies/logger_dependency.py +++ b/app/shared/infrastructure/dependencies/logger_dependency.py @@ -1,6 +1,5 @@ -from os import getenv - 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 @@ -13,7 +12,7 @@ def get_logger() -> LoggerContract: - In test environment (APP_ENV=test): Returns NullLogger - Otherwise: Returns StructlogLogger """ - if getenv("APP_ENV") == "test": + if SharedAppEnv.isTest(): return NullLogger() return StructlogLogger() diff --git a/app/shared/infrastructure/logging/config.py b/app/shared/infrastructure/logging/config.py index 3473953..53cdbc4 100644 --- a/app/shared/infrastructure/logging/config.py +++ b/app/shared/infrastructure/logging/config.py @@ -1,9 +1,10 @@ import logging -import os import sys import structlog +from app.shared.domain.value_objects import SharedAppEnv + def configure_structlog(use_json: bool = False) -> None: """ @@ -56,7 +57,7 @@ def configure_structlog(use_json: bool = False) -> None: logging.root.addHandler(console_handler) # Add Loki handler in development (sends structured data directly) - if os.getenv("APP_ENV") != "prod": + if not SharedAppEnv.isProd(): try: import logging_loki From 0fc4d101245444385639cec86faf33bda3c820f9 Mon Sep 17 00:00:00 2001 From: polivera Date: Wed, 31 Dec 2025 08:57:09 +0100 Subject: [PATCH 51/58] Start working on entry context --- .../infrastructure/models/__init__.py | 3 + .../infrastructure/models/category_model.py | 32 ++++ .../entry/application/commands/__init__.py | 9 ++ .../commands/create_entry_command.py | 7 +- .../commands/delete_entry_command.py | 9 ++ .../commands/update_entry_command.py | 16 ++ .../entry/application/contracts/__init__.py | 13 ++ .../create_entry_handler_contract.py | 13 ++ .../delete_entry_handler_contract.py | 13 ++ ...tries_by_account_month_handler_contract.py | 13 ++ .../find_entry_by_id_handler_contract.py | 13 ++ .../update_entry_handler_contract.py | 13 ++ app/context/entry/application/dto/__init__.py | 20 +++ .../application/dto/create_entry_result.py | 30 ++++ .../application/dto/delete_entry_result.py | 21 +++ .../application/dto/entry_response_dto.py | 36 +++++ .../dto/find_multiple_entries_result.py | 22 +++ .../dto/find_single_entry_result.py | 23 +++ .../application/dto/update_entry_result.py | 27 ++++ .../entry/application/handlers/__init__.py | 13 ++ .../handlers/create_entry_handler.py | 97 ++++++++++++ .../handlers/delete_entry_handler.py | 44 ++++++ .../find_entries_by_account_month_handler.py | 55 +++++++ .../handlers/find_entry_by_id_handler.py | 45 ++++++ .../handlers/update_entry_handler.py | 102 +++++++++++++ .../entry/application/queries/__init__.py | 7 + .../find_entries_by_account_month_query.py | 11 ++ .../queries/find_entry_by_id_query.py | 9 ++ .../entry/domain/contracts/__init__.py | 8 + .../contracts/infrastructure/__init__.py | 3 + .../entry_repository_contract.py | 140 ++++++++++++++++++ .../domain/contracts/services/__init__.py | 7 + .../services/create_entry_service_contract.py | 53 +++++++ .../services/update_entry_service_contract.py | 55 +++++++ app/context/entry/domain/dto/__init__.py | 3 + app/context/entry/domain/dto/entry_dto.py | 28 ++++ .../entry/domain/exceptions/__init__.py | 17 +++ .../entry/domain/exceptions/exceptions.py | 34 +++++ app/context/entry/domain/services/__init__.py | 7 + .../domain/services/create_entry_service.py | 102 +++++++++++++ .../domain/services/update_entry_service.py | 114 ++++++++++++++ .../entry/domain/value_objects/__init__.py | 21 +++ .../domain/value_objects/entry_account_id.py | 10 ++ .../domain/value_objects/entry_amount.py | 10 ++ .../domain/value_objects/entry_category_id.py | 10 ++ .../entry/domain/value_objects/entry_date.py | 29 ++++ .../domain/value_objects/entry_description.py | 20 +++ .../value_objects/entry_household_id.py | 10 ++ .../entry/domain/value_objects/entry_id.py | 10 ++ .../entry/domain/value_objects/entry_type.py | 5 + .../domain/value_objects/entry_user_id.py | 10 ++ .../entry/infrastructure/dependencies.py | 102 +++++++++++++ .../entry/infrastructure/mappers/__init__.py | 3 + .../infrastructure/mappers/entry_mapper.py | 62 ++++++++ .../entry/infrastructure/models/__init__.py | 3 + .../infrastructure/models/entry_model.py | 43 ++++++ .../infrastructure/repositories/__init__.py | 3 + .../repositories/entry_repository.py | 137 +++++++++++++++++ app/context/entry/interface/__init__.py | 1 + app/context/entry/interface/rest/__init__.py | 3 + .../interface/rest/controllers/__init__.py | 7 + .../controllers/create_entry_controller.py | 79 ++++++++++ .../controllers/delete_entry_controller.py | 46 ++++++ .../rest/controllers/find_entry_controller.py | 113 ++++++++++++++ .../controllers/update_entry_controller.py | 76 ++++++++++ app/context/entry/interface/rest/routes.py | 15 ++ .../entry/interface/schemas/__init__.py | 11 ++ .../interface/schemas/create_entry_schema.py | 31 ++++ .../entry/interface/schemas/entry_response.py | 16 ++ .../interface/schemas/update_entry_schema.py | 30 ++++ app/main.py | 2 + .../domain/value_objects/shared_amount.py | 30 ++++ .../value_objects/shared_category_id.py | 21 +++ .../domain/value_objects/shared_entry_id.py | 21 +++ .../domain/value_objects/shared_entry_type.py | 44 ++++++ .../value_objects/shared_household_id.py | 21 +++ app/shared/infrastructure/database.py | 2 + .../01770bb99438_create_household_tables.py | 4 +- .../1cc608a3625d_create_entry_table.py | 115 ++++++++++++++ .../93fa43c670c2_create_categories_table.py | 83 +++++++++++ ...d756346442c3_create_user_accounts_table.py | 2 +- ...make_deleted_at_timezone_aware_in_user_.py | 42 ------ .../e19a954402db_create_credit_cards_table.py | 2 +- opencode.json | 4 + 84 files changed, 2547 insertions(+), 49 deletions(-) create mode 100644 app/context/category/infrastructure/models/__init__.py create mode 100644 app/context/category/infrastructure/models/category_model.py create mode 100644 app/context/entry/application/commands/__init__.py create mode 100644 app/context/entry/application/commands/delete_entry_command.py create mode 100644 app/context/entry/application/commands/update_entry_command.py create mode 100644 app/context/entry/application/contracts/__init__.py create mode 100644 app/context/entry/application/contracts/create_entry_handler_contract.py create mode 100644 app/context/entry/application/contracts/delete_entry_handler_contract.py create mode 100644 app/context/entry/application/contracts/find_entries_by_account_month_handler_contract.py create mode 100644 app/context/entry/application/contracts/find_entry_by_id_handler_contract.py create mode 100644 app/context/entry/application/contracts/update_entry_handler_contract.py create mode 100644 app/context/entry/application/dto/__init__.py create mode 100644 app/context/entry/application/dto/create_entry_result.py create mode 100644 app/context/entry/application/dto/delete_entry_result.py create mode 100644 app/context/entry/application/dto/entry_response_dto.py create mode 100644 app/context/entry/application/dto/find_multiple_entries_result.py create mode 100644 app/context/entry/application/dto/find_single_entry_result.py create mode 100644 app/context/entry/application/dto/update_entry_result.py create mode 100644 app/context/entry/application/handlers/__init__.py create mode 100644 app/context/entry/application/handlers/create_entry_handler.py create mode 100644 app/context/entry/application/handlers/delete_entry_handler.py create mode 100644 app/context/entry/application/handlers/find_entries_by_account_month_handler.py create mode 100644 app/context/entry/application/handlers/find_entry_by_id_handler.py create mode 100644 app/context/entry/application/handlers/update_entry_handler.py create mode 100644 app/context/entry/application/queries/__init__.py create mode 100644 app/context/entry/application/queries/find_entries_by_account_month_query.py create mode 100644 app/context/entry/application/queries/find_entry_by_id_query.py create mode 100644 app/context/entry/domain/contracts/__init__.py create mode 100644 app/context/entry/domain/contracts/infrastructure/__init__.py create mode 100644 app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py create mode 100644 app/context/entry/domain/contracts/services/__init__.py create mode 100644 app/context/entry/domain/contracts/services/create_entry_service_contract.py create mode 100644 app/context/entry/domain/contracts/services/update_entry_service_contract.py create mode 100644 app/context/entry/domain/dto/__init__.py create mode 100644 app/context/entry/domain/dto/entry_dto.py create mode 100644 app/context/entry/domain/exceptions/__init__.py create mode 100644 app/context/entry/domain/exceptions/exceptions.py create mode 100644 app/context/entry/domain/services/__init__.py create mode 100644 app/context/entry/domain/services/create_entry_service.py create mode 100644 app/context/entry/domain/services/update_entry_service.py create mode 100644 app/context/entry/domain/value_objects/__init__.py create mode 100644 app/context/entry/domain/value_objects/entry_account_id.py create mode 100644 app/context/entry/domain/value_objects/entry_amount.py create mode 100644 app/context/entry/domain/value_objects/entry_category_id.py create mode 100644 app/context/entry/domain/value_objects/entry_date.py create mode 100644 app/context/entry/domain/value_objects/entry_description.py create mode 100644 app/context/entry/domain/value_objects/entry_household_id.py create mode 100644 app/context/entry/domain/value_objects/entry_id.py create mode 100644 app/context/entry/domain/value_objects/entry_type.py create mode 100644 app/context/entry/domain/value_objects/entry_user_id.py create mode 100644 app/context/entry/infrastructure/dependencies.py create mode 100644 app/context/entry/infrastructure/mappers/__init__.py create mode 100644 app/context/entry/infrastructure/mappers/entry_mapper.py create mode 100644 app/context/entry/infrastructure/models/__init__.py create mode 100644 app/context/entry/infrastructure/models/entry_model.py create mode 100644 app/context/entry/infrastructure/repositories/__init__.py create mode 100644 app/context/entry/infrastructure/repositories/entry_repository.py create mode 100644 app/context/entry/interface/__init__.py create mode 100644 app/context/entry/interface/rest/__init__.py create mode 100644 app/context/entry/interface/rest/controllers/__init__.py create mode 100644 app/context/entry/interface/rest/controllers/create_entry_controller.py create mode 100644 app/context/entry/interface/rest/controllers/delete_entry_controller.py create mode 100644 app/context/entry/interface/rest/controllers/find_entry_controller.py create mode 100644 app/context/entry/interface/rest/controllers/update_entry_controller.py create mode 100644 app/context/entry/interface/rest/routes.py create mode 100644 app/context/entry/interface/schemas/__init__.py create mode 100644 app/context/entry/interface/schemas/create_entry_schema.py create mode 100644 app/context/entry/interface/schemas/entry_response.py create mode 100644 app/context/entry/interface/schemas/update_entry_schema.py create mode 100644 app/shared/domain/value_objects/shared_amount.py create mode 100644 app/shared/domain/value_objects/shared_category_id.py create mode 100644 app/shared/domain/value_objects/shared_entry_id.py create mode 100644 app/shared/domain/value_objects/shared_entry_type.py create mode 100644 app/shared/domain/value_objects/shared_household_id.py create mode 100644 migrations/versions/1cc608a3625d_create_entry_table.py create mode 100644 migrations/versions/93fa43c670c2_create_categories_table.py delete mode 100644 migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py create mode 100644 opencode.json 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/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 index 1e7e02a..03b58f8 100644 --- a/app/context/entry/application/commands/create_entry_command.py +++ b/app/context/entry/application/commands/create_entry_command.py @@ -4,10 +4,11 @@ @dataclass(frozen=True) class CreateEntryCommand: - expense_type: str - expense_date: datetime + user_id: int account_id: int category_id: int - household_id: int | None + 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..fe40dcc --- /dev/null +++ b/app/context/entry/application/commands/update_entry_command.py @@ -0,0 +1,16 @@ +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 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..641c43f --- /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.from_string(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..6c7ad44 --- /dev/null +++ b/app/context/entry/application/handlers/find_entries_by_account_month_handler.py @@ -0,0 +1,55 @@ +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, EntryUserID +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, + ) + + try: + entries = await self._repository.find_entries_by_account_and_month( + user_id=EntryUserID(query.user_id), + account_id=EntryAccountID(query.account_id), + month=query.month, + year=query.year, + ) + + # 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..aed2116 --- /dev/null +++ b/app/context/entry/application/handlers/update_entry_handler.py @@ -0,0 +1,102 @@ +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, + 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.from_string(command.entry_type), + entry_date=EntryDate(command.entry_date), + amount=EntryAmount.from_float(command.amount), + description=EntryDescription(command.description), + ) + + # 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.value.isoformat(), + 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..00df4e8 --- /dev/null +++ b/app/context/entry/application/queries/find_entries_by_account_month_query.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@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 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..cbdab4f --- /dev/null +++ b/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py @@ -0,0 +1,140 @@ +from abc import ABC, abstractmethod + +from app.context.entry.domain.dto import EntryDTO +from app.context.entry.domain.value_objects import ( + EntryAccountID, + EntryCategoryID, + EntryID, + EntryUserID, +) + + +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: int, + year: int, + ) -> 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..94b49b8 --- /dev/null +++ b/app/context/entry/domain/contracts/services/update_entry_service_contract.py @@ -0,0 +1,55 @@ +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, + 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, + ) -> 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..ae5eda6 --- /dev/null +++ b/app/context/entry/domain/services/update_entry_service.py @@ -0,0 +1,114 @@ +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, + 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, + ) -> EntryDTO: + """Update an existing entry with validation""" + self._logger.debug( + "Updating entry", + entry_id=entry_id.value, + user_id=user_id.value, + ) + + # Verify entry exists and belongs to user + 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=existing_entry.household_id, # Preserve 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..c002e02 --- /dev/null +++ b/app/context/entry/domain/value_objects/__init__.py @@ -0,0 +1,21 @@ +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_type import EntryType +from .entry_user_id import EntryUserID + +__all__ = [ + "EntryID", + "EntryUserID", + "EntryAccountID", + "EntryCategoryID", + "EntryHouseholdID", + "EntryAmount", + "EntryDescription", + "EntryDate", + "EntryType", +] 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..805e0dc --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_date.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from typing import Self + + +@dataclass(frozen=True) +class EntryDate: + """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") + if self.value.tzinfo is None: + raise ValueError("EntryDate must be timezone-aware") + + @classmethod + def now(cls) -> Self: + """Create an EntryDate for the current moment (UTC)""" + return cls(value=datetime.now(UTC), _validated=True) + + @classmethod + def from_trusted_source(cls, value: datetime) -> Self: + """Create EntryDate from trusted source (e.g., database) - skips validation""" + return cls(value=value, _validated=True) 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_type.py b/app/context/entry/domain/value_objects/entry_type.py new file mode 100644 index 0000000..45ee024 --- /dev/null +++ b/app/context/entry/domain/value_objects/entry_type.py @@ -0,0 +1,5 @@ +from app.shared.domain.value_objects.shared_entry_type import SharedEntryType + + +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/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..8cfee43 --- /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_string(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..2da787d --- /dev/null +++ b/app/context/entry/infrastructure/repositories/entry_repository.py @@ -0,0 +1,137 @@ +from sqlalchemy import delete, extract, 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, + EntryID, + EntryUserID, +) +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: int, + year: int, + ) -> 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, + extract("month", EntryModel.entry_date) == month, + extract("year", EntryModel.entry_date) == year, + ) + .order_by(EntryModel.entry_date.desc()) + ) + 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/main.py b/app/main.py index da17271..e914463 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ 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.user_account.interface.rest import user_account_routes from app.shared.domain.value_objects.shared_app_env import SharedAppEnv @@ -22,6 +23,7 @@ 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) 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_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_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..2e2d4aa --- /dev/null +++ b/app/shared/domain/value_objects/shared_entry_type.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass +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 + + @classmethod + def from_string(cls, str_value: str) -> 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 str_value != SharedEntryTypeValues.INCOME and str_value != SharedEntryTypeValues.EXPENSE: + raise ValueError(f"Invalid entry type: '{str_value}'. Expected 'income' or 'expense'") + + return cls(value=str_value) + + @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/infrastructure/database.py b/app/shared/infrastructure/database.py index 7e60259..48cb48b 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -49,9 +49,11 @@ async def get_db(): # ────────────────────────────────────────────────────────────────────────────── 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, diff --git a/migrations/versions/01770bb99438_create_household_tables.py b/migrations/versions/01770bb99438_create_household_tables.py index a1842c2..f032d2f 100644 --- a/migrations/versions/01770bb99438_create_household_tables.py +++ b/migrations/versions/01770bb99438_create_household_tables.py @@ -1,7 +1,7 @@ """Create household tables Revision ID: 01770bb99438 -Revises: d96343c7a2a6 +Revises: e19a954402db Create Date: 2025-12-27 15:30:16.811732 """ @@ -13,7 +13,7 @@ # revision identifiers, used by Alembic. revision: str = "01770bb99438" -down_revision: str | Sequence[str] | None = "d96343c7a2a6" +down_revision: str | Sequence[str] | None = "e19a954402db" branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None diff --git a/migrations/versions/1cc608a3625d_create_entry_table.py b/migrations/versions/1cc608a3625d_create_entry_table.py new file mode 100644 index 0000000..2a20557 --- /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_date", + "entries", + ["user_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/d756346442c3_create_user_accounts_table.py b/migrations/versions/d756346442c3_create_user_accounts_table.py index 6efe814..8ee4b6d 100644 --- a/migrations/versions/d756346442c3_create_user_accounts_table.py +++ b/migrations/versions/d756346442c3_create_user_accounts_table.py @@ -26,7 +26,7 @@ def upgrade() -> None: 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(), nullable=True), + 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"), ) diff --git a/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py b/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py deleted file mode 100644 index eb8d8c8..0000000 --- a/migrations/versions/d96343c7a2a6_make_deleted_at_timezone_aware_in_user_.py +++ /dev/null @@ -1,42 +0,0 @@ -"""make deleted_at timezone aware in user_accounts - -Revision ID: d96343c7a2a6 -Revises: e19a954402db -Create Date: 2025-12-27 11:26:08.433758 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "d96343c7a2a6" -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.""" - # Change deleted_at from TIMESTAMP to TIMESTAMPTZ (timezone-aware) - op.alter_column( - "user_accounts", - "deleted_at", - type_=sa.DateTime(timezone=True), - existing_type=sa.DateTime(), - existing_nullable=True, - ) - - -def downgrade() -> None: - """Downgrade schema.""" - # Revert deleted_at from TIMESTAMPTZ back to TIMESTAMP - op.alter_column( - "user_accounts", - "deleted_at", - type_=sa.DateTime(), - existing_type=sa.DateTime(timezone=True), - existing_nullable=True, - ) diff --git a/migrations/versions/e19a954402db_create_credit_cards_table.py b/migrations/versions/e19a954402db_create_credit_cards_table.py index 364049f..249d0b6 100644 --- a/migrations/versions/e19a954402db_create_credit_cards_table.py +++ b/migrations/versions/e19a954402db_create_credit_cards_table.py @@ -28,7 +28,7 @@ def upgrade() -> None: 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(), nullable=True), + 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"), 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"] +} From d1c213eb793740b8fe6fd149e4f64642f5e07d23 Mon Sep 17 00:00:00 2001 From: polivera Date: Wed, 31 Dec 2025 18:51:00 +0100 Subject: [PATCH 52/58] Fixes on entry context --- .../find_entries_by_account_month_handler.py | 8 +++--- .../find_entries_by_account_month_query.py | 2 ++ .../entry_repository_contract.py | 8 ++++-- .../entry/domain/value_objects/__init__.py | 4 +++ .../entry/domain/value_objects/entry_date.py | 20 ++++----------- .../entry/domain/value_objects/entry_month.py | 8 ++++++ .../entry/domain/value_objects/entry_year.py | 8 ++++++ .../repositories/entry_repository.py | 19 ++++++++++---- app/shared/domain/value_objects/__init__.py | 4 +++ .../domain/value_objects/shared_date.py | 25 +++++++++++++++++++ .../domain/value_objects/shared_month.py | 14 +++++++++++ .../domain/value_objects/shared_year.py | 6 +++++ .../1cc608a3625d_create_entry_table.py | 4 +-- 13 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 app/context/entry/domain/value_objects/entry_month.py create mode 100644 app/context/entry/domain/value_objects/entry_year.py create mode 100644 app/shared/domain/value_objects/shared_month.py create mode 100644 app/shared/domain/value_objects/shared_year.py 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 index 6c7ad44..fc70a32 100644 --- 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 @@ -6,7 +6,7 @@ ) 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, EntryUserID +from app.context.entry.domain.value_objects import EntryAccountID, EntryDate, EntryMonth, EntryUserID, EntryYear from app.shared.domain.contracts import LoggerContract @@ -29,14 +29,16 @@ async def handle(self, query: FindEntriesByAccountMonthQuery) -> FindMultipleEnt 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=query.month, - year=query.year, + 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) 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 index 00df4e8..eef48d9 100644 --- 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 @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import datetime @dataclass(frozen=True) @@ -9,3 +10,4 @@ class FindEntriesByAccountMonthQuery: account_id: int month: int # 1-12 year: int # e.g., 2025 + last_entry_date: datetime | None = None diff --git a/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py b/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py index cbdab4f..39c5071 100644 --- a/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py +++ b/app/context/entry/domain/contracts/infrastructure/entry_repository_contract.py @@ -4,8 +4,11 @@ from app.context.entry.domain.value_objects import ( EntryAccountID, EntryCategoryID, + EntryDate, EntryID, + EntryMonth, EntryUserID, + EntryYear, ) @@ -51,8 +54,9 @@ async def find_entries_by_account_and_month( self, user_id: EntryUserID, account_id: EntryAccountID, - month: int, - year: int, + month: EntryMonth, + year: EntryYear, + last_entry_date: EntryDate | None = None, ) -> list[EntryDTO]: """ Find all entries for a specific account in a given month/year diff --git a/app/context/entry/domain/value_objects/__init__.py b/app/context/entry/domain/value_objects/__init__.py index c002e02..6727286 100644 --- a/app/context/entry/domain/value_objects/__init__.py +++ b/app/context/entry/domain/value_objects/__init__.py @@ -5,8 +5,10 @@ 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", @@ -18,4 +20,6 @@ "EntryDescription", "EntryDate", "EntryType", + "EntryMonth", + "EntryYear", ] diff --git a/app/context/entry/domain/value_objects/entry_date.py b/app/context/entry/domain/value_objects/entry_date.py index 805e0dc..255a4b3 100644 --- a/app/context/entry/domain/value_objects/entry_date.py +++ b/app/context/entry/domain/value_objects/entry_date.py @@ -1,10 +1,11 @@ from dataclasses import dataclass, field -from datetime import UTC, datetime -from typing import Self +from datetime import datetime + +from app.shared.domain.value_objects import SharedDateTime @dataclass(frozen=True) -class EntryDate: +class EntryDate(SharedDateTime): """Value object for entry date (when the financial transaction occurred)""" value: datetime @@ -15,15 +16,4 @@ def __post_init__(self): if not self._validated: if not isinstance(self.value, datetime): raise ValueError("EntryDate must be a datetime object") - if self.value.tzinfo is None: - raise ValueError("EntryDate must be timezone-aware") - - @classmethod - def now(cls) -> Self: - """Create an EntryDate for the current moment (UTC)""" - return cls(value=datetime.now(UTC), _validated=True) - - @classmethod - def from_trusted_source(cls, value: datetime) -> Self: - """Create EntryDate from trusted source (e.g., database) - skips validation""" - return cls(value=value, _validated=True) + super().__post_init__() 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_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/repositories/entry_repository.py b/app/context/entry/infrastructure/repositories/entry_repository.py index 2da787d..6b62887 100644 --- a/app/context/entry/infrastructure/repositories/entry_repository.py +++ b/app/context/entry/infrastructure/repositories/entry_repository.py @@ -1,4 +1,4 @@ -from sqlalchemy import delete, extract, select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from app.context.entry.domain.contracts.infrastructure import EntryRepositoryContract @@ -7,8 +7,11 @@ 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 @@ -49,20 +52,26 @@ async def find_entries_by_account_and_month( self, user_id: EntryUserID, account_id: EntryAccountID, - month: int, - year: int, + 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, - extract("month", EntryModel.entry_date) == month, - extract("year", EntryModel.entry_date) == year, + 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] diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py index 51e4a21..5947109 100644 --- a/app/shared/domain/value_objects/__init__.py +++ b/app/shared/domain/value_objects/__init__.py @@ -5,9 +5,11 @@ from .shared_date import SharedDateTime from .shared_deleted_at import SharedDeletedAt from .shared_email import SharedEmail +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", @@ -20,4 +22,6 @@ "SharedDateTime", "SharedUsername", "SharedAppEnv", + "SharedMonth", + "SharedYear", ] diff --git a/app/shared/domain/value_objects/shared_date.py b/app/shared/domain/value_objects/shared_date.py index c068e38..156f1d0 100644 --- a/app/shared/domain/value_objects/shared_date.py +++ b/app/shared/domain/value_objects/shared_date.py @@ -2,6 +2,9 @@ from datetime import UTC, datetime from typing import Self +from app.shared.domain.value_objects import SharedYear +from app.shared.domain.value_objects.shared_month import SharedMonth + @dataclass(frozen=True) class SharedDateTime: @@ -23,3 +26,25 @@ def __post_init__(self): 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_month.py b/app/shared/domain/value_objects/shared_month.py new file mode 100644 index 0000000..c2a9b8b --- /dev/null +++ b/app/shared/domain/value_objects/shared_month.py @@ -0,0 +1,14 @@ +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 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_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/migrations/versions/1cc608a3625d_create_entry_table.py b/migrations/versions/1cc608a3625d_create_entry_table.py index 2a20557..f7976bc 100644 --- a/migrations/versions/1cc608a3625d_create_entry_table.py +++ b/migrations/versions/1cc608a3625d_create_entry_table.py @@ -99,9 +99,9 @@ def upgrade() -> None: # Composite index for common query pattern: user's entries by date op.create_index( - "ix_entries_user_date", + "ix_entries_user_account_date", "entries", - ["user_id", "entry_date"], + ["user_id", "account_id", "entry_date"], ) From e9958c89a34c9e83ff44d9daa516bd22a3bf7f95 Mon Sep 17 00:00:00 2001 From: polivera Date: Wed, 31 Dec 2025 19:07:32 +0100 Subject: [PATCH 53/58] Adding entry tests --- app/shared/domain/value_objects/__init__.py | 2 + .../domain/value_objects/shared_date.py | 2 +- .../domain/value_objects/shared_month.py | 4 +- .../application/create_entry_handler_test.py | 350 ++++++++++++++++++ .../application/update_entry_handler_test.py | 347 +++++++++++++++++ .../entry/domain/create_entry_service_test.py | 300 +++++++++++++++ .../context/entry/domain/entry_amount_test.py | 95 +++++ .../context/entry/domain/entry_date_test.py | 89 +++++ .../entry/domain/entry_description_test.py | 85 +++++ .../entry/domain/update_entry_service_test.py | 323 ++++++++++++++++ .../entry/infrastructure/entry_mapper_test.py | 308 +++++++++++++++ 11 files changed, 1902 insertions(+), 3 deletions(-) create mode 100644 tests/unit/context/entry/application/create_entry_handler_test.py create mode 100644 tests/unit/context/entry/application/update_entry_handler_test.py create mode 100644 tests/unit/context/entry/domain/create_entry_service_test.py create mode 100644 tests/unit/context/entry/domain/entry_amount_test.py create mode 100644 tests/unit/context/entry/domain/entry_date_test.py create mode 100644 tests/unit/context/entry/domain/entry_description_test.py create mode 100644 tests/unit/context/entry/domain/update_entry_service_test.py create mode 100644 tests/unit/context/entry/infrastructure/entry_mapper_test.py diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py index 5947109..2577ec1 100644 --- a/app/shared/domain/value_objects/__init__.py +++ b/app/shared/domain/value_objects/__init__.py @@ -5,6 +5,7 @@ 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 @@ -14,6 +15,7 @@ __all__ = [ "SharedDeletedAt", "SharedEmail", + "SharedEntryType", "SharedPassword", "SharedBalance", "SharedCurrency", diff --git a/app/shared/domain/value_objects/shared_date.py b/app/shared/domain/value_objects/shared_date.py index 156f1d0..184d08e 100644 --- a/app/shared/domain/value_objects/shared_date.py +++ b/app/shared/domain/value_objects/shared_date.py @@ -2,8 +2,8 @@ from datetime import UTC, datetime from typing import Self -from app.shared.domain.value_objects import SharedYear from app.shared.domain.value_objects.shared_month import SharedMonth +from app.shared.domain.value_objects.shared_year import SharedYear @dataclass(frozen=True) diff --git a/app/shared/domain/value_objects/shared_month.py b/app/shared/domain/value_objects/shared_month.py index c2a9b8b..54bfc01 100644 --- a/app/shared/domain/value_objects/shared_month.py +++ b/app/shared/domain/value_objects/shared_month.py @@ -8,7 +8,7 @@ class SharedMonth: def __post_init__(self): """Validate month""" - if self._validated: - if self.value < 1 or self.value > 12 + 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/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..3ebab5a --- /dev/null +++ b/tests/unit/context/entry/application/create_entry_handler_test.py @@ -0,0 +1,350 @@ +"""Unit tests for CreateEntryHandler""" + +from datetime import datetime, timezone +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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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..12d3cb1 --- /dev/null +++ b/tests/unit/context/entry/application/update_entry_handler_test.py @@ -0,0 +1,347 @@ +"""Unit tests for UpdateEntryHandler""" + +from datetime import datetime, timezone +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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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..8b8306f --- /dev/null +++ b/tests/unit/context/entry/domain/create_entry_service_test.py @@ -0,0 +1,300 @@ +"""Unit tests for CreateEntryService""" + +from datetime import datetime, timezone +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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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..bb81fbd --- /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 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=timezone.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=timezone.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=timezone.utc) + entry_date = EntryDate(dt) + with pytest.raises(FrozenInstanceError): + entry_date.value = datetime(2024, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + def test_past_date(self): + """Test entry date in the past""" + dt = datetime(2020, 1, 1, 0, 0, 0, tzinfo=timezone.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=timezone.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(timezone.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..977e463 --- /dev/null +++ b/tests/unit/context/entry/domain/update_entry_service_test.py @@ -0,0 +1,323 @@ +"""Unit tests for UpdateEntryService""" + +from datetime import datetime, timezone +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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.utc)), + amount=EntryAmount(Decimal("200.00")), + description=EntryDescription("Updated"), + ) + + # 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=timezone.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=timezone.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..1725f78 --- /dev/null +++ b/tests/unit/context/entry/infrastructure/entry_mapper_test.py @@ -0,0 +1,308 @@ +"""Unit tests for EntryMapper""" + +from datetime import datetime, timezone +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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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=timezone.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 From 53b04a6972c0ca6839cb825cae1a282eb378888a Mon Sep 17 00:00:00 2001 From: polivera Date: Wed, 31 Dec 2025 19:40:26 +0100 Subject: [PATCH 54/58] Fixes on entry domain --- .../entry/application/commands/update_entry_command.py | 1 + .../entry/application/handlers/update_entry_handler.py | 2 ++ .../contracts/services/update_entry_service_contract.py | 2 ++ app/context/entry/domain/services/update_entry_service.py | 5 +++-- app/context/entry/domain/value_objects/entry_type.py | 3 +++ 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/context/entry/application/commands/update_entry_command.py b/app/context/entry/application/commands/update_entry_command.py index fe40dcc..bc0f3e1 100644 --- a/app/context/entry/application/commands/update_entry_command.py +++ b/app/context/entry/application/commands/update_entry_command.py @@ -14,3 +14,4 @@ class UpdateEntryCommand: entry_date: datetime amount: float description: str + household_id: int | None = None diff --git a/app/context/entry/application/handlers/update_entry_handler.py b/app/context/entry/application/handlers/update_entry_handler.py index aed2116..c77a3bd 100644 --- a/app/context/entry/application/handlers/update_entry_handler.py +++ b/app/context/entry/application/handlers/update_entry_handler.py @@ -18,6 +18,7 @@ EntryCategoryID, EntryDate, EntryDescription, + EntryHouseholdID, EntryID, EntryType, EntryUserID, @@ -49,6 +50,7 @@ async def handle(self, command: UpdateEntryCommand) -> UpdateEntryResult: 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 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 index 94b49b8..d956634 100644 --- a/app/context/entry/domain/contracts/services/update_entry_service_contract.py +++ b/app/context/entry/domain/contracts/services/update_entry_service_contract.py @@ -7,6 +7,7 @@ EntryCategoryID, EntryDate, EntryDescription, + EntryHouseholdID, EntryID, EntryType, EntryUserID, @@ -27,6 +28,7 @@ async def update_entry( entry_date: EntryDate, amount: EntryAmount, description: EntryDescription, + household_id: EntryHouseholdID | None = None, ) -> EntryDTO: """ Update an existing entry with validation diff --git a/app/context/entry/domain/services/update_entry_service.py b/app/context/entry/domain/services/update_entry_service.py index ae5eda6..ca2f15e 100644 --- a/app/context/entry/domain/services/update_entry_service.py +++ b/app/context/entry/domain/services/update_entry_service.py @@ -12,6 +12,7 @@ EntryCategoryID, EntryDate, EntryDescription, + EntryHouseholdID, EntryID, EntryType, EntryUserID, @@ -40,6 +41,7 @@ async def update_entry( entry_date: EntryDate, amount: EntryAmount, description: EntryDescription, + household_id: EntryHouseholdID | None = None, ) -> EntryDTO: """Update an existing entry with validation""" self._logger.debug( @@ -48,7 +50,6 @@ async def update_entry( user_id=user_id.value, ) - # Verify entry exists and belongs to user existing_entry = await self._repository.find_entry_by_id( entry_id=entry_id, user_id=user_id, @@ -99,7 +100,7 @@ async def update_entry( entry_date=entry_date, amount=amount, description=description, - household_id=existing_entry.household_id, # Preserve household_id + household_id=household_id, ) # Update entry diff --git a/app/context/entry/domain/value_objects/entry_type.py b/app/context/entry/domain/value_objects/entry_type.py index 45ee024..89699e1 100644 --- a/app/context/entry/domain/value_objects/entry_type.py +++ b/app/context/entry/domain/value_objects/entry_type.py @@ -1,5 +1,8 @@ +from dataclasses import dataclass + from app.shared.domain.value_objects.shared_entry_type import SharedEntryType +@dataclass(frozen=True) class EntryType(SharedEntryType): pass From 887c1a50814c3a501b2fa3e1607f4f550a28de50 Mon Sep 17 00:00:00 2001 From: polivera Date: Wed, 31 Dec 2025 19:45:07 +0100 Subject: [PATCH 55/58] Fix unit tests and file format errors --- .../domain/value_objects/shared_month.py | 1 - .../application/create_entry_handler_test.py | 20 ++++++------- .../application/update_entry_handler_test.py | 20 ++++++------- .../entry/domain/create_entry_service_test.py | 18 ++++++------ .../context/entry/domain/entry_date_test.py | 16 +++++------ .../entry/domain/update_entry_service_test.py | 27 +++++++++--------- .../entry/infrastructure/entry_mapper_test.py | 28 +++++++++---------- 7 files changed, 65 insertions(+), 65 deletions(-) diff --git a/app/shared/domain/value_objects/shared_month.py b/app/shared/domain/value_objects/shared_month.py index 54bfc01..499291d 100644 --- a/app/shared/domain/value_objects/shared_month.py +++ b/app/shared/domain/value_objects/shared_month.py @@ -11,4 +11,3 @@ def __post_init__(self): 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/tests/unit/context/entry/application/create_entry_handler_test.py b/tests/unit/context/entry/application/create_entry_handler_test.py index 3ebab5a..e0933db 100644 --- a/tests/unit/context/entry/application/create_entry_handler_test.py +++ b/tests/unit/context/entry/application/create_entry_handler_test.py @@ -1,6 +1,6 @@ """Unit tests for CreateEntryHandler""" -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, MagicMock @@ -54,7 +54,7 @@ def handler(self, mock_service, mock_logger): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, @@ -97,7 +97,7 @@ async def test_create_entry_success(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, @@ -137,7 +137,7 @@ async def test_create_entry_without_household_id(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, @@ -175,7 +175,7 @@ async def test_create_entry_without_entry_id_returns_error(self, handler, mock_s 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=999, @@ -203,7 +203,7 @@ async def test_create_entry_account_not_belongs_to_user_error(self, handler, moc 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, @@ -229,7 +229,7 @@ async def test_create_entry_category_not_found_error(self, handler, mock_service 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, @@ -257,7 +257,7 @@ async def test_create_entry_category_not_belongs_to_user_error(self, handler, mo 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, @@ -283,7 +283,7 @@ async def test_create_entry_mapper_error(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, @@ -309,7 +309,7 @@ async def test_create_entry_unexpected_error(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = CreateEntryCommand( user_id=1, account_id=10, diff --git a/tests/unit/context/entry/application/update_entry_handler_test.py b/tests/unit/context/entry/application/update_entry_handler_test.py index 12d3cb1..f11ed8a 100644 --- a/tests/unit/context/entry/application/update_entry_handler_test.py +++ b/tests/unit/context/entry/application/update_entry_handler_test.py @@ -1,6 +1,6 @@ """Unit tests for UpdateEntryHandler""" -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, MagicMock @@ -54,7 +54,7 @@ def handler(self, mock_service, mock_logger): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, @@ -99,7 +99,7 @@ async def test_update_entry_success(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, @@ -136,7 +136,7 @@ async def test_update_entry_without_household_id(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, @@ -174,7 +174,7 @@ async def test_update_entry_without_entry_id_returns_error(self, handler, mock_s 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=999, user_id=1, @@ -200,7 +200,7 @@ async def test_update_entry_not_found_error(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, @@ -228,7 +228,7 @@ async def test_update_entry_account_not_belongs_to_user_error(self, handler, moc 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, @@ -254,7 +254,7 @@ async def test_update_entry_category_not_found_error(self, handler, mock_service 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, @@ -280,7 +280,7 @@ async def test_update_entry_mapper_error(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, @@ -306,7 +306,7 @@ async def test_update_entry_unexpected_error(self, handler, mock_service): 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=timezone.utc) + entry_date = datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC) command = UpdateEntryCommand( entry_id=100, user_id=1, diff --git a/tests/unit/context/entry/domain/create_entry_service_test.py b/tests/unit/context/entry/domain/create_entry_service_test.py index 8b8306f..105814d 100644 --- a/tests/unit/context/entry/domain/create_entry_service_test.py +++ b/tests/unit/context/entry/domain/create_entry_service_test.py @@ -1,6 +1,6 @@ """Unit tests for CreateEntryService""" -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, MagicMock @@ -54,7 +54,7 @@ async def test_create_entry_success(self, service, mock_repository): account_id = EntryAccountID(10) category_id = EntryCategoryID(5) entry_type = EntryType(SharedEntryTypeValues.EXPENSE) - entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)) + 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) @@ -108,7 +108,7 @@ async def test_create_entry_without_household_id(self, service, mock_repository) account_id = EntryAccountID(10) category_id = EntryCategoryID(5) entry_type = EntryType(SharedEntryTypeValues.INCOME) - entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)) + entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)) amount = EntryAmount(Decimal("1000.00")) description = EntryDescription("Salary") @@ -163,7 +163,7 @@ async def test_create_entry_account_not_belongs_to_user(self, service, mock_repo account_id=account_id, category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Test"), ) @@ -193,7 +193,7 @@ async def test_create_entry_category_not_found(self, service, mock_repository): account_id=account_id, category_id=category_id, entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Test"), ) @@ -209,7 +209,7 @@ async def test_create_entry_with_zero_amount(self, service, mock_repository): account_id = EntryAccountID(10) category_id = EntryCategoryID(5) entry_type = EntryType(SharedEntryTypeValues.EXPENSE) - entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)) + entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)) amount = EntryAmount(Decimal("0.00")) # Zero amount description = EntryDescription("Refund") @@ -255,7 +255,7 @@ async def test_create_entry_with_income_type(self, service, mock_repository): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=entry_type, - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("2000.00")), description=EntryDescription("Monthly salary"), household_id=None, @@ -271,7 +271,7 @@ async def test_create_entry_with_income_type(self, service, mock_repository): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=entry_type, - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("2000.00")), description=EntryDescription("Monthly salary"), ) @@ -294,7 +294,7 @@ async def test_create_entry_propagates_repository_exceptions(self, service, mock account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + 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_date_test.py b/tests/unit/context/entry/domain/entry_date_test.py index bb81fbd..2656499 100644 --- a/tests/unit/context/entry/domain/entry_date_test.py +++ b/tests/unit/context/entry/domain/entry_date_test.py @@ -1,7 +1,7 @@ """Unit tests for EntryDate value object""" from dataclasses import FrozenInstanceError -from datetime import datetime, timezone +from datetime import UTC, datetime, timezone import pytest @@ -14,7 +14,7 @@ class TestEntryDate: def test_valid_timezone_aware_datetime(self): """Test creating entry date with timezone-aware datetime""" - dt = datetime(2024, 12, 31, 15, 30, 0, tzinfo=timezone.utc) + dt = datetime(2024, 12, 31, 15, 30, 0, tzinfo=UTC) entry_date = EntryDate(dt) assert entry_date.value == dt @@ -58,32 +58,32 @@ def test_from_trusted_source_skips_validation(self): 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=timezone.utc) + 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=timezone.utc) + 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=timezone.utc) + 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=timezone.utc) + 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=timezone.utc) + 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(timezone.utc) + 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/update_entry_service_test.py b/tests/unit/context/entry/domain/update_entry_service_test.py index 977e463..fc6403b 100644 --- a/tests/unit/context/entry/domain/update_entry_service_test.py +++ b/tests/unit/context/entry/domain/update_entry_service_test.py @@ -1,6 +1,6 @@ """Unit tests for UpdateEntryService""" -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal from unittest.mock import AsyncMock, MagicMock @@ -56,7 +56,7 @@ async def test_update_entry_success(self, service, mock_repository): account_id = EntryAccountID(10) category_id = EntryCategoryID(5) entry_type = EntryType(SharedEntryTypeValues.EXPENSE) - entry_date = EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)) + 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) @@ -68,7 +68,7 @@ async def test_update_entry_success(self, service, mock_repository): 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=timezone.utc)), + 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, @@ -134,7 +134,7 @@ async def test_update_entry_not_found(self, service, mock_repository): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Test"), ) @@ -158,7 +158,7 @@ async def test_update_entry_account_not_belongs_to_user(self, service, mock_repo account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Old"), household_id=None, @@ -178,7 +178,7 @@ async def test_update_entry_account_not_belongs_to_user(self, service, mock_repo account_id=account_id, category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Test"), ) @@ -201,7 +201,7 @@ async def test_update_entry_category_not_found(self, service, mock_repository): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Old"), household_id=None, @@ -222,7 +222,7 @@ async def test_update_entry_category_not_found(self, service, mock_repository): account_id=EntryAccountID(10), category_id=category_id, entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Test"), ) @@ -244,7 +244,7 @@ async def test_update_entry_preserves_household_id(self, service, mock_repositor account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=timezone.utc)), + 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 @@ -256,7 +256,7 @@ async def test_update_entry_preserves_household_id(self, service, mock_repositor account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + 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 @@ -274,9 +274,10 @@ async def test_update_entry_preserves_household_id(self, service, mock_repositor account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + 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 @@ -298,7 +299,7 @@ async def test_update_entry_propagates_repository_exceptions(self, service, mock account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 30, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Old"), household_id=None, @@ -317,7 +318,7 @@ async def test_update_entry_propagates_repository_exceptions(self, service, mock account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + 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 index 1725f78..24c7376 100644 --- a/tests/unit/context/entry/infrastructure/entry_mapper_test.py +++ b/tests/unit/context/entry/infrastructure/entry_mapper_test.py @@ -1,6 +1,6 @@ """Unit tests for EntryMapper""" -from datetime import datetime, timezone +from datetime import UTC, datetime from decimal import Decimal import pytest @@ -36,7 +36,7 @@ def test_to_dto_with_valid_model(self): account_id=10, category_id=5, entry_type="expense", - entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), amount=Decimal("150.50"), description="Grocery shopping", household_id=3, @@ -53,7 +53,7 @@ def test_to_dto_with_valid_model(self): 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=timezone.utc) + 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) @@ -75,7 +75,7 @@ def test_to_dto_without_household_id(self): account_id=10, category_id=5, entry_type="income", - entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), amount=Decimal("1000.00"), description="Salary", household_id=None, @@ -97,7 +97,7 @@ def test_to_dto_with_income_type(self): account_id=10, category_id=5, entry_type="income", - entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), amount=Decimal("2000.00"), description="Monthly salary", household_id=None, @@ -119,7 +119,7 @@ def test_to_dto_uses_from_trusted_source(self): account_id=10, category_id=5, entry_type="expense", - entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + 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, @@ -141,7 +141,7 @@ def test_to_dto_or_fail_with_valid_model(self): account_id=10, category_id=5, entry_type="expense", - entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), amount=Decimal("150.50"), description="Test", household_id=None, @@ -170,7 +170,7 @@ def test_to_model_with_all_fields(self): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + 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), @@ -186,7 +186,7 @@ def test_to_model_with_all_fields(self): 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=timezone.utc) + 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 @@ -200,7 +200,7 @@ def test_to_model_without_entry_id(self): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("100.00")), description=EntryDescription("Test"), household_id=None, @@ -222,7 +222,7 @@ def test_to_model_without_household_id(self): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.INCOME), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("1000.00")), description=EntryDescription("Salary"), household_id=None, @@ -243,7 +243,7 @@ def test_to_model_with_income_type(self): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.INCOME), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("2000.00")), description=EntryDescription("Monthly salary"), household_id=None, @@ -264,7 +264,7 @@ def test_to_model_preserves_precision(self): account_id=EntryAccountID(10), category_id=EntryCategoryID(5), entry_type=EntryType(SharedEntryTypeValues.EXPENSE), - entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc)), + entry_date=EntryDate(datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC)), amount=EntryAmount(Decimal("123.45")), description=EntryDescription("Test"), household_id=None, @@ -286,7 +286,7 @@ def test_roundtrip_conversion(self): account_id=10, category_id=5, entry_type="expense", - entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=timezone.utc), + entry_date=datetime(2024, 12, 31, 10, 0, 0, tzinfo=UTC), amount=Decimal("150.50"), description="Grocery shopping", household_id=3, From 779c8ba16bd2900ed5501ad8c0c755f47fc95f15 Mon Sep 17 00:00:00 2001 From: polivera Date: Thu, 1 Jan 2026 19:57:35 +0100 Subject: [PATCH 56/58] Working on reminders context --- .dockerignore | 73 +++ .env.template => .env.example | 4 + Dockerfile | 52 +++ README.md | 426 +++++++++++++++++- .../reminder/domain/contracts/__init__.py | 6 + .../contracts/infrastructure/__init__.py | 4 + ...reminder_occurrence_repository_contract.py | 75 +++ .../reminder_repository_contract.py | 71 +++ app/context/reminder/domain/dto/__init__.py | 4 + .../reminder/domain/dto/reminder_dto.py | 24 + .../domain/dto/reminder_occurrence_dto.py | 19 + .../reminder/domain/value_objects/__init__.py | 27 ++ .../domain/value_objects/reminder_currency.py | 8 + .../value_objects/reminder_description.py | 20 + .../domain/value_objects/reminder_end_date.py | 8 + .../value_objects/reminder_entry_type.py | 8 + .../value_objects/reminder_frequency.py | 22 + .../domain/value_objects/reminder_id.py | 20 + .../reminder_occurrence_amount.py | 21 + .../value_objects/reminder_occurrence_id.py | 20 + .../reminder_occurrence_scheduled_date.py | 8 + .../reminder_occurrence_status.py | 34 ++ .../value_objects/reminder_start_date.py | 8 + .../domain/value_objects/reminder_user_id.py | 20 + .../reminder/infrastructure/__init__.py | 9 + .../reminder/infrastructure/dependencies.py | 34 ++ .../infrastructure/mappers/__init__.py | 4 + .../infrastructure/mappers/reminder_mapper.py | 57 +++ .../mappers/reminder_occurrence_mapper.py | 49 ++ .../infrastructure/models/__init__.py | 4 + .../infrastructure/models/reminder_model.py | 39 ++ .../models/reminder_occurrence_model.py | 30 ++ .../infrastructure/repositories/__init__.py | 4 + .../reminder_occurrence_repository.py | 195 ++++++++ .../repositories/reminder_repository.py | 183 ++++++++ .../domain/value_objects/shared_entry_type.py | 14 +- docker-compose.yml | 51 +++ .../a1b2c3d4e5f6_create_reminders_tables.py | 188 ++++++++ 38 files changed, 1835 insertions(+), 8 deletions(-) create mode 100644 .dockerignore rename .env.template => .env.example (73%) create mode 100644 Dockerfile create mode 100644 app/context/reminder/domain/contracts/__init__.py create mode 100644 app/context/reminder/domain/contracts/infrastructure/__init__.py create mode 100644 app/context/reminder/domain/contracts/infrastructure/reminder_occurrence_repository_contract.py create mode 100644 app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py create mode 100644 app/context/reminder/domain/dto/__init__.py create mode 100644 app/context/reminder/domain/dto/reminder_dto.py create mode 100644 app/context/reminder/domain/dto/reminder_occurrence_dto.py create mode 100644 app/context/reminder/domain/value_objects/__init__.py create mode 100644 app/context/reminder/domain/value_objects/reminder_currency.py create mode 100644 app/context/reminder/domain/value_objects/reminder_description.py create mode 100644 app/context/reminder/domain/value_objects/reminder_end_date.py create mode 100644 app/context/reminder/domain/value_objects/reminder_entry_type.py create mode 100644 app/context/reminder/domain/value_objects/reminder_frequency.py create mode 100644 app/context/reminder/domain/value_objects/reminder_id.py create mode 100644 app/context/reminder/domain/value_objects/reminder_occurrence_amount.py create mode 100644 app/context/reminder/domain/value_objects/reminder_occurrence_id.py create mode 100644 app/context/reminder/domain/value_objects/reminder_occurrence_scheduled_date.py create mode 100644 app/context/reminder/domain/value_objects/reminder_occurrence_status.py create mode 100644 app/context/reminder/domain/value_objects/reminder_start_date.py create mode 100644 app/context/reminder/domain/value_objects/reminder_user_id.py create mode 100644 app/context/reminder/infrastructure/__init__.py create mode 100644 app/context/reminder/infrastructure/dependencies.py create mode 100644 app/context/reminder/infrastructure/mappers/__init__.py create mode 100644 app/context/reminder/infrastructure/mappers/reminder_mapper.py create mode 100644 app/context/reminder/infrastructure/mappers/reminder_occurrence_mapper.py create mode 100644 app/context/reminder/infrastructure/models/__init__.py create mode 100644 app/context/reminder/infrastructure/models/reminder_model.py create mode 100644 app/context/reminder/infrastructure/models/reminder_occurrence_model.py create mode 100644 app/context/reminder/infrastructure/repositories/__init__.py create mode 100644 app/context/reminder/infrastructure/repositories/reminder_occurrence_repository.py create mode 100644 app/context/reminder/infrastructure/repositories/reminder_repository.py create mode 100644 migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py 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.template b/.env.example similarity index 73% rename from .env.template rename to .env.example index 08ba587..f8a9315 100644 --- a/.env.template +++ b/.env.example @@ -14,3 +14,7 @@ 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/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..379c155 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,424 @@ -# 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 + +- [ ] 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/app/context/reminder/domain/contracts/__init__.py b/app/context/reminder/domain/contracts/__init__.py new file mode 100644 index 0000000..7dbc878 --- /dev/null +++ b/app/context/reminder/domain/contracts/__init__.py @@ -0,0 +1,6 @@ +from .infrastructure.reminder_repository_contract import ReminderRepositoryContract +from .infrastructure.reminder_occurrence_repository_contract import ( + ReminderOccurrenceRepositoryContract, +) + +__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..b2085d4 --- /dev/null +++ b/app/context/reminder/domain/contracts/infrastructure/__init__.py @@ -0,0 +1,4 @@ +from .reminder_repository_contract import ReminderRepositoryContract +from .reminder_occurrence_repository_contract import ReminderOccurrenceRepositoryContract + +__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..eb02111 --- /dev/null +++ b/app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py @@ -0,0 +1,71 @@ +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 ( + ReminderEndDate, + ReminderFrequency, + ReminderID, + ReminderStartDate, + 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/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..a18c874 --- /dev/null +++ b/app/context/reminder/domain/dto/reminder_dto.py @@ -0,0 +1,24 @@ +from dataclasses import dataclass + +from app.context.reminder.domain.value_objects import ( + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + ReminderStartDate, + ReminderUserID, +) + + +@dataclass(frozen=True) +class ReminderDTO: + user_id: ReminderUserID + entry_type: ReminderEntryType + currency: ReminderCurrency + frequency: ReminderFrequency + start_date: ReminderStartDate + end_date: ReminderEndDate | None + description: ReminderDescription + 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/value_objects/__init__.py b/app/context/reminder/domain/value_objects/__init__.py new file mode 100644 index 0000000..342928c --- /dev/null +++ b/app/context/reminder/domain/value_objects/__init__.py @@ -0,0 +1,27 @@ +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", +] 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..ce21dfe --- /dev/null +++ b/app/context/reminder/domain/value_objects/reminder_occurrence_amount.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Self + + +@dataclass(frozen=True) +class ReminderOccurrenceAmount: + 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"ReminderOccurrenceAmount must be a Decimal, got {type(self.value)}") + if self.value < 0: + raise ValueError(f"ReminderOccurrenceAmount must be non-negative, got {self.value}") + + @classmethod + def from_trusted_source(cls, value: Decimal) -> Self: + """Create ReminderOccurrenceAmount from trusted source (e.g., database) - skips validation""" + return cls(value, _validated=True) 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..45825c6 --- /dev/null +++ b/app/context/reminder/infrastructure/__init__.py @@ -0,0 +1,9 @@ +from app.context.reminder.infrastructure.dependencies import ( + get_reminder_repository, + get_reminder_occurrence_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..60d9dd8 --- /dev/null +++ b/app/context/reminder/infrastructure/dependencies.py @@ -0,0 +1,34 @@ +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.context.reminder.domain.contracts.infrastructure import ( + ReminderOccurrenceRepositoryContract, + ReminderRepositoryContract, +) +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_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) 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..48ea787 --- /dev/null +++ b/app/context/reminder/infrastructure/mappers/reminder_mapper.py @@ -0,0 +1,57 @@ +from app.context.reminder.domain.dto import ReminderDTO +from app.context.reminder.domain.value_objects import ( + ReminderCurrency, + ReminderDescription, + ReminderEndDate, + ReminderEntryType, + ReminderFrequency, + ReminderID, + 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), + entry_type=ReminderEntryType.from_trusted_source(model.entry_type), + currency=ReminderCurrency.from_trusted_source(model.currency), + 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, + frequency=dto.frequency.value, + start_date=dto.start_date.value, + end_date=dto.end_date.value if dto.end_date else None, + category_id=0, # TODO: Add category_id to ReminderDTO + description=dto.description.value, + ) 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..4b0767b --- /dev/null +++ b/app/context/reminder/infrastructure/models/reminder_model.py @@ -0,0 +1,39 @@ +from datetime import UTC, datetime + +from sqlalchemy import 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) + 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..40f61a0 --- /dev/null +++ b/app/context/reminder/infrastructure/repositories/__init__.py @@ -0,0 +1,4 @@ +from .reminder_repository import ReminderRepository +from .reminder_occurrence_repository import ReminderOccurrenceRepository + +__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..14b34f2 --- /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 delete, 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/shared/domain/value_objects/shared_entry_type.py b/app/shared/domain/value_objects/shared_entry_type.py index 2e2d4aa..b8ae7fe 100644 --- a/app/shared/domain/value_objects/shared_entry_type.py +++ b/app/shared/domain/value_objects/shared_entry_type.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from typing import Self @@ -15,9 +15,9 @@ class SharedEntryType: """Entry type for financial entries""" value: str + _validated: bool = field(default=False, repr=False, compare=False) - @classmethod - def from_string(cls, str_value: str) -> Self: + def __post_init__(self): """ Create EntryType from string value. @@ -30,10 +30,12 @@ def from_string(cls, str_value: str) -> Self: Raises: ValueError: If value is not valid """ - if str_value != SharedEntryTypeValues.INCOME and str_value != SharedEntryTypeValues.EXPENSE: - raise ValueError(f"Invalid entry type: '{str_value}'. Expected 'income' or 'expense'") + if self.value != SharedEntryTypeValues.INCOME and self.value != SharedEntryTypeValues.EXPENSE: + raise ValueError(f"Invalid entry type: '{self.value}'. Expected 'income' or 'expense'") - return cls(value=str_value) + @classmethod + def from_trusted_source(cls, value: str) -> Self: + return cls(value, _validated=True) @classmethod def expense(cls) -> Self: diff --git a/docker-compose.yml b/docker-compose.yml index 5bc630b..aac4414 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,39 @@ 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 @@ -16,6 +51,8 @@ services: interval: 10s timeout: 5s retries: 5 + networks: + - homecomp-network postgres-test: image: postgres:16-alpine @@ -34,6 +71,8 @@ services: interval: 10s timeout: 5s retries: 5 + networks: + - homecomp-network valkey: image: valkey/valkey:8-alpine @@ -48,6 +87,8 @@ services: interval: 10s timeout: 5s retries: 5 + networks: + - homecomp-network loki: image: grafana/loki:3.2.0 @@ -64,6 +105,8 @@ services: interval: 10s timeout: 5s retries: 5 + networks: + - homecomp-network promtail: image: grafana/promtail:3.2.0 @@ -77,6 +120,8 @@ services: restart: unless-stopped depends_on: - loki + networks: + - homecomp-network grafana: image: grafana/grafana:11.4.0 @@ -99,6 +144,12 @@ services: interval: 10s timeout: 5s retries: 5 + networks: + - homecomp-network + +networks: + homecomp-network: + driver: bridge volumes: postgres_data: diff --git a/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py b/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py new file mode 100644 index 0000000..a44fa1b --- /dev/null +++ b/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py @@ -0,0 +1,188 @@ +"""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( + "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") From e6027e752cf371ec1ad616a0bf7046862f4d3023 Mon Sep 17 00:00:00 2001 From: polivera Date: Fri, 2 Jan 2026 09:09:03 +0100 Subject: [PATCH 57/58] Working on reminder context --- .../handlers/create_entry_handler.py | 2 +- .../handlers/update_entry_handler.py | 4 +- .../infrastructure/mappers/entry_mapper.py | 2 +- .../reminder/application/commands/__init__.py | 9 + .../commands/create_reminder_command.py | 19 ++ .../commands/delete_reminder_command.py | 11 + .../commands/update_reminder_command.py | 19 ++ .../application/contracts/__init__.py | 15 ++ .../create_reminder_handler_contract.py | 15 ++ .../delete_reminder_handler_contract.py | 15 ++ .../find_reminder_handler_contract.py | 15 ++ .../list_occurrences_handler_contract.py | 15 ++ .../list_reminders_handler_contract.py | 15 ++ .../update_reminder_handler_contract.py | 15 ++ .../reminder/application/dto/__init__.py | 23 ++ .../application/dto/create_reminder_result.py | 33 +++ .../application/dto/delete_reminder_result.py | 24 ++ .../application/dto/find_reminder_result.py | 32 +++ .../dto/list_occurrences_result.py | 40 +++ .../application/dto/list_reminders_result.py | 37 +++ .../application/dto/update_reminder_result.py | 35 +++ .../reminder/application/handlers/__init__.py | 15 ++ .../handlers/create_reminder_handler.py | 100 ++++++++ .../handlers/delete_reminder_handler.py | 46 ++++ .../handlers/find_reminder_handler.py | 56 +++++ .../handlers/list_occurrences_handler.py | 55 ++++ .../handlers/list_reminders_handler.py | 47 ++++ .../handlers/update_reminder_handler.py | 107 ++++++++ .../reminder/application/queries/__init__.py | 9 + .../queries/find_reminder_query.py | 11 + .../queries/list_occurrences_query.py | 15 ++ .../queries/list_reminders_query.py | 11 + .../reminder/domain/contracts/__init__.py | 2 +- .../contracts/infrastructure/__init__.py | 2 +- .../reminder_repository_contract.py | 3 - .../domain/contracts/services/__init__.py | 11 + .../create_reminder_service_contract.py | 55 ++++ .../delete_reminder_service_contract.py | 24 ++ .../generate_occurrences_service_contract.py | 63 +++++ .../update_reminder_service_contract.py | 57 +++++ .../reminder/domain/dto/reminder_dto.py | 6 +- .../reminder/domain/exceptions/__init__.py | 19 ++ .../reminder/domain/exceptions/exceptions.py | 43 ++++ .../reminder/domain/services/__init__.py | 11 + .../services/create_reminder_service.py | 83 +++++++ .../services/delete_reminder_service.py | 39 +++ .../services/generate_occurrences_service.py | 156 ++++++++++++ .../services/update_reminder_service.py | 106 ++++++++ .../reminder/domain/value_objects/__init__.py | 2 + .../value_objects/reminder_category_id.py | 8 + .../reminder_occurrence_amount.py | 23 +- .../reminder/infrastructure/__init__.py | 2 +- .../reminder/infrastructure/dependencies.py | 132 +++++++++- .../infrastructure/mappers/reminder_mapper.py | 9 +- .../infrastructure/models/reminder_model.py | 3 +- .../infrastructure/repositories/__init__.py | 2 +- .../reminder_occurrence_repository.py | 2 +- .../interface/rest/controllers/__init__.py | 7 + .../rest/controllers/occurrence_controller.py | 59 +++++ .../rest/controllers/reminder_controller.py | 235 ++++++++++++++++++ app/context/reminder/interface/rest/routes.py | 12 + .../interface/rest/schemas/__init__.py | 19 ++ .../rest/schemas/reminder_schemas.py | 95 +++++++ app/main.py | 2 + app/shared/domain/value_objects/__init__.py | 2 + .../domain/value_objects/shared_date.py | 3 + app/shared/infrastructure/database.py | 3 + .../a1b2c3d4e5f6_create_reminders_tables.py | 5 + 68 files changed, 2133 insertions(+), 39 deletions(-) create mode 100644 app/context/reminder/application/commands/__init__.py create mode 100644 app/context/reminder/application/commands/create_reminder_command.py create mode 100644 app/context/reminder/application/commands/delete_reminder_command.py create mode 100644 app/context/reminder/application/commands/update_reminder_command.py create mode 100644 app/context/reminder/application/contracts/__init__.py create mode 100644 app/context/reminder/application/contracts/create_reminder_handler_contract.py create mode 100644 app/context/reminder/application/contracts/delete_reminder_handler_contract.py create mode 100644 app/context/reminder/application/contracts/find_reminder_handler_contract.py create mode 100644 app/context/reminder/application/contracts/list_occurrences_handler_contract.py create mode 100644 app/context/reminder/application/contracts/list_reminders_handler_contract.py create mode 100644 app/context/reminder/application/contracts/update_reminder_handler_contract.py create mode 100644 app/context/reminder/application/dto/__init__.py create mode 100644 app/context/reminder/application/dto/create_reminder_result.py create mode 100644 app/context/reminder/application/dto/delete_reminder_result.py create mode 100644 app/context/reminder/application/dto/find_reminder_result.py create mode 100644 app/context/reminder/application/dto/list_occurrences_result.py create mode 100644 app/context/reminder/application/dto/list_reminders_result.py create mode 100644 app/context/reminder/application/dto/update_reminder_result.py create mode 100644 app/context/reminder/application/handlers/__init__.py create mode 100644 app/context/reminder/application/handlers/create_reminder_handler.py create mode 100644 app/context/reminder/application/handlers/delete_reminder_handler.py create mode 100644 app/context/reminder/application/handlers/find_reminder_handler.py create mode 100644 app/context/reminder/application/handlers/list_occurrences_handler.py create mode 100644 app/context/reminder/application/handlers/list_reminders_handler.py create mode 100644 app/context/reminder/application/handlers/update_reminder_handler.py create mode 100644 app/context/reminder/application/queries/__init__.py create mode 100644 app/context/reminder/application/queries/find_reminder_query.py create mode 100644 app/context/reminder/application/queries/list_occurrences_query.py create mode 100644 app/context/reminder/application/queries/list_reminders_query.py create mode 100644 app/context/reminder/domain/contracts/services/__init__.py create mode 100644 app/context/reminder/domain/contracts/services/create_reminder_service_contract.py create mode 100644 app/context/reminder/domain/contracts/services/delete_reminder_service_contract.py create mode 100644 app/context/reminder/domain/contracts/services/generate_occurrences_service_contract.py create mode 100644 app/context/reminder/domain/contracts/services/update_reminder_service_contract.py create mode 100644 app/context/reminder/domain/exceptions/__init__.py create mode 100644 app/context/reminder/domain/exceptions/exceptions.py create mode 100644 app/context/reminder/domain/services/__init__.py create mode 100644 app/context/reminder/domain/services/create_reminder_service.py create mode 100644 app/context/reminder/domain/services/delete_reminder_service.py create mode 100644 app/context/reminder/domain/services/generate_occurrences_service.py create mode 100644 app/context/reminder/domain/services/update_reminder_service.py create mode 100644 app/context/reminder/domain/value_objects/reminder_category_id.py create mode 100644 app/context/reminder/interface/rest/controllers/__init__.py create mode 100644 app/context/reminder/interface/rest/controllers/occurrence_controller.py create mode 100644 app/context/reminder/interface/rest/controllers/reminder_controller.py create mode 100644 app/context/reminder/interface/rest/routes.py create mode 100644 app/context/reminder/interface/rest/schemas/__init__.py create mode 100644 app/context/reminder/interface/rest/schemas/reminder_schemas.py diff --git a/app/context/entry/application/handlers/create_entry_handler.py b/app/context/entry/application/handlers/create_entry_handler.py index 641c43f..293ad63 100644 --- a/app/context/entry/application/handlers/create_entry_handler.py +++ b/app/context/entry/application/handlers/create_entry_handler.py @@ -43,7 +43,7 @@ async def handle(self, command: CreateEntryCommand) -> CreateEntryResult: user_id=EntryUserID(command.user_id), account_id=EntryAccountID(command.account_id), category_id=EntryCategoryID(command.category_id), - entry_type=EntryType.from_string(command.entry_type), + entry_type=EntryType(command.entry_type), entry_date=EntryDate(command.entry_date), amount=EntryAmount.from_float(command.amount), description=EntryDescription(command.description), diff --git a/app/context/entry/application/handlers/update_entry_handler.py b/app/context/entry/application/handlers/update_entry_handler.py index c77a3bd..166bd46 100644 --- a/app/context/entry/application/handlers/update_entry_handler.py +++ b/app/context/entry/application/handlers/update_entry_handler.py @@ -46,7 +46,7 @@ async def handle(self, command: UpdateEntryCommand) -> UpdateEntryResult: user_id=EntryUserID(command.user_id), account_id=EntryAccountID(command.account_id), category_id=EntryCategoryID(command.category_id), - entry_type=EntryType.from_string(command.entry_type), + entry_type=EntryType(command.entry_type), entry_date=EntryDate(command.entry_date), amount=EntryAmount.from_float(command.amount), description=EntryDescription(command.description), @@ -69,7 +69,7 @@ async def handle(self, command: UpdateEntryCommand) -> UpdateEntryResult: 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.value.isoformat(), + 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, diff --git a/app/context/entry/infrastructure/mappers/entry_mapper.py b/app/context/entry/infrastructure/mappers/entry_mapper.py index 8cfee43..153be41 100644 --- a/app/context/entry/infrastructure/mappers/entry_mapper.py +++ b/app/context/entry/infrastructure/mappers/entry_mapper.py @@ -26,7 +26,7 @@ def to_dto(model: EntryModel | None) -> EntryDTO | None: 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_string(model.entry_type), + 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), 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 index 7dbc878..60588a3 100644 --- a/app/context/reminder/domain/contracts/__init__.py +++ b/app/context/reminder/domain/contracts/__init__.py @@ -1,6 +1,6 @@ -from .infrastructure.reminder_repository_contract import ReminderRepositoryContract 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 index b2085d4..9c84223 100644 --- a/app/context/reminder/domain/contracts/infrastructure/__init__.py +++ b/app/context/reminder/domain/contracts/infrastructure/__init__.py @@ -1,4 +1,4 @@ -from .reminder_repository_contract import ReminderRepositoryContract 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_repository_contract.py b/app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py index eb02111..496f093 100644 --- a/app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py +++ b/app/context/reminder/domain/contracts/infrastructure/reminder_repository_contract.py @@ -3,10 +3,7 @@ from app.context.reminder.domain.dto import ReminderDTO from app.context.reminder.domain.value_objects import ( - ReminderEndDate, - ReminderFrequency, ReminderID, - ReminderStartDate, ReminderUserID, ) 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/reminder_dto.py b/app/context/reminder/domain/dto/reminder_dto.py index a18c874..b3bd983 100644 --- a/app/context/reminder/domain/dto/reminder_dto.py +++ b/app/context/reminder/domain/dto/reminder_dto.py @@ -1,12 +1,14 @@ from dataclasses import dataclass from app.context.reminder.domain.value_objects import ( + ReminderCategoryID, ReminderCurrency, ReminderDescription, ReminderEndDate, ReminderEntryType, ReminderFrequency, ReminderID, + ReminderOccurrenceAmount, ReminderStartDate, ReminderUserID, ) @@ -15,10 +17,12 @@ @dataclass(frozen=True) class ReminderDTO: user_id: ReminderUserID + category_id: ReminderCategoryID entry_type: ReminderEntryType currency: ReminderCurrency + amount: ReminderOccurrenceAmount frequency: ReminderFrequency start_date: ReminderStartDate - end_date: ReminderEndDate | None description: ReminderDescription + end_date: ReminderEndDate | None = None reminder_id: ReminderID | 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 index 342928c..00f517f 100644 --- a/app/context/reminder/domain/value_objects/__init__.py +++ b/app/context/reminder/domain/value_objects/__init__.py @@ -1,3 +1,4 @@ +from .reminder_category_id import ReminderCategoryID from .reminder_currency import ReminderCurrency from .reminder_description import ReminderDescription from .reminder_end_date import ReminderEndDate @@ -24,4 +25,5 @@ "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_occurrence_amount.py b/app/context/reminder/domain/value_objects/reminder_occurrence_amount.py index ce21dfe..7e11de7 100644 --- a/app/context/reminder/domain/value_objects/reminder_occurrence_amount.py +++ b/app/context/reminder/domain/value_objects/reminder_occurrence_amount.py @@ -1,21 +1,8 @@ -from dataclasses import dataclass, field -from decimal import Decimal -from typing import Self +from dataclasses import dataclass +from app.shared.domain.value_objects.shared_amount import SharedAmount -@dataclass(frozen=True) -class ReminderOccurrenceAmount: - 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"ReminderOccurrenceAmount must be a Decimal, got {type(self.value)}") - if self.value < 0: - raise ValueError(f"ReminderOccurrenceAmount must be non-negative, got {self.value}") - @classmethod - def from_trusted_source(cls, value: Decimal) -> Self: - """Create ReminderOccurrenceAmount from trusted source (e.g., database) - skips validation""" - return cls(value, _validated=True) +@dataclass(frozen=True) +class ReminderOccurrenceAmount(SharedAmount): + pass diff --git a/app/context/reminder/infrastructure/__init__.py b/app/context/reminder/infrastructure/__init__.py index 45825c6..b0c7f13 100644 --- a/app/context/reminder/infrastructure/__init__.py +++ b/app/context/reminder/infrastructure/__init__.py @@ -1,6 +1,6 @@ from app.context.reminder.infrastructure.dependencies import ( - get_reminder_repository, get_reminder_occurrence_repository, + get_reminder_repository, ) __all__ = [ diff --git a/app/context/reminder/infrastructure/dependencies.py b/app/context/reminder/infrastructure/dependencies.py index 60d9dd8..15055bc 100644 --- a/app/context/reminder/infrastructure/dependencies.py +++ b/app/context/reminder/infrastructure/dependencies.py @@ -3,22 +3,36 @@ 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.shared.domain.contracts import LoggerContract +from app.context.reminder.domain.contracts.services import ( + CreateReminderServiceContract, + DeleteReminderServiceContract, + GenerateOccurrencesServiceContract, + UpdateReminderServiceContract, +) from app.shared.infrastructure.database import get_db -from app.shared.infrastructure.dependencies import get_logger + +# ============================================================================ +# 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, - ) + from app.context.reminder.infrastructure.repositories.reminder_repository import ReminderRepository return ReminderRepository(db) @@ -32,3 +46,111 @@ def get_reminder_occurrence_repository( ) 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/reminder_mapper.py b/app/context/reminder/infrastructure/mappers/reminder_mapper.py index 48ea787..c007e98 100644 --- a/app/context/reminder/infrastructure/mappers/reminder_mapper.py +++ b/app/context/reminder/infrastructure/mappers/reminder_mapper.py @@ -1,11 +1,13 @@ 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, ) @@ -22,8 +24,10 @@ def to_dto(model: ReminderModel | None) -> ReminderDTO | None: 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, @@ -49,9 +53,10 @@ def to_model(dto: ReminderDTO) -> ReminderModel: 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, - end_date=dto.end_date.value if dto.end_date else None, - category_id=0, # TODO: Add category_id to ReminderDTO + 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/models/reminder_model.py b/app/context/reminder/infrastructure/models/reminder_model.py index 4b0767b..5f68ff8 100644 --- a/app/context/reminder/infrastructure/models/reminder_model.py +++ b/app/context/reminder/infrastructure/models/reminder_model.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime -from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String +from sqlalchemy import DECIMAL, BigInteger, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column from app.shared.infrastructure.models import BaseDBModel @@ -17,6 +17,7 @@ class ReminderModel(BaseDBModel): ) 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) diff --git a/app/context/reminder/infrastructure/repositories/__init__.py b/app/context/reminder/infrastructure/repositories/__init__.py index 40f61a0..653da6d 100644 --- a/app/context/reminder/infrastructure/repositories/__init__.py +++ b/app/context/reminder/infrastructure/repositories/__init__.py @@ -1,4 +1,4 @@ -from .reminder_repository import ReminderRepository 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 index 14b34f2..92949f0 100644 --- a/app/context/reminder/infrastructure/repositories/reminder_occurrence_repository.py +++ b/app/context/reminder/infrastructure/repositories/reminder_occurrence_repository.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import Any, cast -from sqlalchemy import delete, select, update +from sqlalchemy import select, update from sqlalchemy.engine import CursorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession 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/main.py b/app/main.py index e914463..3ace51c 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ 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 @@ -25,6 +26,7 @@ app.include_router(credit_card_routes) app.include_router(entry_routes) app.include_router(household_routes) +app.include_router(reminder_context_router) @app.get("/") diff --git a/app/shared/domain/value_objects/__init__.py b/app/shared/domain/value_objects/__init__.py index 2577ec1..f0e185c 100644 --- a/app/shared/domain/value_objects/__init__.py +++ b/app/shared/domain/value_objects/__init__.py @@ -1,6 +1,7 @@ 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 @@ -26,4 +27,5 @@ "SharedAppEnv", "SharedMonth", "SharedYear", + "SharedCategoryID", ] diff --git a/app/shared/domain/value_objects/shared_date.py b/app/shared/domain/value_objects/shared_date.py index 184d08e..efcf29f 100644 --- a/app/shared/domain/value_objects/shared_date.py +++ b/app/shared/domain/value_objects/shared_date.py @@ -22,6 +22,9 @@ def __post_init__(self): 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""" diff --git a/app/shared/infrastructure/database.py b/app/shared/infrastructure/database.py index 48cb48b..e293e82 100644 --- a/app/shared/infrastructure/database.py +++ b/app/shared/infrastructure/database.py @@ -1,3 +1,5 @@ +# pyright: ignore[reportUnusedImport] +# ruff: noqa: F401, E402 from os import getenv from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine @@ -58,6 +60,7 @@ async def get_db(): 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/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py b/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py index a44fa1b..d732e0a 100644 --- a/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py +++ b/migrations/versions/a1b2c3d4e5f6_create_reminders_tables.py @@ -41,6 +41,11 @@ def upgrade() -> None: sa.String(3), nullable=False, ), + sa.Column( + "amount", + sa.DECIMAL(15, 2), + nullable=False, + ), sa.Column( "frequency", sa.String(50), From eeb1de4134afa866b1ddbe193e2ab3e6094ac29a Mon Sep 17 00:00:00 2001 From: polivera Date: Fri, 2 Jan 2026 09:12:26 +0100 Subject: [PATCH 58/58] update readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 379c155..0f29f30 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,7 @@ Building this project taught me: ## Future Enhancements +- [ ] Credit card expenses tracking - [ ] JWT-based authentication as an alternative to sessions - [ ] Real-time notifications via WebSockets - [ ] Budget planning and forecasting features