diff --git a/backend/app/errors/__init__.py b/backend/app/errors/__init__.py new file mode 100644 index 0000000..35eeeb8 --- /dev/null +++ b/backend/app/errors/__init__.py @@ -0,0 +1,14 @@ +from .codes import ErrorCode +from .exceptions import AppError, BadRequestError, ConflictError, NotFoundError +from .handlers import register_exception_handlers +from .schemas import ErrorResponse + +__all__ = [ + "ErrorCode", + "AppError", + "BadRequestError", + "ConflictError", + "NotFoundError", + "ErrorResponse", + "register_exception_handlers", +] diff --git a/backend/app/errors/codes.py b/backend/app/errors/codes.py new file mode 100644 index 0000000..fbfa088 --- /dev/null +++ b/backend/app/errors/codes.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class ErrorCode(str, Enum): + CATEGORY_EXISTS = "CATEGORY_EXISTS" + CATEGORY_NOT_FOUND = "CATEGORY_NOT_FOUND" + PRODUCT_NOT_FOUND = "PRODUCT_NOT_FOUND" + IMAGE_NOT_FOUND = "IMAGE_NOT_FOUND" + VALIDATION_ERROR = "VALIDATION_ERROR" + BAD_REQUEST = "BAD_REQUEST" + NOT_FOUND = "NOT_FOUND" + CONFLICT = "CONFLICT" + INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR" diff --git a/backend/app/errors/exceptions.py b/backend/app/errors/exceptions.py new file mode 100644 index 0000000..e1c8228 --- /dev/null +++ b/backend/app/errors/exceptions.py @@ -0,0 +1,70 @@ +from typing import Any +from .codes import ErrorCode + + +class AppError(Exception): + status_code: int + error_code: ErrorCode + message: str + details: dict[str, Any] | None + + def __init__( + self, + *, + status_code: int, + error_code: ErrorCode, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + self.status_code = status_code + self.error_code = error_code + self.message = message + self.details = details + + +class NotFoundError(AppError): + def __init__( + self, + *, + error_code: ErrorCode, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__( + status_code=404, + error_code=error_code, + message=message, + details=details, + ) + + +class BadRequestError(AppError): + def __init__( + self, + *, + error_code: ErrorCode, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__( + status_code=400, + error_code=error_code, + message=message, + details=details, + ) + + +class ConflictError(AppError): + def __init__( + self, + *, + error_code: ErrorCode, + message: str, + details: dict[str, Any] | None = None, + ) -> None: + super().__init__( + status_code=409, + error_code=error_code, + message=message, + details=details, + ) diff --git a/backend/app/errors/handlers.py b/backend/app/errors/handlers.py new file mode 100644 index 0000000..1344b14 --- /dev/null +++ b/backend/app/errors/handlers.py @@ -0,0 +1,78 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from fastapi.exceptions import RequestValidationError +from starlette.exceptions import HTTPException as StarletteHTTPException +from .schemas import ErrorResponse +from .codes import ErrorCode +from .exceptions import AppError + + +def register_exception_handlers(app: FastAPI) -> None: + @app.exception_handler(AppError) + async def handle_app_error(request: Request, exc: AppError) -> JSONResponse: + body = ErrorResponse( + code=exc.status_code, + error_code=exc.error_code, + message=exc.message, + details=exc.details, + ) + return JSONResponse( + status_code=exc.status_code, + content=body.model_dump(), + ) + + @app.exception_handler(RequestValidationError) + async def handle_validation_error( + request: Request, + exc: RequestValidationError, + ) -> JSONResponse: + errors = [] + for error in exc.errors(): + error_dict = dict(error) + if "ctx" in error_dict: + ctx = error_dict["ctx"] + if isinstance(ctx, dict): + for key, value in ctx.items(): + if isinstance(value, Exception): + ctx[key] = str(value) + error_dict["ctx"] = ctx + errors.append(error_dict) + + details = {"errors": errors} + body = ErrorResponse( + code=422, + error_code=ErrorCode.VALIDATION_ERROR, + message="Validation error", + details=details, + ) + return JSONResponse(status_code=422, content=body.model_dump()) + + @app.exception_handler(StarletteHTTPException) + async def handle_http_exception( + request: Request, + exc: StarletteHTTPException, + ) -> JSONResponse: + if exc.status_code == 404: + code = ErrorCode.NOT_FOUND + elif exc.status_code == 409: + code = ErrorCode.CONFLICT + elif 400 <= exc.status_code < 500: + code = ErrorCode.BAD_REQUEST + else: + code = ErrorCode.INTERNAL_SERVER_ERROR + + body = ErrorResponse( + code=exc.status_code, + error_code=code, + message=str(exc.detail) if exc.detail else "Error", + ) + return JSONResponse(status_code=exc.status_code, content=body.model_dump()) + + @app.exception_handler(Exception) + async def handle_unexpected(request: Request, exc: Exception) -> JSONResponse: + body = ErrorResponse( + code=500, + error_code=ErrorCode.INTERNAL_SERVER_ERROR, + message="Internal server error", + ) + return JSONResponse(status_code=500, content=body.model_dump()) diff --git a/backend/app/errors/schemas.py b/backend/app/errors/schemas.py new file mode 100644 index 0000000..a373e4c --- /dev/null +++ b/backend/app/errors/schemas.py @@ -0,0 +1,10 @@ +from typing import Any +from pydantic import BaseModel +from .codes import ErrorCode + + +class ErrorResponse(BaseModel): + code: int + error_code: ErrorCode + message: str + details: dict[str, Any] | None = None diff --git a/backend/app/main.py b/backend/app/main.py index 0a32167..e1b446d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,4 @@ -from fastapi import FastAPI, Depends, HTTPException, Query +from fastapi import FastAPI, Depends, Query from fastapi.responses import StreamingResponse from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager @@ -10,6 +10,13 @@ import os from .db import get_session, create_db_and_tables +from .errors import ( + BadRequestError, + ConflictError, + ErrorCode, + NotFoundError, + register_exception_handlers, +) from .schemas import ( ProductRead, @@ -66,6 +73,8 @@ async def lifespan(app: FastAPI): lifespan=lifespan, ) +register_exception_handlers(app) + # CORS middleware for frontend integration origins = os.getenv( "CORS_ALLOW_ORIGINS", "http://localhost:3001,http://127.0.0.1:3001" @@ -91,9 +100,9 @@ def create_category(category: CategoryCreate, session: Session = Depends(get_ses # Check if category already exists existing_category = crud.get_category_by_name(session, category.name) if existing_category: - raise HTTPException( - status_code=400, - detail=f"Category with name '{category.name}' already exists", + raise ConflictError( + error_code=ErrorCode.CATEGORY_EXISTS, + message=f"Category with name '{category.name}' already exists", ) return crud.create_category(session, category) @@ -116,7 +125,10 @@ def get_categories(session: Session = Depends(get_session)): def get_category(category_id: int, session: Session = Depends(get_session)): category = crud.get_category(session, category_id) if not category: - raise HTTPException(status_code=404, detail="Category not found") + raise NotFoundError( + error_code=ErrorCode.CATEGORY_NOT_FOUND, + message="Category not found", + ) # Convert to response format with image URLs products_with_images = [] @@ -166,7 +178,10 @@ def create_product(product: ProductCreate, session: Session = Depends(get_sessio # Verify category exists category = crud.get_category(session, product.category_id) if not category: - raise HTTPException(status_code=400, detail="Category not found") + raise BadRequestError( + error_code=ErrorCode.CATEGORY_NOT_FOUND, + message="Category not found", + ) created_product = crud.create_product(session, product) @@ -361,7 +376,10 @@ def get_product(product_id: int, session: Session = Depends(get_session)): ) product = session.exec(stmt).first() if not product: - raise HTTPException(status_code=404, detail="Product not found") + raise NotFoundError( + error_code=ErrorCode.PRODUCT_NOT_FOUND, + message="Product not found", + ) # Filter active options and sort them active_options = [opt for opt in product.delivery_options if opt.is_active] @@ -420,11 +438,17 @@ def update_product( if product_update.category_id: category = crud.get_category(session, product_update.category_id) if not category: - raise HTTPException(status_code=400, detail="Category not found") + raise BadRequestError( + error_code=ErrorCode.CATEGORY_NOT_FOUND, + message="Category not found", + ) updated_product = crud.update_product(session, product_id, product_update) if not updated_product: - raise HTTPException(status_code=404, detail="Product not found") + raise NotFoundError( + error_code=ErrorCode.PRODUCT_NOT_FOUND, + message="Product not found", + ) # Convert to response format with image URL product_dict = { @@ -447,7 +471,10 @@ def update_product( @app.delete("/products/{product_id}") def delete_product(product_id: int, session: Session = Depends(get_session)): if not crud.delete_product(session, product_id): - raise HTTPException(status_code=404, detail="Product not found") + raise NotFoundError( + error_code=ErrorCode.PRODUCT_NOT_FOUND, + message="Product not found", + ) return {"message": "Product deleted successfully"} @@ -456,10 +483,16 @@ def delete_product(product_id: int, session: Session = Depends(get_session)): def get_product_image(product_id: int, session: Session = Depends(get_session)): product = crud.get_product(session, product_id) if not product: - raise HTTPException(status_code=404, detail="Product not found") + raise NotFoundError( + error_code=ErrorCode.PRODUCT_NOT_FOUND, + message="Product not found", + ) if not product.image_data: - raise HTTPException(status_code=404, detail="No image found for this product") + raise NotFoundError( + error_code=ErrorCode.IMAGE_NOT_FOUND, + message="No image found for this product", + ) # Return image as streaming response return StreamingResponse( diff --git a/backend/tests/api/test_categories.py b/backend/tests/api/test_categories.py index f188b54..24e280e 100644 --- a/backend/tests/api/test_categories.py +++ b/backend/tests/api/test_categories.py @@ -65,7 +65,7 @@ def test_create_duplicate_category(client: TestClient): response = client.post("/categories", json=category_data) # This should fail due to unique constraint - assert response.status_code in [400, 422] + assert response.status_code == 409 def test_category_with_products(client: TestClient): @@ -100,8 +100,8 @@ def test_create_category_duplicate_name(client: TestClient): # Duplicate should fail response2 = client.post("/categories", json=category_data) - assert response2.status_code == 400 - assert "already exists" in response2.json()["detail"].lower() + assert response2.status_code == 409 + assert "already exists" in response2.json()["message"].lower() def test_create_category_empty_name(client: TestClient): diff --git a/backend/tests/test_error_handling.py b/backend/tests/test_error_handling.py new file mode 100644 index 0000000..0a93563 --- /dev/null +++ b/backend/tests/test_error_handling.py @@ -0,0 +1,66 @@ +from tests.factories import create_test_category, create_test_product + + +def test_category_not_found_returns_structured_error(client): + resp = client.get("/categories/999999") + assert resp.status_code == 404 + body = resp.json() + assert body["code"] == 404 + assert body["error_code"] == "CATEGORY_NOT_FOUND" + assert body["message"] == "Category not found" + + +def test_category_conflict_returns_structured_error(client, session): + create_test_category(session, name="Books") + + resp = client.post("/categories", json={"name": "Books"}) + assert resp.status_code == 409 + body = resp.json() + assert body["code"] == 409 + assert body["error_code"] == "CATEGORY_EXISTS" + assert "already exists" in body["message"] + + +def test_product_not_found_returns_structured_error(client): + resp = client.get("/products/999999") + assert resp.status_code == 404 + body = resp.json() + assert body["code"] == 404 + assert body["error_code"] == "PRODUCT_NOT_FOUND" + assert body["message"] == "Product not found" + + +def test_validation_error_returns_structured_422(client): + resp = client.post("/products", json={"title": "Invalid"}) + assert resp.status_code == 422 + body = resp.json() + assert body["code"] == 422 + assert body["error_code"] == "VALIDATION_ERROR" + assert isinstance(body.get("details", {}).get("errors"), list) + + +def test_image_not_found_returns_structured_error(client, session): + product = create_test_product(session, title="No Image Product", with_image=False) + + resp = client.get(f"/products/{product.id}/image") + assert resp.status_code == 404 + body = resp.json() + assert body["code"] == 404 + assert body["error_code"] == "IMAGE_NOT_FOUND" + + +def test_category_not_found_during_product_create_returns_bad_request(client): + resp = client.post( + "/products", + json={ + "title": "Test Product", + "description": "Test Description", + "price": 29.99, + "category_id": 999999, + }, + ) + assert resp.status_code == 400 + body = resp.json() + assert body["code"] == 400 + assert body["error_code"] == "CATEGORY_NOT_FOUND" + assert body["message"] == "Category not found"