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,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)

View File

@@ -0,0 +1,74 @@
"""Tests for BookService"""
import pytest
from pathlib import Path
import aiofiles.os as aios
from chitai.schemas import BookCreate
from chitai.services import BookService
from chitai.database import models as m
@pytest.mark.asyncio
class TestBookServiceCRUD:
"""Test CRUD operation for libraries."""
async def test_update_book(
self, books_service: BookService, test_library: m.Library
) -> None:
book_data = BookCreate(
library_id=1,
title="Fellowship of the Ring",
authors=["J.R.R Tolkien"],
tags=["Fantasy"],
identifiers={"isbn-13": "9780261102354"},
pages=427,
)
book = await books_service.to_model_on_create(book_data.model_dump())
# Add path manually as it won't be generated (not using the create function, but manually inserting into db)
book.path = f"{test_library.root_path}/J.R.R Tolkien/The Fellowship of the Ring"
await aios.makedirs(book.path)
books_service.repository.session.add(book)
await books_service.repository.session.commit()
await books_service.repository.session.refresh(book)
await books_service.update(
book.id,
{
"title": "The Fellowship of the Ring",
"identifiers": {"isbn-10": "9780261102354"},
"edition": 3,
"publisher": "Tolkien Estate",
"series": "The Lord of the Rings",
"series_position": "1",
"tags": ["Fantasy", "Adventure"],
},
test_library,
)
updated_book = await books_service.get(book.id)
# Assert updated information is correct
assert updated_book.title == "The Fellowship of the Ring"
assert (
updated_book.path
== f"{test_library.root_path}/J.R.R Tolkien/The Lord of the Rings/01 - The Fellowship of the Ring"
)
assert len(updated_book.identifiers)
assert updated_book.identifiers[0].value == "9780261102354"
assert updated_book.edition == 3
assert updated_book.publisher.name == "Tolkien Estate"
assert len(updated_book.tags) == 2
# book = await books_service.create(book_data.model_dump())

View File

