Initial commit
This commit is contained in:
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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",
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
@@ -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