Initial commit
This commit is contained in:
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
257
backend/tests/conftest.py
Normal file
257
backend/tests/conftest.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
import pytest
|
||||
|
||||
from advanced_alchemy.base import UUIDAuditBase
|
||||
from litestar.testing import AsyncTestClient
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
AsyncEngine,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.engine import URL
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from chitai.database import models as m
|
||||
from chitai.config import settings
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from litestar import Litestar
|
||||
|
||||
from pytest_databases.docker.postgres import PostgresService
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
pytest_plugins = [
|
||||
"tests.data_fixtures",
|
||||
"pytest_databases.docker",
|
||||
"pytest_databases.docker.postgres",
|
||||
]
|
||||
|
||||
# Set the environment to use the testing database
|
||||
# os.environ.update(
|
||||
# {
|
||||
# "POSTGRES_DB": "chitai_testing"
|
||||
# }
|
||||
# )
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(settings, "book_cover_path", f"{tmp_path}/covers")
|
||||
|
||||
|
||||
@pytest.fixture(name="engine")
|
||||
async def fx_engine(
|
||||
postgres_service: PostgresService,
|
||||
) -> AsyncGenerator[AsyncEngine, None]:
|
||||
"""PostgreSQL instance for testing"""
|
||||
engine = create_async_engine(
|
||||
URL(
|
||||
drivername="postgresql+asyncpg",
|
||||
username=postgres_service.user,
|
||||
password=postgres_service.password,
|
||||
host=postgres_service.host,
|
||||
port=postgres_service.port,
|
||||
database=postgres_service.database,
|
||||
query={}, # type:ignore[arg-type]
|
||||
),
|
||||
echo=False,
|
||||
poolclass=NullPool,
|
||||
)
|
||||
|
||||
# Add pg_trgm extension
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
|
||||
# Create all tables
|
||||
metadata = UUIDAuditBase.registry.metadata
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(metadata.create_all)
|
||||
|
||||
yield engine
|
||||
|
||||
# Clean up
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(metadata.drop_all)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(name="sessionmaker")
|
||||
def fx_sessionmaker(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
"""Create sessionmaker factory."""
|
||||
return async_sessionmaker(bind=engine, expire_on_commit=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def session(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create database session for tests."""
|
||||
async with sessionmaker() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
await session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def app() -> Litestar:
|
||||
"""Create Litestar app for testing."""
|
||||
|
||||
from chitai.app import create_app
|
||||
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(app: Litestar) -> AsyncGenerator[AsyncTestClient, None]:
|
||||
"""Create test client."""
|
||||
async with AsyncTestClient(app=app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def authenticated_client(
|
||||
client: AsyncTestClient, test_user: m.User
|
||||
) -> AsyncTestClient:
|
||||
"""Create authenticated test client."""
|
||||
|
||||
# login and set auth headers
|
||||
login_response = await client.post(
|
||||
"access/login", data={"email": test_user.email, "password": "password123"}
|
||||
)
|
||||
|
||||
assert login_response.status_code == 201
|
||||
|
||||
result = login_response.json()
|
||||
token = result["access_token"]
|
||||
client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def other_authenticated_client(
|
||||
app: Litestar, test_user2: m.User
|
||||
) -> AsyncGenerator[AsyncTestClient, None]:
|
||||
"""Create second authenticated test client as different user."""
|
||||
async with AsyncTestClient(app=app) as other_client:
|
||||
login_response = await other_client.post(
|
||||
"access/login", data={"email": test_user2.email, "password": "password234"}
|
||||
)
|
||||
assert login_response.status_code == 201
|
||||
result = login_response.json()
|
||||
token = result["access_token"]
|
||||
other_client.headers.update({"Authorization": f"Bearer {token}"})
|
||||
yield other_client
|
||||
|
||||
|
||||
# Service fixtures
|
||||
from chitai import services
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def user_service(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[services.UserService, None]:
|
||||
"""Create UserService instance."""
|
||||
async with sessionmaker() as session:
|
||||
async with services.UserService.new(session) as service:
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def library_service(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[services.LibraryService, None]:
|
||||
"""Create LibraryService instance."""
|
||||
async with sessionmaker() as session:
|
||||
async with services.LibraryService.new(session) as service:
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def books_service(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[services.BookService, None]:
|
||||
"""Create BookService instance."""
|
||||
async with sessionmaker() as session:
|
||||
async with services.BookService.new(
|
||||
session,
|
||||
load=[
|
||||
selectinload(m.Book.author_links).selectinload(m.BookAuthorLink.author),
|
||||
selectinload(m.Book.tag_links).selectinload(m.BookTagLink.tag),
|
||||
m.Book.publisher,
|
||||
m.Book.files,
|
||||
m.Book.identifiers,
|
||||
m.Book.series,
|
||||
],
|
||||
) as service:
|
||||
yield service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def bookshelf_service(
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
) -> AsyncGenerator[services.ShelfService]:
|
||||
"""Create ShelfService instance."""
|
||||
async with sessionmaker() as session:
|
||||
async with services.ShelfService.new(session) as service:
|
||||
yield service
|
||||
|
||||
|
||||
# Data fixtures
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(session: AsyncSession) -> AsyncGenerator[m.User, None]:
|
||||
"""Create a test user."""
|
||||
|
||||
unique_id = str(uuid4())[:8]
|
||||
user = m.User(
|
||||
email=f"user{unique_id}@example.com",
|
||||
password="password123",
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
yield user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user2(session: AsyncSession) -> AsyncGenerator[m.User, None]:
|
||||
"""Create another test user."""
|
||||
|
||||
unique_id = str(uuid4())[:8]
|
||||
user = m.User(
|
||||
email=f"user{unique_id}@example.com",
|
||||
password="password234",
|
||||
)
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
yield user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_library(
|
||||
session: AsyncSession, tmp_path: Path
|
||||
) -> AsyncGenerator[m.Library, None]:
|
||||
"""Create a test library."""
|
||||
|
||||
library = m.Library(
|
||||
name="Testing Library",
|
||||
slug="testing-library",
|
||||
root_path=str(tmp_path),
|
||||
path_template="{author_name}/{title}.{ext}",
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
session.add(library)
|
||||
await session.commit()
|
||||
await session.refresh(library)
|
||||
yield library
|
||||
Binary file not shown.
Binary file not shown.
BIN
backend/tests/data_files/Frankenstein - Mary Shelley.epub
Normal file
BIN
backend/tests/data_files/Frankenstein - Mary Shelley.epub
Normal file
Binary file not shown.
BIN
backend/tests/data_files/Metamorphosis - Franz Kafka.epub
Normal file
BIN
backend/tests/data_files/Metamorphosis - Franz Kafka.epub
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/tests/data_files/The Art of War - Sun Tzu.epub
Normal file
BIN
backend/tests/data_files/The Art of War - Sun Tzu.epub
Normal file
Binary file not shown.
BIN
backend/tests/data_files/cover.jpg
Normal file
BIN
backend/tests/data_files/cover.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
57
backend/tests/data_fixtures.py
Normal file
57
backend/tests/data_fixtures.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from typing import Any
|
||||
|
||||
from chitai.database import models as m
|
||||
|
||||
|
||||
@pytest.fixture(name="raw_users")
|
||||
def fx_raw_users() -> list[m.User | dict[str, Any]]:
|
||||
"""Unstructured user representations."""
|
||||
|
||||
return [
|
||||
{"email": "user1@example.com", "password": "password123"},
|
||||
{"email": "user2@example.com", "password": "password234"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="raw_libraries")
|
||||
def fx_raw_libraries(tmp_path: Path) -> list[m.Library | dict[str, Any]]:
|
||||
"""Unstructured library representations."""
|
||||
|
||||
return [
|
||||
{
|
||||
"name": "Default Test Library",
|
||||
"slug": "default-test-library",
|
||||
"root_path": f"{tmp_path}/default_library",
|
||||
"path_template": "{author}/{title}",
|
||||
"read_only": False,
|
||||
},
|
||||
{
|
||||
"name": "Test Textbook Library",
|
||||
"slug": "test-textbook-library",
|
||||
"root_path": f"{tmp_path}/textbooks",
|
||||
"path_template": "{author}/{title}",
|
||||
"read_only": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(name="raw_books")
|
||||
def fx_raw_books() -> list[m.Book | dict[str, Any]]:
|
||||
"""Unstructured book representations."""
|
||||
|
||||
return [
|
||||
{
|
||||
"library_id": 1,
|
||||
"title": "The Fellowship of the Ring",
|
||||
"path": "books/J.R.R Tolkien/Lord of the Rings/01 - The Fellowship of The Ring",
|
||||
"pages": 427,
|
||||
"authors": [{"name": "J.R.R Tolkien"}],
|
||||
"tags": [{"name": "Fantasy"}, {"name": "Adventure"}],
|
||||
"identifiers": {"isbn-13": "9780261102354"},
|
||||
"series": {"name": "The Lord of the Rings"},
|
||||
"series_position": "1",
|
||||
}
|
||||
]
|
||||
157
backend/tests/integration/conftest.py
Normal file
157
backend/tests/integration/conftest.py
Normal file
@@ -0,0 +1,157 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from httpx import AsyncClient
|
||||
import pytest
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncSession,
|
||||
AsyncEngine,
|
||||
async_sessionmaker,
|
||||
)
|
||||
from advanced_alchemy.base import UUIDAuditBase
|
||||
from chitai.database import models as m
|
||||
from chitai import services
|
||||
from chitai.database.config import config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patch_db(
|
||||
engine: AsyncEngine,
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(config, "session_maker", sessionmaker)
|
||||
monkeypatch.setattr(config, "engine_instance", engine)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def _seed_db(
|
||||
engine: AsyncEngine,
|
||||
sessionmaker: async_sessionmaker[AsyncSession],
|
||||
raw_users: list[m.User | dict[str, Any]],
|
||||
raw_libraries: list[m.Library | dict[str, Any]],
|
||||
) -> AsyncGenerator[None, None]:
|
||||
"""Populate test database with.
|
||||
|
||||
Args:
|
||||
engine: The SQLAlchemy engine instance.
|
||||
sessionmaker: The SQLAlchemy sessionmaker factory.
|
||||
raw_users: Test users to add to the database
|
||||
raw_teams: Test teams to add to the database
|
||||
|
||||
"""
|
||||
|
||||
metadata = UUIDAuditBase.registry.metadata
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(metadata.drop_all)
|
||||
await conn.run_sync(metadata.create_all)
|
||||
|
||||
async with services.UserService.new(sessionmaker()) as users_service:
|
||||
await users_service.create_many(raw_users, auto_commit=True)
|
||||
|
||||
async with services.LibraryService.new(sessionmaker()) as library_service:
|
||||
await library_service.create_many(raw_libraries, auto_commit=True)
|
||||
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def populated_authenticated_client(
|
||||
authenticated_client: AsyncClient, books_to_upload: list[tuple[Path, dict]]
|
||||
) -> AsyncClient:
|
||||
# Upload books
|
||||
for path, data in books_to_upload:
|
||||
await upload_book_via_api(authenticated_client, data, path)
|
||||
|
||||
await create_bookshelf(
|
||||
authenticated_client, {"title": "Favourites", "library_id": 1}
|
||||
)
|
||||
|
||||
await add_books_to_bookshelf(authenticated_client, 1, [1, 2])
|
||||
|
||||
return authenticated_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def books_to_upload() -> list[tuple[Path, dict]]:
|
||||
return [
|
||||
(
|
||||
Path(
|
||||
"tests/data_files/The Adventures of Sherlock Holmes - Arthur Conan Doyle.epub"
|
||||
),
|
||||
{
|
||||
"library_id": "1",
|
||||
"title": "The Adventures of Sherlock Holmes",
|
||||
"description": "Some description...",
|
||||
"authors": "Arthur Conan Doyle",
|
||||
"publisher": "Some Publisher",
|
||||
"tags": "Mystery",
|
||||
"edition": "2",
|
||||
"language": "en",
|
||||
"pages": "300",
|
||||
"series": "Sherlock Holmes",
|
||||
"series_position": "1",
|
||||
"identifiers": json.dumps({"isbn-10": "1234567890"}),
|
||||
},
|
||||
),
|
||||
(
|
||||
Path("tests/data_files/Frankenstein - Mary Shelley.epub"),
|
||||
{
|
||||
"library_id": "1",
|
||||
"title": "Frankenstein",
|
||||
"description": "Some description...",
|
||||
"authors": "Mary Shelley",
|
||||
"tags": "Mystery",
|
||||
"edition": "1",
|
||||
"language": "en",
|
||||
"pages": "250",
|
||||
},
|
||||
),
|
||||
(
|
||||
Path("tests/data_files/A Tale of Two Cities - Charles Dickens.epub"),
|
||||
{
|
||||
"library_id": "1",
|
||||
"title": "A Tale of Two Cities",
|
||||
"description": "Some description...",
|
||||
"authors": "Charles Dickens",
|
||||
"tags": "Classic",
|
||||
"edition": "1",
|
||||
"language": "en",
|
||||
"pages": "500",
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def upload_book_via_api(
|
||||
client: AsyncClient, book_data: dict, book_file_path: Path
|
||||
) -> None:
|
||||
with book_file_path.open("rb") as file:
|
||||
files = {"files": file}
|
||||
|
||||
response = await client.post("/books?library_id=1", data=book_data, files=files)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
async def create_bookshelf(client: AsyncClient, shelf_data: dict) -> None:
|
||||
response = await client.post("/shelves", json=shelf_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
async def add_books_to_bookshelf(
|
||||
client: AsyncClient, shelf_id: int, book_ids: list[int]
|
||||
) -> None:
|
||||
query_params = ""
|
||||
for id in book_ids:
|
||||
query_params += f"book_ids={id}&"
|
||||
|
||||
response = await client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": book_ids}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
91
backend/tests/integration/test_access.py
Normal file
91
backend/tests/integration/test_access.py
Normal file
@@ -0,0 +1,91 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from chitai.database import models as m
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("email", "password", "expected_status_code"),
|
||||
[
|
||||
("user1@example.com", "password123", 201), # Valid credentials
|
||||
("user1@example.com", "password234", 401), # Invalid password
|
||||
("user2@example.com", "password234", 201), # Valid credentials
|
||||
("user2@example.com", "password123", 401), # Invalid password
|
||||
("nonexistentUser@example.com", "password123", 401), # Invalid email
|
||||
],
|
||||
)
|
||||
async def test_user_login(
|
||||
client: AsyncClient, email: str, password: str, expected_status_code: int
|
||||
) -> None:
|
||||
"""Test login functionality with valid and invalid credentials."""
|
||||
|
||||
response = await client.post(
|
||||
"/access/login", data={"email": email, "password": password}
|
||||
)
|
||||
|
||||
assert response.status_code == expected_status_code
|
||||
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
assert result["access_token"] is not None
|
||||
|
||||
|
||||
async def test_get_user_by_access_token(
|
||||
authenticated_client: AsyncClient, test_user: m.User
|
||||
) -> None:
|
||||
"""Test getting user info via their access token."""
|
||||
|
||||
response = await authenticated_client.get("/access/me")
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.json()
|
||||
assert result["email"] == test_user.email
|
||||
|
||||
|
||||
async def test_get_user_without_access_token(client: AsyncClient) -> None:
|
||||
response = await client.get("/access/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_user_registration_weak_password(client: AsyncClient) -> None:
|
||||
"""Test user registration with a weak password."""
|
||||
|
||||
response = await client.post(
|
||||
"/access/signup", json={"email": "weak@example.com", "password": "weak"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
msg = response.json()["extra"][0]["message"]
|
||||
assert "Password must be at least 8 characters long" in msg
|
||||
|
||||
|
||||
async def test_user_registration(client: AsyncClient) -> None:
|
||||
"""Test registering a new user and successfully loggin in."""
|
||||
|
||||
user_data = {"email": "newuser@example.com", "password": "password123"}
|
||||
|
||||
signup_response = await client.post("/access/signup", json=user_data)
|
||||
|
||||
assert signup_response.status_code == 201
|
||||
|
||||
# Login using the same credentials
|
||||
|
||||
login_response = await client.post("/access/login", data=user_data)
|
||||
|
||||
assert login_response.status_code == 201
|
||||
|
||||
|
||||
async def test_user_registration_with_duplicate_email(client: AsyncClient) -> None:
|
||||
"""Test registerig a new user using a duplicate email."""
|
||||
|
||||
user_data = {"email": "user1@example.com", "password": "password12345"}
|
||||
|
||||
response = await client.post("/access/signup", json=user_data)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
result = response.json()
|
||||
|
||||
assert "A user with this email already exists" in result["detail"]
|
||||
870
backend/tests/integration/test_book.py
Normal file
870
backend/tests/integration/test_book.py
Normal file
@@ -0,0 +1,870 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path_to_book", "library_id", "title", "authors"),
|
||||
[
|
||||
(
|
||||
Path("tests/data_files/Metamorphosis - Franz Kafka.epub"),
|
||||
1,
|
||||
"Metamorphosis",
|
||||
["Franz Kafka"],
|
||||
),
|
||||
(
|
||||
Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"),
|
||||
1,
|
||||
"Moby Dick; Or, The Whale",
|
||||
["Herman Melville"],
|
||||
),
|
||||
(
|
||||
Path(
|
||||
"tests/data_files/Relativity: The Special and General Theory - Albert Einstein.epub"
|
||||
),
|
||||
2,
|
||||
"Relativity : the Special and General Theory",
|
||||
["Albert Einstein"],
|
||||
),
|
||||
(
|
||||
Path(
|
||||
"/home/patrick/projects/chitai-api/tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub"
|
||||
),
|
||||
2,
|
||||
"On the Origin of Species By Means of Natural Selection / Or, the Preservation of Favoured Races in the Struggle for Life",
|
||||
["Charles Darwin"],
|
||||
),
|
||||
(
|
||||
Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
|
||||
2,
|
||||
"The Project Gutenberg eBook #33283: Calculus Made Easy, 2nd Edition",
|
||||
["Silvanus Phillips Thompson"],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_upload_book_without_data(
|
||||
authenticated_client: AsyncClient,
|
||||
path_to_book: Path,
|
||||
library_id: int,
|
||||
title: str,
|
||||
authors: list[str],
|
||||
) -> None:
|
||||
"""Test uploading a book file. Book information should be extracted from file."""
|
||||
# Read file contents
|
||||
file_content = path_to_book.read_bytes()
|
||||
|
||||
# Prepare multipart form data
|
||||
files = [("files", (path_to_book.name, file_content, "application/epub+zip"))]
|
||||
|
||||
# The rest of the book data will be parsed from file
|
||||
data = {
|
||||
"library_id": library_id,
|
||||
}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
f"/books?library_id={library_id}",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
book_data = response.json()
|
||||
|
||||
assert book_data["id"] is not None
|
||||
assert book_data["title"] == title
|
||||
assert book_data["library_id"] == library_id
|
||||
|
||||
# Check if authors were properly parsed
|
||||
book_authors = [author["name"] for author in book_data["authors"]]
|
||||
assert book_authors == authors
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path_to_book", "library_id", "title", "authors", "tags"),
|
||||
[
|
||||
(
|
||||
Path("tests/data_files/Metamorphosis - Franz Kafka.epub"),
|
||||
1,
|
||||
"The Metamorphosis",
|
||||
["Franz Kafka"],
|
||||
["Psychological fiction"],
|
||||
),
|
||||
(
|
||||
Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"),
|
||||
1,
|
||||
"Moby Dick",
|
||||
["Herman Melville"],
|
||||
["Classic Literature", "Whaling"],
|
||||
),
|
||||
(
|
||||
Path(
|
||||
"tests/data_files/Relativity: The Special and General Theory - Albert Einstein.epub"
|
||||
),
|
||||
2,
|
||||
"Relativity: the Special and General Theory",
|
||||
["Albert Einstein"],
|
||||
["Physics", "Mathematics"],
|
||||
),
|
||||
(
|
||||
Path(
|
||||
"/home/patrick/projects/chitai-api/tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub"
|
||||
),
|
||||
2,
|
||||
"On the Origin of Species By Means of Natural Selection",
|
||||
["Charles Darwin"],
|
||||
["Biology"],
|
||||
),
|
||||
(
|
||||
Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
|
||||
2,
|
||||
"Calculus Made Easy",
|
||||
["Silvanus Thompson"],
|
||||
["Mathematics"],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_upload_book_with_data(
|
||||
authenticated_client: AsyncClient,
|
||||
path_to_book: Path,
|
||||
library_id: int,
|
||||
title: str,
|
||||
authors: list[str],
|
||||
tags: list[str],
|
||||
) -> None:
|
||||
"""Test uploading a book file with some book information provided."""
|
||||
# Read file contents
|
||||
file_content = path_to_book.read_bytes()
|
||||
|
||||
# Prepare multipart form data
|
||||
files = [("files", (path_to_book.name, file_content, "application/epub+zip"))]
|
||||
|
||||
# The rest of the book data will be parsed from file
|
||||
data = {"library_id": library_id, "title": title, "authors": authors, "tags": tags}
|
||||
|
||||
response = await authenticated_client.post(
|
||||
f"/books?library_id={library_id}",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
book_data = response.json()
|
||||
|
||||
assert book_data["id"] is not None
|
||||
assert book_data["title"] == title
|
||||
assert book_data["library_id"] == library_id
|
||||
|
||||
book_authors = [author["name"] for author in book_data["authors"]]
|
||||
assert book_authors == authors
|
||||
|
||||
book_tags = [tag["name"] for tag in book_data["tags"]]
|
||||
assert book_tags == tags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path_to_book", "library_id"),
|
||||
[
|
||||
(Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), 1),
|
||||
(Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"), 2),
|
||||
],
|
||||
)
|
||||
async def test_get_book_file(
|
||||
authenticated_client: AsyncClient, path_to_book: Path, library_id: int
|
||||
) -> None:
|
||||
"""Test uploading a book then downloading the book file."""
|
||||
|
||||
# Read file contents
|
||||
file_content = path_to_book.read_bytes()
|
||||
|
||||
# Prepare multipart form data
|
||||
files = [("files", (path_to_book.name, file_content, "application/epub+zip"))]
|
||||
|
||||
# The rest of the book data will be parsed from file
|
||||
data = {
|
||||
"library_id": library_id,
|
||||
}
|
||||
|
||||
create_response = await authenticated_client.post(
|
||||
f"/books?library_id={library_id}",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert create_response.status_code == 201
|
||||
book_data = create_response.json()
|
||||
|
||||
# Retrieve the book file
|
||||
book_id = book_data["id"]
|
||||
file_id = book_data["files"][0]["id"]
|
||||
file_response = await authenticated_client.get(
|
||||
f"/books/download/{book_id}/{file_id}"
|
||||
)
|
||||
|
||||
assert file_response.status_code == 200
|
||||
|
||||
downloaded_content = file_response.content
|
||||
|
||||
assert len(downloaded_content) == len(file_content)
|
||||
assert downloaded_content == file_content
|
||||
|
||||
|
||||
async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> None:
|
||||
"""Test retrieving a specific book by ID."""
|
||||
|
||||
# Retrieve the book
|
||||
response = await populated_authenticated_client.get(f"/books/1")
|
||||
|
||||
assert response.status_code == 200
|
||||
book_data = response.json()
|
||||
assert book_data["id"] == 1
|
||||
assert book_data["title"] == "The Adventures of Sherlock Holmes"
|
||||
assert len(book_data["authors"]) == 1
|
||||
assert book_data["authors"][0]["name"] == "Arthur Conan Doyle"
|
||||
assert len(book_data["tags"]) == 1
|
||||
assert book_data["tags"][0]["name"] == "Mystery"
|
||||
|
||||
|
||||
async def test_list_books(populated_authenticated_client: AsyncClient) -> None:
|
||||
"""Test listing books with pagination and filters."""
|
||||
response = await populated_authenticated_client.get(
|
||||
"/books?library_id=1&pageSize=10&page=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert "items" in data # Pagination structure
|
||||
assert isinstance(data.get("items"), list)
|
||||
assert data["total"] == 3 # There should be 3 books in this library
|
||||
|
||||
|
||||
async def test_list_books_with_tag_filter(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test listing books filtered by tags."""
|
||||
|
||||
response = await populated_authenticated_client.get("/books?library_id=1&tags=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.json()
|
||||
assert len(result["items"]) == 2 # Two books with the "Mystery" tag
|
||||
|
||||
|
||||
async def test_list_books_with_author_filter(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test listing books filtered by authors."""
|
||||
response = await populated_authenticated_client.get("/books?library_id=1&authors=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.json()
|
||||
assert len(result["items"]) == 1 # One book by "Arthur Conan Doyle"
|
||||
|
||||
|
||||
async def test_list_books_with_search(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test listing books with title search."""
|
||||
response = await populated_authenticated_client.get(
|
||||
"/books?library_id=1&searchString=frankenstein"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.json()
|
||||
assert len(result["items"]) == 1 # One matching book
|
||||
|
||||
|
||||
async def test_delete_book_metadata_only(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test deleting a book record without deleting files."""
|
||||
|
||||
# Delete book without deleting files
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/books?book_ids=3&delete_files=false&library_id=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify book is deleted
|
||||
get_response = await populated_authenticated_client.get(f"/books/3")
|
||||
assert get_response.status_code == 404
|
||||
|
||||
|
||||
async def test_delete_book_with_files(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test deleting a book and its associated files."""
|
||||
|
||||
# Delete book and files
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/books?book_ids=3&delete_files=true&library_id=1"
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
async def test_delete_specific_book_files(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test deleting specific files from a book."""
|
||||
|
||||
# Delete specific file
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/books/1/files?file_ids=1",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
async def test_update_reading_progress(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test updating reading progress for a book."""
|
||||
|
||||
# Update progress
|
||||
progress_data = {
|
||||
"progress": 0.5,
|
||||
}
|
||||
|
||||
response = await populated_authenticated_client.post(
|
||||
f"/books/progress/1",
|
||||
json=progress_data,
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
# Get the book and check if the progress is correct
|
||||
response = await populated_authenticated_client.get("/books/1")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
book = response.json()
|
||||
assert book["progress"]["progress"] == 0.5
|
||||
|
||||
|
||||
async def test_create_multiple_books_from_directory(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Test creating multiple books from a directory of files."""
|
||||
path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||
path2 = Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub")
|
||||
|
||||
files = [
|
||||
("files", (path1.name, path1.read_bytes(), "application/epub+zip")),
|
||||
("files", (path2.name, path2.read_bytes(), "application/epub+zip")),
|
||||
]
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/books/fromFiles?library_id=1",
|
||||
files=files,
|
||||
data={"library_id": 1},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert len(data.get("items") or data.get("data")) >= 1
|
||||
|
||||
|
||||
# async def test_delete_book_metadata(authenticated_client: AsyncClient) -> None:
|
||||
# raise NotImplementedError()
|
||||
|
||||
|
||||
# async def test_delete_book_metadata_and_files(
|
||||
# authenticated_client: AsyncClient,
|
||||
# ) -> None:
|
||||
# raise NotImplementedError()
|
||||
|
||||
|
||||
# async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None:
|
||||
# raise NotImplementedError()
|
||||
|
||||
import pytest
|
||||
import aiofiles
|
||||
from httpx import AsyncClient
|
||||
from pathlib import Path
|
||||
from litestar.status_codes import HTTP_400_BAD_REQUEST
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from datetime import date
|
||||
|
||||
|
||||
class TestMetadataUpdates:
|
||||
@pytest.mark.parametrize(
|
||||
("updated_field", "new_value"),
|
||||
[
|
||||
("title", "New Title"),
|
||||
("subtitle", "Updated Subtitle"),
|
||||
("pages", 256),
|
||||
("description", "An updated description"),
|
||||
("edition", 2),
|
||||
("language", "pl"),
|
||||
("published_date", "1910-04-05"),
|
||||
("series_position", "3"),
|
||||
],
|
||||
)
|
||||
async def test_update_single_scalar_field(
|
||||
self,
|
||||
populated_authenticated_client: AsyncClient,
|
||||
updated_field: str,
|
||||
new_value: str,
|
||||
) -> None:
|
||||
"""Test updating a single field without affecting others."""
|
||||
|
||||
# Get original book state
|
||||
original_response = await populated_authenticated_client.get("/books/1")
|
||||
assert original_response.status_code == 200
|
||||
original_data = original_response.json()
|
||||
|
||||
# Update field
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
json={updated_field: str(new_value)},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
updated_data = response.json()
|
||||
|
||||
# Verify updated field
|
||||
assert updated_data[updated_field] == new_value
|
||||
|
||||
# Verify all other fields remain unchanged
|
||||
for field, original_value in original_data.items():
|
||||
if field != updated_field and field not in ["updated_at", "created_at"]:
|
||||
assert updated_data[field] == original_value, (
|
||||
f"Field {field} was unexpectedly changed"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("updated_field", "new_values", "assertion_func"),
|
||||
[
|
||||
(
|
||||
"authors", # Update with new authors
|
||||
["New Author 1", "New Author 2"],
|
||||
lambda data: {a["name"] for a in data["authors"]}
|
||||
== {"New Author 1", "New Author 2"},
|
||||
),
|
||||
(
|
||||
"authors", # Clear authors
|
||||
[],
|
||||
lambda data: data["authors"] == [],
|
||||
),
|
||||
(
|
||||
"tags", # Update with new tags
|
||||
["Tag 1", "Tag 2", "Tag 3"],
|
||||
lambda data: {t["name"] for t in data["tags"]}
|
||||
== {"Tag 1", "Tag 2", "Tag 3"},
|
||||
),
|
||||
(
|
||||
"tags", # Clear tags
|
||||
[],
|
||||
lambda data: data["tags"] == [],
|
||||
),
|
||||
(
|
||||
"publisher", # Update with new publisher
|
||||
"Updated Publisher",
|
||||
lambda data: data["publisher"]["name"] == "Updated Publisher",
|
||||
),
|
||||
(
|
||||
"publisher", # Clear publisher
|
||||
None,
|
||||
lambda data: data["publisher"] is None,
|
||||
),
|
||||
(
|
||||
"identifiers", # Update with new identifiers
|
||||
{"isbn-13": "978-1234567890", "doi": "10.example/id"},
|
||||
lambda data: data["identifiers"]
|
||||
== {"isbn-13": "978-1234567890", "doi": "10.example/id"},
|
||||
),
|
||||
(
|
||||
"identifiers", # Clear identifiers
|
||||
{},
|
||||
lambda data: data["identifiers"] == {},
|
||||
),
|
||||
(
|
||||
"series", # Update with new series
|
||||
"Updated Series",
|
||||
lambda data: data["series"]["title"] == "Updated Series",
|
||||
),
|
||||
(
|
||||
"series", # Clear series
|
||||
None,
|
||||
lambda data: data["series"] is None,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_update_relationship_fields(
|
||||
self,
|
||||
populated_authenticated_client: AsyncClient,
|
||||
updated_field: str,
|
||||
new_values: list[str],
|
||||
assertion_func,
|
||||
) -> None:
|
||||
"""Test updating relationship fields (authors, tags, etc.)"""
|
||||
|
||||
# Get original book state
|
||||
original_response = await populated_authenticated_client.get("/books/1")
|
||||
assert original_response.status_code == 200
|
||||
original_data = original_response.json()
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
json={updated_field: new_values},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify updated field
|
||||
updated_data = response.json()
|
||||
assert assertion_func(updated_data)
|
||||
|
||||
# Verify all other fields remain unchanged
|
||||
for field, original_value in original_data.items():
|
||||
if field != updated_field and field not in ["updated_at", "created_at"]:
|
||||
assert updated_data[field] == original_value, (
|
||||
f"Field {field} was unexpectedly changed"
|
||||
)
|
||||
|
||||
async def test_update_with_invalid_pages_value(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test updating with invalid pages value (non-integer)."""
|
||||
data = {
|
||||
"pages": "not_a_number",
|
||||
}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
files=data,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_update_with_invalid_edition_value(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test updating with invalid edition value (non-integer)."""
|
||||
data = {
|
||||
"edition": "invalid_edition",
|
||||
}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
files=data,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_update_with_invalid_date_format(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test updating with invalid published_date format."""
|
||||
data = {
|
||||
"published_date": "invalid-date",
|
||||
}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
files=data,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_update_with_invalid_identifiers_json(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test updating with invalid JSON in identifiers."""
|
||||
data = {
|
||||
"identifiers": "not valid json",
|
||||
}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
files=data,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_update_multiple_fields_at_once(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test updating multiple fields simultaneously."""
|
||||
data = {
|
||||
"title": "New Title",
|
||||
"description": "New description",
|
||||
"publisher": "New Publisher",
|
||||
"pages": 430,
|
||||
}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
book_data = response.json()
|
||||
assert book_data["title"] == "New Title"
|
||||
assert book_data["description"] == "New description"
|
||||
assert book_data["publisher"]["name"] == "New Publisher"
|
||||
assert book_data["pages"] == 430
|
||||
|
||||
async def test_update_nonexistent_book(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test updating a nonexistent book returns 404."""
|
||||
data = {
|
||||
"title": "New Title",
|
||||
}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/99999",
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "book does not exist" in response.text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("updated_field"),
|
||||
[
|
||||
("subtitle"),
|
||||
("pages"),
|
||||
("description"),
|
||||
("edition"),
|
||||
("language"),
|
||||
("published_date"),
|
||||
("series_position"),
|
||||
],
|
||||
)
|
||||
async def test_update_clears_optional_field(
|
||||
self, populated_authenticated_client: AsyncClient, updated_field: str
|
||||
) -> None:
|
||||
"""Test that optional fields can be cleared by passing None or empty values."""
|
||||
|
||||
data = {updated_field: None}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.json()
|
||||
|
||||
assert result[updated_field] == None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("updated_field"),
|
||||
[
|
||||
("title"),
|
||||
],
|
||||
)
|
||||
async def test_update_clears_required_field(
|
||||
self, populated_authenticated_client: AsyncClient, updated_field: str
|
||||
) -> None:
|
||||
"""Test that optional fields can be cleared by passing None or empty values."""
|
||||
|
||||
data = {updated_field: None}
|
||||
|
||||
response = await populated_authenticated_client.patch(
|
||||
"/books/1",
|
||||
json=data,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
async def test_update_cover_successfully(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test successfully updating a book's cover image."""
|
||||
path = Path("tests/data_files/cover.jpg")
|
||||
file_content = path.read_bytes()
|
||||
|
||||
files = {"cover_image": (path.name, file_content, "image/jpeg")}
|
||||
|
||||
# Get original book state
|
||||
original_response = await populated_authenticated_client.get("/books/1")
|
||||
assert original_response.status_code == 200
|
||||
original_data = original_response.json()
|
||||
|
||||
response = await populated_authenticated_client.put(
|
||||
"/books/1/cover",
|
||||
files=files,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
book_data = response.json()
|
||||
assert book_data["id"] == 1
|
||||
|
||||
assert book_data["cover_image"] is not None
|
||||
assert book_data["cover_image"] != original_data["cover_image"]
|
||||
|
||||
|
||||
class TestFileManagement:
|
||||
async def test_add_multiple_files_to_book(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test adding multiple files to a book in a single request."""
|
||||
path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||
path2 = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
|
||||
|
||||
file1_content = path1.read_bytes()
|
||||
file2_content = path2.read_bytes()
|
||||
|
||||
files = [
|
||||
("files", (path1.name, file1_content, "application/epub+zip")),
|
||||
("files", (path2.name, file2_content, "application/pdf")),
|
||||
]
|
||||
|
||||
response = await populated_authenticated_client.post(
|
||||
"/books/1/files",
|
||||
files=files,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
book_data = response.json()
|
||||
assert len(book_data["files"]) >= 2
|
||||
|
||||
async def test_add_files_to_nonexistent_book(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test adding files to a nonexistent book returns 404."""
|
||||
path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||
file_content = path.read_bytes()
|
||||
files = [("files", (path.name, file_content, "application/epub+zip"))]
|
||||
|
||||
response = await populated_authenticated_client.post(
|
||||
"/books/99999/files?library_id=1",
|
||||
files=files,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_remove_multiple_files_at_once(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test removing multiple files from a book at once."""
|
||||
# First add multiple files
|
||||
path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||
path2 = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
|
||||
|
||||
files = [
|
||||
("files", (path1.name, path1.read_bytes(), "application/epub+zip")),
|
||||
("files", (path2.name, path2.read_bytes(), "application/pdf")),
|
||||
]
|
||||
|
||||
add_response = await populated_authenticated_client.post(
|
||||
"/books/1/files",
|
||||
files=files,
|
||||
)
|
||||
assert add_response.status_code == 201
|
||||
|
||||
book_data = add_response.json()
|
||||
file_ids = [f["id"] for f in book_data["files"]]
|
||||
|
||||
# Remove multiple files
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/books/1/files?file_ids={file_ids[0]}&file_ids={file_ids[1]}&delete_files=false",
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# Verify files were removed
|
||||
book = await populated_authenticated_client.get("/books/1")
|
||||
book_data = book.json()
|
||||
assert len(book_data["files"]) < len(file_ids)
|
||||
|
||||
async def test_remove_zero_files_raises_error(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test error when trying to remove zero files."""
|
||||
response = await populated_authenticated_client.delete(
|
||||
"/books/1/files?delete_files=false",
|
||||
)
|
||||
assert response.status_code == HTTP_400_BAD_REQUEST
|
||||
assert "missing required query parameter 'file_ids'" in response.text.lower()
|
||||
|
||||
async def test_remove_files_from_nonexistent_book(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test removing files from a nonexistent book returns 404."""
|
||||
response = await populated_authenticated_client.delete(
|
||||
"/books/99999/files?file_ids=1&delete_files=false&library_id=1",
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_remove_nonexistent_file_id_from_book(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test removing a file_id that doesn't exist on that book."""
|
||||
# Try to remove a file that doesn't belong to this book
|
||||
response = await populated_authenticated_client.delete(
|
||||
"/books/1/files?file_ids=99999&delete_files=false",
|
||||
)
|
||||
# Should succeed (idempotent) or return 404, depending on implementation
|
||||
assert response.status_code in [204, 404]
|
||||
|
||||
async def test_remove_file_with_delete_files_false_keeps_filesystem_file(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test that removing with delete_files=false keeps file on disk."""
|
||||
# Get the book to retrieve file info
|
||||
book_response = await populated_authenticated_client.get("/books/1")
|
||||
book_data = book_response.json()
|
||||
|
||||
if not book_data["files"]:
|
||||
pytest.skip("Book has no files")
|
||||
|
||||
file_id = book_data["files"][0]["id"]
|
||||
filename = book_data["files"][0].get("path")
|
||||
|
||||
# Remove file without deleting from filesystem
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/books/1/files?file_ids={file_id}&delete_files=false",
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_remove_file_with_delete_files_true_removes_filesystem_file(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test that removing with delete_files=true deletes from filesystem."""
|
||||
# Add a new file first
|
||||
path = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
|
||||
file_content = path.read_bytes()
|
||||
files = [("files", (path.name, file_content, "application/pdf"))]
|
||||
|
||||
add_response = await populated_authenticated_client.post(
|
||||
"/books/1/files",
|
||||
files=files,
|
||||
)
|
||||
assert add_response.status_code == 201
|
||||
|
||||
book_data = add_response.json()
|
||||
file_id = book_data["files"][-1]["id"]
|
||||
file_path = book_data["files"][-1].get("path")
|
||||
|
||||
# Remove file with deletion from filesystem
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/books/1/files?file_ids={file_id}&delete_files=true",
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
async def test_remove_file_idempotent(
|
||||
self, populated_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Test that removing the same file twice doesn't error on second attempt."""
|
||||
book_response = await populated_authenticated_client.get("/books/1")
|
||||
book_data = book_response.json()
|
||||
|
||||
if not book_data["files"]:
|
||||
pytest.skip("Book has no files")
|
||||
|
||||
file_id = book_data["files"][0]["id"]
|
||||
|
||||
# Remove file first time
|
||||
response1 = await populated_authenticated_client.delete(
|
||||
f"/books/1/files?file_ids={file_id}&delete_files=false",
|
||||
)
|
||||
assert response1.status_code == 204
|
||||
|
||||
# Try to remove same file again
|
||||
response2 = await populated_authenticated_client.delete(
|
||||
f"/books/1/files?file_ids={file_id}&delete_files=false",
|
||||
)
|
||||
# Should succeed (idempotent)
|
||||
assert response2.status_code == 204
|
||||
286
backend/tests/integration/test_bookshelf.py
Normal file
286
backend/tests/integration/test_bookshelf.py
Normal file
@@ -0,0 +1,286 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def test_get_shelves_without_auth(client: AsyncClient) -> None:
|
||||
response = await client.get("/shelves")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_get_shelves_with_auth(authenticated_client: AsyncClient) -> None:
|
||||
response = await authenticated_client.get("/shelves")
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.json()
|
||||
assert len(result["items"]) == 0
|
||||
|
||||
|
||||
async def test_get_existing_shelves(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
response = await populated_authenticated_client.get("/shelves")
|
||||
assert response.status_code == 200
|
||||
|
||||
result = response.json()
|
||||
assert len(result["items"]) == 1
|
||||
|
||||
|
||||
async def test_create_shelf(authenticated_client: AsyncClient) -> None:
|
||||
shelf_data = {"title": "Favourites"}
|
||||
|
||||
response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
result = response.json()
|
||||
|
||||
assert result["title"] == "Favourites"
|
||||
assert result["library_id"] is None
|
||||
|
||||
|
||||
async def test_create_shelf_in_nonexistent_library(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
shelf_data = {"title": "Favourites", "library_id": 5}
|
||||
|
||||
response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert f"Library with ID {shelf_data['library_id']} does not exist" in response.text
|
||||
|
||||
|
||||
async def test_create_shelf_in_existing_library(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
shelf_data = {"title": "Favourites", "library_id": 1}
|
||||
|
||||
response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
result = response.json()
|
||||
|
||||
assert result["title"] == "Favourites"
|
||||
assert result["library_id"] == 1
|
||||
|
||||
|
||||
async def test_delete_shelf_without_auth(client: AsyncClient) -> None:
|
||||
response = await client.delete("/shelves/1")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_delete_shelf_unauthorized(
|
||||
authenticated_client: AsyncClient, other_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Verify users can't delete shelves they don't own."""
|
||||
# Create a shelf as authenticated_client
|
||||
shelf_data = {"title": "My Shelf"}
|
||||
shelf_response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
# Try to delete as other_authenticated_client
|
||||
response = await other_authenticated_client.delete(f"/shelves/{shelf_id}")
|
||||
assert response.status_code == 403
|
||||
assert "do not have permission" in response.text
|
||||
|
||||
|
||||
async def test_add_books_unauthorized(
|
||||
authenticated_client: AsyncClient, other_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Verify users can't add books to shelves they don't own."""
|
||||
# Create a shelf as authenticated_client
|
||||
shelf_data = {"title": "Other User's Shelf"}
|
||||
shelf_response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
# Try to add books as other_authenticated_client
|
||||
response = await other_authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [1, 2]}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "do not have permission" in response.text
|
||||
|
||||
|
||||
async def test_remove_books_unauthorized(
|
||||
authenticated_client: AsyncClient, other_authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
"""Verify users can't remove books from shelves they don't own."""
|
||||
# Create a shelf and add books as authenticated_client
|
||||
shelf_data = {"title": "Other User's Shelf"}
|
||||
shelf_response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
await authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [1, 2]}
|
||||
)
|
||||
|
||||
# Try to remove books as other_authenticated_client
|
||||
response = await other_authenticated_client.delete(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [1]}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "do not have permission" in response.text
|
||||
|
||||
|
||||
async def test_delete_nonexistent_shelf(authenticated_client: AsyncClient) -> None:
|
||||
"""Verify 404 when deleting a shelf that doesn't exist."""
|
||||
response = await authenticated_client.delete("/shelves/99999")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_add_books_to_shelf(populated_authenticated_client: AsyncClient) -> None:
|
||||
"""Successfully add books to a shelf."""
|
||||
shelf_data = {"title": "Test Shelf"}
|
||||
shelf_response = await populated_authenticated_client.post(
|
||||
"/shelves", json=shelf_data
|
||||
)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
book_ids = [1, 2]
|
||||
response = await populated_authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": book_ids}
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify by listing books filtered by shelf
|
||||
books_response = await populated_authenticated_client.get(
|
||||
"/books", params={"shelves": shelf_id}
|
||||
)
|
||||
assert books_response.status_code == 200
|
||||
assert len(books_response.json()["items"]) == 2
|
||||
|
||||
|
||||
async def test_add_books_to_nonexistent_shelf(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Verify 404 when adding to nonexistent shelf."""
|
||||
response = await authenticated_client.post(
|
||||
"/shelves/99999/books", params={"book_ids": [1, 2]}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_add_nonexistent_books(authenticated_client: AsyncClient) -> None:
|
||||
"""Verify appropriate error when book IDs don't exist."""
|
||||
shelf_data = {"title": "Test Shelf"}
|
||||
shelf_response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
response = await authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [99999, 99998]}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
async def test_remove_books_from_shelf(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Successfully remove books from a shelf."""
|
||||
shelf_data = {"title": "Test Shelf"}
|
||||
shelf_response = await populated_authenticated_client.post(
|
||||
"/shelves", json=shelf_data
|
||||
)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
# Add books first
|
||||
book_ids = [1, 2, 3]
|
||||
await populated_authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": book_ids}
|
||||
)
|
||||
|
||||
# Remove one book
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [1]}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify by listing books
|
||||
books_response = await populated_authenticated_client.get(
|
||||
"/books", params={"shelves": shelf_id}
|
||||
)
|
||||
|
||||
|
||||
assert books_response.status_code == 200
|
||||
assert books_response.json()["total"] == 2
|
||||
|
||||
|
||||
async def test_remove_books_from_nonexistent_shelf(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Verify 404 when removing from nonexistent shelf."""
|
||||
response = await authenticated_client.delete(
|
||||
"/shelves/99999/books", params={"book_ids": [1, 2]}
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
async def test_remove_nonexistent_books(
|
||||
populated_authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Verify appropriate error handling when removing nonexistent books."""
|
||||
shelf_data = {"title": "Test Shelf"}
|
||||
shelf_response = await populated_authenticated_client.post(
|
||||
"/shelves", json=shelf_data
|
||||
)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
# Add a book first
|
||||
await populated_authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [1]}
|
||||
)
|
||||
|
||||
# Try to remove books that don't exist on shelf
|
||||
response = await populated_authenticated_client.delete(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [99999]}
|
||||
)
|
||||
|
||||
# Idempotent behaviour
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
async def test_add_duplicate_books(populated_authenticated_client: AsyncClient) -> None:
|
||||
"""Verify behavior when adding books already on shelf."""
|
||||
shelf_data = {"title": "Test Shelf"}
|
||||
shelf_response = await populated_authenticated_client.post(
|
||||
"/shelves", json=shelf_data
|
||||
)
|
||||
shelf_id = shelf_response.json()["id"]
|
||||
|
||||
# Add books
|
||||
book_ids = [1, 2]
|
||||
await populated_authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": book_ids}
|
||||
)
|
||||
|
||||
# Try to add some of the same books again
|
||||
response = await populated_authenticated_client.post(
|
||||
f"/shelves/{shelf_id}/books", params={"book_ids": [1, 2, 3]}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
# Verify final state
|
||||
books_response = await populated_authenticated_client.get(
|
||||
"/books", params={"shelves": shelf_id}
|
||||
)
|
||||
result = books_response.json()["items"]
|
||||
|
||||
assert len(result) == 3
|
||||
|
||||
|
||||
async def test_create_shelf_with_empty_title(authenticated_client: AsyncClient) -> None:
|
||||
"""Verify validation rejects empty shelf titles."""
|
||||
shelf_data = {"title": ""}
|
||||
response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
assert response.status_code == 400
|
||||
assert "title" in response.text.lower()
|
||||
|
||||
|
||||
async def test_create_shelf_with_whitespace_only_title(
|
||||
authenticated_client: AsyncClient,
|
||||
) -> None:
|
||||
"""Verify validation rejects whitespace-only titles."""
|
||||
shelf_data = {"title": " "}
|
||||
response = await authenticated_client.post("/shelves", json=shelf_data)
|
||||
assert response.status_code == 400
|
||||
44
backend/tests/integration/test_library.py
Normal file
44
backend/tests/integration/test_library.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
async def test_get_libraries_without_auth(client: AsyncClient) -> None:
|
||||
response = await client.get("/libraries?library_id=1")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_get_libraries_with_auth(authenticated_client: AsyncClient) -> None:
|
||||
response = await authenticated_client.get("/libraries?library_id=1")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "read_only", "expected_status_code"),
|
||||
[("Test Library", False, 201), ("Read-Only Library", True, 400)],
|
||||
)
|
||||
async def test_create_library(
|
||||
authenticated_client: AsyncClient,
|
||||
tmp_path: Path,
|
||||
name: str,
|
||||
read_only: bool,
|
||||
expected_status_code: int,
|
||||
) -> None:
|
||||
library_data = {
|
||||
"name": "Test Library",
|
||||
"root_path": f"{tmp_path}/books",
|
||||
"path_template": "{author}/{title}",
|
||||
"read_only": read_only,
|
||||
}
|
||||
|
||||
response = await authenticated_client.post("/libraries", json=library_data)
|
||||
|
||||
assert response.status_code == expected_status_code
|
||||
|
||||
if response.status_code == 201:
|
||||
result = response.json()
|
||||
assert result["name"] == "Test Library"
|
||||
assert result["root_path"] == f"{tmp_path}/books"
|
||||
assert result["path_template"] == "{author}/{title}"
|
||||
assert result["read_only"] == False
|
||||
assert result["description"] is None
|
||||
17
backend/tests/unit/test_metadata_extractor.py
Normal file
17
backend/tests/unit/test_metadata_extractor.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
from chitai.services.metadata_extractor import EpubExtractor
|
||||
|
||||
|
||||
@pytest.mark.asyncio()
|
||||
class TestEpubExtractor:
|
||||
async def test_extraction_by_path(self):
|
||||
path = Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub")
|
||||
|
||||
metadata = await EpubExtractor.extract_metadata(path)
|
||||
|
||||
assert metadata["title"] == "Moby Dick; Or, The Whale"
|
||||
assert metadata["authors"] == ["Herman Melville"]
|
||||
assert metadata["published_date"] == date(year=2001, month=7, day=1)
|
||||
|
||||
74
backend/tests/unit/test_services/test_book_service.py
Normal file
74
backend/tests/unit/test_services/test_book_service.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Tests for BookService"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
import aiofiles.os as aios
|
||||
|
||||
from chitai.schemas import BookCreate
|
||||
from chitai.services import BookService
|
||||
from chitai.database import models as m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestBookServiceCRUD:
|
||||
"""Test CRUD operation for libraries."""
|
||||
|
||||
async def test_update_book(
|
||||
self, books_service: BookService, test_library: m.Library
|
||||
) -> None:
|
||||
book_data = BookCreate(
|
||||
library_id=1,
|
||||
title="Fellowship of the Ring",
|
||||
authors=["J.R.R Tolkien"],
|
||||
tags=["Fantasy"],
|
||||
identifiers={"isbn-13": "9780261102354"},
|
||||
pages=427,
|
||||
)
|
||||
|
||||
book = await books_service.to_model_on_create(book_data.model_dump())
|
||||
|
||||
# Add path manually as it won't be generated (not using the create function, but manually inserting into db)
|
||||
book.path = f"{test_library.root_path}/J.R.R Tolkien/The Fellowship of the Ring"
|
||||
await aios.makedirs(book.path)
|
||||
|
||||
books_service.repository.session.add(book)
|
||||
await books_service.repository.session.commit()
|
||||
await books_service.repository.session.refresh(book)
|
||||
|
||||
await books_service.update(
|
||||
book.id,
|
||||
{
|
||||
"title": "The Fellowship of the Ring",
|
||||
"identifiers": {"isbn-10": "9780261102354"},
|
||||
"edition": 3,
|
||||
"publisher": "Tolkien Estate",
|
||||
"series": "The Lord of the Rings",
|
||||
"series_position": "1",
|
||||
"tags": ["Fantasy", "Adventure"],
|
||||
},
|
||||
test_library,
|
||||
)
|
||||
|
||||
updated_book = await books_service.get(book.id)
|
||||
|
||||
# Assert updated information is correct
|
||||
|
||||
assert updated_book.title == "The Fellowship of the Ring"
|
||||
assert (
|
||||
updated_book.path
|
||||
== f"{test_library.root_path}/J.R.R Tolkien/The Lord of the Rings/01 - The Fellowship of the Ring"
|
||||
)
|
||||
|
||||
assert len(updated_book.identifiers)
|
||||
assert updated_book.identifiers[0].value == "9780261102354"
|
||||
|
||||
assert updated_book.edition == 3
|
||||
assert updated_book.publisher.name == "Tolkien Estate"
|
||||
|
||||
assert len(updated_book.tags) == 2
|
||||
|
||||
|
||||
# book = await books_service.create(book_data.model_dump())
|
||||
298
backend/tests/unit/test_services/test_bookshelf_service.py
Normal file
298
backend/tests/unit/test_services/test_bookshelf_service.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""Tests for LibraryService"""
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from chitai.services import ShelfService
|
||||
from chitai.database import models as m
|
||||
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from chitai.services.bookshelf import ShelfService
|
||||
from chitai.services import BookService
|
||||
from chitai.database.models.book_list import BookList, BookListLink
|
||||
from chitai.database import models as m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(bookshelf_service: ShelfService) -> AsyncSession:
|
||||
return bookshelf_service.repository.session
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def test_books(db_session: AsyncSession) -> list[m.Book]:
|
||||
"""Create test books in the database."""
|
||||
|
||||
library = m.Library(
|
||||
name="Default Library",
|
||||
slug="default-library",
|
||||
root_path="./path",
|
||||
path_template="{author}/{title}",
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
db_session.add(library)
|
||||
|
||||
books = [m.Book(title=f"Book {i}", library_id=1) for i in range(1, 8)]
|
||||
db_session.add_all(books)
|
||||
|
||||
user = m.User(email="test_user@example.com", password="password123")
|
||||
db_session.add(user)
|
||||
|
||||
await db_session.flush()
|
||||
await db_session.commit()
|
||||
return books
|
||||
|
||||
|
||||
class TestAddBooks:
|
||||
async def test_add_books_to_empty_shelf(
|
||||
self, bookshelf_service: ShelfService, db_session
|
||||
) -> None:
|
||||
"""Successfully add books to an empty shelf."""
|
||||
# Create a shelf
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
# Add books
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
|
||||
|
||||
# Verify books were added
|
||||
links = await db_session.execute(
|
||||
select(BookListLink).where(BookListLink.list_id == shelf.id)
|
||||
)
|
||||
added_links = links.scalars().all()
|
||||
assert len(added_links) == 3
|
||||
assert {link.book_id for link in added_links} == {1, 2, 3}
|
||||
|
||||
async def test_add_books_preserves_positions(
|
||||
self, bookshelf_service: ShelfService, db_session
|
||||
) -> None:
|
||||
"""Verify books are assigned correct positions on the shelf."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink)
|
||||
.where(BookListLink.list_id == shelf.id)
|
||||
.order_by(BookListLink.position)
|
||||
)
|
||||
added_links = links.scalars().all()
|
||||
|
||||
# Verify positions are sequential starting from 0
|
||||
assert [link.position for link in added_links] == [0, 1, 2]
|
||||
|
||||
async def test_add_books_to_shelf_with_existing_books(
|
||||
self, bookshelf_service: ShelfService, db_session
|
||||
) -> None:
|
||||
"""Adding books to a shelf with existing books assigns correct positions."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
# Add initial books
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2])
|
||||
|
||||
# Add more books
|
||||
await bookshelf_service.add_books(shelf.id, [3, 4])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink)
|
||||
.where(BookListLink.list_id == shelf.id)
|
||||
.order_by(BookListLink.position)
|
||||
)
|
||||
added_links = links.scalars().all()
|
||||
|
||||
# Verify new books continue from position 2
|
||||
assert len(added_links) == 4
|
||||
assert [link.position for link in added_links] == [0, 1, 2, 3]
|
||||
|
||||
async def test_add_duplicate_books_is_idempotent(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Adding books already on shelf should not create duplicates."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
# Add books
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
|
||||
|
||||
# Try to add overlapping books
|
||||
await bookshelf_service.add_books(shelf.id, [2, 3, 4])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink).where(BookListLink.list_id == shelf.id)
|
||||
)
|
||||
added_links = links.scalars().all()
|
||||
|
||||
# Should have 4 books total (1, 2, 3, 4), not 7
|
||||
assert len(added_links) == 4
|
||||
assert {link.book_id for link in added_links} == {1, 2, 3, 4}
|
||||
|
||||
async def test_add_books_raises_on_nonexistent_shelf(
|
||||
self, bookshelf_service: ShelfService
|
||||
) -> None:
|
||||
"""Adding books to nonexistent shelf raises error."""
|
||||
with pytest.raises(Exception): # Could be SQLAlchemyError or specific error
|
||||
await bookshelf_service.add_books(99999, [1, 2, 3])
|
||||
|
||||
async def test_add_nonexistent_books_raises_error(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Adding nonexistent books raises ValueError."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
with pytest.raises(ValueError, match="One or more books not found"):
|
||||
await bookshelf_service.add_books(shelf.id, [99999, 99998])
|
||||
|
||||
async def test_add_partial_nonexistent_books_raises_error(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Adding a mix of existent and nonexistent books raises error."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
# Book 1 might exist, but 99999 doesn't
|
||||
with pytest.raises(ValueError, match="One or more books not found"):
|
||||
await bookshelf_service.add_books(shelf.id, [1, 99999])
|
||||
|
||||
async def test_add_empty_book_list(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Adding empty book list should return without error."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
# Should not raise
|
||||
await bookshelf_service.add_books(shelf.id, [])
|
||||
|
||||
|
||||
class TestRemoveBooks:
|
||||
async def test_remove_books_from_shelf(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Successfully remove books from a shelf."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
# Add books
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3, 4])
|
||||
|
||||
# Remove some books
|
||||
await bookshelf_service.remove_books(shelf.id, [2, 3])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink).where(BookListLink.list_id == shelf.id)
|
||||
)
|
||||
remaining_links = links.scalars().all()
|
||||
|
||||
assert len(remaining_links) == 2
|
||||
assert {link.book_id for link in remaining_links} == {1, 4}
|
||||
|
||||
async def test_remove_all_books_from_shelf(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Removing all books should leave shelf empty."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
|
||||
await bookshelf_service.remove_books(shelf.id, [1, 2, 3])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink).where(BookListLink.list_id == shelf.id)
|
||||
)
|
||||
remaining_links = links.scalars().all()
|
||||
|
||||
assert len(remaining_links) == 0
|
||||
|
||||
async def test_remove_nonexistent_book_is_idempotent(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Removing books not on shelf should not raise error."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
|
||||
|
||||
# Remove books that don't exist on shelf - should not raise
|
||||
await bookshelf_service.remove_books(shelf.id, [99, 100])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink).where(BookListLink.list_id == shelf.id)
|
||||
)
|
||||
remaining_links = links.scalars().all()
|
||||
|
||||
# Original books should still be there
|
||||
assert len(remaining_links) == 3
|
||||
|
||||
async def test_remove_books_raises_on_nonexistent_shelf(
|
||||
self, bookshelf_service: ShelfService
|
||||
) -> None:
|
||||
"""Removing books from nonexistent shelf raises error."""
|
||||
with pytest.raises(Exception):
|
||||
await bookshelf_service.remove_books(99999, [1, 2, 3])
|
||||
|
||||
async def test_remove_empty_book_list(
|
||||
self, bookshelf_service: ShelfService, db_session: AsyncSession
|
||||
) -> None:
|
||||
"""Removing empty book list should return without error."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
|
||||
|
||||
# Should not raise
|
||||
await bookshelf_service.remove_books(shelf.id, [])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink).where(BookListLink.list_id == shelf.id)
|
||||
)
|
||||
remaining_links = links.scalars().all()
|
||||
|
||||
# All books should still be there
|
||||
assert len(remaining_links) == 3
|
||||
|
||||
async def test_add_remove_add_sequence(
|
||||
self, bookshelf_service: ShelfService, db_session
|
||||
) -> None:
|
||||
"""Add books, remove from middle, then add more - positions should be maintained."""
|
||||
shelf = BookList(title="Test Shelf", user_id=1)
|
||||
db_session.add(shelf)
|
||||
await db_session.flush()
|
||||
|
||||
# Add initial books [1, 2, 3, 4, 5]
|
||||
await bookshelf_service.add_books(shelf.id, [1, 2, 3, 4, 5])
|
||||
|
||||
# Remove books from middle [2, 3, 4]
|
||||
await bookshelf_service.remove_books(shelf.id, [2, 3, 4])
|
||||
|
||||
# Add more books [6, 7]
|
||||
await bookshelf_service.add_books(shelf.id, [6, 7])
|
||||
|
||||
links = await db_session.execute(
|
||||
select(BookListLink)
|
||||
.where(BookListLink.list_id == shelf.id)
|
||||
.order_by(BookListLink.position)
|
||||
)
|
||||
final_links = links.scalars().all()
|
||||
|
||||
# Should have [1, 5, 6, 7] with positions [0, 1, 2, 3]
|
||||
assert len(final_links) == 4
|
||||
assert [link.book_id for link in final_links] == [1, 5, 6, 7]
|
||||
assert [link.position for link in final_links] == [0, 1, 2, 3]
|
||||
153
backend/tests/unit/test_services/test_library_service.py
Normal file
153
backend/tests/unit/test_services/test_library_service.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Tests for LibraryService"""
|
||||
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from chitai.schemas.library import LibraryCreate
|
||||
from chitai.services import LibraryService
|
||||
from chitai.database import models as m
|
||||
from chitai.services.utils import DirectoryDoesNotExist
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLibraryServiceCRUD:
|
||||
"""Test CRUD operation for libraries."""
|
||||
|
||||
async def test_create_library(
|
||||
self, library_service: LibraryService, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test creating a library with a valid root path."""
|
||||
|
||||
library_path = f"{tmp_path}/books"
|
||||
library_data = LibraryCreate(
|
||||
name="Test Library",
|
||||
root_path=library_path,
|
||||
path_template="{author}/{title}",
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
library = await library_service.create(library_data)
|
||||
|
||||
assert library.name == "Test Library"
|
||||
assert library.root_path == library_path
|
||||
assert library.path_template == "{author}/{title}"
|
||||
assert library.description == None
|
||||
assert library.read_only == False
|
||||
|
||||
# Check if directory was created
|
||||
assert Path(library.root_path).is_dir()
|
||||
|
||||
async def test_create_library_root_path_permission_error(
|
||||
self, library_service: LibraryService, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test creating a library with a root path that is not permitted."""
|
||||
|
||||
# Change permissions on the temp path
|
||||
tmp_path.chmod(0o544)
|
||||
|
||||
library_path = f"{tmp_path}/books"
|
||||
library_data = LibraryCreate(
|
||||
name="Test Library",
|
||||
root_path=library_path,
|
||||
path_template="{author}/{title}",
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError) as exc_info:
|
||||
library = await library_service.create(library_data)
|
||||
|
||||
# Check if directory was created
|
||||
assert not Path(library_path).exists()
|
||||
|
||||
# Change permissions back
|
||||
tmp_path.chmod(0o755)
|
||||
|
||||
async def test_create_library_read_only_path_exists(
|
||||
self, library_service: LibraryService, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test creating a read-only library with a root path that exists."""
|
||||
|
||||
# Create the path beforehand
|
||||
library_path = f"{tmp_path}/books"
|
||||
Path(library_path).mkdir()
|
||||
|
||||
library_data = LibraryCreate(
|
||||
name="Test Library",
|
||||
root_path=library_path,
|
||||
path_template="{author}/{title}",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
library = await library_service.create(library_data)
|
||||
|
||||
assert library.name == "Test Library"
|
||||
assert library.root_path == library_path
|
||||
assert library.path_template == "{author}/{title}"
|
||||
assert library.description == None
|
||||
assert library.read_only == True
|
||||
|
||||
async def test_create_library_read_only_nonexistent_path(
|
||||
self, library_service: LibraryService, tmp_path: Path
|
||||
) -> None:
|
||||
"""Test creating a read-only library with a nonexistent root path."""
|
||||
|
||||
library_path = f"{tmp_path}/books"
|
||||
library_data = LibraryCreate(
|
||||
name="Test Library",
|
||||
root_path=library_path,
|
||||
path_template="{author}/{title}",
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
with pytest.raises(DirectoryDoesNotExist) as exc_info:
|
||||
await library_service.create(library_data)
|
||||
|
||||
assert "Root directory" in str(exc_info.value)
|
||||
assert "must exist for a read-only library" in str(exc_info.value)
|
||||
|
||||
# Check if directory was created
|
||||
assert not Path(library_path).exists()
|
||||
|
||||
async def test_get_library(
|
||||
self, session: AsyncSession, library_service: LibraryService
|
||||
) -> None:
|
||||
"""Test retrieving a library."""
|
||||
|
||||
# Add a library to the database
|
||||
library_data = m.Library(
|
||||
name="Testing Library",
|
||||
slug="testing-library",
|
||||
root_path="./books",
|
||||
path_template="{author}/{title}",
|
||||
description=None,
|
||||
read_only=False,
|
||||
)
|
||||
|
||||
session.add(library_data)
|
||||
await session.commit()
|
||||
await session.refresh(library_data)
|
||||
|
||||
library = await library_service.get(library_data.id)
|
||||
|
||||
assert library is not None
|
||||
assert library.id == library_data.id
|
||||
assert library.name == "Testing Library"
|
||||
assert library.root_path == "./books"
|
||||
assert library.path_template == "{author}/{title}"
|
||||
assert library.description is None
|
||||
assert library.read_only == False
|
||||
|
||||
# async def test_delete_library_keep_files(
|
||||
# self, session: AsyncSession, library_service: LibraryService
|
||||
# ) -> None:
|
||||
# """Test deletion of a library's metadata and associated entities."""
|
||||
# raise NotImplementedError()
|
||||
|
||||
# async def test_delete_library_delete_files(
|
||||
# self, session: AsyncSession, library_service: LibraryService
|
||||
# ) -> None:
|
||||
# """Test deletion of a library's metadata, associated enties, and files/directories."""
|
||||
# raise NotImplementedError()
|
||||
116
backend/tests/unit/test_services/test_user_service.py
Normal file
116
backend/tests/unit/test_services/test_user_service.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Tests for UserService"""
|
||||
|
||||
import pytest
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from litestar.exceptions import PermissionDeniedException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from chitai.services import UserService
|
||||
from chitai.database import models as m
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserServiceAuthentication:
|
||||
"""Test authentication functionality."""
|
||||
|
||||
async def test_authenticate_success(
|
||||
self, session: AsyncSession, user_service: UserService
|
||||
) -> None:
|
||||
"""Test successful user authentication."""
|
||||
|
||||
# Create a user with a known password
|
||||
password = "password123"
|
||||
user = m.User(email=f"test@example.com", password=password)
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
# Authenticate user
|
||||
authenticated_user = await user_service.authenticate(
|
||||
"test@example.com", password
|
||||
)
|
||||
|
||||
assert authenticated_user is not None
|
||||
assert authenticated_user.email == "test@example.com"
|
||||
assert authenticated_user.id == user.id
|
||||
|
||||
async def test_authenticate_user_not_found(
|
||||
self, session: AsyncSession, user_service: UserService
|
||||
) -> None:
|
||||
"""Test authentication with non-existent user."""
|
||||
|
||||
with pytest.raises(PermissionDeniedException) as exc_info:
|
||||
await user_service.authenticate("nonexistent@example.com", "password")
|
||||
|
||||
assert "User not found or password invalid" in str(exc_info.value)
|
||||
|
||||
async def test_authenticate_wrong_password(
|
||||
self, session: AsyncSession, user_service: UserService
|
||||
) -> None:
|
||||
"""Test authentication with wrong password."""
|
||||
|
||||
# Create user
|
||||
password = "password123"
|
||||
user = m.User(email=f"test@example.com", password=password)
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
# Attempt authentication
|
||||
with pytest.raises(PermissionDeniedException) as exc_info:
|
||||
await user_service.authenticate("test@example.com", "WrongPassword")
|
||||
|
||||
assert "User not found or password invalid" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUserServiceCRUD:
|
||||
"""Test basic CRUD operations."""
|
||||
|
||||
async def test_create_user_with_password_hashing(
|
||||
self, session: AsyncSession, user_service: UserService
|
||||
) -> None:
|
||||
user_data = {"email": "newuser@example.com", "password": "password123"}
|
||||
|
||||
user = await user_service.create(data=user_data)
|
||||
|
||||
assert user.email == "newuser@example.com"
|
||||
assert user.password is not None
|
||||
assert user.password != "password123" # Password should be hashed
|
||||
assert user.password.verify("password123")
|
||||
|
||||
async def test_get_user_by_email(
|
||||
self, session: AsyncSession, user_service: UserService
|
||||
) -> None:
|
||||
"""Test getting user by email."""
|
||||
|
||||
user = m.User(email=f"test@example.com", password="password123")
|
||||
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
found_user = await user_service.get_one_or_none(email="test@example.com")
|
||||
|
||||
assert found_user is not None
|
||||
assert found_user.id == user.id
|
||||
assert found_user.email == user.email
|
||||
|
||||
async def test_create_user_with_duplicate_email(
|
||||
self, session: AsyncSession, user_service: UserService
|
||||
) -> None:
|
||||
"""Test creating a new user with a duplicate email."""
|
||||
|
||||
# Create first user
|
||||
user = m.User(email=f"test@example.com", password="password123")
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
# Create second user
|
||||
user = m.User(email=f"test@example.com", password="password12345")
|
||||
|
||||
with pytest.raises(IntegrityError) as exc_info:
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
|
||||
assert "violates unique constraint" in str(exc_info.value)
|
||||
Reference in New Issue
Block a user