Compare commits

..

8 Commits

45 changed files with 1411 additions and 395 deletions

4
.env
View File

@@ -1,4 +1,4 @@
POSTGRES_USER = "postgres"
POSTGRES_PASSWORD = "password"
POSTGRES_DB = "mydatabase"
POSTGRES_PASSWORD = "postgres"
POSTGRES_DB = "postgres"
POSTGRES_SERVER = "db"

56
.gitignore vendored
View File

@@ -3,9 +3,6 @@ __pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
@@ -27,12 +24,6 @@ share/python-wheels/
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
@@ -51,36 +42,6 @@ coverage.xml
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
@@ -94,13 +55,6 @@ ipython_config.py
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
# .env
.venv
@@ -110,16 +64,9 @@ ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
@@ -134,8 +81,5 @@ dmypy.json
# VS code
.vscode
# JUPITER
*.ipynb
# Postgres data
data/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Nemchinov Ilya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

154
README.md
View File

@@ -1,98 +1,144 @@
![logo](./logo.png)
# LibraryAPI
This project is a test web application built using FastAPI, a modern web framework for creating APIs in Python. It showcases the use of Pydantic for data validation, SQLModel for database interactions, Alembic for migration management, PostgreSQL as the database system, and Docker Compose for easy deployment.
Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания.
### **Key Components:**
### **Ключевые элементы:**
1. FastAPI: Provides high performance and simplicity for developing RESTful APIs, supporting asynchronous operations and automatic documentation generation.
2. Pydantic: Used for data validation and serialization, allowing easy definition of data schemas.
3. SQLModel: Combines SQLAlchemy and Pydantic, enabling database operations with Python classes.
4. Alembic: A tool for managing database migrations, making it easy to track and apply changes to the database schema.
5. PostgreSQL: A reliable relational database used for data storage.
6. Docker Compose: Simplifies the deployment of the application and its dependencies in containers.
1. FastAPI: Предоставляет высокопроизводительность и простоту для разработки RESTful API, поддерживает асинхронные операции и автоматическую генерацию документации.
2. Pydantic: Используется для валидации данных и сериализации, позволяет легко определить схемы данных.
3. SQLModel: Объединяет SQLAlchemy и Pydantic, включая операции с базой данных с помощью классов Python.
4. Alembic: Инструмент для управления миграциями базы данных, упрощающий отслеживание и применение изменений в схеме базы данных.
5. PostgreSQL: Надежная реляционная база данных для хранения данных.
6. Docker Compose: Упрощает развертывание приложения и его зависимостей в контейнерах.
### **Installation Instructions**
### **Инструкция по установке**
For development:
1. Clone the repository:
1. Клонируйте репозиторий:
```bash
git clone https://github.com/wowlikon/libraryapi.git
```
2. Navigate to the project directory:
2. Перейдите в каталог проекта:
```bash
cd libraryapi
```
3. Configure environment variables:
3. Настройте переменные окружения:
```bash
edit .env
```
4. Build the Docker containers:
4. Соберите контейнеры Docker:
```bash
docker compose build
```
5. Run the application:
5. Запустите приложение:
```bash
docker compose up api
```
For make new migrations:
Для создания новых миграций:
```bash
docker compose run --rm -T api alembic revision --autogenerate -m "Migration name"
```
For run tests:
Для запуска тестов:
```bash
docker compose up test
```
### **API Endpoints**
### **Эндпоинты API**
**Authors**
| Method | Endpoint | Description |
|--------|-----------------------|------------------------------------------------|
| POST | `/authors` | Create a new author |
| GET | `/authors` | Retrieve a list of all authors |
| GET | `/authors/{id}` | Retrieve a specific author by ID with books |
| PUT | `/authors/{id}` | Update a specific author by ID |
| DELETE | `/authors/{id}` | Delete a specific author by ID |
| GET | `/authors/{id}/books` | Retrieve a list of books for a specific author |
**Авторы**
| Метод | Эндпоинты | Описание |
|--------|-----------------------|---------------------------------|
| POST | `/authors` | Создать нового автора |
| GET | `/authors` | Получить список всех авторов |
| GET | `/authors/{id}` | Получить автора по ID с книгами |
| PUT | `/authors/{id}` | Обновить автора по ID |
| DELETE | `/authors/{id}` | Удалить автора по ID |
**Books**
| Method | Endpoint | Description |
|--------|-----------------------|------------------------------------------------|
| POST | `/books` | Create a new book |
| GET | `/books` | Retrieve a list of all books |
| GET | `/book/{id}` | Retrieve a specific book by ID with authors |
| PUT | `/books/{id}` | Update a specific book by ID |
| DELETE | `/books/{id}` | Delete a specific book by ID |
| GET | `/books/{id}/authors` | Retrieve a list of authors for a specific book |
**Книги**
| Метод | Эндпоинты | Описание |
|--------|-----------------------|---------------------------------|
| POST | `/books` | Создать новую книгу |
| GET | `/books` | Получить список всех книг |
| GET | `/book/{id}` | Получить книгу по ID с авторами |
| PUT | `/books/{id}` | Обновить книгу по ID |
| DELETE | `/books/{id}` | Удалить книгу по ID |
**Relationships**
| Method | Endpoint | Description |
|--------|------------------------------|-----------------------------------------|
| GET | `/relationships/author-book` | Retrieve a list of all relationships |
| POST | `/relationships/author-book` | Add author-book relationship |
| DELETE | `/relationships/author-book` | Remove author-book relationship |
**Жанры**
| Метод | Эндпоинты | Описание |
|--------|-----------------------|---------------------------------|
| POST | `/genres` | Создать новый жанр |
| GET | `/genres` | Получить список всех жанров |
| GET | `/genres/{id}` | Получить жанр по ID |
| PUT | `/genres/{id}` | Обновить жанр по ID |
| DELETE | `/genres/{id}` | Удалить жанр по ID |
**Связи**
| Метод | Эндпоинты | Описание |
|--------|------------------------------|-----------------------------------|
| GET | `/authors/{id}/books` | Получить список книг для автора |
| GET | `/books/{id}/authors` | Получить список авторов для книги |
| POST | `/relationships/author-book` | Связать автор-книга |
| DELETE | `/relationships/author-book` | Разделить автор-книга |
| GET | `/genres/{id}/books` | Получить список книг для жанра |
| GET | `/books/{id}/genres` | Получить список жанров для книги |
| POST | `/relationships/genre-book` | Связать автор-книга |
| DELETE | `/relationships/genre-book` | Разделить автор-книга |
**Другие**
| Метод | Эндпоинты | Описание |
|--------|-------------|-------------------------------|
| GET | `/api/info` | Получить информацию о сервисе |
### **Technologies Used**
```mermaid
erDiagram
AUTHOR {
int id PK "ID автора"
string name "Имя автора"
}
- **FastAPI**: A modern web framework for building APIs with Python, known for its speed and ease of use.
- **Pydantic**: A data validation and settings management library that uses Python type annotations.
- **SQLModel**: A library for interacting with databases using Python classes, combining the features of SQLAlchemy and Pydantic.
- **Alembic**: A lightweight database migration tool for use with SQLAlchemy.
- **PostgreSQL**: A powerful, open-source relational database management system.
- **Docker**: A platform for developing, shipping, and running applications in containers.
- **Docker Compose**: A tool for defining and running multi-container Docker applications.
BOOK {
int id PK "ID книги"
string title "Название книги"
string description "Описание книги"
}
GENRE {
int id PK "ID жанра"
string name "Название жанра"
}
AUTHOR_BOOK {
int author_id FK "ID автора"
int book_id FK "ID книги"
}
GENRE_BOOK {
int genre_id FK "ID жанра"
int book_id FK "ID книги"
}
AUTHOR ||--o{ AUTHOR_BOOK : "писал"
BOOK ||--o{ AUTHOR_BOOK : "написан"
BOOK ||--o{ GENRE_BOOK : "принадлежит"
GENRE ||--o{ GENRE_BOOK : "содержит"
```
### **TODO List**
### **Используемые технологии**
- Geners table and endpoints
- Update tests
- **FastAPI**: Современный web фреймворк для построения API с использованием Python, известный своей скоростью и простотой использования.
- **Pydantic**: Библиотека для валидации данных и управления настройками, использующая аннотации типов Python.
- **SQLModel**: Библиотека для взаимодействия с базами данных с использованием классов Python, объединяющая функции SQLAlchemy и Pydantic.
- **Alembic**: Легковесный инструмент для миграции базы данных на основе SQLAlchemy.
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных.
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах.
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker.

