diff --git a/.env.example b/.env.example index 220a084..89b63f6 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,7 @@ RELOAD=false # ---------------------------------- # PostgreSQL connection URL # Format: postgresql+asyncpg://user:password@host:port/database -DATABASE_URL=postgresql+asyncpg://opentaberna:opentaberna@localhost:5432/opentaberna +DATABASE_URL=postgresql+asyncpg://opentaberna:opentaberna_password@opentaberna-db:5432/opentaberna # Connection pool settings DATABASE_POOL_SIZE=20 diff --git a/.gitignore b/.gitignore index 9208e45..a078e09 100644 --- a/.gitignore +++ b/.gitignore @@ -141,4 +141,7 @@ cython_debug/ # PyCharm # .idea/ -.env.test \ No newline at end of file +.env.test + +# postgresql db directory +opentaberna-postgres \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 0b1a009..03cd3ad 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,10 +14,10 @@ services: context: . dockerfile: src/Dockerfile image: opentaberna-api:latest - command: uvicorn app.main:app --host 0.0.0.0 --port 8000 + command: uvicorn app.main:app --host 0.0.0.0 --port 8001 env_file: .env - expose: - - "8000" + ports: + - "8001:8001" networks: frontproxy_fnet: ipv4_address: 172.20.20.21 diff --git a/docs/crud_item_store.md b/docs/crud_item_store.md new file mode 100644 index 0000000..353594f --- /dev/null +++ b/docs/crud_item_store.md @@ -0,0 +1,235 @@ +# CRUD Item Store Service Documentation + +## Overview + +The **crud-item-store** service provides CRUD operations for managing store items with self-contained models, business logic, and API endpoints. + +## Architecture + +``` +src/app/services/crud-item-store/ +├── __init__.py # Service entry point & router exports +├── models/ +│ ├── __init__.py # Model exports +│ ├── item.py # Pydantic data models (ItemBase, ItemCreate, ItemUpdate) +│ └── database.py # SQLAlchemy ORM model +├── responses/ +│ ├── __init__.py # Response model exports +│ └── items.py # API response schemas (ItemResponse) +├── routers/ +│ ├── __init__.py # Router exports +│ └── items.py # CRUD API endpoints +├── services/ +│ ├── __init__.py # Service exports +│ └── database.py # Database repository layer +└── functions/ + ├── __init__.py # Function exports + ├── transformations.py # Data transformation functions + └── validation.py # Validation functions +``` + +--- + +## Pydantic Models + +### Data Models (`models/item.py`) + +#### `ItemBase` +Base schema with all core item fields (shared by Create, Update, Response). + +#### `ItemCreate` +Schema for creating new items. All fields from `ItemBase` are required except those with defaults. + +#### `ItemUpdate` +Schema for updating items. All fields are optional - only provided fields will be updated. + +### Response Models (`responses/items.py`) + +#### `ItemResponse` +API response schema including `uuid`, `created_at`, and `updated_at` timestamps. Extends `ItemBase` with database-generated fields. + +**Note:** List endpoints use shared `PaginatedResponse[ItemResponse]` with `success`, `items`, `page_info`, `message`, and `timestamp` fields. + +### Nested Models + +`PriceModel`, `MediaModel`, `InventoryModel`, `ShippingModel`, `WeightModel`, `DimensionsModel`, `IdentifiersModel`, `SystemModel` + +### Enums + +`ItemStatus`, `StockStatus`, `TaxClass`, `ShippingClass`, `WeightUnit`, `DimensionUnit` + +--- + +## Database Model + +### `ItemDB` (SQLAlchemy) + +Stored in PostgreSQL with optimized structure: + +**Columns** (indexed for queries): +- `uuid`: Primary key (UUID) +- `sku`: Unique stock keeping unit (indexed) +- `status`: Item status (indexed) +- `name`: Display name (indexed) +- `slug`: URL-friendly identifier (unique, indexed) +- `short_description`: Brief text +- `description`: Full text/HTML +- `brand`: Brand name (indexed) + +**JSONB Fields** (for complex nested data): +- `categories`: Array of category UUIDs +- `price`: Price information object +- `media`: Media assets object +- `inventory`: Inventory data object +- `shipping`: Shipping information object +- `attributes`: Custom key-value pairs +- `identifiers`: Product codes object +- `custom`: Extensible plugin data +- `system`: System metadata + +**Timestamps**: `created_at`, `updated_at` (auto-managed via `TimestampMixin`) + +--- + +## Repository Layer + +### `ItemRepository` (`services/database.py`) + +Extends `BaseRepository[ItemDB]` with item-specific methods: + +#### Basic CRUD (inherited from BaseRepository) +- `create(**fields)`: Create new item +- `get(uuid)`: Get by UUID +- `update(uuid, **fields)`: Update item +- `delete(uuid)`: Delete item +- `get_all(skip, limit, **filters)`: List with pagination +- `count(**filters)`: Count items +- `get_by(**filters)`: Get single item by field(s) + +#### Item-Specific Queries +- `get_by_sku(sku)`: Find by SKU +- `get_by_slug(slug)`: Find by URL slug +- `search(name, status, category_uuid, brand, skip, limit)`: Generic search with multiple optional criteria (AND logic) +- `field_exists(field_name, field_value, exclude_uuid)`: Generic field existence check + +--- + +## API Endpoints + +Base path: `/v1/items` + +- `POST /items/` - Create item (201 Created) +- `GET /items/{uuid}` - Get by UUID (200 OK / 404 Not Found) +- `GET /items/by-slug/{slug}` - Get by slug (200 OK / 404 Not Found) +- `GET /items/?skip=0&limit=50&status=active` - List with pagination (200 OK) +- `PATCH /items/{uuid}` - Update item (200 OK / 404 Not Found) +- `DELETE /items/{uuid}` - Delete item (204 No Content / 404 Not Found) + +**Validations:** SKU and slug uniqueness, currency codes (3 chars), non-negative amounts + + + +--- + +## Integration + +### Shared Module Integration + +- **Exceptions**: `entity_not_found()`, `duplicate_entry()` → standardized error responses +- **Responses**: `PaginatedResponse[ItemResponse]`, `ErrorResponse` → consistent API format +- **Database**: `get_session_dependency`, `BaseRepository` → session management and base CRUD + +### Register in Main App + +In `src/app/main.py`: + +```python +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from app.chore import lifespan +from app.services.crud_item_store import router as item_store_router +from app.shared.exceptions import AppException +from app.shared.responses import ErrorResponse + +app = FastAPI(title="OpenTaberna API", lifespan=lifespan) + +# Global exception handler for standardized error responses +@app.exception_handler(AppException) +async def app_exception_handler(request: Request, exc: AppException): + error_response = ErrorResponse.from_exception(exc) + return JSONResponse( + status_code=error_response.status_code, + content=error_response.model_dump(mode="json") + ) + +# Include the item store router +app.include_router(item_store_router, prefix="/v1") +``` + +### Create Database Migration + +```bash +alembic revision --autogenerate -m "create_items_table" +alembic upgrade head +``` + +--- + +## Validation + +**Automatic (Pydantic):** SKU/slug uniqueness, currency codes, non-negative amounts, URL formats + +**Business Logic (`functions/validation.py`):** +- `check_duplicate_field(repo, field_name, field_value, exclude_uuid)` - Generic uniqueness validation for any model field + +--- + +## Functions Layer + +**Transformations (`functions/transformations.py`):** +- `db_to_response(item_db)` - Converts SQLAlchemy models to Pydantic responses + +**Validation (`functions/validation.py`):** +- `check_duplicate_field(repo, field_name, value, exclude_uuid)` - Generic uniqueness check + + + +--- + +## Performance + +**Indexes:** `uuid` (PK), `sku` (unique), `slug` (unique), `status`, `name`, `brand` + +**Recommended GIN indexes for JSONB:** `price`, `categories`, `attributes` + +**Pagination:** Max limit 100, default 50 + +--- + +## Error Handling + +**Status Codes:** 200 (OK), 201 (Created), 204 (No Content), 404 (Not Found), 422 (Validation), 500 (Server Error) + +**Format:** Standardized `ErrorResponse` with `success`, `error` (code, message, category), and `timestamp` + +**Helpers:** `entity_not_found()` → 404, `duplicate_entry()` → 422 + + + +--- + +## Summary + +**Features:** +- Complete CRUD operations with PostgreSQL JSONB storage +- Type-safe Pydantic models with nested structures +- Repository pattern with generic search and validation +- Shared exception/response system integration +- Pagination, error handling, and proper indexing + +**Architecture:** +- `models/` - Data models (Pydantic) +- `responses/` - API responses +- `functions/` - Business logic +- `services/` - Database operations +- `routers/` - HTTP endpoints \ No newline at end of file diff --git a/src/app/chore/__init__.py b/src/app/chore/__init__.py new file mode 100644 index 0000000..23e6e23 --- /dev/null +++ b/src/app/chore/__init__.py @@ -0,0 +1,10 @@ +""" +Chore Module + +Infrastructure and operational tasks such as application lifecycle management, +database initialization, health checks, and scheduled maintenance tasks. +""" + +from .lifespan import lifespan + +__all__ = ["lifespan"] diff --git a/src/app/chore/lifespan.py b/src/app/chore/lifespan.py new file mode 100644 index 0000000..13104af --- /dev/null +++ b/src/app/chore/lifespan.py @@ -0,0 +1,36 @@ +""" +Application Lifespan Management + +Handles startup and shutdown events for the FastAPI application, +including database initialization and cleanup. +""" + +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.shared.database.base import Base +from app.shared.database.engine import close_database, get_engine, init_database + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Application lifespan events. + + Startup: + - Initialize database connection pool + - Create all tables from SQLAlchemy models + + Shutdown: + - Close database connections gracefully + """ + # Startup: Initialize database and create tables + await init_database() + engine = get_engine() + async with engine.begin() as conn: + # This creates all tables from SQLAlchemy models that inherit from Base + await conn.run_sync(Base.metadata.create_all) + yield + # Shutdown + await close_database() diff --git a/src/app/main.py b/src/app/main.py index d0066f7..3e52d50 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -1,8 +1,99 @@ -from fastapi import FastAPI +from fastapi import FastAPI, Request +from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from app.chore import lifespan +from app.services.crud_item_store import router as item_store_router +from app.shared.exceptions import AppException, InternalError +from app.shared.responses import ErrorResponse, ValidationErrorResponse +from app.shared.logger import get_logger -app = FastAPI(title="OpenTaberna API") +logger = get_logger(__name__) + + +app = FastAPI(title="OpenTaberna API", lifespan=lifespan) + + +# Global exception handler for AppException +@app.exception_handler(AppException) +async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: + """ + Handle all AppException instances and convert them to HTTP responses. + + The ErrorResponse.from_exception method automatically maps error categories + to appropriate HTTP status codes (404, 422, 401, 403, 400, 500, 502). + """ + error_response = ErrorResponse.from_exception(exc) + return JSONResponse( + status_code=error_response.status_code, + content=error_response.model_dump(mode="json"), + ) + + +# Handler for FastAPI/Pydantic request validation errors (wrong types, missing fields, etc.) +@app.exception_handler(RequestValidationError) +async def request_validation_exception_handler( + request: Request, exc: RequestValidationError +) -> JSONResponse: + """ + Handle FastAPI RequestValidationError and convert to our standard ValidationErrorResponse. + + FastAPI raises this for invalid path/query params and request body validation failures. + Maps Pydantic's raw error format to our structured ValidationErrorResponse so the + actual 422 response always matches the schema documented in /docs. + """ + validation_errors = [ + { + "loc": list(error["loc"]), + "msg": error["msg"], + "type": error["type"], + } + for error in exc.errors() + ] + error_response = ValidationErrorResponse( + message="Validation failed", + validation_errors=validation_errors, + ) + return JSONResponse( + status_code=422, + content=error_response.model_dump(mode="json"), + ) + + +# Catch-all exception handler for unexpected errors +@app.exception_handler(Exception) +async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + Handle all unexpected exceptions and convert them to structured error responses. + + This is a safety net for any exceptions not caught by AppException handler. + Logs the error for debugging and returns a generic 500 error to the client. + """ + logger.error( + "Unhandled exception occurred", + extra={ + "error_type": type(exc).__name__, + "error_message": str(exc), + "path": request.url.path, + "method": request.method, + }, + exc_info=True, + ) + + # Wrap in InternalError for consistent error response structure + error = InternalError( + message="An unexpected error occurred", + context={ + "error_type": type(exc).__name__, + }, + original_exception=exc, + ) + error_response = ErrorResponse.from_exception(error) + return JSONResponse( + status_code=500, + content=error_response.model_dump(mode="json"), + ) origins = ["*"] # Consider restricting this in a production environment @@ -16,8 +107,8 @@ ) -# Example router (you can replace this with your actual router) -# app.include_router(any_router.router) +# Include crud-item-store router +app.include_router(item_store_router, prefix="/v1") if __name__ == "__main__": diff --git a/src/app/services/crud-item-store/__init__.py b/src/app/services/crud-item-store/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/app/services/crud-item-store/functions/__init__.py b/src/app/services/crud-item-store/functions/__init__.py deleted file mode 100644 index 807cea6..0000000 --- a/src/app/services/crud-item-store/functions/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Init file for functions diff --git a/src/app/services/crud-item-store/models/__init__.py b/src/app/services/crud-item-store/models/__init__.py deleted file mode 100644 index 7ba8c15..0000000 --- a/src/app/services/crud-item-store/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Init file for models diff --git a/src/app/services/crud-item-store/routers/__init__.py b/src/app/services/crud-item-store/routers/__init__.py deleted file mode 100644 index d3bf4e7..0000000 --- a/src/app/services/crud-item-store/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Init file for routers diff --git a/src/app/services/crud_item_store/__init__.py b/src/app/services/crud_item_store/__init__.py new file mode 100644 index 0000000..31ad4ab --- /dev/null +++ b/src/app/services/crud_item_store/__init__.py @@ -0,0 +1,9 @@ +""" +CRUD Item Store Service Package + +Entry point for importing the item store service. +""" + +from .crud_item_store import router + +__all__ = ["router"] diff --git a/src/app/services/crud_item_store/crud_item_store.py b/src/app/services/crud_item_store/crud_item_store.py new file mode 100644 index 0000000..b2ea11f --- /dev/null +++ b/src/app/services/crud_item_store/crud_item_store.py @@ -0,0 +1,33 @@ +""" +CRUD Item Store Service + +Entry point for the item-store service module. +This is a complete "mini-API" for managing store items. + +Features: +- Full CRUD operations for items +- Advanced filtering and search +- Inventory management +- Price management +- Category management + +Usage: + from app.services.crud_item_store import crud_item_store + app.include_router(crud_item_store.router, prefix="/api/v1") +""" + +from fastapi import APIRouter + +# Import routers +from .routers import items + +# Create the main router for this service +router = APIRouter( + prefix="/items", + tags=["Item Store"], +) + +# Include sub-routers +router.include_router(items.router) + +__all__ = ["router"] diff --git a/src/app/services/crud_item_store/functions/__init__.py b/src/app/services/crud_item_store/functions/__init__.py new file mode 100644 index 0000000..a69ea4a --- /dev/null +++ b/src/app/services/crud_item_store/functions/__init__.py @@ -0,0 +1,15 @@ +""" +Item Functions + +Business logic and transformation functions for items. +""" + +from .transformations import db_to_response, prepare_item_update_data +from .validation import check_duplicate_field, validate_update_conflicts + +__all__ = [ + "db_to_response", + "prepare_item_update_data", + "check_duplicate_field", + "validate_update_conflicts", +] diff --git a/src/app/services/crud_item_store/functions/transformations.py b/src/app/services/crud_item_store/functions/transformations.py new file mode 100644 index 0000000..f9e9964 --- /dev/null +++ b/src/app/services/crud_item_store/functions/transformations.py @@ -0,0 +1,57 @@ +""" +Item Transformations + +Functions for converting between different item representations. +""" + +from typing import Any + +from ..models import ItemStatus, ItemDB +from ..responses import ItemResponse + + +def db_to_response(item: ItemDB) -> ItemResponse: + """ + Convert database model to response model. + + Uses Pydantic's model_validate with from_attributes=True (set in ItemResponse's + model_config) so all field coercions (str→ItemStatus, list[str]→list[UUID], etc.) + are handled automatically. Adding a new field to ItemDB/ItemResponse no longer + requires a manual update here. + + Args: + item: Database item instance + + Returns: + ItemResponse with all fields + """ + return ItemResponse.model_validate(item) + + +def prepare_item_update_data(update_data: dict[str, Any]) -> dict[str, Any]: + """ + Convert update data to database-ready format. + + Transforms enums to their string values, UUIDs to strings, + and nested Pydantic models to dictionaries for JSONB storage. + + Args: + update_data: Raw update data from Pydantic model + + Returns: + Transformed data ready for database storage + + Examples: + >>> data = {"status": ItemStatus.ACTIVE, "price": PriceModel(...)} + >>> prepared = prepare_item_update_data(data) + >>> # Returns: {"status": "active", "price": {...}} + """ + for key, value in update_data.items(): + if key == "status" and isinstance(value, ItemStatus): + update_data[key] = value.value + elif key == "categories" and value is not None: + update_data[key] = [str(cat) for cat in value] + elif hasattr(value, "model_dump"): + update_data[key] = value.model_dump() + + return update_data diff --git a/src/app/services/crud_item_store/functions/validation.py b/src/app/services/crud_item_store/functions/validation.py new file mode 100644 index 0000000..21ae37c --- /dev/null +++ b/src/app/services/crud_item_store/functions/validation.py @@ -0,0 +1,90 @@ +""" +Item Validation Functions + +Functions for validating item data and checking business rules. +""" + +from typing import Any, Optional +from uuid import UUID + +from app.shared.exceptions import duplicate_entry +from ..models import ItemDB +from ..services import ItemRepository + + +async def check_duplicate_field( + repo: ItemRepository, + field_name: str, + field_value: Any, + exclude_uuid: Optional[UUID] = None, +) -> None: + """ + Check if a field value already exists and raise exception if duplicate found. + + This is a meta function that can check any field for duplicates using the + repository's generic field_exists() method. + + Args: + repo: Item repository instance + field_name: Name of the field to check (e.g., "sku", "slug", "name") + field_value: Value to check for duplicates + exclude_uuid: Optional UUID to exclude from the check (for updates) + + Raises: + ValidationError: If duplicate is found (via duplicate_entry helper) + ValueError: If field_name is not a valid model field + + Examples: + >>> # Check for duplicate SKU + >>> await check_duplicate_field(repo, "sku", "CHAIR-RED-001") + + >>> # Check for duplicate slug, excluding current item + >>> await check_duplicate_field(repo, "slug", "red-chair", exclude_uuid=item_uuid) + + >>> # Can check any field on the model + >>> await check_duplicate_field(repo, "name", "Test Product") + """ + # Use the repository's generic field_exists method + # This will raise ValueError if field doesn't exist on the model + exists = await repo.field_exists(field_name, field_value, exclude_uuid=exclude_uuid) + + if exists: + raise duplicate_entry("Item", field_name, field_value) + + +async def validate_update_conflicts( + repo: ItemRepository, + existing_item: ItemDB, + update_data: dict[str, Any], + item_uuid: UUID, +) -> None: + """ + Validate that update data doesn't create conflicts with existing items. + + Checks for SKU and slug conflicts when these fields are being updated. + Only validates if the value is actually changing. + + Args: + repo: Item repository instance + existing_item: The current item being updated + update_data: Dictionary of fields to update + item_uuid: UUID of the item being updated + + Raises: + ValidationError: If SKU or slug conflicts with another item + + Examples: + >>> update_data = {"sku": "NEW-SKU", "name": "Updated Name"} + >>> await validate_update_conflicts(repo, item, update_data, item_uuid) + """ + # Check for SKU conflicts + if "sku" in update_data and update_data["sku"] != existing_item.sku: + await check_duplicate_field( + repo, "sku", update_data["sku"], exclude_uuid=item_uuid + ) + + # Check for slug conflicts + if "slug" in update_data and update_data["slug"] != existing_item.slug: + await check_duplicate_field( + repo, "slug", update_data["slug"], exclude_uuid=item_uuid + ) diff --git a/src/app/services/crud_item_store/models/__init__.py b/src/app/services/crud_item_store/models/__init__.py new file mode 100644 index 0000000..5195e71 --- /dev/null +++ b/src/app/services/crud_item_store/models/__init__.py @@ -0,0 +1,52 @@ +""" +Item Store Models Package + +Exports all Pydantic models and database models for the item-store service. +Response models are in the responses/ directory. +""" + +from .database import ItemDB +from .item import ( + DimensionUnit, + DimensionsModel, + IdentifiersModel, + InventoryModel, + ItemBase, + ItemCreate, + ItemStatus, + ItemUpdate, + MediaModel, + PriceModel, + ShippingClass, + ShippingModel, + StockStatus, + SystemModel, + TaxClass, + WeightModel, + WeightUnit, +) + +__all__ = [ + # Database Models + "ItemDB", + # Main Item Models + "ItemBase", + "ItemCreate", + "ItemUpdate", + # Nested Models + "PriceModel", + "MediaModel", + "InventoryModel", + "ShippingModel", + "WeightModel", + "DimensionsModel", + "IdentifiersModel", + "SystemModel", + # Enums + "ItemStatus", + "StockStatus", + "TaxClass", + "ShippingClass", + "WeightUnit", + "DimensionUnit", +] diff --git a/src/app/services/crud_item_store/models/database.py b/src/app/services/crud_item_store/models/database.py new file mode 100644 index 0000000..074c899 --- /dev/null +++ b/src/app/services/crud_item_store/models/database.py @@ -0,0 +1,167 @@ +""" +Item Store Database Models + +SQLAlchemy ORM models for the item-store service. +These models map to PostgreSQL tables and use JSONB for nested structures. +""" + +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy import String, Text +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.shared.database.base import Base, TimestampMixin + + +class ItemDB(Base, TimestampMixin): + """ + Item database model. + + Stores item information with nested JSON structures for complex data. + Inherits created_at and updated_at from TimestampMixin. + + Table Structure: + - Core fields (uuid, sku, name, etc.) as columns + - Complex nested structures (price, media, shipping, etc.) as JSONB + - JSONB allows efficient querying and indexing of nested data + """ + + __tablename__ = "items" + + # Primary key + uuid: Mapped[UUID] = mapped_column( + PGUUID(as_uuid=True), + primary_key=True, + default=uuid4, + doc="Unique item identifier", + ) + + # Core fields + sku: Mapped[str] = mapped_column( + String(100), + unique=True, + nullable=False, + index=True, + doc="Stock Keeping Unit (unique identifier)", + ) + + status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="draft", + index=True, + doc="Item status: draft, active, archived", + ) + + name: Mapped[str] = mapped_column( + String(255), + nullable=False, + index=True, + doc="Item display name", + ) + + slug: Mapped[str] = mapped_column( + String(255), + unique=True, + nullable=False, + index=True, + doc="URL-friendly identifier", + ) + + short_description: Mapped[str | None] = mapped_column( + String(500), + nullable=True, + doc="Brief item description", + ) + + description: Mapped[str | None] = mapped_column( + Text, + nullable=True, + doc="Full HTML/Markdown description", + ) + + brand: Mapped[str | None] = mapped_column( + String(100), + nullable=True, + index=True, + doc="Brand name", + ) + + # JSONB fields for nested structures (efficiently queryable in PostgreSQL) + categories: Mapped[list[str]] = mapped_column( + JSONB, + nullable=False, + default=list, + doc="List of category UUIDs (JSONB for efficient querying)", + ) + + price: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + doc="Price information (amount, currency, tax, etc.)", + ) + + media: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default=dict, + doc="Media assets (main_image, gallery)", + ) + + inventory: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default=dict, + doc="Inventory data (stock_quantity, stock_status, allow_backorder)", + ) + + shipping: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default=dict, + doc="Shipping data (weight, dimensions, shipping_class)", + ) + + attributes: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default=dict, + doc="Custom attributes (color, material, etc.)", + ) + + identifiers: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default=dict, + doc="Product identification codes (barcode, MPN, country)", + ) + + custom: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default=dict, + doc="Custom plugin data for extensibility", + ) + + system: Mapped[dict[str, Any]] = mapped_column( + JSONB, + nullable=False, + default=dict, + doc="System metadata (log references, etc.)", + ) + + def __repr__(self) -> str: + """String representation of Item.""" + return f"ItemDB(uuid={self.uuid}, sku={self.sku!r}, name={self.name!r}, status={self.status!r})" + + # Indexes for common query patterns + __table_args__ = ( + # Add GIN index for JSONB fields to enable efficient querying + # Example: WHERE price->>'currency' = 'EUR' + # These would be added in migrations: + # CREATE INDEX idx_items_price ON items USING GIN (price); + # CREATE INDEX idx_items_categories ON items USING GIN (categories); + # CREATE INDEX idx_items_attributes ON items USING GIN (attributes); + ) diff --git a/src/app/services/crud_item_store/models/item.py b/src/app/services/crud_item_store/models/item.py new file mode 100644 index 0000000..b52ee10 --- /dev/null +++ b/src/app/services/crud_item_store/models/item.py @@ -0,0 +1,259 @@ +""" +Item Store Pydantic Models + +This module defines all Pydantic models for the item-store service. +""" + +from enum import Enum +from typing import Annotated, Any +from uuid import UUID + +from pydantic import BaseModel, BeforeValidator, Field + + +# ============================================================================ +# Type Annotations +# ============================================================================ + + +NormalizedSlug = Annotated[ + str, BeforeValidator(lambda v: v.lower().strip() if v else v) +] +UpperCaseStr = Annotated[str, BeforeValidator(lambda v: v.upper() if v else v)] + + +# ============================================================================ +# Enums +# ============================================================================ + + +class ItemStatus(str, Enum): + """Item lifecycle status.""" + + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class StockStatus(str, Enum): + """Inventory stock status.""" + + IN_STOCK = "in_stock" + OUT_OF_STOCK = "out_of_stock" + PREORDER = "preorder" + BACKORDER = "backorder" + + +class TaxClass(str, Enum): + """Tax classification.""" + + STANDARD = "standard" + REDUCED = "reduced" + NONE = "none" + + +class ShippingClass(str, Enum): + """Shipping classification.""" + + STANDARD = "standard" + BULKY = "bulky" + LETTER = "letter" + + +class WeightUnit(str, Enum): + """Weight measurement units.""" + + KG = "kg" + LB = "lb" + G = "g" + + +class DimensionUnit(str, Enum): + """Dimension measurement units.""" + + CM = "cm" + M = "m" + IN = "in" + FT = "ft" + + +# ============================================================================ +# Nested Models +# ============================================================================ + + +class PriceModel(BaseModel): + """Price information for an item.""" + + amount: int = Field( + ..., description="Price in smallest currency unit (e.g., cents)", ge=0 + ) + currency: UpperCaseStr = Field( + ..., min_length=3, max_length=3, description="ISO 4217 currency code" + ) + includes_tax: bool = Field(default=True, description="Whether price includes tax") + original_amount: int | None = Field( + default=None, description="Original price before discount (in cents)", ge=0 + ) + tax_class: TaxClass = Field( + default=TaxClass.STANDARD, description="Tax classification" + ) + + +class MediaModel(BaseModel): + """Media assets for an item.""" + + main_image: str | None = Field(default=None, description="Main product image URL") + gallery: list[str] = Field( + default_factory=list, description="Additional product images" + ) + + +class WeightModel(BaseModel): + """Weight specification.""" + + value: float = Field(..., description="Weight value", gt=0) + unit: WeightUnit = Field(default=WeightUnit.KG, description="Weight unit") + + +class DimensionsModel(BaseModel): + """Physical dimensions specification.""" + + width: float = Field(..., description="Width", gt=0) + height: float = Field(..., description="Height", gt=0) + length: float = Field(..., description="Length/Depth", gt=0) + unit: DimensionUnit = Field(default=DimensionUnit.CM, description="Dimension unit") + + +class ShippingModel(BaseModel): + """Shipping information for an item.""" + + is_physical: bool = Field( + default=True, description="Whether item requires physical shipping" + ) + weight: WeightModel | None = Field(default=None, description="Item weight") + dimensions: DimensionsModel | None = Field( + default=None, description="Item dimensions" + ) + shipping_class: ShippingClass = Field( + default=ShippingClass.STANDARD, description="Shipping classification" + ) + + +class InventoryModel(BaseModel): + """Inventory tracking information.""" + + stock_quantity: int = Field(default=0, description="Available stock quantity", ge=0) + stock_status: StockStatus = Field( + default=StockStatus.IN_STOCK, description="Stock availability status" + ) + allow_backorder: bool = Field( + default=False, description="Allow ordering when out of stock" + ) + + +class IdentifiersModel(BaseModel): + """Product identification codes.""" + + barcode: str | None = Field( + default=None, description="Product barcode (EAN, UPC, etc.)" + ) + manufacturer_part_number: str | None = Field( + default=None, description="Manufacturer's part number" + ) + country_of_origin: UpperCaseStr | None = Field( + default=None, + min_length=2, + max_length=2, + description="ISO 3166-1 alpha-2 country code", + ) + + +class SystemModel(BaseModel): + """System-level metadata.""" + + log_table: str | None = Field( + default=None, description="Reference to conversation log in different table" + ) + + +# ============================================================================ +# Main Item Models +# ============================================================================ + + +class ItemBase(BaseModel): + """Base item model with common fields.""" + + sku: str = Field( + ..., min_length=1, max_length=100, description="Stock Keeping Unit" + ) + status: ItemStatus = Field( + default=ItemStatus.DRAFT, description="Item lifecycle status" + ) + name: str = Field( + ..., min_length=1, max_length=255, description="Item display name" + ) + slug: NormalizedSlug = Field( + ..., + min_length=1, + max_length=255, + description="URL-friendly identifier (e.g., red-wooden-chair)", + ) + short_description: str | None = Field( + default=None, max_length=500, description="Brief item description" + ) + description: str | None = Field( + default=None, description="Full HTML/Markdown description" + ) + categories: list[UUID] = Field( + default_factory=list, description="Category UUID references" + ) + brand: str | None = Field(default=None, max_length=100, description="Brand name") + price: PriceModel = Field(..., description="Pricing information") + media: MediaModel = Field(default_factory=MediaModel, description="Media assets") + inventory: InventoryModel = Field( + default_factory=InventoryModel, description="Inventory data" + ) + shipping: ShippingModel = Field( + default_factory=ShippingModel, description="Shipping data" + ) + attributes: dict[str, Any] = Field( + default_factory=dict, description="Custom attributes (e.g., color, material)" + ) + identifiers: IdentifiersModel = Field( + default_factory=IdentifiersModel, description="Product identification codes" + ) + custom: dict[str, Any] = Field( + default_factory=dict, description="Custom plugin data (extensibility)" + ) + system: SystemModel = Field( + default_factory=SystemModel, description="System metadata" + ) + + +class ItemCreate(ItemBase): + """Schema for creating a new item.""" + + pass + + +class ItemUpdate(BaseModel): + """Schema for updating an existing item (all fields optional).""" + + sku: str | None = Field(default=None, min_length=1, max_length=100) + status: ItemStatus | None = None + name: str | None = Field(default=None, min_length=1, max_length=255) + slug: NormalizedSlug | None = Field(default=None, min_length=1, max_length=255) + short_description: str | None = Field(default=None, max_length=500) + description: str | None = None + categories: list[UUID] | None = None + brand: str | None = Field(default=None, max_length=100) + price: PriceModel | None = None + media: MediaModel | None = None + inventory: InventoryModel | None = None + shipping: ShippingModel | None = None + attributes: dict[str, Any] | None = None + identifiers: IdentifiersModel | None = None + custom: dict[str, Any] | None = None + system: SystemModel | None = None diff --git a/src/app/services/crud_item_store/responses/__init__.py b/src/app/services/crud_item_store/responses/__init__.py new file mode 100644 index 0000000..4b6c660 --- /dev/null +++ b/src/app/services/crud_item_store/responses/__init__.py @@ -0,0 +1,12 @@ +""" +CRUD Item Store Response Models + +API response models for the item store service. +Combines shared response structures with feature-specific models. +""" + +from .items import ItemResponse + +__all__ = [ + "ItemResponse", +] diff --git a/src/app/services/crud_item_store/responses/docs.py b/src/app/services/crud_item_store/responses/docs.py new file mode 100644 index 0000000..b1fffdc --- /dev/null +++ b/src/app/services/crud_item_store/responses/docs.py @@ -0,0 +1,470 @@ +""" +OpenAPI Documentation for Item Store Endpoints + +Contains response schemas, examples, and documentation for all item endpoints. +Separates API documentation from business logic to keep routers clean. +""" + +from app.shared.responses import ( + ErrorResponse, + PaginatedResponse, + ValidationErrorResponse, +) +from ..responses import ItemResponse + + +# ============================================================================ +# Common Error Examples +# ============================================================================ + +INVALID_UUID_EXAMPLE = { + "success": False, + "message": "Validation failed", + "status_code": 422, + "error_code": "invalid_input", + "error_category": "validation", + "details": { + "errors": [ + { + "loc": ["path", "item_uuid"], + "msg": "Input should be a valid UUID", + "type": "uuid_parsing", + } + ] + }, +} + +ITEM_NOT_FOUND_EXAMPLE = { + "success": False, + "message": "Item with ID '123e4567-e89b-12d3-a456-426614174000' not found", + "status_code": 404, + "error_code": "entity_not_found", + "error_category": "not_found", + "details": { + "entity_type": "Item", + "entity_id": "123e4567-e89b-12d3-a456-426614174000", + }, +} + +DUPLICATE_SKU_EXAMPLE = { + "success": False, + "message": "Item with sku='CHAIR-001' already exists", + "status_code": 422, + "error_code": "duplicate_entry", + "error_category": "validation", + "details": {"entity_type": "Item", "field": "sku", "value": "CHAIR-001"}, +} + +DUPLICATE_SLUG_EXAMPLE = { + "success": False, + "message": "Item with slug='red-chair' already exists", + "status_code": 422, + "error_code": "duplicate_entry", + "error_category": "validation", + "details": {"entity_type": "Item", "field": "slug", "value": "red-chair"}, +} + +DATABASE_ERROR_EXAMPLE = { + "success": False, + "message": "Database operation failed", + "status_code": 500, + "error_code": "database_query_error", + "error_category": "database", + "details": {"error_type": "DatabaseError"}, +} + +INTERNAL_ERROR_EXAMPLE = { + "success": False, + "message": "An unexpected error occurred", + "status_code": 500, + "error_code": "internal_error", + "error_category": "internal", + "details": {"error_type": "ValueError"}, +} + + +# ============================================================================ +# Create Item Documentation +# ============================================================================ + +CREATE_ITEM_RESPONSES = { + 201: { + "description": "Item created successfully", + "model": ItemResponse, + }, + 422: { + "description": "Validation error - duplicate SKU/slug or invalid input data", + "model": ValidationErrorResponse, + "content": { + "application/json": { + "examples": { + "duplicate_sku": { + "summary": "Duplicate SKU", + "value": DUPLICATE_SKU_EXAMPLE, + }, + "duplicate_slug": { + "summary": "Duplicate slug", + "value": DUPLICATE_SLUG_EXAMPLE, + }, + "invalid_input": { + "summary": "Invalid input data", + "value": { + "success": False, + "message": "Validation failed", + "status_code": 422, + "error_code": "invalid_input", + "error_category": "validation", + "details": { + "errors": [ + { + "loc": ["body", "price", "amount"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ] + }, + }, + }, + } + } + }, + }, + 500: { + "description": "Internal server error - database or unexpected error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, + } + } + }, + }, +} + + +# ============================================================================ +# Get Item by UUID Documentation +# ============================================================================ + +GET_ITEM_RESPONSES = { + 200: { + "description": "Item retrieved successfully", + "model": ItemResponse, + }, + 404: { + "description": "Item not found", + "model": ErrorResponse, + "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, + }, + 422: { + "description": "Invalid UUID format", + "model": ValidationErrorResponse, + "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, + }, + 500: { + "description": "Internal server error - database or unexpected error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, + } + } + }, + }, +} + + +# ============================================================================ +# List Items Documentation +# ============================================================================ + +LIST_ITEMS_RESPONSES = { + 200: { + "description": "Items retrieved successfully", + "model": PaginatedResponse[ItemResponse], + }, + 422: { + "description": "Invalid query parameters", + "model": ValidationErrorResponse, + "content": { + "application/json": { + "examples": { + "invalid_skip": { + "summary": "Negative skip value", + "value": { + "success": False, + "message": "Validation failed", + "status_code": 422, + "error_code": "invalid_input", + "error_category": "validation", + "details": { + "errors": [ + { + "loc": ["query", "skip"], + "msg": "Input should be greater than or equal to 0", + "type": "greater_than_equal", + } + ] + }, + }, + }, + "invalid_limit": { + "summary": "Limit out of range", + "value": { + "success": False, + "message": "Validation failed", + "status_code": 422, + "error_code": "invalid_input", + "error_category": "validation", + "details": { + "errors": [ + { + "loc": ["query", "limit"], + "msg": "Input should be less than or equal to 100", + "type": "less_than_equal", + } + ] + }, + }, + }, + "invalid_status": { + "summary": "Invalid status value", + "value": { + "success": False, + "message": "Validation failed", + "status_code": 422, + "error_code": "invalid_input", + "error_category": "validation", + "details": { + "errors": [ + { + "loc": ["query", "status"], + "msg": "Input should be 'draft', 'active' or 'archived'", + "type": "enum", + } + ] + }, + }, + }, + } + } + }, + }, + 500: { + "description": "Internal server error - database or unexpected error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, + } + } + }, + }, +} + + +# ============================================================================ +# Get Item by SKU Documentation +# ============================================================================ + +GET_ITEM_BY_SKU_RESPONSES = { + 200: { + "description": "Item retrieved successfully", + "model": ItemResponse, + }, + 404: { + "description": "Item not found", + "model": ErrorResponse, + "content": { + "application/json": { + "example": { + "success": False, + "message": "Item with ID 'CHAIR-001' not found", + "status_code": 404, + "error_code": "entity_not_found", + "error_category": "not_found", + "details": {"entity_type": "Item", "entity_id": "CHAIR-001"}, + } + } + }, + }, + 500: { + "description": "Internal server error - database or unexpected error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, + } + } + }, + }, +} + + +# ============================================================================ +# Update Item Documentation +# ============================================================================ + +UPDATE_ITEM_RESPONSES = { + 200: { + "description": "Item updated successfully", + "model": ItemResponse, + }, + 404: { + "description": "Item not found", + "model": ErrorResponse, + "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, + }, + 422: { + "description": "Validation error - duplicate SKU/slug, invalid UUID, or invalid input data", + "model": ValidationErrorResponse, + "content": { + "application/json": { + "examples": { + "duplicate_sku": { + "summary": "SKU conflicts with another item", + "value": { + "success": False, + "message": "Item with sku='CHAIR-002' already exists", + "status_code": 422, + "error_code": "duplicate_entry", + "error_category": "validation", + "details": { + "entity_type": "Item", + "field": "sku", + "value": "CHAIR-002", + }, + }, + }, + "duplicate_slug": { + "summary": "Slug conflicts with another item", + "value": { + "success": False, + "message": "Item with slug='blue-chair' already exists", + "status_code": 422, + "error_code": "duplicate_entry", + "error_category": "validation", + "details": { + "entity_type": "Item", + "field": "slug", + "value": "blue-chair", + }, + }, + }, + "invalid_uuid": { + "summary": "Invalid UUID format", + "value": INVALID_UUID_EXAMPLE, + }, + "invalid_input": { + "summary": "Invalid field values", + "value": { + "success": False, + "message": "Validation failed", + "status_code": 422, + "error_code": "invalid_input", + "error_category": "validation", + "details": { + "errors": [ + { + "loc": ["body", "status"], + "msg": "Input should be 'draft', 'active' or 'archived'", + "type": "enum", + } + ] + }, + }, + }, + } + } + }, + }, + 500: { + "description": "Internal server error - database or unexpected error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, + } + } + }, + }, +} + + +# ============================================================================ +# Delete Item Documentation +# ============================================================================ + +DELETE_ITEM_RESPONSES = { + 204: { + "description": "Item deleted successfully", + }, + 404: { + "description": "Item not found", + "model": ErrorResponse, + "content": {"application/json": {"example": ITEM_NOT_FOUND_EXAMPLE}}, + }, + 422: { + "description": "Invalid UUID format", + "model": ValidationErrorResponse, + "content": {"application/json": {"example": INVALID_UUID_EXAMPLE}}, + }, + 500: { + "description": "Internal server error - database or unexpected error", + "model": ErrorResponse, + "content": { + "application/json": { + "examples": { + "database_error": { + "summary": "Database error", + "value": DATABASE_ERROR_EXAMPLE, + }, + "internal_error": { + "summary": "Unexpected error", + "value": INTERNAL_ERROR_EXAMPLE, + }, + } + } + }, + }, +} diff --git a/src/app/services/crud_item_store/responses/items.py b/src/app/services/crud_item_store/responses/items.py new file mode 100644 index 0000000..a0c18dd --- /dev/null +++ b/src/app/services/crud_item_store/responses/items.py @@ -0,0 +1,27 @@ +""" +Item Response Models + +API response schemas for item endpoints. +""" + +from datetime import datetime +from uuid import UUID + +from pydantic import ConfigDict, Field + +from ..models import ItemBase + + +class ItemResponse(ItemBase): + """ + Schema for item API responses. + + Extends ItemBase with database-generated fields like UUID and timestamps. + Used for returning item data from GET, POST, PATCH endpoints. + """ + + uuid: UUID = Field(..., description="Unique item identifier") + created_at: datetime = Field(..., description="Item creation timestamp") + updated_at: datetime = Field(..., description="Last update timestamp") + + model_config = ConfigDict(from_attributes=True) diff --git a/src/app/services/crud_item_store/routers/__init__.py b/src/app/services/crud_item_store/routers/__init__.py new file mode 100644 index 0000000..c665580 --- /dev/null +++ b/src/app/services/crud_item_store/routers/__init__.py @@ -0,0 +1,9 @@ +""" +Item Store Routers Package + +Exports all routers for the item-store service. +""" + +from . import items + +__all__ = ["items"] diff --git a/src/app/services/crud_item_store/routers/items.py b/src/app/services/crud_item_store/routers/items.py new file mode 100644 index 0000000..f3d8b61 --- /dev/null +++ b/src/app/services/crud_item_store/routers/items.py @@ -0,0 +1,300 @@ +""" +Item CRUD Router + +FastAPI router for item CRUD operations. +Provides endpoints for creating, reading, updating, and deleting items. +""" + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.session import get_session_dependency +from app.shared.exceptions import entity_not_found +from app.shared.responses import PaginatedResponse, PageInfo +from ..models import ItemCreate, ItemStatus, ItemUpdate +from ..responses import ItemResponse +from ..responses.docs import ( + CREATE_ITEM_RESPONSES, + GET_ITEM_RESPONSES, + LIST_ITEMS_RESPONSES, + GET_ITEM_BY_SKU_RESPONSES, + UPDATE_ITEM_RESPONSES, + DELETE_ITEM_RESPONSES, +) +from ..services import get_item_repository +from ..functions import ( + db_to_response, + check_duplicate_field, + validate_update_conflicts, + prepare_item_update_data, +) + + +router = APIRouter() + + +@router.post( + "/", + response_model=ItemResponse, + status_code=status.HTTP_201_CREATED, + summary="Create a new item", + description="Create a new item in the store with all required information.", + responses=CREATE_ITEM_RESPONSES, +) +async def create_item( + item: ItemCreate, + session: AsyncSession = Depends(get_session_dependency), +) -> ItemResponse: + """ + Create a new item. + + Args: + item: Item creation data + session: Database session + + Returns: + ItemResponse: Created item with UUID and timestamps + + Raises: + ValidationError (422): If SKU or slug already exists + RequestValidationError (422): If input data is invalid (wrong types, missing fields, constraint violations) + DatabaseError (500): If database operation fails + """ + repo = get_item_repository(session) + + # Check for duplicate SKU and slug using validation function + await check_duplicate_field(repo, "sku", item.sku) + await check_duplicate_field(repo, "slug", item.slug) + + # Convert nested Pydantic models to dicts for JSONB storage + created = await repo.create( + sku=item.sku, + status=item.status.value, + name=item.name, + slug=item.slug, + short_description=item.short_description, + description=item.description, + categories=[str(cat) for cat in item.categories], + brand=item.brand, + price=item.price.model_dump(), + media=item.media.model_dump(), + inventory=item.inventory.model_dump(), + shipping=item.shipping.model_dump(), + attributes=item.attributes, + identifiers=item.identifiers.model_dump(), + custom=item.custom, + system=item.system.model_dump(), + ) + return db_to_response(created) + + +@router.get( + "/{item_uuid}", + response_model=ItemResponse, + summary="Get item by UUID", + description="Retrieve a single item by its UUID.", + responses=GET_ITEM_RESPONSES, +) +async def get_item( + item_uuid: UUID, + session: AsyncSession = Depends(get_session_dependency), +) -> ItemResponse: + """ + Get item by UUID. + + Args: + item_uuid: Item UUID + session: Database session + + Returns: + ItemResponse: Item details + + Raises: + NotFoundError (404): If item with given UUID does not exist + RequestValidationError (422): If UUID format is invalid + DatabaseError (500): If database operation fails + """ + repo = get_item_repository(session) + item = await repo.get(item_uuid) + + if not item: + raise entity_not_found("Item", item_uuid) + + return db_to_response(item) + + +@router.get( + "/", + response_model=PaginatedResponse[ItemResponse], + summary="List items", + description="List items with pagination and optional filtering by status.", + responses=LIST_ITEMS_RESPONSES, +) +async def list_items( + skip: int = Query(0, ge=0, description="Number of items to skip"), + limit: int = Query( + 50, ge=1, le=100, description="Maximum number of items to return" + ), + status_filter: ItemStatus | None = Query( + None, alias="status", description="Filter by status" + ), + session: AsyncSession = Depends(get_session_dependency), +) -> PaginatedResponse[ItemResponse]: + """ + List items with pagination. + + Args: + skip: Number of items to skip (must be >= 0) + limit: Maximum items to return (1-100) + status_filter: Optional status filter ('draft', 'active', or 'archived') + session: Database session + + Returns: + PaginatedResponse[ItemResponse]: Paginated list of items with metadata + + Raises: + RequestValidationError (422): If skip < 0, limit out of range, or invalid status + DatabaseError (500): If database operation fails + """ + repo = get_item_repository(session) + + # Apply filters + filters = {} + if status_filter: + filters["status"] = status_filter.value + + items = await repo.filter(skip=skip, limit=limit, **filters) + total = await repo.count(**filters) + + # Calculate pagination metadata + total_pages = (total + limit - 1) // limit if total > 0 else 0 + page = (skip // limit) + 1 + + return PaginatedResponse[ItemResponse]( + success=True, + items=[db_to_response(item) for item in items], + page_info=PageInfo( + page=page, + size=limit, + total=total, + pages=total_pages, + ), + message="Items retrieved successfully", + ) + + +@router.get( + "/by-sku/{sku}", + response_model=ItemResponse, + summary="Get item by SKU", + description="Retrieve a single item by its SKU (Stock Keeping Unit).", + responses=GET_ITEM_BY_SKU_RESPONSES, +) +async def get_item_by_sku( + sku: str, + session: AsyncSession = Depends(get_session_dependency), +) -> ItemResponse: + """ + Get item by SKU. + + Args: + sku: Item SKU (Stock Keeping Unit) + session: Database session + + Returns: + ItemResponse: Item details + + Raises: + NotFoundError (404): If item with given SKU does not exist + DatabaseError (500): If database operation fails + """ + repo = get_item_repository(session) + item = await repo.get_by(sku=sku) + + if not item: + raise entity_not_found("Item", sku) + + return db_to_response(item) + + +@router.patch( + "/{item_uuid}", + response_model=ItemResponse, + summary="Update item", + description="Update an existing item. Only provided fields will be updated.", + responses=UPDATE_ITEM_RESPONSES, +) +async def update_item( + item_uuid: UUID, + item_update: ItemUpdate, + session: AsyncSession = Depends(get_session_dependency), +) -> ItemResponse: + """ + Update an existing item. + + Args: + item_uuid: Item UUID + item_update: Fields to update (only provided fields will be updated) + session: Database session + + Returns: + ItemResponse: Updated item + + Raises: + NotFoundError (404): If item with given UUID does not exist + ValidationError (422): If updated SKU or slug conflicts with another item + RequestValidationError (422): If UUID format or input data is invalid + DatabaseError (500): If database operation fails + """ + repo = get_item_repository(session) + item = await repo.get(item_uuid) + + if not item: + raise entity_not_found("Item", item_uuid) + + # Get update data, excluding unset fields + update_data = item_update.model_dump(exclude_unset=True) + + # Validate for conflicts (SKU and slug) + await validate_update_conflicts(repo, item, update_data, item_uuid) + + # Convert enums and nested models to database format + update_data = prepare_item_update_data(update_data) + + # Update item + updated = await repo.update(item_uuid, **update_data) + return db_to_response(updated) + + +@router.delete( + "/{item_uuid}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete item", + description="Permanently delete an item from the store.", + responses=DELETE_ITEM_RESPONSES, +) +async def delete_item( + item_uuid: UUID, + session: AsyncSession = Depends(get_session_dependency), +) -> None: + """ + Delete an item. + + Args: + item_uuid: Item UUID + session: Database session + + Raises: + NotFoundError (404): If item with given UUID does not exist + RequestValidationError (422): If UUID format is invalid + DatabaseError (500): If database operation fails + """ + repo = get_item_repository(session) + item = await repo.get(item_uuid) + + if not item: + raise entity_not_found("Item", item_uuid) + + await repo.delete(item_uuid) diff --git a/src/app/services/crud_item_store/services/__init__.py b/src/app/services/crud_item_store/services/__init__.py new file mode 100644 index 0000000..0e2cd68 --- /dev/null +++ b/src/app/services/crud_item_store/services/__init__.py @@ -0,0 +1,7 @@ +""" +Init file for services +""" + +from .database import ItemRepository, get_item_repository + +__all__ = ["ItemRepository", "get_item_repository"] diff --git a/src/app/services/crud_item_store/services/database.py b/src/app/services/crud_item_store/services/database.py new file mode 100644 index 0000000..7b981a1 --- /dev/null +++ b/src/app/services/crud_item_store/services/database.py @@ -0,0 +1,161 @@ +""" +Item Database Service + +Database operations for the item-store service. +Uses the generic BaseRepository with item-specific queries. +""" + +from typing import Any, Optional +from uuid import UUID + +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.shared.database.repository import BaseRepository +from ..models import ItemDB + + +class ItemRepository(BaseRepository[ItemDB]): + """ + Repository for Item database operations. + + Extends BaseRepository with item-specific queries like: + - Generic search with multiple criteria + - Field existence checks + + Use inherited get_by() for single-field lookups: + - get_by(sku="...") for SKU lookups + - get_by(slug="...") for slug lookups + """ + + def __init__(self, session: AsyncSession): + """Initialize item repository with session.""" + super().__init__(ItemDB, session) + + async def search( + self, + name: Optional[str] = None, + status: Optional[str] = None, + category_uuid: Optional[UUID] = None, + brand: Optional[str] = None, + skip: int = 0, + limit: int = 100, + ) -> list[ItemDB]: + """ + Generic search function for items with multiple optional criteria. + + All criteria are combined with AND logic. Any criteria left as None + will be ignored in the search. + + Args: + name: Search term for name (case-insensitive partial match) + status: Exact status match (draft, active, archived) + category_uuid: Filter by category UUID + brand: Filter by brand name + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of matching items + + Examples: + >>> # Search by name only + >>> items = await repo.search(name="chair") + + >>> # Search active items in category + >>> items = await repo.search( + ... status="active", + ... category_uuid=UUID("2f61e8db..."), + ... limit=20 + ... ) + + >>> # Search by brand and status + >>> items = await repo.search(brand="TestBrand", status="active") + """ + stmt = select(self.model) + conditions = [] + + # Add filters based on provided criteria + if name is not None: + conditions.append(self.model.name.ilike(f"%{name}%")) + + if status is not None: + conditions.append(self.model.status == status) + + if category_uuid is not None: + conditions.append(self.model.categories.contains([str(category_uuid)])) + + if brand is not None: + conditions.append(self.model.brand == brand) + + # Combine all conditions with AND + if conditions: + stmt = stmt.where(and_(*conditions)) + + # Apply pagination + stmt = stmt.offset(skip).limit(limit) + + result = await self.session.execute(stmt) + return list(result.scalars().all()) + + async def field_exists( + self, field_name: str, field_value: Any, exclude_uuid: Optional[UUID] = None + ) -> bool: + """ + Generic method to check if a field value already exists. + + Args: + field_name: Name of the field to check (e.g., "sku", "slug") + field_value: Value to check for existence + exclude_uuid: Optionally exclude an item UUID (for updates) + + Returns: + True if field value exists, False otherwise + + Raises: + ValueError: If field_name is not a valid column + + Examples: + >>> # Check if SKU exists + >>> exists = await repo.field_exists("sku", "CHAIR-RED-001") + + >>> # Check if slug exists, excluding current item + >>> exists = await repo.field_exists( + ... "slug", "red-chair", exclude_uuid=item_uuid + ... ) + """ + # Validate field exists on model + if not hasattr(self.model, field_name): + raise ValueError(f"Field '{field_name}' does not exist on ItemDB model") + + field = getattr(self.model, field_name) + stmt = select(self.model.uuid).where(field == field_value) + + if exclude_uuid: + stmt = stmt.where(self.model.uuid != exclude_uuid) + + result = await self.session.execute(stmt) + return result.scalar_one_or_none() is not None + + +def get_item_repository(session: AsyncSession) -> ItemRepository: + """ + Dependency injection factory for ItemRepository. + + Args: + session: Database session (typically from FastAPI dependency) + + Returns: + ItemRepository instance + + Example: + >>> # In FastAPI router + >>> @router.get("/items/{uuid}") + >>> async def get_item( + ... uuid: UUID, + ... session: AsyncSession = Depends(get_session), + ... ): + ... repo = get_item_repository(session) + ... return await repo.get(uuid) + """ + return ItemRepository(session) diff --git a/src/app/shared/database/engine.py b/src/app/shared/database/engine.py index 88af4b6..523a39a 100644 --- a/src/app/shared/database/engine.py +++ b/src/app/shared/database/engine.py @@ -10,7 +10,7 @@ create_async_engine, AsyncEngine, ) -from sqlalchemy.pool import NullPool, QueuePool +from sqlalchemy.pool import NullPool from app.shared.database.utils import ( get_logger, @@ -109,7 +109,6 @@ def create_engine( pool_timeout=pool_timeout, pool_recycle=pool_recycle, pool_pre_ping=pool_pre_ping, - poolclass=QueuePool, connect_args=connect_args, ) logger.info("Database engine created successfully") diff --git a/src/app/shared/database/session.py b/src/app/shared/database/session.py index ebd41f7..8dffa01 100644 --- a/src/app/shared/database/session.py +++ b/src/app/shared/database/session.py @@ -7,11 +7,13 @@ from contextlib import asynccontextmanager from typing import AsyncGenerator +from fastapi import HTTPException +from fastapi.exceptions import RequestValidationError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.shared.database.engine import get_engine - from app.shared.database.utils import get_logger, DatabaseError +from app.shared.exceptions import AppException logger = get_logger(__name__) @@ -68,6 +70,16 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]: yield session await session.commit() logger.debug("Database session committed") + except (RequestValidationError, HTTPException): + # FastAPI validation and HTTP errors should pass through unchanged + await session.rollback() + logger.debug("Database session rolled back due to FastAPI exception") + raise + except AppException: + # Application exceptions should pass through to the global handler + await session.rollback() + logger.debug("Database session rolled back due to AppException") + raise except DatabaseError: # Already a DatabaseError, just rollback and re-raise await session.rollback() diff --git a/src/app/shared/responses/error.py b/src/app/shared/responses/error.py index 638d026..f80e232 100644 --- a/src/app/shared/responses/error.py +++ b/src/app/shared/responses/error.py @@ -157,12 +157,12 @@ class ValidationErrorResponse(ErrorResponse): """ error_code: str = Field( - default="VALIDATION_ERROR", - description="Error code, defaults to VALIDATION_ERROR", + default="invalid_input", + description="Error code, defaults to invalid_input", ) error_category: str = Field( - default="VALIDATION", description="Error category, defaults to VALIDATION" + default="validation", description="Error category, defaults to validation" ) status_code: int = Field( @@ -180,8 +180,8 @@ class ValidationErrorResponse(ErrorResponse): "success": False, "message": "Validation failed", "status_code": 422, - "error_code": "VALIDATION_ERROR", - "error_category": "VALIDATION", + "error_code": "invalid_input", + "error_category": "validation", "validation_errors": [ { "field": "email", diff --git a/tests/test_crud_item_store.py b/tests/test_crud_item_store.py new file mode 100644 index 0000000..d08672b --- /dev/null +++ b/tests/test_crud_item_store.py @@ -0,0 +1,560 @@ +""" +Tests for CRUD Item Store Service + +Following the project's testing patterns from testing.md. +Tests cover Pydantic models, validation, enums, and business rules. +""" + +import pytest +from uuid import UUID, uuid4 +from datetime import datetime + +from app.services.crud_item_store.models import ( + DimensionUnit, + DimensionsModel, + IdentifiersModel, + InventoryModel, + ItemCreate, + ItemStatus, + ItemUpdate, + MediaModel, + PriceModel, + ShippingClass, + ShippingModel, + StockStatus, + SystemModel, + TaxClass, + WeightModel, + WeightUnit, +) +from app.services.crud_item_store.responses import ItemResponse + + +class TestPriceModel: + """Test PriceModel validation and transformations.""" + + def test_create_price_with_required_fields(self): + """Test creating price with minimum required fields.""" + price = PriceModel(amount=9999, currency="EUR") + + assert price.amount == 9999 + assert price.currency == "EUR" + assert price.includes_tax is True # Default + assert price.tax_class == TaxClass.STANDARD # Default + + def test_currency_uppercase_validation(self): + """Test that currency is converted to uppercase.""" + price = PriceModel(amount=9999, currency="eur") + + assert price.currency == "EUR" + + def test_currency_mixed_case(self): + """Test currency normalization with mixed case.""" + price = PriceModel(amount=9999, currency="uSd") + + assert price.currency == "USD" + + def test_negative_amount_rejected(self): + """Test that negative amounts are rejected.""" + with pytest.raises(ValueError): + PriceModel(amount=-100, currency="EUR") + + def test_zero_amount_allowed(self): + """Test that zero amount is allowed.""" + price = PriceModel(amount=0, currency="EUR") + + assert price.amount == 0 + + def test_price_with_discount(self): + """Test price with original amount for discounts.""" + price = PriceModel( + amount=9999, currency="EUR", original_amount=12999, includes_tax=True + ) + + assert price.amount == 9999 + assert price.original_amount == 12999 + + def test_original_amount_greater_than_amount(self): + """Test discount scenario where original > current.""" + price = PriceModel(amount=8000, currency="EUR", original_amount=10000) + + # Original amount should be higher (discount applied) + assert price.original_amount > price.amount + + def test_tax_class_options(self): + """Test different tax class values.""" + standard = PriceModel(amount=1000, currency="EUR", tax_class=TaxClass.STANDARD) + reduced = PriceModel(amount=1000, currency="EUR", tax_class=TaxClass.REDUCED) + none_tax = PriceModel(amount=1000, currency="EUR", tax_class=TaxClass.NONE) + + assert standard.tax_class == TaxClass.STANDARD + assert reduced.tax_class == TaxClass.REDUCED + assert none_tax.tax_class == TaxClass.NONE + + +class TestMediaModel: + """Test MediaModel validation.""" + + def test_empty_media(self): + """Test creating empty media model.""" + media = MediaModel() + + assert media.main_image is None + assert media.gallery == [] + + def test_media_with_main_image(self): + """Test media with main image only.""" + media = MediaModel(main_image="https://example.com/image.jpg") + + assert media.main_image == "https://example.com/image.jpg" + assert media.gallery == [] + + def test_media_with_gallery(self): + """Test media with gallery images.""" + media = MediaModel( + main_image="https://example.com/main.jpg", + gallery=[ + "https://example.com/side.jpg", + "https://example.com/back.jpg", + ], + ) + + assert len(media.gallery) == 2 + assert "https://example.com/side.jpg" in media.gallery + + +class TestWeightModel: + """Test WeightModel validation.""" + + def test_weight_with_defaults(self): + """Test weight with default unit.""" + weight = WeightModel(value=7.5) + + assert weight.value == 7.5 + assert weight.unit == WeightUnit.KG + + def test_weight_with_custom_unit(self): + """Test weight with custom unit.""" + weight = WeightModel(value=16.5, unit=WeightUnit.LB) + + assert weight.value == 16.5 + assert weight.unit == WeightUnit.LB + + def test_zero_weight_rejected(self): + """Test that zero weight is rejected.""" + with pytest.raises(ValueError): + WeightModel(value=0) + + def test_negative_weight_rejected(self): + """Test that negative weight is rejected.""" + with pytest.raises(ValueError): + WeightModel(value=-5.0) + + +class TestDimensionsModel: + """Test DimensionsModel validation.""" + + def test_dimensions_with_defaults(self): + """Test dimensions with default unit.""" + dims = DimensionsModel(width=45.0, height=90.0, length=50.0) + + assert dims.width == 45.0 + assert dims.height == 90.0 + assert dims.length == 50.0 + assert dims.unit == DimensionUnit.CM + + def test_dimensions_with_custom_unit(self): + """Test dimensions with custom unit.""" + dims = DimensionsModel( + width=18.0, height=36.0, length=20.0, unit=DimensionUnit.IN + ) + + assert dims.unit == DimensionUnit.IN + + def test_zero_dimension_rejected(self): + """Test that zero dimensions are rejected.""" + with pytest.raises(ValueError): + DimensionsModel(width=0, height=90.0, length=50.0) + + +class TestShippingModel: + """Test ShippingModel validation.""" + + def test_shipping_defaults(self): + """Test shipping with default values.""" + shipping = ShippingModel() + + assert shipping.is_physical is True + assert shipping.weight is None + assert shipping.dimensions is None + assert shipping.shipping_class == ShippingClass.STANDARD + + def test_shipping_with_weight_and_dimensions(self): + """Test shipping with full details.""" + shipping = ShippingModel( + is_physical=True, + weight=WeightModel(value=7.5, unit=WeightUnit.KG), + dimensions=DimensionsModel(width=45.0, height=90.0, length=50.0), + shipping_class=ShippingClass.BULKY, + ) + + assert shipping.weight.value == 7.5 + assert shipping.dimensions.width == 45.0 + assert shipping.shipping_class == ShippingClass.BULKY + + +class TestInventoryModel: + """Test InventoryModel validation.""" + + def test_inventory_defaults(self): + """Test inventory with default values.""" + inventory = InventoryModel() + + assert inventory.stock_quantity == 0 + assert inventory.stock_status == StockStatus.IN_STOCK + assert inventory.allow_backorder is False + + def test_inventory_with_stock(self): + """Test inventory with stock quantity.""" + inventory = InventoryModel(stock_quantity=25, stock_status=StockStatus.IN_STOCK) + + assert inventory.stock_quantity == 25 + assert inventory.stock_status == StockStatus.IN_STOCK + + def test_inventory_out_of_stock(self): + """Test out of stock inventory.""" + inventory = InventoryModel( + stock_quantity=0, stock_status=StockStatus.OUT_OF_STOCK + ) + + assert inventory.stock_quantity == 0 + assert inventory.stock_status == StockStatus.OUT_OF_STOCK + + def test_negative_stock_rejected(self): + """Test that negative stock is rejected.""" + with pytest.raises(ValueError): + InventoryModel(stock_quantity=-5) + + +class TestIdentifiersModel: + """Test IdentifiersModel validation.""" + + def test_empty_identifiers(self): + """Test identifiers with all None values.""" + identifiers = IdentifiersModel() + + assert identifiers.barcode is None + assert identifiers.manufacturer_part_number is None + assert identifiers.country_of_origin is None + + def test_country_code_uppercase(self): + """Test that country code is converted to uppercase.""" + identifiers = IdentifiersModel(country_of_origin="de") + + assert identifiers.country_of_origin == "DE" + + def test_full_identifiers(self): + """Test identifiers with all fields.""" + identifiers = IdentifiersModel( + barcode="4006381333931", + manufacturer_part_number="AC-CHAIR-RED-01", + country_of_origin="de", + ) + + assert identifiers.barcode == "4006381333931" + assert identifiers.manufacturer_part_number == "AC-CHAIR-RED-01" + assert identifiers.country_of_origin == "DE" + + +class TestItemCreate: + """Test ItemCreate model validation.""" + + def test_create_minimal_item(self): + """Test creating item with minimal required fields.""" + item = ItemCreate( + sku="CHAIR-RED-001", + name="Red Wooden Chair", + slug="red-wooden-chair", + price=PriceModel(amount=9999, currency="EUR"), + ) + + assert item.sku == "CHAIR-RED-001" + assert item.name == "Red Wooden Chair" + assert item.slug == "red-wooden-chair" + assert item.price.amount == 9999 + assert item.status == ItemStatus.DRAFT # Default + + def test_slug_lowercase_validation(self): + """Test that slug is converted to lowercase.""" + item = ItemCreate( + sku="TEST-001", + name="Test", + slug="RED-Wooden-CHAIR", + price=PriceModel(amount=1000, currency="EUR"), + ) + + assert item.slug == "red-wooden-chair" + + def test_slug_whitespace_stripped(self): + """Test that slug whitespace is stripped.""" + item = ItemCreate( + sku="TEST-001", + name="Test", + slug=" test-item ", + price=PriceModel(amount=1000, currency="EUR"), + ) + + assert item.slug == "test-item" + + def test_create_full_item(self): + """Test creating item with all fields.""" + item = ItemCreate( + sku="CHAIR-RED-001", + status=ItemStatus.ACTIVE, + name="Red Wooden Chair", + slug="red-wooden-chair", + short_description="Comfortable red wooden chair", + description="Long description here...", + categories=[uuid4(), uuid4()], + brand="Acme Furniture", + price=PriceModel( + amount=9999, currency="EUR", includes_tax=True, original_amount=12999 + ), + media=MediaModel( + main_image="https://example.com/main.jpg", + gallery=["https://example.com/side.jpg"], + ), + inventory=InventoryModel( + stock_quantity=25, stock_status=StockStatus.IN_STOCK + ), + shipping=ShippingModel( + is_physical=True, + weight=WeightModel(value=7.5, unit=WeightUnit.KG), + dimensions=DimensionsModel(width=45.0, height=90.0, length=50.0), + ), + attributes={"color": "red", "material": "wood"}, + identifiers=IdentifiersModel( + barcode="4006381333931", + manufacturer_part_number="AC-CHAIR-RED-01", + country_of_origin="DE", + ), + ) + + assert item.sku == "CHAIR-RED-001" + assert item.status == ItemStatus.ACTIVE + assert len(item.categories) == 2 + assert item.brand == "Acme Furniture" + assert item.attributes["color"] == "red" + assert item.identifiers.country_of_origin == "DE" + + def test_required_fields_validation(self): + """Test that required fields are enforced.""" + with pytest.raises(ValueError): + ItemCreate( + # Missing sku, name, slug, price + ) + + +class TestItemUpdate: + """Test ItemUpdate model for partial updates.""" + + def test_empty_update(self): + """Test that all fields are optional in update.""" + update = ItemUpdate() + + data = update.model_dump(exclude_unset=True) + assert data == {} + + def test_partial_update_name_only(self): + """Test updating only name field.""" + update = ItemUpdate(name="Updated Name") + + data = update.model_dump(exclude_unset=True) + assert "name" in data + assert "sku" not in data + assert data["name"] == "Updated Name" + + def test_partial_update_price(self): + """Test updating only price.""" + update = ItemUpdate(price=PriceModel(amount=8999, currency="EUR")) + + data = update.model_dump(exclude_unset=True) + assert "price" in data + assert data["price"]["amount"] == 8999 + + def test_slug_validation_in_update(self): + """Test slug normalization in updates.""" + update = ItemUpdate(slug="UPPER-CASE-SLUG") + + assert update.slug == "upper-case-slug" + + def test_update_multiple_fields(self): + """Test updating multiple fields.""" + update = ItemUpdate( + name="New Name", + status=ItemStatus.ACTIVE, + price=PriceModel(amount=7999, currency="EUR"), + ) + + data = update.model_dump(exclude_unset=True) + assert len(data) == 3 + assert data["name"] == "New Name" + assert data["status"] == ItemStatus.ACTIVE + + +class TestItemResponse: + """Test ItemResponse model.""" + + def test_item_response_includes_system_fields(self): + """Test that response includes uuid and timestamps.""" + response = ItemResponse( + uuid=uuid4(), + sku="TEST-001", + name="Test Item", + slug="test-item", + status=ItemStatus.ACTIVE, + price=PriceModel(amount=1000, currency="EUR"), + media=MediaModel(), + inventory=InventoryModel(), + shipping=ShippingModel(), + categories=[], + attributes={}, + identifiers=IdentifiersModel(), + custom={}, + system=SystemModel(), + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + assert isinstance(response.uuid, UUID) + assert isinstance(response.created_at, datetime) + assert isinstance(response.updated_at, datetime) + + +class TestEnums: + """Test enum values and conversions.""" + + def test_item_status_values(self): + """Test ItemStatus enum has correct values.""" + assert ItemStatus.DRAFT.value == "draft" + assert ItemStatus.ACTIVE.value == "active" + assert ItemStatus.ARCHIVED.value == "archived" + + def test_stock_status_values(self): + """Test StockStatus enum values.""" + assert StockStatus.IN_STOCK.value == "in_stock" + assert StockStatus.OUT_OF_STOCK.value == "out_of_stock" + assert StockStatus.PREORDER.value == "preorder" + assert StockStatus.BACKORDER.value == "backorder" + + def test_tax_class_values(self): + """Test TaxClass enum values.""" + assert TaxClass.STANDARD.value == "standard" + assert TaxClass.REDUCED.value == "reduced" + assert TaxClass.NONE.value == "none" + + def test_shipping_class_values(self): + """Test ShippingClass enum values.""" + assert ShippingClass.STANDARD.value == "standard" + assert ShippingClass.BULKY.value == "bulky" + assert ShippingClass.LETTER.value == "letter" + + def test_weight_unit_values(self): + """Test WeightUnit enum values.""" + assert WeightUnit.KG.value == "kg" + assert WeightUnit.LB.value == "lb" + assert WeightUnit.G.value == "g" + + def test_dimension_unit_values(self): + """Test DimensionUnit enum values.""" + assert DimensionUnit.CM.value == "cm" + assert DimensionUnit.M.value == "m" + assert DimensionUnit.IN.value == "in" + assert DimensionUnit.FT.value == "ft" + + +class TestValidationEdgeCases: + """Test edge cases and validation scenarios.""" + + @pytest.mark.parametrize( + "currency,expected", + [ + ("eur", "EUR"), + ("usd", "USD"), + ("gbp", "GBP"), + ("EUR", "EUR"), + ("UsD", "USD"), + ], + ) + def test_currency_normalization(self, currency, expected): + """Test currency normalization with various inputs.""" + price = PriceModel(amount=1000, currency=currency) + assert price.currency == expected + + @pytest.mark.parametrize( + "slug,expected", + [ + ("test-item", "test-item"), + ("TEST-ITEM", "test-item"), + (" Test Item ", "test item"), + ("MiXeD-CaSe", "mixed-case"), + ], + ) + def test_slug_normalization(self, slug, expected): + """Test slug normalization with various inputs.""" + item = ItemCreate( + sku="TEST", + name="Test", + slug=slug, + price=PriceModel(amount=1000, currency="EUR"), + ) + assert item.slug == expected + + @pytest.mark.parametrize( + "country,expected", + [ + ("de", "DE"), + ("us", "US"), + ("DE", "DE"), + ("Us", "US"), + ], + ) + def test_country_code_normalization(self, country, expected): + """Test country code normalization.""" + identifiers = IdentifiersModel(country_of_origin=country) + assert identifiers.country_of_origin == expected + + def test_custom_field_accepts_any_structure(self): + """Test that custom field accepts any structure.""" + item = ItemCreate( + sku="TEST", + name="Test", + slug="test", + price=PriceModel(amount=1000, currency="EUR"), + custom={ + "seo": {"meta_title": "Test", "meta_description": "Desc"}, + "reviews": {"average_rating": 4.5, "total": 127}, + "anything": {"can": {"go": ["here"]}}, + }, + ) + + assert "seo" in item.custom + assert item.custom["reviews"]["average_rating"] == 4.5 + + def test_attributes_field_accepts_any_structure(self): + """Test that attributes field accepts any structure.""" + item = ItemCreate( + sku="TEST", + name="Test", + slug="test", + price=PriceModel(amount=1000, currency="EUR"), + attributes={ + "color": "red", + "size": "large", + "material": "wood", + "count": 5, + }, + ) + + assert item.attributes["color"] == "red" + assert item.attributes["count"] == 5 diff --git a/tests/test_crud_item_store_integration.py b/tests/test_crud_item_store_integration.py new file mode 100644 index 0000000..d8c0202 --- /dev/null +++ b/tests/test_crud_item_store_integration.py @@ -0,0 +1,244 @@ +""" +Integration tests for crud-item-store API endpoints. + +These tests run against the actual running API and database. +Make sure Docker containers are running before executing these tests. +""" + +import uuid +import pytest +import requests + +# API base URL - adjust if your API is on a different host/port +API_BASE_URL = "http://172.20.20.21:8001/v1/items" + + +@pytest.fixture +def valid_item_data(): + """Return valid item creation data.""" + unique_id = uuid.uuid4().hex[:8] + return { + "sku": f"TEST-{unique_id.upper()}", + "status": "active", + "name": "Integration Test Product", + "slug": f"integration-test-product-{unique_id}", + "short_description": "A short description", + "description": "A product for integration testing", + "brand": "TestBrand", + "categories": [str(uuid.uuid4())], + "price": { + "amount": 9999, + "currency": "USD", + "includes_tax": True, + "original_amount": None, + "tax_class": "standard", + }, + "media": {"main_image": None, "gallery": []}, + "inventory": { + "stock_quantity": 100, + "stock_status": "in_stock", + "allow_backorder": False, + }, + "shipping": { + "is_physical": True, + "shipping_class": "standard", + "weight": None, + "dimensions": None, + }, + "attributes": {}, + "identifiers": { + "barcode": None, + "manufacturer_part_number": None, + "country_of_origin": None, + }, + "custom": {}, + "system": {"version": 1, "source": "api", "locale": "en_US"}, + } + + +@pytest.fixture +def created_item(valid_item_data): + """Create an item and return its UUID for cleanup.""" + response = requests.post(API_BASE_URL + "/", json=valid_item_data) + assert response.status_code == 201 + item = response.json() + yield item + # Cleanup: delete the item after test + requests.delete(f"{API_BASE_URL}/{item['uuid']}") + + +class TestItemCRUD: + """Test CRUD operations on items.""" + + def test_create_item_success(self, valid_item_data): + """Test creating a new item.""" + response = requests.post(API_BASE_URL + "/", json=valid_item_data) + + assert response.status_code == 201 + data = response.json() + assert data["sku"] == valid_item_data["sku"] + assert data["name"] == valid_item_data["name"] + assert "uuid" in data + assert "created_at" in data + assert "updated_at" in data + + # Cleanup + requests.delete(f"{API_BASE_URL}/{data['uuid']}") + + def test_create_item_duplicate_sku(self, created_item): + """Test creating item with duplicate SKU fails.""" + duplicate_data = { + "sku": created_item["sku"], + "name": "Duplicate", + "slug": "duplicate-slug", + "brand": "Test", + "categories": [str(uuid.uuid4())], + "price": {"amount": 1000, "currency": "USD"}, + } + + response = requests.post(API_BASE_URL + "/", json=duplicate_data) + assert response.status_code == 422 + assert "already exists" in response.json()["message"] + + def test_get_item_by_uuid(self, created_item): + """Test retrieving an item by UUID.""" + response = requests.get(f"{API_BASE_URL}/{created_item['uuid']}") + + assert response.status_code == 200 + data = response.json() + assert data["uuid"] == created_item["uuid"] + assert data["sku"] == created_item["sku"] + + def test_get_item_not_found(self): + """Test retrieving non-existent item returns 404.""" + fake_uuid = str(uuid.uuid4()) + response = requests.get(f"{API_BASE_URL}/{fake_uuid}") + + assert response.status_code == 404 + assert "not found" in response.json()["message"] + + def test_get_item_by_sku(self, created_item): + """Test retrieving an item by SKU.""" + response = requests.get(f"{API_BASE_URL}/by-sku/{created_item['sku']}") + + assert response.status_code == 200 + data = response.json() + assert data["uuid"] == created_item["uuid"] + assert data["sku"] == created_item["sku"] + + def test_list_items(self, created_item): + """Test listing items with pagination.""" + response = requests.get(API_BASE_URL + "/") + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert "page_info" in data + assert data["page_info"]["total"] >= 1 + assert isinstance(data["items"], list) + assert data["success"] is True + + def test_list_items_pagination(self, created_item): + """Test pagination parameters.""" + response = requests.get(API_BASE_URL + "/?skip=0&limit=10") + + assert response.status_code == 200 + data = response.json() + assert data["page_info"]["size"] == 10 + assert len(data["items"]) <= 10 + + def test_update_item(self, created_item): + """Test updating an item.""" + update_data = { + "name": "Updated Product Name", + "price": {"amount": 15999, "currency": "EUR"}, + } + + response = requests.patch( + f"{API_BASE_URL}/{created_item['uuid']}", json=update_data + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Updated Product Name" + assert data["price"]["amount"] == 15999 + assert data["price"]["currency"] == "EUR" + # SKU should not change + assert data["sku"] == created_item["sku"] + + def test_update_item_not_found(self): + """Test updating non-existent item returns 404.""" + fake_uuid = str(uuid.uuid4()) + update_data = {"name": "Updated"} + + response = requests.patch(f"{API_BASE_URL}/{fake_uuid}", json=update_data) + assert response.status_code == 404 + + def test_delete_item(self, valid_item_data): + """Test deleting an item.""" + # Create item + create_response = requests.post(API_BASE_URL + "/", json=valid_item_data) + assert create_response.status_code == 201, ( + f"Failed to create item: {create_response.json()}" + ) + item_uuid = create_response.json()["uuid"] + + # Delete item + delete_response = requests.delete(f"{API_BASE_URL}/{item_uuid}") + assert delete_response.status_code == 204 + + # Verify deleted + get_response = requests.get(f"{API_BASE_URL}/{item_uuid}") + assert get_response.status_code == 404 + + def test_delete_item_not_found(self): + """Test deleting non-existent item returns 404.""" + fake_uuid = str(uuid.uuid4()) + response = requests.delete(f"{API_BASE_URL}/{fake_uuid}") + assert response.status_code == 404 + + +class TestValidation: + """Test API validation.""" + + def test_invalid_price_amount(self): + """Test that float price amounts are rejected.""" + invalid_data = { + "sku": "TEST-001", + "name": "Test", + "slug": "test", + "brand": "Test", + "categories": [str(uuid.uuid4())], + "price": {"amount": 99.99, "currency": "USD"}, # Should be int + } + + response = requests.post(API_BASE_URL + "/", json=invalid_data) + assert response.status_code == 422 + + def test_invalid_category_uuid(self): + """Test that invalid UUIDs in categories are rejected.""" + invalid_data = { + "sku": "TEST-001", + "name": "Test", + "slug": "test", + "brand": "Test", + "categories": ["not-a-uuid"], + "price": {"amount": 9999, "currency": "USD"}, + } + + response = requests.post(API_BASE_URL + "/", json=invalid_data) + assert response.status_code == 422 + + def test_missing_required_fields(self): + """Test that missing required fields are rejected.""" + invalid_data = { + "name": "Test" + # Missing sku, slug, brand, categories, price + } + + response = requests.post(API_BASE_URL + "/", json=invalid_data) + assert response.status_code == 422 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])