mirror of
https://github.com/wowlikon/LibraryAPI.git
synced 2025-12-11 21:30:46 +00:00
Compare commits
6 Commits
a3ccd8a466
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c24f66de0 | |||
| a757e69ad5 | |||
|
|
20dbf34fa6 | ||
| a7dc4890c6 | |||
| 99de648fa9 | |||
|
|
e57812ffc9 |
4
.env
4
.env
@@ -1,4 +1,4 @@
|
||||
POSTGRES_USER = "postgres"
|
||||
POSTGRES_PASSWORD = "password"
|
||||
POSTGRES_DB = "mydatabase"
|
||||
POSTGRES_PASSWORD = "postgres"
|
||||
POSTGRES_DB = "postgres"
|
||||
POSTGRES_SERVER = "db"
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
82
README.md
82
README.md
@@ -1,3 +1,4 @@
|
||||

|
||||
# LibraryAPI
|
||||
|
||||
Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания.
|
||||
@@ -53,40 +54,83 @@
|
||||
|
||||
**Авторы**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------------------|
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/authors` | Создать нового автора |
|
||||
| GET | `/authors` | Получить список всех авторов |
|
||||
| GET | `/authors/{id}` | Получить конкретного автора по ID с книгами |
|
||||
| PUT | `/authors/{id}` | Обновить конкретного автора по ID |
|
||||
| DELETE | `/authors/{id}` | Удалить конкретного автора по ID |
|
||||
| GET | `/authors/{id}/books` | Получить список книг для конкретного автора |
|
||||
| GET | `/authors/{id}` | Получить автора по ID с книгами |
|
||||
| PUT | `/authors/{id}` | Обновить автора по ID |
|
||||
| DELETE | `/authors/{id}` | Удалить автора по ID |
|
||||
|
||||
**Книги**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|----------------------------------------------|
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/books` | Создать новую книгу |
|
||||
| GET | `/books` | Получить список всех книг |
|
||||
| GET | `/book/{id}` | Получить конкретную книгу по ID с авторами |
|
||||
| PUT | `/books/{id}` | Обновить конкретную книгу по ID |
|
||||
| DELETE | `/books/{id}` | Удалить конкретную книгу по ID |
|
||||
| GET | `/books/{id}/authors` | Получить список авторов для конкретной книги |
|
||||
| GET | `/book/{id}` | Получить книгу по ID с авторами |
|
||||
| PUT | `/books/{id}` | Обновить книгу по ID |
|
||||
| DELETE | `/books/{id}` | Удалить книгу по ID |
|
||||
|
||||
**Жанры**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|----------------------------------------------|
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/genres` | Создать новый жанр |
|
||||
| GET | `/genres` | Получить список всех жанров |
|
||||
| GET | `/genres/{id}` | Получить конкретный жанр по ID |
|
||||
| PUT | `/genres/{id}` | Обновить конкретный жанр по ID |
|
||||
| DELETE | `/genres/{id}` | Удалить конкретный жанр по ID |
|
||||
| GET | `/books/{id}/genres` | Получить список жанров для конкретной книги |
|
||||
| GET | `/genres/{id}` | Получить жанр по ID |
|
||||
| PUT | `/genres/{id}` | Обновить жанр по ID |
|
||||
| DELETE | `/genres/{id}` | Удалить жанр по ID |
|
||||
|
||||
**Связи**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|------------------------------|-----------------------------------------|
|
||||
| GET | `/relationships/author-book` | Получить список всех связей автор-книга |
|
||||
| POST | `/relationships/author-book` | Добавить связь автор-книга |
|
||||
| DELETE | `/relationships/author-book` | Удалить связь автор-книга |
|
||||
|--------|------------------------------|-----------------------------------|
|
||||
| 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` | Получить информацию о сервисе |
|
||||
|
||||
|
||||
```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 : "содержит"
|
||||
```
|
||||
|
||||
|
||||
### **Используемые технологии**
|
||||
|
||||
@@ -2,17 +2,17 @@ services:
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:17
|
||||
expose:
|
||||
- 5432
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
import asyncpg
|
||||
|
||||
router = APIRouter(
|
||||
prefix='/devices'
|
||||
)
|
||||
1
library_service/favicon.svg
Normal file
1
library_service/favicon.svg
Normal 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 |
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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
|
||||
|
||||
# Загрузка шаблонов
|
||||
@@ -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)):
|
||||
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
|
||||
@router.get("/api/info")
|
||||
@router.get(
|
||||
"/api/info",
|
||||
summary="Информация о сервисе",
|
||||
description="Возвращает информацию о системе",
|
||||
)
|
||||
async def api_info(app=Depends(get_app)):
|
||||
return JSONResponse(content=get_info(app))
|
||||
|
||||
@@ -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,15 +66,55 @@ 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/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
|
||||
@router.post("/relationships/genre-book", response_model=GenreBookLink)
|
||||
def add_genre_to_book(genre_id: int, book_id: int, session: Session = Depends(get_session)):
|
||||
# Get author's books
|
||||
@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)
|
||||
).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)
|
||||
if not genre:
|
||||
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)
|
||||
return link
|
||||
|
||||
|
||||
# Remove author from book
|
||||
@router.delete("/relationships/genre-book", response_model=Dict[str, str])
|
||||
def remove_genre_from_book(genre_id: int, book_id: int, session: Session = Depends(get_session)):
|
||||
@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)
|
||||
@@ -98,38 +162,40 @@ def remove_genre_from_book(genre_id: int, book_id: int, session: Session = Depen
|
||||
session.commit()
|
||||
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
|
||||
@router.get("/authors/{author_id}/books/", response_model=List[BookRead])
|
||||
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")
|
||||
# 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(AuthorBookLink)
|
||||
.where(AuthorBookLink.author_id == author_id)
|
||||
select(Book).join(GenreBookLink).where(GenreBookLink.author_id == genre_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])
|
||||
|
||||
# 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")
|
||||
|
||||
authors = session.exec(
|
||||
select(Author)
|
||||
.join(AuthorBookLink)
|
||||
.where(AuthorBookLink.book_id == book_id)
|
||||
genres = session.exec(
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
|
||||
).all()
|
||||
|
||||
return [AuthorRead(**author.model_dump()) for author in authors]
|
||||
return [GenreRead(**author.model_dump()) for genre in genres]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[tool.poetry]
|
||||
name = "LibraryAPI"
|
||||
version = "0.1.2"
|
||||
description = "Это простое API для управления авторами и книгами."
|
||||
version = "0.1.3"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = ["wowlikon"]
|
||||
readme = "README.md"
|
||||
packages = [{ include = "library_service" }]
|
||||
|
||||
169
tests/README.md
Normal file
169
tests/README.md
Normal 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
26
tests/mock_app.py
Normal 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()
|
||||
0
tests/mock_routers/__init__.py
Normal file
0
tests/mock_routers/__init__.py
Normal file
43
tests/mock_routers/authors.py
Normal file
43
tests/mock_routers/authors.py
Normal 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
|
||||
45
tests/mock_routers/books.py
Normal file
45
tests/mock_routers/books.py
Normal 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
|
||||
43
tests/mock_routers/genres.py
Normal file
43
tests/mock_routers/genres.py
Normal 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
|
||||
40
tests/mock_routers/relationships.py
Normal file
40
tests/mock_routers/relationships.py
Normal 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
0
tests/mocks/__init__.py
Normal file
62
tests/mocks/mock_session.py
Normal file
62
tests/mocks/mock_session.py
Normal 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
169
tests/mocks/mock_storage.py
Normal 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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,42 +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)
|
||||
|
||||
|
||||
@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):
|
||||
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"
|
||||
|
||||
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"
|
||||
|
||||
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"})
|
||||
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)
|
||||
|
||||
response = client.get("/relationships/author-book")
|
||||
assert response.status_code == 200, "Invalid response status"
|
||||
assert len(response.json()) == 5, "Invalid number of relationships"
|
||||
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"
|
||||
@@ -59,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"
|
||||
|
||||
Reference in New Issue
Block a user