@@ -0,0 +1,298 @@
"""Tests for LibraryService"""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from chitai.services import ShelfService
from chitai.database import models as m
import pytest
from sqlalchemy import select
from chitai.services.bookshelf import ShelfService
from chitai.services import BookService
from chitai.database.models.book_list import BookList, BookListLink
from chitai.database import models as m
@pytest.fixture
async def db_session(bookshelf_service: ShelfService) -> AsyncSession:
return bookshelf_service.repository.session
@pytest.fixture(autouse=True)
async def test_books(db_session: AsyncSession) -> list[m.Book]:
"""Create test books in the database."""
library = m.Library(
name="Default Library",
slug="default-library",
root_path="./path",
path_template="{author}/{title}",
read_only=False,
)
db_session.add(library)
books = [m.Book(title=f"Book {i}", library_id=1) for i in range(1, 8)]
db_session.add_all(books)
user = m.User(email="test_user@example.com", password="password123")
db_session.add(user)
await db_session.flush()
await db_session.commit()
return books
class TestAddBooks:
async def test_add_books_to_empty_shelf(
self, bookshelf_service: ShelfService, db_session
) -> None:
"""Successfully add books to an empty shelf."""
# Create a shelf
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
# Add books
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
# Verify books were added
links = await db_session.execute(
select(BookListLink).where(BookListLink.list_id == shelf.id)
)
added_links = links.scalars().all()
assert len(added_links) == 3
assert {link.book_id for link in added_links} == {1, 2, 3}
async def test_add_books_preserves_positions(
self, bookshelf_service: ShelfService, db_session
) -> None:
"""Verify books are assigned correct positions on the shelf."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
links = await db_session.execute(
select(BookListLink)
.where(BookListLink.list_id == shelf.id)
.order_by(BookListLink.position)
)
added_links = links.scalars().all()
# Verify positions are sequential starting from 0
assert [link.position for link in added_links] == [0, 1, 2]
async def test_add_books_to_shelf_with_existing_books(
self, bookshelf_service: ShelfService, db_session
) -> None:
"""Adding books to a shelf with existing books assigns correct positions."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
# Add initial books
await bookshelf_service.add_books(shelf.id, [1, 2])
# Add more books
await bookshelf_service.add_books(shelf.id, [3, 4])
links = await db_session.execute(
select(BookListLink)
.where(BookListLink.list_id == shelf.id)
.order_by(BookListLink.position)
)
added_links = links.scalars().all()
# Verify new books continue from position 2
assert len(added_links) == 4
assert [link.position for link in added_links] == [0, 1, 2, 3]
async def test_add_duplicate_books_is_idempotent(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Adding books already on shelf should not create duplicates."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
# Add books
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
# Try to add overlapping books
await bookshelf_service.add_books(shelf.id, [2, 3, 4])
links = await db_session.execute(
select(BookListLink).where(BookListLink.list_id == shelf.id)
)
added_links = links.scalars().all()
# Should have 4 books total (1, 2, 3, 4), not 7
assert len(added_links) == 4
assert {link.book_id for link in added_links} == {1, 2, 3, 4}
async def test_add_books_raises_on_nonexistent_shelf(
self, bookshelf_service: ShelfService
) -> None:
"""Adding books to nonexistent shelf raises error."""
with pytest.raises(Exception): # Could be SQLAlchemyError or specific error
await bookshelf_service.add_books(99999, [1, 2, 3])
async def test_add_nonexistent_books_raises_error(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Adding nonexistent books raises ValueError."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
with pytest.raises(ValueError, match="One or more books not found"):
await bookshelf_service.add_books(shelf.id, [99999, 99998])
async def test_add_partial_nonexistent_books_raises_error(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Adding a mix of existent and nonexistent books raises error."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
# Book 1 might exist, but 99999 doesn't
with pytest.raises(ValueError, match="One or more books not found"):
await bookshelf_service.add_books(shelf.id, [1, 99999])
async def test_add_empty_book_list(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Adding empty book list should return without error."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
# Should not raise
await bookshelf_service.add_books(shelf.id, [])
class TestRemoveBooks:
async def test_remove_books_from_shelf(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Successfully remove books from a shelf."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
# Add books
await bookshelf_service.add_books(shelf.id, [1, 2, 3, 4])
# Remove some books
await bookshelf_service.remove_books(shelf.id, [2, 3])
links = await db_session.execute(
select(BookListLink).where(BookListLink.list_id == shelf.id)
)
remaining_links = links.scalars().all()
assert len(remaining_links) == 2
assert {link.book_id for link in remaining_links} == {1, 4}
async def test_remove_all_books_from_shelf(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Removing all books should leave shelf empty."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
await bookshelf_service.remove_books(shelf.id, [1, 2, 3])
links = await db_session.execute(
select(BookListLink).where(BookListLink.list_id == shelf.id)
)
remaining_links = links.scalars().all()
assert len(remaining_links) == 0
async def test_remove_nonexistent_book_is_idempotent(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Removing books not on shelf should not raise error."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
# Remove books that don't exist on shelf - should not raise
await bookshelf_service.remove_books(shelf.id, [99, 100])
links = await db_session.execute(
select(BookListLink).where(BookListLink.list_id == shelf.id)
)
remaining_links = links.scalars().all()
# Original books should still be there
assert len(remaining_links) == 3
async def test_remove_books_raises_on_nonexistent_shelf(
self, bookshelf_service: ShelfService
) -> None:
"""Removing books from nonexistent shelf raises error."""
with pytest.raises(Exception):
await bookshelf_service.remove_books(99999, [1, 2, 3])
async def test_remove_empty_book_list(
self, bookshelf_service: ShelfService, db_session: AsyncSession
) -> None:
"""Removing empty book list should return without error."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
await bookshelf_service.add_books(shelf.id, [1, 2, 3])
# Should not raise
await bookshelf_service.remove_books(shelf.id, [])
links = await db_session.execute(
select(BookListLink).where(BookListLink.list_id == shelf.id)
)
remaining_links = links.scalars().all()
# All books should still be there
assert len(remaining_links) == 3
async def test_add_remove_add_sequence(
self, bookshelf_service: ShelfService, db_session
) -> None:
"""Add books, remove from middle, then add more - positions should be maintained."""
shelf = BookList(title="Test Shelf", user_id=1)
db_session.add(shelf)
await db_session.flush()
# Add initial books [1, 2, 3, 4, 5]
await bookshelf_service.add_books(shelf.id, [1, 2, 3, 4, 5])
# Remove books from middle [2, 3, 4]
await bookshelf_service.remove_books(shelf.id, [2, 3, 4])
# Add more books [6, 7]
await bookshelf_service.add_books(shelf.id, [6, 7])
links = await db_session.execute(
select(BookListLink)
.where(BookListLink.list_id == shelf.id)
.order_by(BookListLink.position)
)
final_links = links.scalars().all()
# Should have [1, 5, 6, 7] with positions [0, 1, 2, 3]
assert len(final_links) == 4
assert [link.book_id for link in final_links] == [1, 5, 6, 7]
assert [link.position for link in final_links] == [0, 1, 2, 3]

View File

@@ -0,0 +1,153 @@
"""Tests for LibraryService"""
import pytest
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from chitai.schemas.library import LibraryCreate
from chitai.services import LibraryService
from chitai.database import models as m
from chitai.services.utils import DirectoryDoesNotExist
@pytest.mark.asyncio
class TestLibraryServiceCRUD:
"""Test CRUD operation for libraries."""
async def test_create_library(
self, library_service: LibraryService, tmp_path: Path
) -> None:
"""Test creating a library with a valid root path."""
library_path = f"{tmp_path}/books"
library_data = LibraryCreate(
name="Test Library",
root_path=library_path,
path_template="{author}/{title}",
read_only=False,
)
library = await library_service.create(library_data)
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == False
# Check if directory was created
assert Path(library.root_path).is_dir()
async def test_create_library_root_path_permission_error(
self, library_service: LibraryService, tmp_path: Path
) -> None:
"""Test creating a library with a root path that is not permitted."""
# Change permissions on the temp path
tmp_path.chmod(0o544)
library_path = f"{tmp_path}/books"
library_data = LibraryCreate(
name="Test Library",
root_path=library_path,
path_template="{author}/{title}",
read_only=False,
)
with pytest.raises(PermissionError) as exc_info:
library = await library_service.create(library_data)
# Check if directory was created
assert not Path(library_path).exists()
# Change permissions back
tmp_path.chmod(0o755)
async def test_create_library_read_only_path_exists(
self, library_service: LibraryService, tmp_path: Path
) -> None:
"""Test creating a read-only library with a root path that exists."""
# Create the path beforehand
library_path = f"{tmp_path}/books"
Path(library_path).mkdir()
library_data = LibraryCreate(
name="Test Library",
root_path=library_path,
path_template="{author}/{title}",
read_only=True,
)
library = await library_service.create(library_data)
assert library.name == "Test Library"
assert library.root_path == library_path
assert library.path_template == "{author}/{title}"
assert library.description == None
assert library.read_only == True
async def test_create_library_read_only_nonexistent_path(
self, library_service: LibraryService, tmp_path: Path
) -> None:
"""Test creating a read-only library with a nonexistent root path."""
library_path = f"{tmp_path}/books"
library_data = LibraryCreate(
name="Test Library",
root_path=library_path,
path_template="{author}/{title}",
read_only=True,
)
with pytest.raises(DirectoryDoesNotExist) as exc_info:
await library_service.create(library_data)
assert "Root directory" in str(exc_info.value)
assert "must exist for a read-only library" in str(exc_info.value)
# Check if directory was created
assert not Path(library_path).exists()
async def test_get_library(
self, session: AsyncSession, library_service: LibraryService
) -> None:
"""Test retrieving a library."""
# Add a library to the database
library_data = m.Library(
name="Testing Library",
slug="testing-library",
root_path="./books",
path_template="{author}/{title}",
description=None,
read_only=False,
)
session.add(library_data)
await session.commit()
await session.refresh(library_data)
library = await library_service.get(library_data.id)
assert library is not None
assert library.id == library_data.id
assert library.name == "Testing Library"
assert library.root_path == "./books"
assert library.path_template == "{author}/{title}"
assert library.description is None
assert library.read_only == False
# async def test_delete_library_keep_files(
# self, session: AsyncSession, library_service: LibraryService
# ) -> None:
# """Test deletion of a library's metadata and associated entities."""
# raise NotImplementedError()
# async def test_delete_library_delete_files(
# self, session: AsyncSession, library_service: LibraryService
# ) -> None:
# """Test deletion of a library's metadata, associated enties, and files/directories."""
# raise NotImplementedError()

View File

@@ -0,0 +1,116 @@
"""Tests for UserService"""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from litestar.exceptions import PermissionDeniedException
from sqlalchemy.exc import IntegrityError
from chitai.services import UserService
from chitai.database import models as m
@pytest.mark.asyncio
class TestUserServiceAuthentication:
"""Test authentication functionality."""
async def test_authenticate_success(
self, session: AsyncSession, user_service: UserService
) -> None:
"""Test successful user authentication."""
# Create a user with a known password
password = "password123"
user = m.User(email=f"test@example.com", password=password)
session.add(user)
await session.commit()
# Authenticate user
authenticated_user = await user_service.authenticate(
"test@example.com", password
)
assert authenticated_user is not None
assert authenticated_user.email == "test@example.com"
assert authenticated_user.id == user.id
async def test_authenticate_user_not_found(
self, session: AsyncSession, user_service: UserService
) -> None:
"""Test authentication with non-existent user."""
with pytest.raises(PermissionDeniedException) as exc_info:
await user_service.authenticate("nonexistent@example.com", "password")
assert "User not found or password invalid" in str(exc_info.value)
async def test_authenticate_wrong_password(
self, session: AsyncSession, user_service: UserService
) -> None:
"""Test authentication with wrong password."""
# Create user
password = "password123"
user = m.User(email=f"test@example.com", password=password)
session.add(user)
await session.commit()
# Attempt authentication
with pytest.raises(PermissionDeniedException) as exc_info:
await user_service.authenticate("test@example.com", "WrongPassword")
assert "User not found or password invalid" in str(exc_info.value)
@pytest.mark.asyncio
class TestUserServiceCRUD:
"""Test basic CRUD operations."""
async def test_create_user_with_password_hashing(
self, session: AsyncSession, user_service: UserService
) -> None:
user_data = {"email": "newuser@example.com", "password": "password123"}
user = await user_service.create(data=user_data)
assert user.email == "newuser@example.com"
assert user.password is not None
assert user.password != "password123" # Password should be hashed
assert user.password.verify("password123")
async def test_get_user_by_email(
self, session: AsyncSession, user_service: UserService
) -> None:
"""Test getting user by email."""
user = m.User(email=f"test@example.com", password="password123")
session.add(user)
await session.commit()
found_user = await user_service.get_one_or_none(email="test@example.com")
assert found_user is not None
assert found_user.id == user.id
assert found_user.email == user.email
async def test_create_user_with_duplicate_email(
self, session: AsyncSession, user_service: UserService
) -> None:
"""Test creating a new user with a duplicate email."""
# Create first user
user = m.User(email=f"test@example.com", password="password123")
session.add(user)
await session.commit()
# Create second user
user = m.User(email=f"test@example.com", password="password12345")
with pytest.raises(IntegrityError) as exc_info:
session.add(user)
await session.commit()
assert "violates unique constraint" in str(exc_info.value)