diff --git a/mypy.ini b/mypy.ini index 53eb59b..33f7120 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,3 +7,16 @@ ignore_errors = True [mypy-src.shared.environment] ignore_errors = True + +[mypy-src.shared.base_error_internal] +ignore_errors = True + +[mypy-src.shared.base_contextvars] +ignore_errors = True + +[mypy-src.shared.base_responses] +ignore_errors = True + +[mypy-src.shared.base_exceptions] +ignore_errors = True + diff --git a/src/api/v1/endpoints.py b/src/api/v1/endpoints.py index 77fa21b..e4f08f8 100644 --- a/src/api/v1/endpoints.py +++ b/src/api/v1/endpoints.py @@ -1,65 +1,52 @@ -from fastapi import HTTPException -from api.v1.schema import Book, BookCreate +from api.v1.schema import BookCreateSchema from loguru import logger - -from fastapi import ( - APIRouter, +from shared.base_responses import create_response_for_fast_api, EnvelopeResponse +from fastapi import APIRouter, Response +from uuid import UUID +from api.v1.services import ( + BooksListService, + BookCreateService, + BookRetrieveService, + BookUpdateService, + BookDeleteService, ) -router = APIRouter(prefix="/books",tags=["Books"]) +router = APIRouter(prefix="/books", tags=["Books"]) -# Simulated database -books_db: list[Book] = [] -counter_id = 0 +@router.get("", response_model=EnvelopeResponse) +async def get_books() -> EnvelopeResponse: + logger.info("Retrieving all books") + books = BooksListService.list() + return create_response_for_fast_api(data=books) -# Get all books -@router.get("", response_model=list[Book]) -async def get_books() -> list[Book]: - logger.info("Retrieving all books") - return books_db +@router.post("", response_model=EnvelopeResponse) +async def create_book(book: BookCreateSchema) -> EnvelopeResponse: + logger.info("Creating new book") + new_book = BookCreateService.create(book) + logger.info(f"Book created with ID: {new_book.id}") + return create_response_for_fast_api(data=new_book, status_code_http=201) -@router.post("", response_model=Book, status_code=201) -async def create_book(book: BookCreate) -> Book: - global counter_id - counter_id += 1 - new_book = Book(id=counter_id, **book.model_dump()) - books_db.append(new_book) - logger.info(f"Created new book with ID: {counter_id}") - return new_book -# Get book by ID -@router.get("/{book_id}", response_model=Book) -async def get_book(book_id: int) -> Book: +@router.get("/{book_id}", response_model=EnvelopeResponse) +async def get_book(book_id: UUID) -> EnvelopeResponse: logger.info(f"Retrieving book with ID: {book_id}") - for book in books_db: - if book.id == book_id: - return book - logger.error(f"Book with ID {book_id} not found") - raise HTTPException(status_code=404, detail="Book not found") + book = BookRetrieveService.retrieve(book_id) + return create_response_for_fast_api(data=book) + + +@router.put("/{book_id}", response_model=EnvelopeResponse) +async def update_book(book_id: UUID, updated: BookCreateSchema) -> EnvelopeResponse: + logger.info(f"Updating book with ID: {book_id}") + updated_book = BookUpdateService.update(book_id, updated) + logger.info(f"Successfully updated book with ID: {book_id}") + return create_response_for_fast_api(data=updated_book) -# Update a book -@router.put("/{book_id}", response_model=Book) -async def update_book(book_id: int, updated: BookCreate) -> Book: - logger.info(f"Attempting to update book with ID: {book_id}") - for i, book in enumerate(books_db): - if book.id == book_id: - updated_book = Book(id=book_id, **updated.model_dump()) - books_db[i] = updated_book - logger.info(f"Successfully updated book with ID: {book_id}") - return updated_book - logger.error(f"Book with ID {book_id} not found for update") - raise HTTPException(status_code=404, detail="Book not found") -# Delete a book @router.delete("/{book_id}", status_code=204) -async def delete_book(book_id: int) -> None: +async def delete_book(book_id: UUID) -> None: logger.info(f"Attempting to delete book with ID: {book_id}") - for i, book in enumerate(books_db): - if book.id == book_id: - books_db.pop(i) - logger.info(f"Successfully deleted book with ID: {book_id}") - return - logger.error(f"Book with ID {book_id} not found for deletion") - raise HTTPException(status_code=404, detail="Book not found") + BookDeleteService.delete(book_id) + logger.info(f"Successfully deleted book with ID: {book_id}") + return Response(status_code=204) diff --git a/src/api/v1/repositories.py b/src/api/v1/repositories.py new file mode 100644 index 0000000..e59ac07 --- /dev/null +++ b/src/api/v1/repositories.py @@ -0,0 +1,49 @@ +from db.posgresql import get_db_context +from db.posgresql.models.public import Book +from api.v1.schema import BookCreateSchema +from sqlalchemy import select + +class BookRepository: + + @staticmethod + def get_all() -> tuple[bool, list[Book]]: + with get_db_context() as session: + books = session.scalars(select(Book)).all() + return True, books + + @staticmethod + def get_by_id(book_id: int) -> tuple[bool, Book | None]: + with get_db_context() as session: + book = session.get(Book, book_id) + return (True, book) if book else (False, None) + + @staticmethod + def create(book_create: BookCreateSchema) -> tuple[bool, Book]: + with get_db_context() as session: + new_book = Book(**book_create.model_dump()) + session.add(new_book) + session.commit() + session.refresh(new_book) + return True, new_book + + @staticmethod + def update(book_id: int, book_update: BookCreateSchema) -> tuple[bool, Book | None]: + with get_db_context() as session: + book = session.get(Book, book_id) + if not book: + return False, None + for key, value in book_update.model_dump().items(): + setattr(book, key, value) + session.commit() + session.refresh(book) + return True, book + + @staticmethod + def delete(book_id: int) -> tuple[bool, None]: + with get_db_context() as session: + book = session.get(Book, book_id) + if not book: + return False, None + session.delete(book) + session.commit() + return True, None diff --git a/src/api/v1/schema.py b/src/api/v1/schema.py index 7b986c3..d020da4 100644 --- a/src/api/v1/schema.py +++ b/src/api/v1/schema.py @@ -1,15 +1,24 @@ -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, field_serializer +from db.posgresql.models.public import BookType +from uuid import UUID # Data model -class Book(BaseModel): - id: int +class BookSchema(BaseModel): + id: UUID title: str author: str year: int + type: BookType + model_config = ConfigDict(validate_by_name=False) + + @field_serializer('id') + def serialize_id(self, v: UUID, _info): + return str(v) # Create a new book -class BookCreate(BaseModel): +class BookCreateSchema(BaseModel): title: str author: str - year: int \ No newline at end of file + year: int + type: BookType \ No newline at end of file diff --git a/src/api/v1/services.py b/src/api/v1/services.py new file mode 100644 index 0000000..bfece7f --- /dev/null +++ b/src/api/v1/services.py @@ -0,0 +1,57 @@ +from api.v1.repositories import BookRepository +from api.v1.schema import BookSchema, BookCreateSchema +from core.exceptions import BookException +from uuid import UUID + + +class BooksListService: + @staticmethod + def list() -> list[BookSchema]: + success, list_books = BookRepository.get_all() + if not success: + raise BookException(message="Failed to retrieve books") + return [BookSchema(**book.to_dict()) for book in list_books] + + +class BookCreateService: + @staticmethod + def create(book_data: BookCreateSchema) -> BookSchema: + success, new_book = BookRepository.create(book_data) + if not success: + raise BookException(message="Failed to create book") + return BookSchema(**new_book.to_dict()) + + +class BookRetrieveService: + @staticmethod + def retrieve(book_id: UUID) -> BookSchema: + success, book = BookRepository.get_by_id(book_id) + if not success: + raise BookException( + message=f"Book with ID {book_id} not found", + data={"payload": {"book_id": str(book_id)}} + ) + return BookSchema(**book.to_dict()) + + +class BookUpdateService: + @staticmethod + def update(book_id: UUID, book_data: BookCreateSchema) -> BookSchema: + success, updated_book = BookRepository.update(book_id, book_data) + if not success: + raise BookException( + message=f"Book with ID {book_id} not found for update", + data={"payload": {"book_id": str(book_id)}} + ) + return BookSchema(**updated_book.to_dict()) + + +class BookDeleteService: + @staticmethod + def delete(book_id: UUID) -> None: + success, _ = BookRepository.delete(book_id) + if not success: + raise BookException( + message=f"Book with ID {book_id} not found for deletion", + data={"payload": {"book_id": str(book_id)}} + ) diff --git a/src/core/exceptions.py b/src/core/exceptions.py new file mode 100644 index 0000000..26dbd4c --- /dev/null +++ b/src/core/exceptions.py @@ -0,0 +1,7 @@ +from shared.base_exceptions import BaseApiRestException +from core.internal_codes import InternalCodesApiBook +import fastapi + +class BookException(BaseApiRestException): + GENERAL_ERROR_CODE = InternalCodesApiBook.BOOK_API_ERROR + GENERAL_STATUS_CODE_HTTP = fastapi.status.HTTP_400_BAD_REQUEST \ No newline at end of file diff --git a/src/core/internal_codes.py b/src/core/internal_codes.py new file mode 100644 index 0000000..ff782eb --- /dev/null +++ b/src/core/internal_codes.py @@ -0,0 +1,7 @@ +from shared.base_internal_codes import InternalCodeBase + +class InternalCodesApiBook(InternalCodeBase): + BOOK_API_ERROR = 1000, "Book API error" + BOOK_NOT_FOUND = 1001, "Book not found" + + \ No newline at end of file diff --git a/src/db/mongo/base.py b/src/db/mongo/base.py index 6975ffa..4ce31b2 100644 --- a/src/db/mongo/base.py +++ b/src/db/mongo/base.py @@ -3,7 +3,7 @@ from datetime import datetime from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ConfigDict, field_serializer from shared.utils_dates import get_app_current_time @@ -24,11 +24,12 @@ class BaseMongoDocument(BaseModel): updated_at: datetime = Field(default_factory=default_mongodb_created_at) deleted_at: datetime | None = Field(default=None) - class Config: - allow_population_by_field_name = False - json_encoders = { - UUID: lambda v: str(v), - } + + model_config = ConfigDict(validate_by_name=False) + + @field_serializer('id') + def serialize_id(self, v: UUID, _info): + return str(v) class MongoAbstractRepository(ABC): diff --git a/src/db/posgresql/base.py b/src/db/posgresql/base.py index bf35622..1701886 100644 --- a/src/db/posgresql/base.py +++ b/src/db/posgresql/base.py @@ -24,4 +24,10 @@ def updated_at(cls): @declared_attr def deleted_at(cls): - return Column(DateTime, nullable=True) \ No newline at end of file + return Column(DateTime, nullable=True) + + def to_dict(self): + return { + column.name: getattr(self, column.name) + for column in self.__table__.columns + } \ No newline at end of file diff --git a/src/db/posgresql/models/public/books.py b/src/db/posgresql/models/public/books.py index 5302864..b58d8d3 100644 --- a/src/db/posgresql/models/public/books.py +++ b/src/db/posgresql/models/public/books.py @@ -10,7 +10,4 @@ class Book(Base, BaseModel): title: str = Column(String, nullable=False) author: str = Column(String, nullable=False) year: int = Column(Integer, nullable=False) - type: BookType = Column(Enum(BookType), nullable=False) - - - \ No newline at end of file + type: BookType = Column(Enum(BookType), nullable=False) \ No newline at end of file diff --git a/src/main.py b/src/main.py index c645e31..c44f3b4 100644 --- a/src/main.py +++ b/src/main.py @@ -7,13 +7,20 @@ from api.endpoints import index_router from typing import Any from core.settings import settings +from shared.middlewares import ( + CatcherExceptions, + CatcherExceptionsPydantic +) +from fastapi.middleware import Middleware app = FastAPI( title=settings.PROJECT.NAME, version=settings.PROJECT.VERSION, description=settings.PROJECT.DESCRIPTION, root_path=settings.ROOT_PATH, - middleware=[] + middleware=[ + Middleware(CatcherExceptions) + ] ) @@ -34,5 +41,5 @@ def custom_openapi() -> dict[str, Any]: app.openapi = custom_openapi # type: ignore app.include_router(api_v1_router) app.include_router(index_router) - +CatcherExceptionsPydantic(app) handler = Mangum(app) diff --git a/src/shared/base_contextvars.py b/src/shared/base_contextvars.py new file mode 100644 index 0000000..cb5b926 --- /dev/null +++ b/src/shared/base_contextvars.py @@ -0,0 +1,4 @@ +from contextvars import ContextVar + +ctx_trace_id = ContextVar("ctx_trace_id", default=None) +ctx_caller_id = ContextVar("ctx_caller_id", default=None) \ No newline at end of file diff --git a/src/shared/base_exceptions.py b/src/shared/base_exceptions.py new file mode 100644 index 0000000..9e29c33 --- /dev/null +++ b/src/shared/base_exceptions.py @@ -0,0 +1,23 @@ +import fastapi +from loguru import logger +from shared.base_internal_codes import CommonInternalCode, InternalCode + +class BaseApiRestException(Exception): + GENERAL_STATUS_CODE_HTTP = fastapi.status.HTTP_400_BAD_REQUEST + GENERAL_ERROR_CODE = CommonInternalCode.UNKNOWN + + def __init__(self, + status_code_http: int = None, + error_code: InternalCode = None, + message: str | None = None, + data: dict[str, any] | None = None): + super().__init__(message) + self.status_code_http = status_code_http if status_code_http else self.GENERAL_STATUS_CODE_HTTP + self.error_code = error_code if error_code else self.GENERAL_ERROR_CODE + self.data = data + self.message = message + logger.warning(self.__str__()) + + def __str__(self): + return f"[{self.status_code_http}] {self.error_code.description}: {self.message}" + diff --git a/src/shared/base_internal_codes.py b/src/shared/base_internal_codes.py new file mode 100644 index 0000000..a7d89f1 --- /dev/null +++ b/src/shared/base_internal_codes.py @@ -0,0 +1,30 @@ +from enum import IntEnum +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class InternalCode(Protocol): + value: int + description: str + + def to_dict(self) -> dict[str, any]: ... + + +class InternalCodeBase(IntEnum): + def __new__(cls, value: int, description: str): + obj = int.__new__(cls, value) + obj._value_ = value + obj._description = description + return obj + + @property + def description(self) -> str: + return self._description + + def to_dict(self) -> dict: + return {"code": int(self), "description": self.description} + + +class CommonInternalCode(InternalCodeBase): + UNKNOWN = 100, "Unknown error" + PYDANTIC_VALIDATIONS_REQUEST = 8001, "Failed Pydantic validations on request" diff --git a/src/shared/base_responses.py b/src/shared/base_responses.py new file mode 100644 index 0000000..e0e17b5 --- /dev/null +++ b/src/shared/base_responses.py @@ -0,0 +1,67 @@ +from typing import TypeVar, Any +from pydantic import BaseModel +from shared.base_contextvars import ctx_trace_id +from fastapi.responses import JSONResponse +import fastapi +import json +from shared.base_internal_codes import InternalCode +from shared.base_internal_codes import CommonInternalCode as CC + +T = TypeVar("T", bound=InternalCode) + +class EnvelopeResponse(BaseModel): + success: bool + message: str + data: dict[str, Any] | list | None = None + trace_id: str | None = None + +class ErrorDetailResponse(BaseModel): + internal_error: dict[str, Any] + details: dict[str, Any] + + @staticmethod + def from_error_code(error_code: T | None = CC.UNKNOWN, details: dict[str, Any] | None = None) -> 'ErrorDetailResponse': + return ErrorDetailResponse( + internal_error={ + "code": error_code.value, + "description": error_code.description, + }, + details=details or {} + ).model_dump() + +def create_response_for_fast_api( + status_code_http: int = fastapi.status.HTTP_200_OK, + data: Any = None, + error_code: T | None = CC.UNKNOWN, + message: str | None = None +) -> JSONResponse: + success = 200 <= status_code_http < 300 + message = message or ("Operation successful" if success else "An error occurred") + + if isinstance(data,list): + if len(data) == 0: + data = None + else: + first_element = data[0] + if isinstance(first_element,BaseModel): + data = [element.model_dump(mode="json") for element in data] + + + elif isinstance(data, BaseModel): + data = data.model_dump_json() + data = json.loads(data) + + if not success: + data = ErrorDetailResponse.from_error_code(error_code=error_code, details=data) + + envelope_response = EnvelopeResponse( + success=success, + message=message, + data=data, + trace_id=ctx_trace_id.get() + ) + + return JSONResponse( + content=envelope_response.model_dump(), + status_code=status_code_http + ) \ No newline at end of file diff --git a/src/shared/middlewares/__init__.py b/src/shared/middlewares/__init__.py new file mode 100644 index 0000000..2d168b6 --- /dev/null +++ b/src/shared/middlewares/__init__.py @@ -0,0 +1,4 @@ +from .catcher_exceptions import CatcherExceptions +from .catcher_pydantic_errors import CatcherExceptionsPydantic + +__all__ = ["CatcherExceptions", "CatcherExceptionsPydantic"] diff --git a/src/shared/middlewares/catcher_exceptions.py b/src/shared/middlewares/catcher_exceptions.py new file mode 100644 index 0000000..5afb23f --- /dev/null +++ b/src/shared/middlewares/catcher_exceptions.py @@ -0,0 +1,42 @@ +from fastapi import HTTPException, Request, status +from fastapi.responses import JSONResponse +from sqlalchemy.orm.exc import NoResultFound +from starlette.middleware.base import BaseHTTPMiddleware + +from shared.base_exceptions import BaseApiRestException +from shared.base_responses import create_response_for_fast_api +from shared.base_internal_codes import CommonInternalCode + +class CatcherExceptions(BaseHTTPMiddleware): + def _init_(self, app): + super()._init_(app) + + async def dispatch(self, request: Request, call_next): + try: + return await call_next(request) + except Exception as e: # noqa: BLE001 + internal_error = CommonInternalCode.UNKNOWN + error_message = None + error_data = None + status_code_http = status.HTTP_500_INTERNAL_SERVER_ERROR + if isinstance(e, HTTPException): + error_data = {"detail": str(e.detail)} + status_code_http = e.status_code + if isinstance(e, NoResultFound): + error_data = {"detail": f"No found: {e}"} + status_code_http = status.HTTP_404_NOT_FOUND + elif isinstance(e, BaseApiRestException): + error_data = e.data + error_message = e.message + status_code_http = e.status_code_http + internal_error = e.error_code + else: + error_data = {"detail": str(e)} + status_code_http = status.HTTP_500_INTERNAL_SERVER_ERROR + + return create_response_for_fast_api( + status_code_http=status_code_http, + data=error_data, + error_code=internal_error, + message=error_message + ) \ No newline at end of file diff --git a/src/shared/middlewares/catcher_pydantic_errors.py b/src/shared/middlewares/catcher_pydantic_errors.py new file mode 100644 index 0000000..47c2905 --- /dev/null +++ b/src/shared/middlewares/catcher_pydantic_errors.py @@ -0,0 +1,24 @@ +from collections import defaultdict + +from fastapi import FastAPI, status +from fastapi.exceptions import RequestValidationError +from fastapi.requests import Request +from shared.base_internal_codes import CommonInternalCode + +from shared.base_responses import create_response_for_fast_api + + +def CatcherExceptionsPydantic(app: FastAPI): + @app.exception_handler(RequestValidationError) + async def validate(_: Request, exc: Exception): + error_detail = defaultdict(list) + for error in exc.errors(): + field = error["loc"][1] if "loc" in error else None + error_msg = error["msg"] + error_detail[field].append(error_msg) + + return create_response_for_fast_api( + data=error_detail, + status_code_http=status.HTTP_400_BAD_REQUEST, + error_code=CommonInternalCode.PYDANTIC_VALIDATIONS_REQUEST + ) \ No newline at end of file diff --git a/src/tests/__init__.py b/src/tests/__init__.py index e22c3f8..bad3e76 100644 --- a/src/tests/__init__.py +++ b/src/tests/__init__.py @@ -7,7 +7,8 @@ from shared.environment import AppEnvironment from tests.create_databases import prepare_database -if settings.ENVIRONMENT in [AppEnvironment.TESTING, AppEnvironment.TESTING_DOCKER]: +logger.info(f"Check if ENVIRONMENT=testing: {settings.ENVIRONMENT} in {AppEnvironment.TESTING.value} or {AppEnvironment.TESTING_DOCKER.value}") +if settings.ENVIRONMENT in [AppEnvironment.TESTING.value, AppEnvironment.TESTING_DOCKER.value]: logger.info("Preparing database for tests") prepare_database( schemas_to_create=["public"], diff --git a/src/tests/test_init.py b/src/tests/test_init.py deleted file mode 100644 index a2befce..0000000 --- a/src/tests/test_init.py +++ /dev/null @@ -1,108 +0,0 @@ -import unittest -from fastapi.testclient import TestClient -from main import app -from api.v1.endpoints import books_db, counter_id -from unittest import TestCase - -class TestBooksAPI(TestCase): - def setUp(self) -> None: - # Reset global state before each test - global books_db, counter_id - books_db.clear() - counter_id = 0 - self.client = TestClient(app) - - def test_read_root(self) -> None: - response = self.client.get("/") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), {"message": "Books API πŸ“š"}) - - def test_get_books_empty(self) -> None: - response = self.client.get("/v1/books") - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json(), []) - - def test_create_book(self) -> None: - payload = {"title": "Test Book", "author": "Author A", "year": 2020} - response = self.client.post("/v1/books", json=payload) - self.assertEqual(response.status_code, 201) - data = response.json() - self.assertEqual(data["id"], 1) - self.assertEqual(data["title"], payload["title"]) - self.assertEqual(data["author"], payload["author"]) - self.assertEqual(data["year"], payload["year"]) - - def test_get_book(self) -> None: - # First create a book - payload = {"title": "Test Book", "author": "Author A", "year": 2020} - create_response = self.client.post("/v1/books", json=payload) - book_id = create_response.json()["id"] - - # Retrieve the created book - response = self.client.get(f"/v1/books/{book_id}") - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["id"], book_id) - self.assertEqual(data["title"], payload["title"]) - - def test_update_book(self) -> None: - # Create a book - payload = {"title": "Old Title", "author": "Author A", "year": 2020} - create_response = self.client.post("/v1/books", json=payload) - book_id = create_response.json()["id"] - - # Update the book - updated_payload = {"title": "New Title", "author": "Author B", "year": 2021} - response = self.client.put(f"/v1/books/{book_id}", json=updated_payload) - self.assertEqual(response.status_code, 200) - data = response.json() - self.assertEqual(data["id"], book_id) - self.assertEqual(data["title"], updated_payload["title"]) - self.assertEqual(data["author"], updated_payload["author"]) - self.assertEqual(data["year"], updated_payload["year"]) - - def test_delete_book(self) -> None: - # Create a book to delete later - payload = {"title": "Delete Me", "author": "Author A", "year": 2020} - create_response = self.client.post("/v1/books", json=payload) - book_id = create_response.json()["id"] - - # Delete the book - response = self.client.delete(f"/v1/books/{book_id}") - self.assertEqual(response.status_code, 204) - - # Verify the book no longer exists - get_response = self.client.get(f"/v1/books/{book_id}") - self.assertEqual(get_response.status_code, 404) - self.assertEqual(get_response.json()["detail"], "Book not found") - - def test_get_nonexistent_book(self) -> None: - response = self.client.get("/v1/books/999") - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["detail"], "Book not found") - - def test_update_nonexistent_book(self) -> None: - updated_payload = {"title": "New Title", "author": "Author B", "year": 2021} - response = self.client.put("/v1/books/999", json=updated_payload) - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["detail"], "Book not found") - - def test_delete_nonexistent_book(self) -> None: - response = self.client.delete("/v1/books/999") - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["detail"], "Book not found") - - def test_openapi_schema(self) -> None: - response = self.client.get("/openapi.json") - self.assertEqual(response.status_code, 200) - data = response.json() - # Verify that the openapi version is the forced one - self.assertEqual(data["openapi"], "3.0.3") - self.assertEqual(data["info"]["title"], "Books API") - self.assertEqual(data["info"]["version"], "1.0.0") - - def tearDown(self) -> None: - # Reset global state after each test - global books_db, counter_id - books_db.clear() - counter_id = 0 diff --git a/src/tests/test_repositories.py b/src/tests/test_repositories.py new file mode 100644 index 0000000..4239e43 --- /dev/null +++ b/src/tests/test_repositories.py @@ -0,0 +1,38 @@ +import unittest +from api.v1.repositories import BookRepository +from .utils import DBMixin + +# ───────────────────────── TESTS REPOSITORIO ────────────────────────── # + +class TestBooksRepository(DBMixin, unittest.TestCase): + + def test_repository_crud(self): + # CREATE + ok, book = BookRepository.create(self.create_schema()) + self.assertTrue(ok) + self.assertIsNotNone(book.id) + + # GET ALL + ok, all_books = BookRepository.get_all() + self.assertTrue(ok) + self.assertEqual(len(all_books), 1) + + # GET BY ID + ok, fetched = BookRepository.get_by_id(book.id) + self.assertTrue(ok) + self.assertEqual(fetched.title, book.title) + + # UPDATE + new_schema = self.create_schema(title="Pragmatic Programmer", year=1999) + ok, updated = BookRepository.update(book.id, new_schema) + self.assertTrue(ok) + self.assertEqual(updated.title, "Pragmatic Programmer") + + # DELETE + ok, _ = BookRepository.delete(book.id) + self.assertTrue(ok) + + # Ya no existe + ok, none = BookRepository.get_by_id(book.id) + self.assertFalse(ok) + self.assertIsNone(none) diff --git a/src/tests/test_responses.py b/src/tests/test_responses.py new file mode 100644 index 0000000..20160d4 --- /dev/null +++ b/src/tests/test_responses.py @@ -0,0 +1,17 @@ +import unittest +from .utils import DBMixin + +class TestEnvelopeFormat(DBMixin, unittest.TestCase): + """Comprueba estructura genΓ©rica del sobre de respuesta.""" + + def test_envelope_keys(self): + res = self.client.get("/v1/books") + env = res.json() + self.assertSetEqual( + set(env.keys()), {"success", "message", "data", "trace_id"} + ) + self.assertIsInstance(env["success"], bool) + self.assertTrue(env["message"]) + # trace_id puede ser None o str + self.assertTrue(env["trace_id"] is None or isinstance(env["trace_id"], str)) + diff --git a/src/tests/test_services.py b/src/tests/test_services.py new file mode 100644 index 0000000..ddb049d --- /dev/null +++ b/src/tests/test_services.py @@ -0,0 +1,51 @@ +import json +import uuid +import unittest +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import text + +from main import app +from db.posgresql import get_db_context +from db.posgresql.models.public import BookType +from api.v1.schema import BookCreateSchema +from api.v1.repositories import BookRepository +from api.v1.services import ( + BooksListService, + BookCreateService, + BookRetrieveService, + BookUpdateService, + BookDeleteService, +) +from .utils import DBMixin + +# ───────────────────────── TESTS SERVICIOS ────────────────────────── # + +class TestBooksServices(DBMixin, unittest.TestCase): + + def test_service_create_retrieve_update_delete_flow(self): + # CREATE + schema = self.create_schema() + created = BookCreateService.create(schema) + self.assertEqual(created.title, schema.title) + + # LIST debe contener el nuevo + ids = [b.id for b in BooksListService.list()] + self.assertIn(created.id, ids) + + # RETRIEVE + fetched = BookRetrieveService.retrieve(created.id) + self.assertEqual(fetched.author, schema.author) + + # UPDATE + upd_schema = self.create_schema(title="DDD Updated", year=2004) + updated = BookUpdateService.update(created.id, upd_schema) + self.assertEqual(updated.title, "DDD Updated") + self.assertEqual(updated.year, 2004) + + # DELETE + BookDeleteService.delete(created.id) + with self.assertRaises(Exception): + BookRetrieveService.retrieve(created.id) + diff --git a/src/tests/tests_endpoints.py b/src/tests/tests_endpoints.py new file mode 100644 index 0000000..e33dafc --- /dev/null +++ b/src/tests/tests_endpoints.py @@ -0,0 +1,94 @@ +import uuid +import unittest +from .utils import DBMixin + + +# ───────────────────────── TESTS ENDPOINTS ────────────────────────── # + +class TestBooksEndpoints(DBMixin, unittest.TestCase): + + # ---------- GET /books vacΓ­o ---------- # + def test_list_books_empty(self): + res = self.client.get("/v1/books") + self.assertEqual(res.status_code, 200) + env = res.json() + self.assertTrue(env["success"]) + self.assertIsNone(env["data"]) + + # ---------- POST /books ---------- # + def test_create_book_success(self): + res = self.client.post("/v1/books", json=self.payload()) + self.assertEqual(res.status_code, 201) + env = res.json() + book = env["data"] + # id vΓ‘lido + uuid.UUID(book["id"]) + self.assertEqual(book["title"], "Clean Code") + + def test_create_book_missing_field(self): + bad = self.payload() + bad.pop("title") + res = self.client.post("/v1/books", json=bad) + self.assertEqual(res.status_code, 422) # validation error + + # ---------- GET /books/{id} ---------- # + def test_get_book_success(self): + book_id = self.client.post("/v1/books", json=self.payload()) \ + .json()["data"]["id"] + + res = self.client.get(f"/v1/books/{book_id}") + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["data"]["id"], book_id) + + def test_get_book_not_found(self): + fake = "11111111-1111-1111-1111-111111111111" + res = self.client.get(f"/v1/books/{fake}") + self.assertEqual(res.status_code, 404) + env = res.json() + self.assertFalse(env["success"]) + self.assertEqual( + env["data"]["internal_error"]["code"], "BOOK_NOT_FOUND" + ) + + # ---------- PUT /books/{id} ---------- # + def test_update_book_success(self): + book_id = self.client.post("/v1/books", json=self.payload()) \ + .json()["data"]["id"] + + new_payload = self.payload(title="Clean Architecture", year=2017) + res = self.client.put(f"/v1/books/{book_id}", json=new_payload) + self.assertEqual(res.status_code, 200) + book = res.json()["data"] + self.assertEqual(book["title"], "Clean Architecture") + self.assertEqual(book["year"], 2017) + + def test_update_book_not_found(self): + fake = "22222222-2222-2222-2222-222222222222" + res = self.client.put(f"/v1/books/{fake}", json=self.payload()) + self.assertEqual(res.status_code, 404) + + # ---------- DELETE /books/{id} ---------- # + def test_delete_book_success(self): + book_id = self.client.post("/v1/books", json=self.payload()) \ + .json()["data"]["id"] + + res = self.client.delete(f"/v1/books/{book_id}") + self.assertEqual(res.status_code, 204) + + # ya no existe + res = self.client.get(f"/v1/books/{book_id}") + self.assertEqual(res.status_code, 404) + + def test_delete_book_not_found(self): + fake = "33333333-3333-3333-3333-333333333333" + res = self.client.delete(f"/v1/books/{fake}") + self.assertEqual(res.status_code, 404) + + # ---------- OPENAPI ---------- # + def test_openapi_schema_version(self): + res = self.client.get("/openapi.json") + self.assertEqual(res.status_code, 200) + data = res.json() + self.assertEqual(data["openapi"], "3.0.3") + self.assertEqual(data["info"]["title"], "Books API") + diff --git a/src/tests/utils.py b/src/tests/utils.py new file mode 100644 index 0000000..3e78219 --- /dev/null +++ b/src/tests/utils.py @@ -0,0 +1,50 @@ +from typing import Any + +from fastapi.testclient import TestClient +from sqlalchemy import text + +from main import app +from db.posgresql import get_db_context +from db.posgresql.models.public import BookType +from api.v1.schema import BookCreateSchema + +class DBMixin: + """MΓ©todos auxiliares para limpiar la tabla `book` entre tests.""" + + @staticmethod + def _truncate_books() -> None: + with get_db_context() as session: + session.execute( + text("TRUNCATE TABLE public.books RESTART IDENTITY CASCADE;") + ) + session.commit() + + def setUp(self) -> None: # se ejecuta antes de *cada* test + self._truncate_books() + self.client = TestClient(app) + + def tearDown(self) -> None: # limpieza final + self._truncate_books() + + # ---------- datos de apoyo ---------- # + @staticmethod + def payload(**overrides: Any) -> dict[str, Any]: + data = { + "title": "Clean Code", + "author": "Robert C. Martin", + "year": 2008, + "type": BookType.ONLINE.value, # Enum β†’ string para JSON + } + data.update(overrides) + return data + + @staticmethod + def create_schema(**overrides: Any) -> BookCreateSchema: + base = dict( + title="Domain-Driven Design", + author="Eric Evans", + year=2003, + type=BookType.ONLINE, + ) + base.update(overrides) + return BookCreateSchema(**base)