Initial commit

This commit is contained in:
hiperman
2025-12-04 00:33:37 -05:00
commit 7ca0a21283
798 changed files with 190424 additions and 0 deletions

View 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

View 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"]

View 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

View 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

View 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