Skip to content

Commit

Permalink
Add Catalogue APIs and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
subhashb committed Jul 24, 2024
1 parent fbc628d commit f54b0ef
Show file tree
Hide file tree
Showing 15 changed files with 1,007 additions and 46 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:

- name: Test with pytest
run: |
pytest
pytest tests/lending/ tests/catalogue/
- name: Upload coverage reports to Codecov
uses: codecov/[email protected]
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,5 @@ cython_debug/

.DS_Store
*.todo
todos
todos
*.db
717 changes: 694 additions & 23 deletions poetry.lock

Large diffs are not rendered by default.

15 changes: 8 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ classifiers=[
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3 :: Only",
]
packages = [
{ include = "lending", from = "src" },
{ include = "catalogue", from = "src" },
]

[tool.poetry.dependencies]
python = "^3.11"
protean = {version = "0.12.1", extras=[] }
# protean = {path = "../../protean", develop = true}
# protean = {version = "0.12.1", extras=[] }
protean = { git = "https://github.com/proteanhq/protean.git", branch = "main" }
sqlalchemy = "^2.0.31"
fastapi = "^0.111.1"

[tool.poetry.group.test]
optional = true
Expand All @@ -36,11 +42,6 @@ build-backend = "poetry.core.masonry.api"

# TOOLING #

[tool.pytest.ini_options]
addopts = [
"tests/lending",
]

[tool.ruff.lint.isort]
known-first-party = ["lending"]
known-third-party = ["protean"]
20 changes: 20 additions & 0 deletions src/catalogue/database.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from sqlalchemy import create_engine, event
from sqlalchemy.orm import declarative_base, sessionmaker

SQLALCHEMY_DATABASE_URL = "sqlite:///./books.db"

engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
)


