Compare commits

..

6 Commits

Author SHA1 Message Date
2c24f66de0 Merge remote-tracking branch 'origin/main' 2025-11-30 21:51:58 +03:00
a757e69ad5 Обновление описания 2025-11-30 21:51:26 +03:00
wowlikon
20dbf34fa6 Добавление диаграммы
Добавлена диаграмма сущностей и описание технологий.
2025-11-30 21:45:32 +03:00
a7dc4890c6 Merge remote-tracking branch 'origin/main' 2025-11-30 21:00:27 +03:00
99de648fa9 Форматирование кода, добавление лого, исправление тестов, улучшение эндпоинтов и документации 2025-11-30 20:57:49 +03:00
wowlikon
e57812ffc9 Add MIT License to the project 2025-11-13 21:20:16 +03:00
39 changed files with 1320 additions and 309 deletions

4
.env
View File

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

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.

102
README.md
View File

@@ -1,3 +1,4 @@
![logo](./logo.png)
# LibraryAPI # LibraryAPI
Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания. Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания.
@@ -52,41 +53,84 @@
### **Эндпоинты API** ### **Эндпоинты API**
**Авторы** **Авторы**
| Метод | Эндпоинты | Описание | | Метод | Эндпоинты | Описание |
|--------|-----------------------|---------------------------------------------| |--------|-----------------------|---------------------------------|
| POST | `/authors` | Создать нового автора | | POST | `/authors` | Создать нового автора |
| GET | `/authors` | Получить список всех авторов | | GET | `/authors` | Получить список всех авторов |
| GET | `/authors/{id}` | Получить конкретного автора по ID с книгами | | GET | `/authors/{id}` | Получить автора по ID с книгами |
| PUT | `/authors/{id}` | Обновить конкретного автора по ID | | PUT | `/authors/{id}` | Обновить автора по ID |
| DELETE | `/authors/{id}` | Удалить конкретного автора по ID | | DELETE | `/authors/{id}` | Удалить автора по ID |
| GET | `/authors/{id}/books` | Получить список книг для конкретного автора |
**Книги** **Книги**
| Метод | Эндпоинты | Описание | | Метод | Эндпоинты | Описание |
|--------|-----------------------|----------------------------------------------| |--------|-----------------------|---------------------------------|
| POST | `/books` | Создать новую книгу | | POST | `/books` | Создать новую книгу |
| GET | `/books` | Получить список всех книг | | GET | `/books` | Получить список всех книг |
| GET | `/book/{id}` | Получить конкретную книгу по ID с авторами | | GET | `/book/{id}` | Получить книгу по ID с авторами |
| PUT | `/books/{id}` | Обновить конкретную книгу по ID | | PUT | `/books/{id}` | Обновить книгу по ID |
| DELETE | `/books/{id}` | Удалить конкретную книгу по ID | | DELETE | `/books/{id}` | Удалить книгу по ID |
| GET | `/books/{id}/authors` | Получить список авторов для конкретной книги |
**Жанры** **Жанры**
| Метод | Эндпоинты | Описание | | Метод | Эндпоинты | Описание |
|--------|-----------------------|----------------------------------------------| |--------|-----------------------|---------------------------------|
| POST | `/genres` | Создать новый жанр | | POST | `/genres` | Создать новый жанр |
| GET | `/genres` | Получить список всех жанров | | GET | `/genres` | Получить список всех жанров |
| GET | `/genres/{id}` | Получить конкретный жанр по ID | | GET | `/genres/{id}` | Получить жанр по ID |
| PUT | `/genres/{id}` | Обновить конкретный жанр по ID | | PUT | `/genres/{id}` | Обновить жанр по ID |
| DELETE | `/genres/{id}` | Удалить конкретный жанр по ID | | DELETE | `/genres/{id}` | Удалить жанр по ID |
| GET | `/books/{id}/genres` | Получить список жанров для конкретной книги |
**Связи** **Связи**
| Метод | Эндпоинты | Описание | | Метод | Эндпоинты | Описание |
|--------|------------------------------|-----------------------------------------| |--------|------------------------------|-----------------------------------|
| GET | `/relationships/author-book` | Получить список всех связей автор-книга | | GET | `/authors/{id}/books` | Получить список книг для автора |
| POST | `/relationships/author-book` | Добавить связь автор-книга | | GET | `/books/{id}/authors` | Получить список авторов для книги |
| DELETE | `/relationships/author-book` | Удалить связь автор-книга | | 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` | Получить информацию о сервисе |
```mermaid
erDiagram
AUTHOR {
int id PK "ID автора"
string name "Имя автора"
}
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 : "содержит"
```
### **Используемые технологии** ### **Используемые технологии**

View File

@@ -2,17 +2,17 @@ services:
db: db:
container_name: db container_name: db
image: postgres:17 image: postgres:17
expose: ports:
- 5432 - 5432:5432
volumes: # volumes:
- ./data/db:/var/lib/postgresql/data # - ./data/db:/var/lib/postgresql/data
env_file: env_file:
- ./.env - ./.env
api: api:
container_name: api container_name: api
build: . 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: volumes:
- .:/code - .:/code
ports: ports:
@@ -26,5 +26,3 @@ services:
command: bash -c "pytest tests" command: bash -c "pytest tests"
volumes: volumes:
- .:/code - .:/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

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

View File

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

View File

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

View File

@@ -6,9 +6,9 @@ from .links import GenreBookLink
if TYPE_CHECKING: if TYPE_CHECKING:
from .book import Book from .book import Book
class Genre(GenreBase, table=True): class Genre(GenreBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True, index=True) id: Optional[int] = Field(default=None, primary_key=True, index=True)
books: List["Book"] = Relationship( books: List["Book"] = Relationship(
back_populates="genres", back_populates="genres", link_model=GenreBookLink
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.book import BookRead
from library_service.models.dto.genre import GenreRead from library_service.models.dto.genre import GenreRead
class AuthorBookLink(SQLModel, table=True): 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) book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
class GenreBookLink(SQLModel, table=True): class GenreBookLink(SQLModel, table=True):
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=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) book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
class AuthorWithBooks(AuthorRead): class AuthorWithBooks(AuthorRead):
books: List[BookRead] = Field(default_factory=list) books: List[BookRead] = Field(default_factory=list)
class BookWithAuthors(BookRead): class BookWithAuthors(BookRead):
authors: List[AuthorRead] = Field(default_factory=list) authors: List[AuthorRead] = Field(default_factory=list)
class BookWithGenres(BookRead): class BookWithGenres(BookRead):
genres: List[GenreRead] = Field(default_factory=list) genres: List[GenreRead] = Field(default_factory=list)
class GenreWithBooks(GenreRead): class GenreWithBooks(GenreRead):
books: List[BookRead] = Field(default_factory=list) books: List[BookRead] = Field(default_factory=list)
class BookWithAuthorsAndGenres(BookRead): class BookWithAuthorsAndGenres(BookRead):
authors: List[AuthorRead] = Field(default_factory=list) authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list) genres: List[GenreRead] = Field(default_factory=list)

View File

@@ -1,19 +1,22 @@
from .author import ( from .author import AuthorBase, AuthorCreate, AuthorUpdate, AuthorRead, AuthorList
AuthorBase, AuthorCreate, AuthorUpdate, from .book import BookBase, BookCreate, BookUpdate, BookRead, BookList
AuthorRead, AuthorList
)
from .book import (
BookBase, BookCreate, BookUpdate,
BookRead, BookList
)
from .genre import ( from .genre import GenreBase, GenreCreate, GenreUpdate, GenreRead, GenreList
GenreBase, GenreCreate, GenreUpdate,
GenreRead, GenreList
)
__all__ = [ __all__ = [
'AuthorBase', 'AuthorCreate', 'AuthorUpdate', 'AuthorRead', 'AuthorList', "AuthorBase",
'BookBase', 'BookCreate', 'BookUpdate', 'BookRead', 'BookList', "AuthorCreate",
'GenreBase', 'GenreCreate', 'GenreUpdate', 'GenreRead', 'GenreList', "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 pydantic import ConfigDict
from typing import Optional, List from typing import Optional, List
class AuthorBase(SQLModel): class AuthorBase(SQLModel):
name: str name: str
model_config = ConfigDict( #pyright: ignore model_config = ConfigDict( # pyright: ignore
json_schema_extra={ json_schema_extra={"example": {"name": "author_name"}}
"example": {"name": "author_name"}
}
) )
class AuthorCreate(AuthorBase): class AuthorCreate(AuthorBase):
pass pass
class AuthorUpdate(SQLModel): class AuthorUpdate(SQLModel):
name: Optional[str] = None name: Optional[str] = None
class AuthorRead(AuthorBase): class AuthorRead(AuthorBase):
id: int id: int
class AuthorList(SQLModel): class AuthorList(SQLModel):
authors: List[AuthorRead] authors: List[AuthorRead]
total: int total: int

View File

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

View File

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

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 sqlmodel import Session, select
from library_service.settings import get_session from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks from library_service.models.db import Author, AuthorBookLink, Book, AuthorWithBooks
from library_service.models.dto import ( from library_service.models.dto import (
AuthorCreate, AuthorUpdate, AuthorRead, AuthorCreate,
AuthorList, BookRead AuthorUpdate,
AuthorRead,
AuthorList,
BookRead,
) )
router = APIRouter(prefix="/authors", tags=["authors"]) router = APIRouter(prefix="/authors", tags=["authors"])
# Create an author # 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)): def create_author(author: AuthorCreate, session: Session = Depends(get_session)):
db_author = Author(**author.model_dump()) db_author = Author(**author.model_dump())
session.add(db_author) session.add(db_author)
@@ -19,41 +29,60 @@ def create_author(author: AuthorCreate, session: Session = Depends(get_session))
session.refresh(db_author) session.refresh(db_author)
return AuthorRead(**db_author.model_dump()) return AuthorRead(**db_author.model_dump())
# Read authors # Read authors
@router.get("/", response_model=AuthorList) @router.get(
"/",
response_model=AuthorList,
summary="Получить список авторов",
description="Возвращает список всех авторов в системе",
)
def read_authors(session: Session = Depends(get_session)): def read_authors(session: Session = Depends(get_session)):
authors = session.exec(select(Author)).all() authors = session.exec(select(Author)).all()
return AuthorList( return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors], authors=[AuthorRead(**author.model_dump()) for author in authors],
total=len(authors) total=len(authors),
) )
# Read an author with their books # Read an author with their books
@router.get("/{author_id}", response_model=AuthorWithBooks) @router.get(
def get_author(author_id: int, session: Session = Depends(get_session)): "/{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) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
books = session.exec( books = session.exec(
select(Book) select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
.join(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id)
).all() ).all()
book_reads = [BookRead(**book.model_dump()) for book in books] book_reads = [BookRead(**book.model_dump()) for book in books]
author_data = author.model_dump() author_data = author.model_dump()
author_data['books'] = book_reads author_data["books"] = book_reads
return AuthorWithBooks(**author_data) return AuthorWithBooks(**author_data)
# Update an author # Update an author
@router.put("/{author_id}", response_model=AuthorRead) @router.put(
"/{author_id}",
response_model=AuthorRead,
summary="Обновить информацию об авторе",
description="Обновляет информацию об авторе в системе",
)
def update_author( def update_author(
author_id: int,
author: AuthorUpdate, 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) db_author = session.get(Author, author_id)
if not db_author: if not db_author:
@@ -67,9 +96,18 @@ def update_author(
session.refresh(db_author) session.refresh(db_author)
return AuthorRead(**db_author.model_dump()) return AuthorRead(**db_author.model_dump())
# Delete an author # Delete an author
@router.delete("/{author_id}", response_model=AuthorRead) @router.delete(
def delete_author(author_id: int, session: Session = Depends(get_session)): "/{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) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") 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 sqlmodel import Session, select
from library_service.models.db.links import BookWithAuthorsAndGenres
from library_service.settings import get_session from library_service.settings import get_session
from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink from library_service.models.db import Author, Book, BookWithAuthors, AuthorBookLink
from library_service.models.dto import ( from library_service.models.dto import (
AuthorRead, BookList, BookRead, AuthorRead,
BookCreate, BookUpdate BookList,
BookRead,
BookCreate,
BookUpdate,
) )
router = APIRouter(prefix="/books", tags=["books"]) router = APIRouter(prefix="/books", tags=["books"])
# Create a book # 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)): def create_book(book: BookCreate, session: Session = Depends(get_session)):
db_book = Book(**book.model_dump()) db_book = Book(**book.model_dump())
session.add(db_book) session.add(db_book)
@@ -19,38 +30,67 @@ def create_book(book: BookCreate, session: Session = Depends(get_session)):
session.refresh(db_book) session.refresh(db_book)
return BookRead(**db_book.model_dump()) return BookRead(**db_book.model_dump())
# Read books # Read books
@router.get("/", response_model=BookList) @router.get(
"/",
response_model=BookList,
summary="Получить список книг",
description="Возвращает список всех книг в системе",
)
def read_books(session: Session = Depends(get_session)): def read_books(session: Session = Depends(get_session)):
books = session.exec(select(Book)).all() books = session.exec(select(Book)).all()
return BookList( return BookList(
books=[BookRead(**book.model_dump()) for book in books], books=[BookRead(**book.model_dump()) for book in books], total=len(books)
total=len(books)
) )
# Read a book with their authors
@router.get("/{book_id}", response_model=BookWithAuthors) # Read a book with their authors and genres
def get_book(book_id: int, session: Session = Depends(get_session)): @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) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
authors = session.exec( authors = session.exec(
select(Author) select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
.join(AuthorBookLink)
.where(AuthorBookLink.book_id == book_id)
).all() ).all()
author_reads = [AuthorRead(**author.model_dump()) for author in authors] 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 = book.model_dump()
book_data['authors'] = author_reads book_data["authors"] = author_reads
book_data["genres"] = genre_reads
return BookWithAuthors(**book_data) return BookWithAuthors(**book_data)
# Update a book # Update a book
@router.put("/{book_id}", response_model=Book) @router.put(
def update_book(book_id: int, book: BookUpdate, session: Session = Depends(get_session)): "/{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) db_book = session.get(Book, book_id)
if not db_book: if not db_book:
raise HTTPException(status_code=404, detail="Book not found") 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) session.refresh(db_book)
return db_book return db_book
# Delete a book # Delete a book
@router.delete("/{book_id}", response_model=BookRead) @router.delete(
def delete_book(book_id: int, session: Session = Depends(get_session)): "/{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) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") 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.delete(book)
session.commit() session.commit()
return book_read 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 sqlmodel import Session, select
from library_service.settings import get_session from library_service.settings import get_session
from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks from library_service.models.db import Genre, GenreBookLink, Book, GenreWithBooks
from library_service.models.dto import ( from library_service.models.dto import (
GenreCreate, GenreUpdate, GenreRead, GenreCreate,
GenreList, BookRead GenreUpdate,
GenreRead,
GenreList,
BookRead,
) )
router = APIRouter(prefix="/genres", tags=["genres"]) router = APIRouter(prefix="/genres", tags=["genres"])
# Create a genre # 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)): def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
db_genre = Genre(**genre.model_dump()) db_genre = Genre(**genre.model_dump())
session.add(db_genre) session.add(db_genre)
@@ -19,41 +29,59 @@ def create_genre(genre: GenreCreate, session: Session = Depends(get_session)):
session.refresh(db_genre) session.refresh(db_genre)
return GenreRead(**db_genre.model_dump()) return GenreRead(**db_genre.model_dump())
# Read genres # Read genres
@router.get("/", response_model=GenreList) @router.get(
"/",
response_model=GenreList,
summary="Получить список жанров",
description="Возвращает список всех жанров в системе",
)
def read_genres(session: Session = Depends(get_session)): def read_genres(session: Session = Depends(get_session)):
genres = session.exec(select(Genre)).all() genres = session.exec(select(Genre)).all()
return GenreList( return GenreList(
genres=[GenreRead(**genre.model_dump()) for genre in genres], genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
total=len(genres)
) )
# Read a genre with their books # Read a genre with their books
@router.get("/{genre_id}", response_model=GenreWithBooks) @router.get(
def get_genre(genre_id: int, session: Session = Depends(get_session)): "/{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) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
books = session.exec( books = session.exec(
select(Book) select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
.join(GenreBookLink)
.where(GenreBookLink.genre_id == genre_id)
).all() ).all()
book_reads = [BookRead(**book.model_dump()) for book in books] book_reads = [BookRead(**book.model_dump()) for book in books]
genre_data = genre.model_dump() genre_data = genre.model_dump()
genre_data['books'] = book_reads genre_data["books"] = book_reads
return GenreWithBooks(**genre_data) return GenreWithBooks(**genre_data)
# Update a genre # Update a genre
@router.put("/{genre_id}", response_model=GenreRead) @router.put(
"/{genre_id}",
response_model=GenreRead,
summary="Обновляет информацию о жанре",
description="Обновляет информацию о жанре в системе",
)
def update_genre( def update_genre(
genre_id: int,
genre: GenreUpdate, 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) db_genre = session.get(Genre, genre_id)
if not db_genre: if not db_genre:
@@ -67,9 +95,18 @@ def update_genre(
session.refresh(db_genre) session.refresh(db_genre)
return GenreRead(**db_genre.model_dump()) return GenreRead(**db_genre.model_dump())
# Delete a genre # Delete a genre
@router.delete("/{genre_id}", response_model=GenreRead) @router.delete(
def delete_genre(genre_id: int, session: Session = Depends(get_session)): "/{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) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")

View File

@@ -1,13 +1,11 @@
from fastapi import APIRouter, Request, FastAPI from fastapi import APIRouter, Path, Request
from fastapi.params import Depends 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 fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from typing import Dict from typing import Dict
from httpx import get
from library_service.settings import get_app from library_service.settings import get_app
# Загрузка шаблонов # Загрузка шаблонов
@@ -30,12 +28,28 @@ def get_info(app) -> Dict:
# Эндпоинт главной страницы # Эндпоинт главной страницы
@router.get("/", response_class=HTMLResponse) @router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)): async def root(request: Request, app=Depends(get_app)):
return templates.TemplateResponse(request, "index.html", get_info(app)) return templates.TemplateResponse(request, "index.html", get_info(app))
# Редирект иконки вкладки
@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 # Эндпоинт информации об API
@router.get("/api/info") @router.get(
"/api/info",
summary="Информация о сервисе",
description="Возвращает информацию о системе",
)
async def api_info(app=Depends(get_app)): async def api_info(app=Depends(get_app)):
return JSONResponse(content=get_info(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"]) router = APIRouter(tags=["relations"])
# Add author to book # Add author to book
@router.post("/relationships/author-book", response_model=AuthorBookLink) @router.post(
def add_author_to_book(author_id: int, book_id: int, session: Session = Depends(get_session)): "/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) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") 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) session.refresh(link)
return link return link
# Remove author from book # Remove author from book
@router.delete("/relationships/author-book", response_model=Dict[str, str]) @router.delete(
def remove_author_from_book(author_id: int, book_id: int, session: Session = Depends(get_session)): "/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( link = session.exec(
select(AuthorBookLink) select(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id) .where(AuthorBookLink.author_id == author_id)
@@ -50,15 +66,55 @@ def remove_author_from_book(author_id: int, book_id: int, session: Session = Dep
session.commit() session.commit()
return {"message": "Relationship removed successfully"} return {"message": "Relationship removed successfully"}
# Get relationships
@router.get("/relationships/genre-book", response_model=List[GenreBookLink])
def get_relationships(session: Session = Depends(get_session)):
relationships = session.exec(select(GenreBookLink)).all()
return relationships
# Add author to book # Get author's books
@router.post("/relationships/genre-book", response_model=GenreBookLink) @router.get(
def add_genre_to_book(genre_id: int, book_id: int, session: Session = Depends(get_session)): "/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)
).all()
return [BookRead(**book.model_dump()) for book in books]
# Get book's authors
@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)
).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) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
@@ -82,9 +138,17 @@ def add_genre_to_book(genre_id: int, book_id: int, session: Session = Depends(ge
session.refresh(link) session.refresh(link)
return link return link
# Remove author from book # Remove author from book
@router.delete("/relationships/genre-book", response_model=Dict[str, str]) @router.delete(
def remove_genre_from_book(genre_id: int, book_id: int, session: Session = Depends(get_session)): "/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( link = session.exec(
select(GenreBookLink) select(GenreBookLink)
.where(GenreBookLink.genre_id == genre_id) .where(GenreBookLink.genre_id == genre_id)
@@ -98,38 +162,40 @@ def remove_genre_from_book(genre_id: int, book_id: int, session: Session = Depen
session.commit() session.commit()
return {"message": "Relationship removed successfully"} return {"message": "Relationship removed successfully"}
# Get relationships
@router.get("/relationships/genre-book", response_model=List[GenreBookLink])
def get__genre_relationships(session: Session = Depends(get_session)):
relationships = session.exec(select(GenreBookLink)).all()
return relationships
# Get author's books # Get genre's books
@router.get("/authors/{author_id}/books/", response_model=List[BookRead]) @router.get(
def get_books_for_author(author_id: int, session: Session = Depends(get_session)): "/genres/{author_id}/books/",
author = session.get(Author, author_id) response_model=List[BookRead],
if not author: summary="Получить книги, написанные в жанре",
raise HTTPException(status_code=404, detail="Author not found") 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( books = session.exec(
select(Book) select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_id)
.join(AuthorBookLink)
.where(AuthorBookLink.author_id == author_id)
).all() ).all()
return [BookRead(**book.model_dump()) for book in books] return [BookRead(**book.model_dump()) for book in books]
# Get book's authors
@router.get("/books/{book_id}/authors/", response_model=List[AuthorRead]) # 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)): def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
authors = session.exec( genres = session.exec(
select(Author) select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
.join(AuthorBookLink)
.where(AuthorBookLink.book_id == book_id)
).all() ).all()
return [AuthorRead(**author.model_dump()) for author in authors] return [GenreRead(**author.model_dump()) for genre in genres]

View File

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

View File

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

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

View File

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

View File

@@ -1,7 +1,7 @@
[tool.poetry] [tool.poetry]
name = "LibraryAPI" name = "LibraryAPI"
version = "0.1.2" version = "0.1.3"
description = "Это простое API для управления авторами и книгами." description = "Это простое API для управления авторами, книгами и их жанрами."
authors = ["wowlikon"] authors = ["wowlikon"]
readme = "README.md" readme = "README.md"
packages = [{ include = "library_service" }] 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 import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient 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 client = TestClient(mock_app)
from tests.test_misc import setup_database
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") response = client.get("/authors")
print(response.json()) print(response.json())
assert response.status_code == 200, "Invalid response status" assert response.status_code == 200, "Invalid response status"
assert response.json() == {"authors": [], "total": 0}, "Invalid response data" 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"}) response = client.post("/authors", json={"name": "Test Author"})
print(response.json()) print(response.json())
assert response.status_code == 200, "Invalid response status" assert response.status_code == 200, "Invalid response status"
assert response.json() == {"id": 1, "name": "Test Author"}, "Invalid response data" 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") response = client.get("/authors")
print(response.json()) print(response.json())
assert response.status_code == 200, "Invalid response status" 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") response = client.get("/authors/1")
print(response.json()) print(response.json())
assert response.status_code == 200, "Invalid response status" 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") response = client.get("/authors/2")
print(response.json()) print(response.json())
assert response.status_code == 404, "Invalid response status" assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Author not found"}, "Invalid response data" 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") response = client.get("/authors/1")
assert response.status_code == 200, "Invalid response status" assert response.status_code == 200, "Invalid response status"
response = client.put("/authors/1", json={"name": "Updated Author"}) response = client.put("/authors/1", json={"name": "Updated Author"})
assert response.status_code == 200, "Invalid response status" 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"}) response = client.put("/authors/2", json={"name": "Updated Author"})
assert response.status_code == 404, "Invalid response status" assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Author not found"}, "Invalid response data" 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") response = client.get("/authors/1")
assert response.status_code == 200, "Invalid response status" assert response.status_code == 200, "Invalid response status"
response = client.delete("/authors/1") response = client.delete("/authors/1")
assert response.status_code == 200, "Invalid response status" 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") response = client.delete("/authors/2")
assert response.status_code == 404, "Invalid response status" assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Author not found"}, "Invalid response data" assert response.json() == {"detail": "Author not found"}, "Invalid response data"

View File

@@ -1,68 +1,130 @@
import pytest import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient 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 client = TestClient(mock_app)
from tests.test_misc import setup_database
client = TestClient(app)
#TODO: assert descriptions @pytest.fixture(autouse=True)
#TODO: add comments def setup_database():
#TODO: update tests """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") response = client.get("/books")
print(response.json()) print(response.json())
assert response.status_code == 200, "Invalid response status" assert response.status_code == 200, "Invalid response status"
assert response.json() == {"books": [], "total": 0}, "Invalid response data" 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()) print(response.json())
assert response.status_code == 200, "Invalid response status" 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") response = client.get("/books")
print(response.json()) print(response.json())
assert response.status_code == 200, "Invalid response status" 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") response = client.get("/books/1")
print(response.json()) print(response.json())
assert response.status_code == 200, "Invalid response status" 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") response = client.get("/books/2")
print(response.json()) print(response.json())
assert response.status_code == 404, "Invalid response status" assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Book not found"}, "Invalid response data" 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") response = client.get("/books/1")
assert response.status_code == 200, "Invalid response status" 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(
response = client.put("/books/2", json={"title": "Updated Book", "description": "Updated Description"}) "/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.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Book not found"}, "Invalid response data" 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") response = client.get("/books/1")
assert response.status_code == 200, "Invalid response status" assert response.status_code == 200, "Invalid response status"
response = client.delete("/books/1") response = client.delete("/books/1")
assert response.status_code == 200, "Invalid response status" 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") response = client.delete("/books/2")
assert response.status_code == 404, "Invalid response status" assert response.status_code == 404, "Invalid response status"
assert response.json() == {"detail": "Book not found"}, "Invalid response data" assert response.json() == {"detail": "Book not found"}, "Invalid response data"

View File

@@ -1,56 +1,27 @@
import pytest import pytest
from alembic import command
from alembic.config import Config
from datetime import datetime from datetime import datetime
from fastapi.testclient import TestClient 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 client = TestClient(mock_app)
from library_service.models.db import Author, Book, Genre
from library_service.models.db import AuthorBookLink, GenreBookLink
client = TestClient(app)
@pytest.fixture(scope="module") @pytest.fixture(autouse=True)
def setup_database(): def setup_database():
# Save original data backup """Setup and cleanup mock database for each test"""
with Session(engine) as session: # Clear data before each test
original_authors = session.exec(select(Author)).all() mock_storage.clear_all()
original_books = session.exec(select(Book)).all() yield
original_genres = session.exec(select(Genre)).all() # Clear data after each test (optional, but good practice)
original_author_book_links = session.exec(select(AuthorBookLink)).all() mock_storage.clear_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()
# Test the main page of the application # Test the main page of the application
def test_main_page(): def test_main_page():
response = client.get("/") # Send GET request to the main page response = client.get("/") # Send GET request to the main page
try: 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 # Find indices of key elements in the content
title_idx = content.index("Welcome to ") title_idx = content.index("Welcome to ")
description_idx = content.index("Description: ") description_idx = content.index("Description: ")
@@ -59,17 +30,18 @@ def test_main_page():
status_idx = content.index("Status: ") status_idx = content.index("Status: ")
assert response.status_code == 200, "Invalid response status" assert response.status_code == 200, "Invalid response status"
assert content.startswith('<!doctype html>'), "Not HTML" assert content.startswith("<!doctype html>"), "Not HTML"
assert content.endswith('</html>'), "HTML tag not closed" assert content.endswith("</html>"), "HTML tag not closed"
assert content[title_idx+1] != '<', "Title not provided" assert content[title_idx + 1] != "<", "Title not provided"
assert content[description_idx+1] != '<', "Description not provided" assert content[description_idx + 1] != "<", "Description not provided"
assert content[version_idx+1] != '<', "Version not provided" assert content[version_idx + 1] != "<", "Version not provided"
assert content[time_idx+1] != '<', "Time not provided" assert content[time_idx + 1] != "<", "Time not provided"
assert content[status_idx+1] != '<', "Status not provided" assert content[status_idx + 1] != "<", "Status not provided"
except Exception as e: except Exception as e:
print(f"Error: {e}") # Print error if an exception occurs print(f"Error: {e}") # Print error if an exception occurs
assert False, "Unexpected error" # Force test failure on unexpected error assert False, "Unexpected error" # Force test failure on unexpected error
# Test application info endpoint # Test application info endpoint
def test_app_info_test(): def test_app_info_test():
response = client.get("/api/info") # Send GET request to the info endpoint 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"]["description"] != "", "Description not provided"
assert response.json()["app_info"]["version"] != "", "Version not provided" assert response.json()["app_info"]["version"] != "", "Version not provided"
# Check time difference # Check time difference
assert 0 < (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds(), "Negative time difference" assert (
assert (datetime.now() - datetime.fromisoformat(response.json()["server_time"])).total_seconds() < 1, "Time difference too large" 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,42 +1,72 @@
import pytest import pytest
from alembic import command
from alembic.config import Config
from fastapi.testclient import TestClient 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 client = TestClient(mock_app)
from tests.test_misc import setup_database
@pytest.fixture(autouse=True)
def setup_database():
"""Clear mock storage before each test"""
mock_storage.clear_all()
yield
mock_storage.clear_all()
client = TestClient(app)
def make_authorbook_relationship(author_id, book_id): def make_authorbook_relationship(author_id, book_id):
response = client.post("/relationships/author-book", params={"author_id": author_id, "book_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" assert response.status_code == 200, "Invalid response status"
def make_genrebook_relationship(author_id, book_id):
response = client.post("/relationships/genre-book", params={"genre_id": author_id, "book_id": book_id}) 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 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"}) def test_prepare_data():
response = client.post("/authors", json={"name": "Test Author 2"}) # Create books
response = client.post("/authors", json={"name": "Test Author 3"}) 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(1, 1)
make_authorbook_relationship(2, 1) make_authorbook_relationship(2, 1)
make_authorbook_relationship(1, 2) make_authorbook_relationship(1, 2)
make_authorbook_relationship(2, 3) make_authorbook_relationship(2, 3)
make_authorbook_relationship(3, 3) make_authorbook_relationship(3, 3)
make_genrebook_relationship(1, 1)
response = client.get("/relationships/author-book") make_genrebook_relationship(2, 1)
assert response.status_code == 200, "Invalid response status" make_genrebook_relationship(1, 2)
assert len(response.json()) == 5, "Invalid number of relationships" make_genrebook_relationship(2, 3)
make_genrebook_relationship(3, 3)
def test_get_book_authors(): def test_get_book_authors():
# Setup test data
test_prepare_data()
response1 = client.get("/books/1/authors") response1 = client.get("/books/1/authors")
assert response1.status_code == 200, "Invalid response status" assert response1.status_code == 200, "Invalid response status"
assert len(response1.json()) == 2, "Invalid number of authors" assert len(response1.json()) == 2, "Invalid number of authors"
@@ -59,7 +89,11 @@ def test_get_book_authors():
assert response3.json()[0]["id"] == 2 assert response3.json()[0]["id"] == 2
assert response3.json()[1]["id"] == 3 assert response3.json()[1]["id"] == 3
def test_get_author_books(): def test_get_author_books():
# Setup test data
test_prepare_data()
response1 = client.get("/authors/1/books") response1 = client.get("/authors/1/books")
assert response1.status_code == 200, "Invalid response status" assert response1.status_code == 200, "Invalid response status"
assert len(response1.json()) == 2, "Invalid number of books" assert len(response1.json()) == 2, "Invalid number of books"