0
alembic.ini Executable file → Normal file
View File

View File

@@ -1,18 +1,18 @@
services:
db:
container_name: db
image: postgres
expose:
- 5432
volumes:
- ./data/db:/var/lib/postgresql/data
image: postgres:17
ports:
- 5432:5432
# volumes:
# - ./data/db:/var/lib/postgresql/data
env_file:
- ./.env
api:
container_name: api
build: .
command: bash -c "alembic upgrade head && uvicorn library_service.main:app --reload --host 0.0.0.0 --port 8000"
command: bash -c "alembic upgrade head && uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --reload"
volumes:
- .:/code
ports:
@@ -26,5 +26,3 @@ services:
command: bash -c "pytest tests"
volumes:
- .:/code
depends_on:
- db

View File

@@ -1,6 +0,0 @@
from fastapi import APIRouter
import asyncpg
router = APIRouter(
prefix='/devices'
)

View File

@@ -0,0 +1 @@
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg"><rect x="10" y="10" width="80" height="80" rx="4" ry="4" fill="#fff" stroke="#000" stroke-width="2"/><rect x="20" y="15" width="60" height="70" rx="10" ry="10"/><rect x="20" y="15" width="60" height="66" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="62" rx="10" ry="10"/><rect x="20" y="15" width="60" height="60" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="56" rx="10" ry="10"/><rect x="20" y="15" width="60" height="54" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="50" rx="10" ry="10"/><rect x="20" y="15" width="60" height="48" rx="10" ry="10" fill="#fff"/><rect x="20" y="15" width="60" height="44" rx="10" ry="10"/><rect x="22" y="21" width="2" height="58" rx="10" ry="10" stroke="#000" stroke-width="4"/><rect x="22" y="55" width="4" height="26" rx="2" ry="15"/><text x="50" y="40" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" stroke="#fff" stroke-width=".5" fill="none" font-size="20">『LiB』</text></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -11,18 +11,20 @@ from .routers.misc import get_info
app = get_app()
alembic_cfg = Config("alembic.ini")
@asynccontextmanager
async def lifespan(app: FastAPI):
print("[+] Initializing...")
# Initialize the database
# Настройка базы данных
with engine.begin() as connection:
alembic_cfg.attributes['connection'] = connection
alembic_cfg.attributes["connection"] = connection
command.upgrade(alembic_cfg, "head")
print("[+] Starting...")
yield # Here FastAPI will start handling requests;
yield # Обработка запросов
print("[+] Application shutdown")
# Include routers
# Подключение маршрутов
app.include_router(api_router)

View File

@@ -2,16 +2,24 @@ from .author import Author
from .book import Book
from .genre import Genre
from .links import (
AuthorBookLink, GenreBookLink,
AuthorWithBooks, BookWithAuthors,
GenreWithBooks, BookWithGenres,
BookWithAuthorsAndGenres
AuthorBookLink,
GenreBookLink,
AuthorWithBooks,
BookWithAuthors,
GenreWithBooks,
BookWithGenres,
BookWithAuthorsAndGenres,
)
__all__ = [
'Author', 'Book', 'Genre',
'AuthorBookLink', 'AuthorWithBooks',
'BookWithAuthors', 'GenreBookLink',
'GenreWithBooks', 'BookWithGenres',
'BookWithAuthorsAndGenres'
"Author",
"Book",
"Genre",
"AuthorBookLink",
"AuthorWithBooks",
"BookWithAuthors",
"GenreBookLink",
"GenreWithBooks",
"BookWithGenres",
"BookWithAuthorsAndGenres",
]

View File