@event.listens_for(engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()


SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

Base = declarative_base()
59 changes: 59 additions & 0 deletions src/catalogue/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from typing import Annotated

from fastapi import Depends, FastAPI
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from starlette import status

from .database import Base, SessionLocal, engine
from .models import Book, BookInstance

app = FastAPI()


Base.metadata.create_all(bind=engine)


def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()


db_dependency = Annotated[Session, Depends(get_db)]


class BookRequest(BaseModel):
isbn: str = Field(min_length=13, max_length=13)
title: str = Field(min_length=1, max_length=255)
summary: str = Field(max_length=1024)
price: float = Field(gt=0)


# Pydantic model for a book instance
class BookInstanceRequest(BaseModel):
isbn: str = Field(min_length=13, max_length=13)
is_circulating: bool = Field(default=True)


@app.post("/book", status_code=status.HTTP_201_CREATED)
async def add_book(db: db_dependency, book_request: BookRequest):
new_book = Book(**book_request.model_dump())

db.add(new_book)
db.commit()
return {"message": "Book added successfully"}


# Add a book instance to the catalogue
@app.post("/book_instance", status_code=status.HTTP_201_CREATED)
async def add_book_instance(
db: db_dependency, book_instance_request: BookInstanceRequest
):
new_book_instance = BookInstance(**book_instance_request.model_dump())

db.add(new_book_instance)
db.commit()
return {"message": "Book instance added successfully"}
20 changes: 20 additions & 0 deletions src/catalogue/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from sqlalchemy import Boolean, Column, Float, ForeignKey, Integer, String, Text

from .database import Base


class Book(Base):
__tablename__ = "books"

isbn = Column(String(13), primary_key=True, index=True)
title = Column(String(255), nullable=False, index=True)
summary = Column(Text)
price = Column(Float, nullable=False)


class BookInstance(Base):
__tablename__ = "book_instances"

id = Column(Integer, primary_key=True, index=True, autoincrement=True)
isbn = Column(String(13), ForeignKey("books.isbn"), nullable=False)
is_circulating = Column(Boolean, default=True)
3 changes: 2 additions & 1 deletion src/lending/domain.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ provider = "inline"
provider = "memory"

[custom]
CHECKOUT_PERIOD = 60 # Days
CHECKOUT_PERIOD = 60 # Days
HOLD_EXPIRY_DAYS = 7 # Days
2 changes: 1 addition & 1 deletion src/lending/model/holding_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def open_holds_do_not_have_expiry_date(self):
def __call__(self):
expires_on = None
if self.hold_type == HoldType.CLOSED_ENDED:
expires_on = date.today() + timedelta(days=7)
expires_on = date.today() + timedelta(days=lending.HOLD_EXPIRY_DAYS)

hold = Hold(
book_id=self.book.id,
Expand Down
Empty file added tests/catalogue/bdd/__init__.py
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,15 @@ Feature: Add Specific Book Instances

Scenario: Add a circulating book instance for an existing book
Given a book exists in the catalogue
And a librarian is logged in
When the librarian adds a circulating book instance
Then the book instance is successfully added
Then the book instance is successfully added to the catalogue

Scenario: Add a restricted book instance for an existing book
Given a book exists in the catalogue
And a librarian is logged in
When the librarian adds a restricted book instance
Then the book instance is successfully added
Then the book instance is successfully added to the catalogue

Scenario: Try to add a book instance without an existing book
Given no book exists with the provided ISBN
And a librarian is logged in
When the librarian tries to add a book instance
Then the book instance addition is rejected
Then the book instance is not added to the catalogue
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
Feature: Add New Books to the Catalogue

Scenario: Add a new book with a valid ISBN, title, and price
Given a librarian is logged in
When the librarian adds a new book with a valid ISBN, title, and price
When the librarian adds a new book with valid details
Then the book is successfully added to the catalogue

Scenario: Try to add a book with an empty title
Given a librarian is logged in
When the librarian tries to add a book with an empty title
Then the book addition is rejected
Then the book is not added to the catalogue

Scenario: Try to add a book with a missing price
Given a librarian is logged in
When the librarian tries to add a book with a missing price
Then the book addition is rejected
Then the book is not added to the catalogue
140 changes: 140 additions & 0 deletions tests/catalogue/bdd/step_defs/catalogue_steps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import pytest
from catalogue.main import BookInstanceRequest, BookRequest
from catalogue.models import Book, BookInstance
from pytest_bdd import given, then, when
from sqlalchemy.exc import IntegrityError


@pytest.fixture
def book_data():
return BookRequest(
isbn="1234567890123",
title="Test Book",
summary="This is a test book",
price=9.99,
)


@pytest.fixture
def book_instance_data():
return BookInstanceRequest(
isbn="1234567890123",
is_circulating=True,
)


@given("a book exists in the catalogue")
def add_book_to_catalogue(db_session, book_data):
book = Book(**book_data.model_dump())
db_session.add(book)
db_session.commit()


@given("no book exists with the provided ISBN")
def no_book_exists_with_provided_isbn(db_session, book_data):
pass


@when("the librarian adds a new book with valid details")
def add_new_book_with_valid_details(client, book_data):
# Test creating a book
response = client.post("/book", json=book_data.model_dump())
assert response.status_code == 201
assert response.json() == {"message": "Book added successfully"}


@when("the librarian tries to add a book with a missing price")
def add_new_book_with_missing_price(client, book_data):
del book_data.price
response = client.post("/book", json=book_data.model_dump())
assert response.status_code == 422
assert response.json() == {
"detail": [
{
"type": "missing",
"loc": ["body", "price"],
"msg": "Field required",
"input": {
"isbn": "1234567890123",
"title": "Test Book",
"summary": "This is a test book",
},
}
]
}


@when("the librarian tries to add a book with an empty title")
def add_new_book_with_empty_title(client, book_data):
book_data.title = ""
response = client.post("/book", json=book_data.model_dump())
assert response.status_code == 422
assert response.json() == {
"detail": [
{
"type": "string_too_short",
"loc": ["body", "title"],
"msg": "String should have at least 1 character",
"input": "",
"ctx": {"min_length": 1},
}
]
}


@when("the librarian adds a circulating book instance")
def add_circulating_book_instance(client, book_instance_data):
response = client.post("/book_instance", json=book_instance_data.model_dump())
assert response.status_code == 201
assert response.json() == {"message": "Book instance added successfully"}


@when("the librarian tries to add a book instance")
def add_book_instance(client, book_instance_data):
try:
client.post("/book_instance", json=book_instance_data.model_dump())
except IntegrityError as e:
assert e.args[0] == "(sqlite3.IntegrityError) FOREIGN KEY constraint failed"


@when("the librarian adds a restricted book instance")
def add_restricted_book_instance(client, book_instance_data):
book_instance_data.is_circulating = False
response = client.post("/book_instance", json=book_instance_data.model_dump())
assert response.status_code == 201
assert response.json() == {"message": "Book instance added successfully"}


@then("the book is successfully added to the catalogue")
def verify_book_added_to_catalogue(db_session, book_data):
# Verify that the book is added to the database
book = db_session.query(Book).filter_by(isbn=book_data.isbn).first()
assert book is not None
assert book.title == book_data.title
assert book.summary == book_data.summary
assert book.price == book_data.price


@then("the book is not added to the catalogue")
def verify_book_not_added_to_catalogue(db_session, book_data):
# Verify that the book is not added to the database
book = db_session.query(Book).filter_by(isbn=book_data.isbn).first()
assert book is None


@then("the book instance is successfully added to the catalogue")
def verify_book_instance_added_to_catalogue(db_session, book_data):
# Verify that the book instance is added to the database
book_instance = (
db_session.query(BookInstance).filter_by(isbn=book_data.isbn).first()
)
assert book_instance is not None


@then("the book instance is not added to the catalogue")
def verify_book_instance_not_added_to_catalogue(db_session, book_data):
# Verify that the book instance is not added to the database
book_instance = (
db_session.query(BookInstance).filter_by(isbn=book_data.isbn).first()
)
assert book_instance is None
5 changes: 5 additions & 0 deletions tests/catalogue/bdd/test_catalogue.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pytest_bdd import scenarios

from .step_defs.catalogue_steps import * # noqa: F403

scenarios("./features")
Loading

0 comments on commit f54b0ef

Please sign in to comment.