diff --git a/.gitignore b/.gitignore index aa0926b..adc6fc3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ # macOS .DS_Store + +# Environment files containing secrets +.env diff --git a/Dockerfile b/Dockerfile index b482401..4b6119e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -FROM python:3.11 -RUN apt-get update && apt-get install -y libpq-dev +FROM python:3.11-slim +RUN apt-get update && apt-get install -y libpq-dev && rm -rf /var/lib/apt/lists/* COPY . /app WORKDIR /app -RUN pip install -r requirements.txt +RUN pip install --upgrade pip && pip install -r requirements.txt +RUN adduser --disabled-password --gecos "" appuser && chown -R appuser /app +USER appuser EXPOSE 8000 -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "trace"] +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"] diff --git a/conftest.py b/conftest.py index b9cb0aa..3e93a1a 100644 --- a/conftest.py +++ b/conftest.py @@ -1,46 +1,70 @@ +"""Pytest configuration and shared fixtures for the Books API test suite.""" + +import os + +# Set DATABASE_URL before any application modules are imported so that +# dependencies.py does not raise EnvironmentError during test collection. +# The actual test fixtures use their own in-memory SQLite engines, so this +# value is only used for the initial module-level import guard. +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") + import pytest -from sqlalchemy import create_engine, inspect, text +from sqlalchemy import create_engine, inspect from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool from fastapi.testclient import TestClient from main import create_app -from dependencies import get_db, database_url +from dependencies import get_db from models import Base + +def _make_memory_engine(): + """Return a fresh in-memory SQLite engine with the schema applied.""" + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + return engine + + @pytest.fixture(scope="session") def test_engine(): - """Create test database engine""" - test_engine = create_engine(database_url) - Base.metadata.create_all(bind=test_engine) - yield test_engine - Base.metadata.drop_all(bind=test_engine) + """Session-scoped engine used by application-level tests.""" + engine = _make_memory_engine() + yield engine + engine.dispose() @pytest.fixture(scope="function") -def test_db(test_engine): - """Create test database session that rolls back after each test""" - connection = test_engine.connect() - transaction = connection.begin() - db = sessionmaker( - autocommit=False, autoflush=False, bind=connection, - join_transaction_mode="create_savepoint" - )() +def test_db(): + """ + Function-scoped DB session backed by a fresh in-memory SQLite database. + + A new engine (and therefore a new empty database) is created for every + test function, giving full isolation without relying on savepoint rollback + semantics that differ between SQLite and PostgreSQL. + """ + engine = _make_memory_engine() + db = sessionmaker(autocommit=False, autoflush=False, bind=engine)() try: yield db finally: db.close() - transaction.rollback() - connection.close() + engine.dispose() @pytest.fixture -def test_app(test_engine): - """Create test FastAPI application with test database""" +def test_app(): + """ + Create a test FastAPI application backed by a fresh in-memory SQLite + database for each test function. + """ + engine = _make_memory_engine() def override_get_db(): - TestingSessionLocal = sessionmaker( - autocommit=False, autoflush=False, bind=test_engine - ) - db = TestingSessionLocal() + db = sessionmaker(autocommit=False, autoflush=False, bind=engine)() try: yield db finally: @@ -48,10 +72,11 @@ def override_get_db(): app = create_app() app.dependency_overrides[get_db] = override_get_db - return app + yield app + engine.dispose() @pytest.fixture def client(test_app): - """Create test client""" + """Return a TestClient for the test application.""" return TestClient(test_app) diff --git a/docker-compose.yml b/docker-compose.yml index d222eda..e4222f3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,7 +24,7 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - - "5432:5432" + - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./db.sql:/docker-entrypoint-initdb.d/db.sql diff --git a/models.py b/models.py index dd91182..40eba37 100644 --- a/models.py +++ b/models.py @@ -1,3 +1,5 @@ +"""Database and Pydantic models for the Books API.""" + from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from sqlalchemy import String from pydantic import BaseModel, ConfigDict @@ -7,32 +9,33 @@ class Base(DeclarativeBase): """Base class for all database models""" - pass - class Book(Base): - """Book model""" + """Book ORM model representing the books table.""" __tablename__ = "books" id: Mapped[int] = mapped_column(primary_key=True, index=True) title: Mapped[str] = mapped_column(String(255), index=True) author: Mapped[str] = mapped_column(String(255)) - isbn: Mapped[str] = mapped_column(String(255), nullable=False) + isbn: Mapped[str] = mapped_column(String(20), unique=True, index=True) + # Pydantic models class BookIn(BaseModel): - """Pydantic model for book input""" + """Pydantic model for book input (create / update).""" title: str author: str + isbn: str class BookOut(BaseModel): - """Pydantic model for book output""" + """Pydantic model for book output (API responses).""" id: int title: str author: str + isbn: str model_config = ConfigDict(from_attributes=True) diff --git a/repositories.py b/repositories.py index dc9ff88..5044daf 100644 --- a/repositories.py +++ b/repositories.py @@ -1,40 +1,85 @@ +"""Repository layer: database CRUD operations for books.""" + from sqlalchemy.orm import Session import models -# Create a new book -def create_book(db: Session, book: models.BookIn): - db_book = models.Book(title=book.title, author=book.author) +def create_book(db: Session, book: models.BookIn) -> models.Book: + """Create and persist a new book record. + + Args: + db: Active database session. + book: Validated input data for the new book. + + Returns: + The newly created Book ORM instance. + """ + db_book = models.Book(title=book.title, author=book.author, isbn=book.isbn) db.add(db_book) db.commit() db.refresh(db_book) return db_book -# Get all books -def get_books(db: Session, skip: int = 0, limit: int = 10): +def get_books(db: Session, skip: int = 0, limit: int = 10) -> list[models.Book]: + """Return a paginated list of books. + + Args: + db: Active database session. + skip: Number of records to skip (offset). + limit: Maximum number of records to return. + + Returns: + List of Book ORM instances. + """ return db.query(models.Book).offset(skip).limit(limit).all() -# Get a book by ID -def get_book(db: Session, book_id: int): +def get_book(db: Session, book_id: int) -> models.Book | None: + """Fetch a single book by primary key. + + Args: + db: Active database session. + book_id: Primary key of the book. + + Returns: + The matching Book ORM instance, or None if not found. + """ return db.query(models.Book).filter(models.Book.id == book_id).first() -# Update a book -def update_book(db: Session, book_id: int, book: models.BookIn): +def update_book(db: Session, book_id: int, book: models.BookIn) -> models.Book | None: + """Update an existing book record. + + Args: + db: Active database session. + book_id: Primary key of the book to update. + book: Validated input data with updated fields. + + Returns: + The updated Book ORM instance, or None if not found. + """ db_book = db.query(models.Book).filter(models.Book.id == book_id).first() if db_book: db_book.title = book.title db_book.author = book.author + db_book.isbn = book.isbn db.commit() db.refresh(db_book) return db_book return None -# Delete a book -def delete_book(db: Session, book_id: int): +def delete_book(db: Session, book_id: int) -> models.Book | None: + """Delete a book record by primary key. + + Args: + db: Active database session. + book_id: Primary key of the book to delete. + + Returns: + The deleted Book ORM instance, or None if not found. + """ db_book = db.query(models.Book).filter(models.Book.id == book_id).first() if db_book: db.delete(db_book) diff --git a/routers.py b/routers.py index db667c3..c73c1f9 100644 --- a/routers.py +++ b/routers.py @@ -1,10 +1,17 @@ +"""FastAPI router: HTTP endpoints for the Books API.""" + +import logging +from typing import List + from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from typing import List + import models import repositories from dependencies import get_db +logger = logging.getLogger(__name__) + # Create router with prefix router = APIRouter() @@ -13,28 +20,37 @@ "/books/", response_model=models.BookOut, status_code=status.HTTP_201_CREATED ) def create_book(book: models.BookIn, db: Session = Depends(get_db)): + """Create a new book record.""" try: return repositories.create_book(db=db, book=book) except Exception as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + logger.error("Failed to create book: %s", e, exc_info=True) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Could not create book.", + ) from e @router.get( "/books/", response_model=List[models.BookOut], status_code=status.HTTP_200_OK ) def get_books(skip: int = 0, limit: int = 10, db: Session = Depends(get_db)): + """Return a paginated list of books.""" try: return repositories.get_books(db=db, skip=skip, limit=limit) except Exception as e: + logger.error("Failed to retrieve books: %s", e, exc_info=True) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) - ) + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Could not retrieve books.", + ) from e @router.get( "/books/{book_id}", response_model=models.BookOut, status_code=status.HTTP_200_OK ) def get_book(book_id: int, db: Session = Depends(get_db)): + """Return a single book by ID.""" db_book = repositories.get_book(db=db, book_id=book_id) if db_book is None: raise HTTPException( @@ -47,6 +63,7 @@ def get_book(book_id: int, db: Session = Depends(get_db)): "/books/{book_id}", response_model=models.BookOut, status_code=status.HTTP_200_OK ) def update_book(book_id: int, book: models.BookIn, db: Session = Depends(get_db)): + """Update an existing book by ID.""" db_book = repositories.update_book(db=db, book_id=book_id, book=book) if db_book is None: raise HTTPException( @@ -59,6 +76,7 @@ def update_book(book_id: int, book: models.BookIn, db: Session = Depends(get_db) "/books/{book_id}", response_model=models.BookOut, status_code=status.HTTP_200_OK ) def delete_book(book_id: int, db: Session = Depends(get_db)): + """Delete a book by ID.""" db_book = repositories.delete_book(db=db, book_id=book_id) if db_book is None: raise HTTPException( diff --git a/test_main.py b/test_main.py index 3fea397..81248e8 100644 --- a/test_main.py +++ b/test_main.py @@ -1,71 +1,206 @@ +"""Tests for the Books API: repository layer and HTTP router endpoints.""" + +from sqlalchemy import inspect + from repositories import create_book, get_books, get_book, update_book, delete_book from models import BookIn -from sqlalchemy import create_engine, inspect +# --------------------------------------------------------------------------- # Test data constants +# --------------------------------------------------------------------------- + TEST_BOOKS = [ - {"title": "Carrie", "author": "Stephen King"}, - {"title": "Ready Player One", "author": "Ernest Cline"}, + {"title": "Carrie", "author": "Stephen King", "isbn": "9780385086950"}, + {"title": "Ready Player One", "author": "Ernest Cline", "isbn": "9780307887436"}, ] + +# --------------------------------------------------------------------------- +# Application-level tests +# --------------------------------------------------------------------------- + class TestMainApp: def test_create_app(self, test_app): - """Test application creation""" + """Test application creation.""" assert test_app is not None def test_database_initialization(self, test_engine): - """Test database initialization""" + """Test database initialization.""" inspector = inspect(test_engine) assert "books" in inspector.get_table_names() -# Repository Tests + +# --------------------------------------------------------------------------- +# Repository tests +# --------------------------------------------------------------------------- + class TestBookRepository: def test_create_book(self, test_db): - """Test creating a new book""" + """Test creating a new book.""" book = create_book(test_db, BookIn(**TEST_BOOKS[0])) assert book.title == TEST_BOOKS[0]["title"] assert book.author == TEST_BOOKS[0]["author"] + assert book.isbn == TEST_BOOKS[0]["isbn"] assert book.id is not None def test_get_books(self, test_db): - """Test getting all books""" + """Test getting all books.""" book1 = create_book(test_db, BookIn(**TEST_BOOKS[0])) book2 = create_book(test_db, BookIn(**TEST_BOOKS[1])) books = get_books(test_db) - #assert len(books) >= 2 + assert len(books) == 2 assert any(b.id == book1.id for b in books) assert any(b.id == book2.id for b in books) def test_get_book(self, test_db): - """Test getting a specific book""" + """Test getting a specific book.""" created_book = create_book(test_db, BookIn(**TEST_BOOKS[0])) retrieved_book = get_book(test_db, created_book.id) assert retrieved_book is not None assert retrieved_book.id == created_book.id assert retrieved_book.title == TEST_BOOKS[0]["title"] + assert retrieved_book.isbn == TEST_BOOKS[0]["isbn"] def test_update_book(self, test_db): - """Test updating a book""" + """Test updating a book including its ISBN.""" book = create_book(test_db, BookIn(**TEST_BOOKS[0])) updated_data = BookIn(**TEST_BOOKS[1]) updated_book = update_book(test_db, book.id, updated_data) assert updated_book is not None assert updated_book.title == TEST_BOOKS[1]["title"] assert updated_book.author == TEST_BOOKS[1]["author"] + assert updated_book.isbn == TEST_BOOKS[1]["isbn"] def test_delete_book(self, test_db): - """Test deleting a book""" - book = create_book(test_db, BookIn(title="To Delete", author="Author")) + """Test deleting a book.""" + book = create_book( + test_db, BookIn(title="To Delete", author="Author", isbn="9780000000001") + ) deleted_book = delete_book(test_db, book.id) assert deleted_book is not None assert deleted_book.id == book.id assert get_book(test_db, book.id) is None - def test_nonexistent_operations(self, test_db): - """Test operations on nonexistent books""" + def test_nonexistent_get(self, test_db): + """Test get on a nonexistent book returns None.""" assert get_book(test_db, 999999) is None - assert update_book(test_db, 999999, BookIn(title="Test", author="Test")) is None + + def test_nonexistent_update(self, test_db): + """Test update on a nonexistent book returns None.""" + assert update_book( + test_db, 999999, BookIn(title="Test", author="Test", isbn="9780000000002") + ) is None + + def test_nonexistent_delete(self, test_db): + """Test delete on a nonexistent book returns None.""" assert delete_book(test_db, 999999) is None + + +# --------------------------------------------------------------------------- +# HTTP router (integration) tests +# --------------------------------------------------------------------------- + +class TestBookRouter: + def test_create_book_success(self, client): + """POST /api/books/ with valid payload returns 201 and the new book.""" + payload = {"title": "Dune", "author": "Frank Herbert", "isbn": "9780441013593"} + response = client.post("/api/books/", json=payload) + assert response.status_code == 201 + data = response.json() + assert data["title"] == payload["title"] + assert data["author"] == payload["author"] + assert data["isbn"] == payload["isbn"] + assert "id" in data + + def test_create_book_missing_isbn_returns_422(self, client): + """POST /api/books/ without isbn should return 422 Unprocessable Entity.""" + payload = {"title": "Dune", "author": "Frank Herbert"} + response = client.post("/api/books/", json=payload) + assert response.status_code == 422 + + def test_get_books(self, client): + """GET /api/books/ returns a list of books.""" + client.post( + "/api/books/", + json={"title": "Book A", "author": "Author A", "isbn": "9780000000010"}, + ) + client.post( + "/api/books/", + json={"title": "Book B", "author": "Author B", "isbn": "9780000000011"}, + ) + response = client.get("/api/books/") + assert response.status_code == 200 + books = response.json() + assert isinstance(books, list) + assert len(books) >= 2 + + def test_get_books_pagination(self, client): + """GET /api/books/?skip=0&limit=1 returns at most one book.""" + client.post( + "/api/books/", + json={"title": "Pag A", "author": "Author", "isbn": "9780000000020"}, + ) + client.post( + "/api/books/", + json={"title": "Pag B", "author": "Author", "isbn": "9780000000021"}, + ) + response = client.get("/api/books/?skip=0&limit=1") + assert response.status_code == 200 + assert len(response.json()) == 1 + + def test_get_single_book_success(self, client): + """GET /api/books/{id} returns the correct book.""" + created = client.post( + "/api/books/", + json={"title": "Neuromancer", "author": "William Gibson", "isbn": "9780441569595"}, + ).json() + response = client.get(f"/api/books/{created['id']}") + assert response.status_code == 200 + assert response.json()["isbn"] == "9780441569595" + + def test_get_nonexistent_book_returns_404(self, client): + """GET /api/books/999999 returns 404.""" + response = client.get("/api/books/999999") + assert response.status_code == 404 + + def test_update_book_success(self, client): + """PUT /api/books/{id} updates all fields including isbn.""" + created = client.post( + "/api/books/", + json={"title": "Old Title", "author": "Old Author", "isbn": "9780000000030"}, + ).json() + update_payload = {"title": "New Title", "author": "New Author", "isbn": "9780000000031"} + response = client.put(f"/api/books/{created['id']}", json=update_payload) + assert response.status_code == 200 + data = response.json() + assert data["title"] == "New Title" + assert data["author"] == "New Author" + assert data["isbn"] == "9780000000031" + + def test_update_nonexistent_book_returns_404(self, client): + """PUT /api/books/999999 returns 404.""" + response = client.put( + "/api/books/999999", + json={"title": "X", "author": "Y", "isbn": "9780000000032"}, + ) + assert response.status_code == 404 + + def test_delete_book_success(self, client): + """DELETE /api/books/{id} removes the book and returns it.""" + created = client.post( + "/api/books/", + json={"title": "To Delete", "author": "Author", "isbn": "9780000000040"}, + ).json() + response = client.delete(f"/api/books/{created['id']}") + assert response.status_code == 200 + assert response.json()["id"] == created["id"] + # Confirm it is gone + assert client.get(f"/api/books/{created['id']}").status_code == 404 + + def test_delete_nonexistent_book_returns_404(self, client): + """DELETE /api/books/999999 returns 404.""" + response = client.delete("/api/books/999999") + assert response.status_code == 404