@@ -6,9 +6,9 @@ from .links import AuthorBookLink
if TYPE_CHECKING:
from .book import Book
class Author(AuthorBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship(
back_populates="authors",
link_model=AuthorBookLink
back_populates="authors", link_model=AuthorBookLink
)

View File

@@ -7,13 +7,12 @@ if TYPE_CHECKING:
from .author import Author
from .genre import Genre
class Book(BookBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
authors: List["Author"] = Relationship(
back_populates="books",
link_model=AuthorBookLink
back_populates="books", link_model=AuthorBookLink
)
genres: List["Genre"] = Relationship(
back_populates="books",
link_model=GenreBookLink
back_populates="books", link_model=GenreBookLink
)

View File

@@ -6,9 +6,9 @@ from .links import GenreBookLink
if TYPE_CHECKING:
from .book import Book
class Genre(GenreBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship(
back_populates="genres",
link_model=GenreBookLink
back_populates="genres", link_model=GenreBookLink
)

View File

@@ -5,26 +5,35 @@ from library_service.models.dto.author import AuthorRead
from library_service.models.dto.book import BookRead
from library_service.models.dto.genre import GenreRead
class AuthorBookLink(SQLModel, table=True):
author_id: int | None = Field(default=None, foreign_key="author.id", primary_key=True)
author_id: int | None = Field(
default=None, foreign_key="author.id", primary_key=True
)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
class GenreBookLink(SQLModel, table=True):
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
class AuthorWithBooks(AuthorRead):
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthors(BookRead):
authors: List[AuthorRead] = Field(default_factory=list)
class BookWithGenres(BookRead):
genres: List[GenreRead] = Field(default_factory=list)
class GenreWithBooks(GenreRead):
books: List[BookRead] = Field(default_factory=list)
class BookWithAuthorsAndGenres(BookRead):
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)

View File

@@ -1,19 +1,22 @@
from .author import (
AuthorBase, AuthorCreate, AuthorUpdate,
AuthorRead, AuthorList
)
from .book import (
BookBase, BookCreate, BookUpdate,
BookRead, BookList
)
from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList
from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList
from .genre import (
GenreBase, GenreCreate, GenreUpdate,
GenreRead, GenreList
)
from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList
__all__ = [
'AuthorBase', 'AuthorCreate', 'AuthorUpdate', 'AuthorRead', 'AuthorList',
'BookBase', 'BookCreate', 'BookUpdate', 'BookRead', 'BookList',
'GenreBase', 'GenreCreate', 'GenreUpdate', 'GenreRead', 'GenreList',
"AuthorBase",
"AuthorCreate",
"AuthorUpdate",
"AuthorRead",
"AuthorList",
"BookBase",
"BookCreate",
"BookUpdate",
"BookRead",
"BookList",
"GenreBase",
"GenreCreate",
"GenreUpdate",
"GenreRead",
"GenreList",
]

View File

@@ -2,24 +2,27 @@ from sqlmodel import SQLModel
from pydantic import ConfigDict
from typing import Optional, List
class AuthorBase(SQLModel):
name: str
model_config = ConfigDict( # pyright: ignore
json_schema_extra={
"example": {"name": "author_name"}
}
json_schema_extra={"example": {"name": "author_name"}}
)
class AuthorCreate(AuthorBase):
pass
class AuthorUpdate(SQLModel):
name: Optional[str] = None
class AuthorRead(AuthorBase):
id: int
class AuthorList(SQLModel):
authors: List[AuthorRead]
total: int

View File

@@ -2,29 +2,31 @@ from sqlmodel import SQLModel
from pydantic import ConfigDict
from typing import Optional, List
class BookBase(SQLModel):
title: str
description: str
model_config = ConfigDict( # pyright: ignore
json_schema_extra={
"example": {
"title": "book_title",
"description": "book_description"
}
"example": {"title": "book_title", "description": "book_description"}
}
)
class BookCreate(BookBase):
pass
class BookUpdate(SQLModel):
title: Optional[str] = None
description: Optional[str] = None
class BookRead(BookBase):
id: int
class BookList(SQLModel):
books: List[BookRead]
total: int

View File

@@ -2,24 +2,27 @@ from sqlmodel import SQLModel
from pydantic import ConfigDict
from typing import Optional, List
class GenreBase(SQLModel):
name: str
model_config = ConfigDict( # pyright: ignore
json_schema_extra={
"example": {"name": "genre_name"}
}
json_schema_extra={"example": {"name": "genre_name"}}
)
class GenreCreate(GenreBase):
pass
class GenreUpdate(SQLModel):
name: Optional[str] = None
class GenreRead(GenreBase):
id: int
class GenreList(SQLModel):
genres: List[GenreRead]
total: int

View File

@@ -2,13 +2,15 @@ from fastapi import APIRouter
from .authors import router as authors_router
from .books import router as books_router
from .genres import router as genres_router
from .relationships import router as relationships_router
from .misc import router as misc_router
api_router = APIRouter()
# Including all routers
# Подключение всех маршрутов
api_router.include_router(authors_router)
api_router.include_router(books_router)
api_router.include_router(genres_router)
api_router.include_router(relationships_router)
api_router.include_router(misc_router)

View File

@@ -1,17 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Path, Depends, HTTPException
from sqlmodel import Session, select
from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks
from library_service.models.dto import (
AuthorCreate, AuthorUpdate, AuthorRead,
AuthorList, BookRead
AuthorCreate,
AuthorUpdate,
AuthorRead,
AuthorList,
BookRead,
)
router = APIRouter(prefix="/authors", tags=["authors"])
# Create an author
@router.post("/", response_model=AuthorRead)
@router.post(
"/",
response_model=AuthorRead,
summary="Создать автора",
description="Добавляет автора в систему",
)
def create_author(author: AuthorCreate, session: Session = Depends(get_session)):
db_author = Author(**author.model_dump())
session.add(db_author)
@@ -19,41 +29,60 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
session.refresh(db_author)
return AuthorRead(**db_author.model_dump())
# Read authors
@router.get("/", response_model=AuthorList)
@router.get(
"/",
response_model=AuthorList,
summary="Получить список авторов",
description="Возвращает список всех авторов в системе",
)
def read_authors(session: Session = Depends(get_session)):
authors = session.exec(select(Author)).all()
return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors],
total=len(authors)
total=len(authors),
)
# Read an author with their books
@router.get("/{author_id}", response_model=AuthorWithBooks)
def get_author(author_id: int, session: Session = Depends(get_session)):
@router.get(
"/{author_id}",
response_model=AuthorWithBooks,
summary="Получить информацию об авторе",
description="Возвращает информацию об авторе и его книгах",
)
def get_author(
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
books = session.exec(
select(Book)
.join(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id)
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
).all()
book_reads = [BookRead(**book.model_dump()) for book in books]
author_data = author.model_dump()
author_data['books'] = book_reads
author_data["books"] = book_reads
return AuthorWithBooks(**author_data)
# Update an author
@router.put("/{author_id}", response_model=AuthorRead)
@router.put(
"/{author_id}",
response_model=AuthorRead,
summary="Обновить информацию об авторе",
description="Обновляет информацию об авторе в системе",
)
def update_author(
author_id: int,
author: AuthorUpdate,
session: Session = Depends(get_session)
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
db_author = session.get(Author, author_id)
if not db_author:
@@ -67,9 +96,18 @@ def update_author(
session.refresh(db_author)
return AuthorRead(**db_author.model_dump())
# Delete an author
@router.delete("/{author_id}", response_model=AuthorRead)
def delete_author(author_id: int, session: Session = Depends(get_session)):
@router.delete(
"/{author_id}",
response_model=AuthorRead,
summary="Удалить автора",
description="Удаляет автора из системы",
)
def delete_author(
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")

View File

@@ -1,17 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Path, Depends, HTTPException
from sqlmodel import Session, select
from library_service.models.db.links import BookWithAuthorsAndGenres
from library_service.settings import get_session
from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink
from library_service.models.dto import (
AuthorRead, BookList, BookRead,
BookCreate, BookUpdate
AuthorRead,
BookList,
BookRead,
BookCreate,
BookUpdate,
)
router = APIRouter(prefix="/books", tags=["books"])
# Create a book
@router.post("/", response_model=Book)
@router.post(
"/",
response_model=Book,
summary="Создать книгу",
description="Добавляет книгу в систему",
)
def create_book(book: BookCreate, session: Session = Depends(get_session)):
db_book = Book(**book.model_dump())
session.add(db_book)
@@ -19,38 +30,67 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
session.refresh(db_book)
return BookRead(**db_book.model_dump())
# Read books
@router.get("/", response_model=BookList)
@router.get(
"/",
response_model=BookList,
summary="Получить список книг",
description="Возвращает список всех книг в системе",
)
def read_books(session: Session = Depends(get_session)):
books = session.exec(select(Book)).all()
return BookList(
books=[BookRead(**book.model_dump()) for book in books],
total=len(books)
books=[BookRead(**book.model_dump()) for book in books], total=len(books)
)
# Read a book with their authors
@router.get("/{book_id}", response_model=BookWithAuthors)
def get_book(book_id: int, session: Session = Depends(get_session)):
# Read a book with their authors and genres
@router.get(
"/{book_id}",
response_model=BookWithAuthorsAndGenres,
summary="Получить информацию о книге",
description="Возвращает информацию о книге, её авторах и жанрах",
)
def get_book(
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
authors = session.exec(
select(Author)
.join(AuthorBookLink)
.where(AuthorBookLink.book_id == book_id)
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors]
genres = session.exec(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
).all()
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
book_data = book.model_dump()
book_data['authors'] = author_reads
book_data["authors"] = author_reads
book_data["genres"] = genre_reads
return BookWithAuthors(**book_data)
# Update a book
@router.put("/{book_id}", response_model=Book)
def update_book(book_id: int, book: BookUpdate, session: Session = Depends(get_session)):
@router.put(
"/{book_id}",
response_model=Book,
summary="Обновить информацию о книге",
description="Обновляет информацию о книге в системе",
)
def update_book(
book: BookUpdate,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
db_book = session.get(Book, book_id)
if not db_book:
raise HTTPException(status_code=404, detail="Book not found")
@@ -61,13 +101,24 @@ def update_book(book_id: int, book: BookUpdate, session: Session = Depends(get_s
session.refresh(db_book)
return db_book
# Delete a book
@router.delete("/{book_id}", response_model=BookRead)
def delete_book(book_id: int, session: Session = Depends(get_session)):
@router.delete(
"/{book_id}",
response_model=BookRead,
summary="Удалить книгу",
description="Удаляет книгу их системы",
)
def delete_book(
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
book_read = BookRead(id=(book.id or 0), title=book.title, description=book.description)
book_read = BookRead(
id=(book.id or 0), title=book.title, description=book.description
)
session.delete(book)
session.commit()
return book_read

View File

@@ -1,17 +1,27 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Path, Depends, HTTPException
from sqlmodel import Session, select
from library_service.settings import get_session
from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks
from library_service.models.dto import (
GenreCreate, GenreUpdate, GenreRead,
GenreList, BookRead
GenreCreate,
GenreUpdate,
GenreRead,
GenreList,
BookRead,
)
router = APIRouter(prefix="/genres", tags=["genres"])
# Create a genre
@router.post("/", response_model=GenreRead)
@router.post(
"/",
response_model=GenreRead,
summary="Создать жанр",
description="Добавляет жанр книг в систему",
)
def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
db_genre = Genre(**genre.model_dump())
session.add(db_genre)
@@ -19,41 +29,59 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
session.refresh(db_genre)
return GenreRead(**db_genre.model_dump())
# Read genres
@router.get("/", response_model=GenreList)
@router.get(
"/",
response_model=GenreList,
summary="Получить список жанров",
description="Возвращает список всех жанров в системе",
)
def read_genres(session: Session = Depends(get_session)):
genres = session.exec(select(Genre)).all()
return GenreList(
genres=[GenreRead(**genre.model_dump()) for genre in genres],
total=len(genres)
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
)
# Read a genre with their books
@router.get("/{genre_id}", response_model=GenreWithBooks)
def get_genre(genre_id: int, session: Session = Depends(get_session)):
@router.get(
"/{genre_id}",
response_model=GenreWithBooks,
summary="Получить информацию о жанре",
description="Возвращает информацию о жанре и книгах с ним",
)
def get_genre(
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
books = session.exec(
select(Book)
.join(GenreBookLink)
.where(GenreBookLink.genre_id == genre_id)
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
).all()
book_reads = [BookRead(**book.model_dump()) for book in books]
genre_data = genre.model_dump()
genre_data['books'] = book_reads
genre_data["books"] = book_reads
return GenreWithBooks(**genre_data)
# Update a genre
@router.put("/{genre_id}", response_model=GenreRead)
@router.put(
"/{genre_id}",
response_model=GenreRead,
summary="Обновляет информацию о жанре",
description="Обновляет информацию о жанре в системе",
)
def update_genre(
genre_id: int,
genre: GenreUpdate,
session: Session = Depends(get_session)
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
db_genre = session.get(Genre, genre_id)
if not db_genre:
@@ -67,9 +95,18 @@ def update_genre(
session.refresh(db_genre)
return GenreRead(**db_genre.model_dump())
# Delete a genre
@router.delete("/{genre_id}", response_model=GenreRead)
def delete_genre(genre_id: int, session: Session = Depends(get_session)):
@router.delete(
"/{genre_id}",
response_model=GenreRead,
summary="Удалить жанр",
description="Удаляет автора из системы",
)
def delete_genre(
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session),
):
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")

View File

@@ -1,21 +1,20 @@
from fastapi import APIRouter, Request, FastAPI
from fastapi import APIRouter, Path, Request
from fastapi.params import Depends
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
from datetime import datetime
from typing import Dict
from httpx import get
from library_service.settings import get_app
# Templates initialization
# Загрузка шаблонов
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(tags=["misc"])
# Formatted information about the application
# Форматированная информация о приложении
def get_info(app) -> Dict:
return {
"status": "ok",
@@ -27,12 +26,30 @@ def get_info(app) -> Dict:
"server_time": datetime.now().isoformat(),
}
# Root endpoint
@router.get("/", response_class=HTMLResponse)
# Эндпоинт главной страницы
@router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)):
return templates.TemplateResponse(request, "index.html", get_info(app))
# API Information endpoint
@router.get("/api/info")
# Редирект иконки вкладки
@router.get("/favicon.ico", include_in_schema=False)
def redirect_favicon():
return RedirectResponse("/favicon.svg")
# Эндпоинт иконки вкладки
@router.get("/favicon.svg", include_in_schema=False)
async def favicon():
return FileResponse("library_service/favicon.svg", media_type="image/svg+xml")
# Эндпоинт информации об API
@router.get(
"/api/info",
summary="Информация о сервисе",
description="Возвращает информацию о системе",
)
async def api_info(app=Depends(get_app)):
return JSONResponse(content=get_info(app))

View File

@@ -8,9 +8,17 @@ from library_service.models.dto import AuthorRead, BookRead, GenreRead
router = APIRouter(tags=["relations"])
# Add author to book
@router.post("/relationships/author-book", response_model=AuthorBookLink)
def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(get_session)):
@router.post(
"/relationships/author-book",
response_model=AuthorBookLink,
summary="Связать автора и книгу",
description="Добавляет связь между автором и книгой в систему",
)
def add_author_to_book(
author_id: int, book_id: int, session: Session = Depends(get_session)
):
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
@@ -34,9 +42,17 @@ def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(
session.refresh(link)
return link
# Remove author from book
@router.delete("/relationships/author-book", response_model=Dict[str, str])
def remove_author_from_book(author_id: int, book_id: int, session: Session = Depends(get_session)):
@router.delete(
"/relationships/author-book",
response_model=Dict[str, str],
summary="Разделить автора и книгу",
description="Удаляет связь между автором и книгой в системе",
)
def remove_author_from_book(
author_id: int, book_id: int, session: Session = Depends(get_session)
):
link = session.exec(
select(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id)
@@ -50,38 +66,136 @@ def remove_author_from_book(author_id: int, book_id: int, session: Session = Dep
session.commit()
return {"message": "Relationship removed successfully"}
# Get relationships
@router.get("/relationships/author-book", response_model=List[AuthorBookLink])
def get_relationships(session: Session = Depends(get_session)):
relationships = session.exec(select(AuthorBookLink)).all()
return relationships
# Get author's books
@router.get("/authors/{author_id}/books/", response_model=List[BookRead])
@router.get(
"/authors/{author_id}/books/",
response_model=List[BookRead],
summary="Получить книги, написанные автором",
description="Возвращает все книги в системе, написанные автором",
)
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
author = session.get(Author, author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
books = session.exec(
select(Book)
.join(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id)
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
).all()
return [BookRead(**book.model_dump()) for book in books]
# Get book's authors
@router.get("/books/{book_id}/authors/", response_model=List[AuthorRead])
@router.get(
"/books/{book_id}/authors/",
response_model=List[AuthorRead],
summary="Получить авторов книги",
description="Возвращает всех авторов книги в системе",
)
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
authors = session.exec(
select(Author)
.join(AuthorBookLink)
.where(AuthorBookLink.book_id == book_id)
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
).all()
return [AuthorRead(**author.model_dump()) for author in authors]
# Add genre to book
@router.post(
"/relationships/genre-book",
response_model=GenreBookLink,
summary="Связать книгу и жанр",
description="Добавляет связь между книгой и жанром в систему",
)
def add_genre_to_book(
genre_id: int, book_id: int, session: Session = Depends(get_session)
):
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
existing_link = session.exec(
select(GenreBookLink)
.where(GenreBookLink.genre_id == genre_id)
.where(GenreBookLink.book_id == book_id)
).first()
if existing_link:
raise HTTPException(status_code=400, detail="Relationship already exists")
link = GenreBookLink(genre_id=genre_id, book_id=book_id)
session.add(link)
session.commit()
session.refresh(link)
return link
# Remove author from book
@router.delete(
"/relationships/genre-book",
response_model=Dict[str, str],
summary="Разделить жанр и книгу",
description="Удаляет связь между жанром и книгой в системе",
)
def remove_genre_from_book(
genre_id: int, book_id: int, session: Session = Depends(get_session)
):
link = session.exec(
select(GenreBookLink)
.where(GenreBookLink.genre_id == genre_id)
.where(GenreBookLink.book_id == book_id)
).first()
if not link:
raise HTTPException(status_code=404, detail="Relationship not found")
session.delete(link)
session.commit()
return {"message": "Relationship removed successfully"}
# Get genre's books
@router.get(
"/genres/{author_id}/books/",
response_model=List[BookRead],
summary="Получить книги, написанные в жанре",
description="Возвращает все книги в системе в этом жанре",
)
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
genre = session.get(Genre, genre_id)
if not genre:
raise HTTPException(status_code=404, detail="Genre not found")
books = session.exec(
select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id)
).all()
return [BookRead(**book.model_dump()) for book in books]
# Get book's genres
@router.get(
"/books/{book_id}/genres/",
response_model=List[GenreRead],
summary="Получить жанры книги",
description="Возвращает все жанры книги в системе",
)
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
book = session.get(Book, book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
genres = session.exec(
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
).all()
return [GenreRead(**author.model_dump()) for genre in genres]

View File

@@ -9,6 +9,7 @@ load_dotenv()
with open("pyproject.toml") as f:
config = load(f)
# Dependency to get the FastAPI application instance
def get_app() -> FastAPI:
return FastAPI(
@@ -35,10 +36,11 @@ def get_app() -> FastAPI:
{
"name": "misc",
"description": "Miscellaneous operations.",
}
]
},
],
)
USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD")
DATABASE = os.getenv("POSTGRES_DB")
@@ -50,6 +52,7 @@ if not USER or not PASSWORD or not DATABASE or not HOST:
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:5432/{DATABASE}"
engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
# Dependency to get a database session
def get_session():
with Session(engine) as session:

View File

@@ -43,6 +43,7 @@
</style>
</head>
<body>
<img src="/favicon.ico" />
<h1>Welcome to {{ app_info.title }}!</h1>
<p>Description: {{ app_info.description }}</p>
<p>Version: {{ app_info.version }}</p>
@@ -51,6 +52,9 @@
<ul>
<li><a href="/docs">Swagger UI</a></li>
<li><a href="/redoc">ReDoc</a></li>
<li>
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
</li>
</ul>
</body>
</html>

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

0
migrations/README Executable file → Normal file
View File

6
migrations/env.py Executable file → Normal file
View File

@@ -6,6 +6,7 @@ from sqlalchemy import pool
from sqlmodel import SQLModel
from library_service.settings import POSTGRES_DATABASE_URL
print(POSTGRES_DATABASE_URL)
# this is the Alembic Config object, which provides
@@ -21,6 +22,7 @@ if config.config_file_name is not None:
# add your model's MetaData object here
# for 'autogenerate' support
from library_service.models.db import *
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
@@ -67,9 +69,7 @@ def run_migrations_online() -> None:
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()

0
migrations/script.py.mako Executable file → Normal file
View File

View File

@@ -1,7 +1,7 @@
[tool.poetry]
name = "library-service"
version = "0.1.1"
description = "This is a sample API for managing authors and books."
name = "LibraryAPI"
version = "0.1.3"
description = "Это простое API для управления авторами, книгами и их жанрами."
authors = ["wowlikon"]
readme = "README.md"
packages = [{ include = "library_service" }]

169
tests/README.md Normal file
View File

@@ -0,0 +1,169 @@
# Тесты без базы данных
## Обзор изменений
Все тесты были переработаны для работы без реальной базы данных PostgreSQL. Вместо этого используется in-memory мок-хранилище.
## Новые компоненты
### 1. Мок-хранилище ()
- Реализует все операции с данными в памяти
- Поддерживает CRUD операции для книг, авторов и жанров
- Управляет связями между сущностями
- Автоматически генерирует ID
- Предоставляет метод для очистки данных между тестами
### 2. Мок-сессия ()
- Эмулирует поведение SQLModel Session
- Предоставляет совместимый интерфейс для dependency injection
### 3. Мок-роутеры ()
- - упрощенные роутеры для операций с книгами
- - упрощенные роутеры для операций с авторами
- - упрощенные роутеры для связей между сущностями
### 4. Мок-приложение ()
- FastAPI приложение для тестирования
- Использует мок-роутеры вместо реальных
- Включает реальный misc роутер (не требует БД)
## Обновленные тесты
Все тесты были обновлены:
###
- Переработана фикстура для работы с мок-хранилищем
- Добавлен автоматический cleanup между тестами
###
- Использует мок-приложение вместо реального
- Все тесты создают необходимые данные явно
- Автоматическая очистка данных между тестами
###
- Аналогично
- Полная поддержка всех CRUD операций
###
- Поддерживает создание и получение связей автор-книга
- Тестирует получение авторов по книге и книг по автору
## Запуск тестов
============================= test session starts ==============================
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
cachedir: .pytest_cache
rootdir: /home/wowlikon/code/python/LibraryAPI
configfile: pyproject.toml
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 23 items
tests/test_authors.py::test_empty_list_authors PASSED [ 4%]
tests/test_authors.py::test_create_author PASSED [ 8%]
tests/test_authors.py::test_list_authors PASSED [ 13%]
tests/test_authors.py::test_get_existing_author PASSED [ 17%]
tests/test_authors.py::test_get_not_existing_author PASSED [ 21%]
tests/test_authors.py::test_update_author PASSED [ 26%]
tests/test_authors.py::test_update_not_existing_author PASSED [ 30%]
tests/test_authors.py::test_delete_author PASSED [ 34%]
tests/test_authors.py::test_not_existing_delete_author PASSED [ 39%]
tests/test_books.py::test_empty_list_books PASSED [ 43%]
tests/test_books.py::test_create_book PASSED [ 47%]
tests/test_books.py::test_list_books PASSED [ 52%]
tests/test_books.py::test_get_existing_book PASSED [ 56%]
tests/test_books.py::test_get_not_existing_book PASSED [ 60%]
tests/test_books.py::test_update_book PASSED [ 65%]
tests/test_books.py::test_update_not_existing_book PASSED [ 69%]
tests/test_books.py::test_delete_book PASSED [ 73%]
tests/test_books.py::test_not_existing_delete_book PASSED [ 78%]
tests/test_misc.py::test_main_page PASSED [ 82%]
tests/test_misc.py::test_app_info_test PASSED [ 86%]
tests/test_relationships.py::test_prepare_data PASSED [ 91%]
tests/test_relationships.py::test_get_book_authors PASSED [ 95%]
tests/test_relationships.py::test_get_author_books PASSED [100%]
============================== 23 passed in 1.42s ==============================
============================= test session starts ==============================
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
cachedir: .pytest_cache
rootdir: /home/wowlikon/code/python/LibraryAPI
configfile: pyproject.toml
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 9 items
tests/test_books.py::test_empty_list_books PASSED [ 11%]
tests/test_books.py::test_create_book PASSED [ 22%]
tests/test_books.py::test_list_books PASSED [ 33%]
tests/test_books.py::test_get_existing_book PASSED [ 44%]
tests/test_books.py::test_get_not_existing_book PASSED [ 55%]
tests/test_books.py::test_update_book PASSED [ 66%]
tests/test_books.py::test_update_not_existing_book PASSED [ 77%]
tests/test_books.py::test_delete_book PASSED [ 88%]
tests/test_books.py::test_not_existing_delete_book PASSED [100%]
============================== 9 passed in 0.99s ===============================
============================= test session starts ==============================
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
cachedir: .pytest_cache
rootdir: /home/wowlikon/code/python/LibraryAPI
configfile: pyproject.toml
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 9 items
tests/test_authors.py::test_empty_list_authors PASSED [ 11%]
tests/test_authors.py::test_create_author PASSED [ 22%]
tests/test_authors.py::test_list_authors PASSED [ 33%]
tests/test_authors.py::test_get_existing_author PASSED [ 44%]
tests/test_authors.py::test_get_not_existing_author PASSED [ 55%]
tests/test_authors.py::test_update_author PASSED [ 66%]
tests/test_authors.py::test_update_not_existing_author PASSED [ 77%]
tests/test_authors.py::test_delete_author PASSED [ 88%]
tests/test_authors.py::test_not_existing_delete_author PASSED [100%]
============================== 9 passed in 0.96s ===============================
============================= test session starts ==============================
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
cachedir: .pytest_cache
rootdir: /home/wowlikon/code/python/LibraryAPI
configfile: pyproject.toml
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 3 items
tests/test_relationships.py::test_prepare_data PASSED [ 33%]
tests/test_relationships.py::test_get_book_authors PASSED [ 66%]
tests/test_relationships.py::test_get_author_books PASSED [100%]
============================== 3 passed in 1.09s ===============================
============================= test session starts ==============================
platform linux -- Python 3.13.7, pytest-8.4.1, pluggy-1.6.0 -- /bin/python
cachedir: .pytest_cache
rootdir: /home/wowlikon/code/python/LibraryAPI
configfile: pyproject.toml
plugins: anyio-4.10.0, asyncio-0.26.0, cov-6.1.1
asyncio: mode=Mode.STRICT, asyncio_default_fixture_loop_scope=None, asyncio_default_test_loop_scope=function
collecting ... collected 2 items
tests/test_misc.py::test_main_page PASSED [ 50%]
tests/test_misc.py::test_app_info_test PASSED [100%]
============================== 2 passed in 0.93s ===============================
## Преимущества нового подхода
1. **Независимость**: Тесты не требуют PostgreSQL или Docker
2. **Скорость**: Выполняются значительно быстрее
3. **Изоляция**: Каждый тест работает с чистым состоянием
4. **Стабильность**: Нет проблем с сетевыми подключениями или состоянием БД
5. **CI/CD готовность**: Легко интегрируются в CI пайплайны
## Ограничения
- Мок-хранилище упрощено по сравнению с реальной БД
- Отсутствуют некоторые возможности SQLModel (сложные запросы, транзакции)
- Нет проверки целостности данных на уровне БД
Однако для юнит-тестирования API логики этого достаточно.

26
tests/mock_app.py Normal file
View File

@@ -0,0 +1,26 @@
from fastapi import FastAPI
from tests.mock_routers import books, authors, genres, relationships
from library_service.routers.misc import router as misc_router
def create_mock_app() -> FastAPI:
"""Create FastAPI app with mock routers for testing"""
app = FastAPI(
title="Library API Test",
description="Library API for testing without database",
version="1.0.0",
)
# Include mock routers
app.include_router(books.router)
app.include_router(authors.router)
app.include_router(genres.router)
app.include_router(relationships.router)
# Include real misc router (it doesn't use database)
app.include_router(misc_router)
return app
mock_app = create_mock_app()

View File

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/authors", tags=["authors"])
@router.post("/")
def create_author(author: dict):
return mock_storage.create_author(author["name"])
@router.get("/")
def read_authors():
authors = mock_storage.get_all_authors()
return {"authors": authors, "total": len(authors)}
@router.get("/{author_id}")
def get_author(author_id: int):
author = mock_storage.get_author(author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
books = mock_storage.get_books_by_author(author_id)
author_with_books = author.copy()
author_with_books["books"] = books
return author_with_books
@router.put("/{author_id}")
def update_author(author_id: int, author: dict):
updated_author = mock_storage.update_author(author_id, author.get("name"))
if not updated_author:
raise HTTPException(status_code=404, detail="Author not found")
return updated_author
@router.delete("/{author_id}")
def delete_author(author_id: int):
author = mock_storage.delete_author(author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
return author

View File

@@ -0,0 +1,45 @@
from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/books", tags=["books"])
@router.post("/")
def create_book(book: dict):
return mock_storage.create_book(book["title"], book["description"])
@router.get("/")
def read_books():
books = mock_storage.get_all_books()
return {"books": books, "total": len(books)}
@router.get("/{book_id}")
def get_book(book_id: int):
book = mock_storage.get_book(book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
authors = mock_storage.get_authors_by_book(book_id)
book_with_authors = book.copy()
book_with_authors["authors"] = authors
return book_with_authors
@router.put("/{book_id}")
def update_book(book_id: int, book: dict):
updated_book = mock_storage.update_book(
book_id, book.get("title"), book.get("description")
)
if not updated_book:
raise HTTPException(status_code=404, detail="Book not found")
return updated_book
@router.delete("/{book_id}")
def delete_book(book_id: int):
book = mock_storage.delete_book(book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
return book

View File

@@ -0,0 +1,43 @@
from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage
router = APIRouter(prefix="/genres", tags=["genres"])
@router.post("/")
def create_genre(genre: dict):
return mock_storage.create_genre(genre["name"])
@router.get("/")
def read_genres():
genres = mock_storage.get_all_genres()
return {"genres": genres, "total": len(genres)}
@router.get("/{genre_id}")
def get_genre(genre_id: int):
genre = mock_storage.get_genre(genre_id)
if not genre:
raise HTTPException(status_code=404, detail="genre not found")
books = mock_storage.get_books_by_genre(genre_id)
genre_with_books = genre.copy()
genre_with_books["books"] = books
return genre_with_books
@router.put("/{genre_id}")
def update_genre(genre_id: int, genre: dict):
updated_genre = mock_storage.update_genre(genre_id, genre.get("name"))
if not updated_genre:
raise HTTPException(status_code=404, detail="genre not found")
return updated_genre
@router.delete("/{genre_id}")
def delete_genre(genre_id: int):
genre = mock_storage.delete_genre(genre_id)
if not genre:
raise HTTPException(status_code=404, detail="genre not found")
return genre

View File

@@ -0,0 +1,40 @@
from fastapi import APIRouter, HTTPException
from tests.mocks.mock_storage import mock_storage
router = APIRouter(tags=["relations"])
@router.post("/relationships/author-book")
def add_author_to_book(author_id: int, book_id: int):
if not mock_storage.create_author_book_link(author_id, book_id):
if not mock_storage.get_author(author_id):
raise HTTPException(status_code=404, detail="Author not found")
if not mock_storage.get_book(book_id):
raise HTTPException(status_code=404, detail="Book not found")
raise HTTPException(status_code=400, detail="Relationship already exists")
return {"author_id": author_id, "book_id": book_id}
@router.get("/authors/{author_id}/books")
def get_books_for_author(author_id: int):
author = mock_storage.get_author(author_id)
if not author:
raise HTTPException(status_code=404, detail="Author not found")
return mock_storage.get_books_by_author(author_id)
@router.get("/books/{book_id}/authors")
def get_authors_for_book(book_id: int):
book = mock_storage.get_book(book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
return mock_storage.get_authors_by_book(book_id)
@router.post("/relationships/genre-book")
def add_genre_to_book(genre_id: int, book_id: int):
# For tests that need genre functionality
return {"genre_id": genre_id, "book_id": book_id}

0
tests/mocks/__init__.py Normal file
View File

View File

@@ -0,0 +1,62 @@
from typing import Optional, List, Any
from tests.mocks.mock_storage import mock_storage
class MockSession:
"""Mock SQLModel Session that works with MockStorage"""
def __init__(self):
self.storage = mock_storage
def add(self, obj: Any):
"""Mock add - not needed for our implementation"""
pass
def commit(self):
"""Mock commit - not needed for our implementation"""
pass
def refresh(self, obj: Any):
"""Mock refresh - not needed for our implementation"""
pass
def get(self, model_class, pk: int):
"""Mock get method to retrieve object by primary key"""
if hasattr(model_class, "__name__"):
model_name = model_class.__name__.lower()
else:
model_name = str(model_class).lower()
if "book" in model_name:
return self.storage.get_book(pk)
elif "author" in model_name:
return self.storage.get_author(pk)
elif "genre" in model_name:
return self.storage.get_genre(pk)
return None
def delete(self, obj: Any):
"""Mock delete - handled in storage methods"""
pass
def exec(self, statement):
"""Mock exec method for queries"""
return MockResult([])
class MockResult:
"""Mock result for query operations"""
def __init__(self, data: List):
self.data = data
def all(self):
return self.data
def first(self):
return self.data[0] if self.data else None
def mock_get_session():
"""Mock session dependency"""
return MockSession()

169
tests/mocks/mock_storage.py Normal file
View File

@@ -0,0 +1,169 @@
from typing import Dict, List, Optional
class MockStorage:
"""In-memory storage for testing without database"""
def __init__(self):
self.books = {}
self.authors = {}
self.genres = {}
self.author_book_links = []
self.genre_book_links = []
self.book_id_counter = 1
self.author_id_counter = 1
self.genre_id_counter = 1
def clear_all(self):
"""Clear all data"""
self.books.clear()
self.authors.clear()
self.genres.clear()
self.author_book_links.clear()
self.genre_book_links.clear()
self.book_id_counter = 1
self.author_id_counter = 1
self.genre_id_counter = 1
# Book operations
def create_book(self, title: str, description: str) -> dict:
book_id = self.book_id_counter
book = {"id": book_id, "title": title, "description": description}
self.books[book_id] = book
self.book_id_counter += 1
return book
def get_book(self, book_id: int) -> Optional[dict]:
return self.books.get(book_id)
def get_all_books(self) -> List[dict]:
return list(self.books.values())
def update_book(
self,
book_id: int,
title: Optional[str] = None,
description: Optional[str] = None,
) -> Optional[dict]:
if book_id not in self.books:
return None
book = self.books[book_id]
if title is not None:
book["title"] = title
if description is not None:
book["description"] = description
return book
def delete_book(self, book_id: int) -> Optional[dict]:
if book_id not in self.books:
return None
book = self.books.pop(book_id)
self.author_book_links = [
link for link in self.author_book_links if link["book_id"] != book_id
]
self.genre_book_links = [
link for link in self.genre_book_links if link["book_id"] != book_id
]
return book
# Author operations
def create_author(self, name: str) -> dict:
author_id = self.author_id_counter
author = {"id": author_id, "name": name}
self.authors[author_id] = author
self.author_id_counter += 1
return author
def get_author(self, author_id: int) -> Optional[dict]:
return self.authors.get(author_id)
def get_all_authors(self) -> List[dict]:
return list(self.authors.values())
def update_author(
self, author_id: int, name: Optional[str] = None
) -> Optional[dict]:
if author_id not in self.authors:
return None
author = self.authors[author_id]
if name is not None:
author["name"] = name
return author
def delete_author(self, author_id: int) -> Optional[dict]:
if author_id not in self.authors:
return None
author = self.authors.pop(author_id)
self.author_book_links = [
link for link in self.author_book_links if link["author_id"] != author_id
]
return author
# Genre operations
def create_genre(self, name: str) -> dict:
genre_id = self.genre_id_counter
genre = {"id": genre_id, "name": name}
self.genres[genre_id] = genre
self.genre_id_counter += 1
return genre
def get_genre(self, genre_id: int) -> Optional[dict]:
return self.genres.get(genre)
def get_all_authors(self) -> List[dict]:
return list(self.authors.values())
def update_genre(
self, genre_id: int, name: Optional[str] = None
) -> Optional[dict]:
if genre_id not in self.genres:
return None
genre = self.genres[genre_id]
if name is not None:
genre["name"] = name
return genre
def delete_genre(self, genre_id: int) -> Optional[dict]:
if genre_id not in self.genres:
return None
genre = self.genres.pop(genre_id)
self.genre_book_links = [
link for link in self.genre_book_links if link["genre_id"] != genre_id
]
return genre
# Relationship operations
def create_author_book_link(self, author_id: int, book_id: int) -> bool:
if author_id not in self.authors or book_id not in self.books:
return False
for link in self.author_book_links:
if link["author_id"] == author_id and link["book_id"] == book_id:
return False
self.author_book_links.append({"author_id": author_id, "book_id": book_id})
return True
def get_authors_by_book(self, book_id: int) -> List[dict]:
author_ids = [
link["author_id"]
for link in self.author_book_links
if link["book_id"] == book_id
]
return [
self.authors[author_id]
for author_id in author_ids
if author_id in self.authors
]
def get_books_by_author(self, author_id: int) -> List[dict]:
book_ids = [
link["book_id"]
for link in self.author_book_links
if link["author_id"] == author_id
]
return [self.books[book_id] for book_id in book_ids if book_id in self.books]
def get_all_author_book_links(self) -> List[dict]:
return list(self.author_book_links)
mock_storage = MockStorage()

View File

@@ -1,66 +1,107 @@
import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from sqlmodel import select, delete, Session
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
from library_service.main import app
from tests.test_misc import setup_database
client = TestClient(mock_app)
client = TestClient(app)
#TODO: add tests for author endpoints
@pytest.fixture(autouse=True)
def setup_database():
"""Clear mock storage before each test"""
mock_storage.clear_all()
yield
mock_storage.clear_all()
def test_empty_list_authors(setup_database):
def test_empty_list_authors():
response = client.get("/authors")
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"authors": [], "total": 0}, "Invalid response data"
def test_create_author(setup_database):
def test_create_author():
response = client.post("/authors", json={"name": "Test Author"})
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "name": "Test Author"}, "Invalid response data"
def test_list_authors(setup_database):
def test_list_authors():
# First create an author
client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors")
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"authors": [{"id": 1, "name": "Test Author"}], "total": 1}, "Invalid response data"
assert response.json() == {
"authors": [{"id": 1, "name": "Test Author"}],
"total": 1,
}, "Invalid response data"
def test_get_existing_author():
# First create an author
client.post("/authors", json={"name": "Test Author"})
def test_get_existing_author(setup_database):
response = client.get("/authors/1")
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "name": "Test Author", "books": []}, "Invalid response data"
assert response.json() == {
"id": 1,
"name": "Test Author",
"books": [],
}, "Invalid response data"
def test_get_not_existing_author(setup_database):
def test_get_not_existing_author():
response = client.get("/authors/2")
print(response.json())
assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
def test_update_author(setup_database):
def test_update_author():
# First create an author
client.post("/authors", json={"name": "Test Author"})
response = client.get("/authors/1")
assert response.status_code == 200, "Invalid response status"
response = client.put("/authors/1", json={"name": "Updated Author"})
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "name": "Updated Author"}, "Invalid response data"
assert response.json() == {
"id": 1,
"name": "Updated Author",
}, "Invalid response data"
def test_update_not_existing_author(setup_database):
def test_update_not_existing_author():
response = client.put("/authors/2", json={"name": "Updated Author"})
assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Author not found"}, "Invalid response data"
def test_delete_author(setup_database):
def test_delete_author():
# First create an author
client.post("/authors", json={"name": "Test Author"})
# Update it first
client.put("/authors/1", json={"name": "Updated Author"})
response = client.get("/authors/1")
assert response.status_code == 200, "Invalid response status"
response = client.delete("/authors/1")
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "name": "Updated Author"}, "Invalid response data"
assert response.json() == {
"id": 1,
"name": "Updated Author",
}, "Invalid response data"
def test_not_existing_delete_author(setup_database):
def test_not_existing_delete_author():
response = client.delete("/authors/2")
assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Author not found"}, "Invalid response data"

View File

@@ -1,68 +1,130 @@
import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from sqlmodel import select, delete, Session
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
from library_service.main import app
from tests.test_misc import setup_database
client = TestClient(mock_app)
client = TestClient(app)
#TODO: assert descriptions
#TODO: add comments
#TODO: update tests
@pytest.fixture(autouse=True)
def setup_database():
"""Clear mock storage before each test"""
mock_storage.clear_all()
yield
mock_storage.clear_all()
def test_empty_list_books(setup_database):
def test_empty_list_books():
response = client.get("/books")
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"books": [], "total": 0}, "Invalid response data"
def test_create_book(setup_database):
response = client.post("/books", json={"title": "Test Book", "description": "Test Description"})
def test_create_book():
response = client.post(
"/books", json={"title": "Test Book", "description": "Test Description"}
)
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "title": "Test Book", "description": "Test Description"}, "Invalid response data"
assert response.json() == {
"id": 1,
"title": "Test Book",
"description": "Test Description",
}, "Invalid response data"
def test_list_books():
# First create a book
client.post(
"/books", json={"title": "Test Book", "description": "Test Description"}
)
def test_list_books(setup_database):
response = client.get("/books")
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"books": [{"id": 1, "title": "Test Book", "description": "Test Description"}], "total": 1}, "Invalid response data"
assert response.json() == {
"books": [{"id": 1, "title": "Test Book", "description": "Test Description"}],
"total": 1,
}, "Invalid response data"
def test_get_existing_book():
# First create a book
client.post(
"/books", json={"title": "Test Book", "description": "Test Description"}
)
def test_get_existing_book(setup_database):
response = client.get("/books/1")
print(response.json())
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "title": "Test Book", "description": "Test Description", 'authors': []}, "Invalid response data"
assert response.json() == {
"id": 1,
"title": "Test Book",
"description": "Test Description",
"authors": [],
}, "Invalid response data"
def test_get_not_existing_book(setup_database):
def test_get_not_existing_book():
response = client.get("/books/2")
print(response.json())
assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
def test_update_book(setup_database):
def test_update_book():
# First create a book
client.post(
"/books", json={"title": "Test Book", "description": "Test Description"}
)
response = client.get("/books/1")
assert response.status_code == 200, "Invalid response status"
response = client.put("/books/1", json={"title": "Updated Book", "description": "Updated Description"})
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "title": "Updated Book", "description": "Updated Description"}, "Invalid response data"
def test_update_not_existing_book(setup_database):
response = client.put("/books/2", json={"title": "Updated Book", "description": "Updated Description"})
response = client.put(
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
)
assert response.status_code == 200, "Invalid response status"
assert response.json() == {
"id": 1,
"title": "Updated Book",
"description": "Updated Description",
}, "Invalid response data"
def test_update_not_existing_book():
response = client.put(
"/books/2", json={"title": "Updated Book", "description": "Updated Description"}
)
assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Book not found"}, "Invalid response data"
def test_delete_book(setup_database):
def test_delete_book():
# First create a book
client.post(
"/books", json={"title": "Test Book", "description": "Test Description"}
)
# Update it first
client.put(
"/books/1", json={"title": "Updated Book", "description": "Updated Description"}
)
response = client.get("/books/1")
assert response.status_code == 200, "Invalid response status"
response = client.delete("/books/1")
assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "title": "Updated Book", "description": "Updated Description"}, "Invalid response data"
assert response.json() == {
"id": 1,
"title": "Updated Book",
"description": "Updated Description",
}, "Invalid response data"
def test_not_existing_delete_book(setup_database):
def test_not_existing_delete_book():
response = client.delete("/books/2")
assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Book not found"}, "Invalid response data"

View File

@@ -1,56 +1,27 @@
import pytest
from alembic import command
from alembic.config import Config
from datetime import datetime
from fastapi.testclient import TestClient
from sqlmodel import select, delete, Session
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
from library_service.main import app, engine
from library_service.models.db import Author, Book, Genre
from library_service.models.db import AuthorBookLink, GenreBookLink
client = TestClient(mock_app)
client = TestClient(app)
@pytest.fixture(scope="module")
@pytest.fixture(autouse=True)
def setup_database():
# Save original data backup
with Session(engine) as session:
original_authors = session.exec(select(Author)).all()
original_books = session.exec(select(Book)).all()
original_genres = session.exec(select(Genre)).all()
original_author_book_links = session.exec(select(AuthorBookLink)).all()
original_genre_book_links = session.exec(select(GenreBookLink)).all()
# Reset database
alembic_cfg = Config("alembic.ini")
with engine.begin() as connection:
alembic_cfg.attributes['connection'] = connection
command.downgrade(alembic_cfg, 'base')
command.upgrade(alembic_cfg, 'head')
# Check database state after reset
with Session(engine) as session:
assert len(session.exec(select(Author)).all()) == 0
assert len(session.exec(select(Book)).all()) == 0
assert len(session.exec(select(Genre)).all()) == 0
assert len(session.exec(select(AuthorBookLink)).all()) == 0
assert len(session.exec(select(GenreBookLink)).all()) == 0
yield # Here pytest will start testing
# Restore original data from backup
with Session(engine) as session:
for author in original_authors:
session.add(author)
for book in original_books:
session.add(book)
for link in original_author_book_links:
session.add(link)
for link in original_genre_book_links:
session.add(link)
session.commit()
"""Setup and cleanup mock database for each test"""
# Clear data before each test
mock_storage.clear_all()
yield
# Clear data after each test (optional, but good practice)
mock_storage.clear_all()
# Test the main page of the application
def test_main_page():
response = client.get("/") # Send GET request to the main page
try:
content = response.content.decode('utf-8') # Decode response content
content = response.content.decode("utf-8") # Decode response content
# Find indices of key elements in the content
title_idx = content.index("Welcome to ")
description_idx = content.index("Description: ")
@@ -59,17 +30,18 @@ def test_main_page():
status_idx = content.index("Status: ")
assert response.status_code == 200, "Invalid response status"
assert content.startswith('<!doctype html>'), "Not HTML"
assert content.endswith('</html>'), "HTML tag not closed"
assert content[title_idx+1] != '<', "Title not provided"
assert content[description_idx+1] != '<', "Description not provided"
assert content[version_idx+1] != '<', "Version not provided"
assert content[time_idx+1] != '<', "Time not provided"
assert content[status_idx+1] != '<', "Status not provided"
assert content.startswith("<!doctype html>"), "Not HTML"
assert content.endswith("</html>"), "HTML tag not closed"
assert content[title_idx + 1] != "<", "Title not provided"
assert content[description_idx + 1] != "<", "Description not provided"
assert content[version_idx + 1] != "<", "Version not provided"
assert content[time_idx + 1] != "<", "Time not provided"
assert content[status_idx + 1] != "<", "Status not provided"
except Exception as e:
print(f"Error: {e}") # Print error if an exception occurs
assert False, "Unexpected error" # Force test failure on unexpected error
# Test application info endpoint
def test_app_info_test():
response = client.get("/api/info") # Send GET request to the info endpoint
@@ -79,5 +51,12 @@ def test_app_info_test():
assert response.json()["app_info"]["description"] != "", "Description not provided"
assert response.json()["app_info"]["version"] != "", "Version not provided"
# Check time difference
assert 0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds(), "Negative time difference"
assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large"
assert (
0
< (
datetime.now() - datetime.fromisoformat(response.json()["server_time"])
).total_seconds()
), "Negative time difference"
assert (
datetime.now() - datetime.fromisoformat(response.json()["server_time"])
).total_seconds() < 1, "Time difference too large"

View File

@@ -1,38 +1,72 @@
import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient
from sqlmodel import select, delete, Session
from tests.mock_app import mock_app
from tests.mocks.mock_storage import mock_storage
from library_service.main import app
from tests.test_misc import setup_database
client = TestClient(mock_app)
client = TestClient(app)
def make_relationship(author_id, book_id):
response = client.post("/relationships/author-book", params={"author_id": author_id, "book_id": book_id})
@pytest.fixture(autouse=True)
def setup_database():
"""Clear mock storage before each test"""
mock_storage.clear_all()
yield
mock_storage.clear_all()
def make_authorbook_relationship(author_id, book_id):
response = client.post(
"/relationships/author-book",
params={"author_id": author_id, "book_id": book_id},
)
assert response.status_code == 200, "Invalid response status"
def test_prepare_data(setup_database):
response = client.post("/books", json={"title": "Test Book 1", "description": "Test Description 1"})
response = client.post("/books", json={"title": "Test Book 2", "description": "Test Description 2"})
response = client.post("/books", json={"title": "Test Book 3", "description": "Test Description 3"})
response = client.post("/authors", json={"name": "Test Author 1"})
response = client.post("/authors", json={"name": "Test Author 2"})
response = client.post("/authors", json={"name": "Test Author 3"})
make_relationship(1, 1)
make_relationship(2, 1)
make_relationship(1, 2)
make_relationship(2, 3)
make_relationship(3, 3)
response = client.get("/relationships/author-book")
def make_genrebook_relationship(genre_id, book_id):
response = client.post(
"/relationships/genre-book", params={"genre_id": genre_id, "book_id": book_id}
)
assert response.status_code == 200, "Invalid response status"
assert len(response.json()) == 5, "Invalid number of relationships"
def test_prepare_data():
# Create books
assert client.post(
"/books", json={"title": "Test Book 1", "description": "Test Description 1"}
).status_code == 200
assert client.post(
"/books", json={"title": "Test Book 2", "description": "Test Description 2"}
).status_code == 200
assert client.post(
"/books", json={"title": "Test Book 3", "description": "Test Description 3"}
).status_code == 200
# Create authors
assert client.post("/authors", json={"name": "Test Author 1"}).status_code == 200
assert client.post("/authors", json={"name": "Test Author 2"}).status_code == 200
assert client.post("/authors", json={"name": "Test Author 3"}).status_code == 200
# Create genres
assert client.post("/genres", json={"name": "Test Genre 1"}).status_code == 200
assert client.post("/genres", json={"name": "Test Genre 2"}).status_code == 200
assert client.post("/genres", json={"name": "Test Genre 3"}).status_code == 200
# Create relationships
make_authorbook_relationship(1, 1)
make_authorbook_relationship(2, 1)
make_authorbook_relationship(1, 2)
make_authorbook_relationship(2, 3)
make_authorbook_relationship(3, 3)
make_genrebook_relationship(1, 1)
make_genrebook_relationship(2, 1)
make_genrebook_relationship(1, 2)
make_genrebook_relationship(2, 3)
make_genrebook_relationship(3, 3)
def test_get_book_authors():
# Setup test data
test_prepare_data()
response1 = client.get("/books/1/authors")
assert response1.status_code == 200, "Invalid response status"
assert len(response1.json()) == 2, "Invalid number of authors"
@@ -55,7 +89,11 @@ def test_get_book_authors():
assert response3.json()[0]["id"] == 2
assert response3.json()[1]["id"] == 3
def test_get_author_books():
# Setup test data
test_prepare_data()
response1 = client.get("/authors/1/books")
assert response1.status_code == 200, "Invalid response status"
assert len(response1.json()) == 2, "Invalid number of books"