Rework core functionality, add CLI and tests

This commit is contained in:
2026-03-16 00:34:32 +01:00
parent c30ad57051
commit 40ca08359f
9 changed files with 3118 additions and 35 deletions

70
tests/conftest.py Normal file
View File

@@ -0,0 +1,70 @@
"""
Test configuration and fixtures for HXBooks.
Provides isolated test database, Flask app instances, and CLI testing utilities.
"""
import tempfile
from pathlib import Path
from typing import Generator
import pytest
from click.testing import CliRunner
from flask import Flask
from flask.testing import FlaskClient
from hxbooks import cli, create_app
from hxbooks.db import db
from hxbooks.models import User
@pytest.fixture
def app(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Flask:
"""Create Flask app with test configuration."""
test_db_path = tmp_path / "test.db"
test_config = {
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{test_db_path}",
"SECRET_KEY": "test-secret-key",
"WTF_CSRF_ENABLED": False,
}
app = create_app(test_config)
with app.app_context():
db.create_all()
monkeypatch.setattr(cli, "get_app", lambda: app)
return app
@pytest.fixture
def client(app: Flask) -> FlaskClient:
"""Create test client for Flask app."""
return app.test_client()
@pytest.fixture
def cli_runner() -> CliRunner:
"""Create Click CLI test runner."""
return CliRunner()
@pytest.fixture
def test_user(app: Flask) -> User:
"""Create a test user in the database."""
with app.app_context():
user = User(username="testuser")
db.session.add(user)
db.session.commit()
# Refresh to get the ID
db.session.refresh(user)
return user
@pytest.fixture
def db_session(app: Flask):
"""Create database session for direct database testing."""
with app.app_context():
yield db.session

930
tests/test_cli.py Normal file
View File

@@ -0,0 +1,930 @@
"""
CLI command tests for HXBooks.
Tests all CLI commands for correct behavior, database integration, and output formatting.
"""
import json
import re
import tempfile
from datetime import date
from pathlib import Path
import pytest
from click.testing import CliRunner
from hxbooks.cli import cli
from hxbooks.db import db
from hxbooks.models import Author, Book, Genre, Reading, User, Wishlist
class TestBookAddCommand:
"""Test the 'hxbooks book add' command."""
def test_book_add_basic(self, app, cli_runner):
"""Test basic book addition with title and owner."""
# Run the CLI command
result = cli_runner.invoke(
cli,
[
"book",
"add",
"The Hobbit",
"--owner",
"frodo",
"--authors",
"J.R.R. Tolkien",
"--genres",
"Fantasy,Adventure",
"--isbn",
"9780547928227",
"--publisher",
"Houghton Mifflin Harcourt",
"--place",
"home",
"--bookshelf",
"living room",
"--shelf",
"2",
"--description",
"A classic fantasy tale",
"--notes",
"First edition",
],
)
# Verify CLI command succeeded
assert result.exit_code == 0, f"CLI command failed with output: {result.output}"
# Verify success message format
assert "Added book: The Hobbit (ID:" in result.output
assert "Created user: frodo" in result.output
# Verify database state
with app.app_context():
# Check user was created
users = db.session.execute(db.select(User)).scalars().all()
assert len(users) == 1
user = users[0]
assert user.username == "frodo"
# Check book was created with correct fields
books = (
db.session.execute(db.select(Book).join(Book.authors).join(Book.genres))
.unique()
.scalars()
.all()
)
assert len(books) == 1
book = books[0]
assert book.title == "The Hobbit"
assert book.owner_id == user.id
assert book.isbn == "9780547928227"
assert book.publisher == "Houghton Mifflin Harcourt"
assert book.location_place == "home"
assert book.location_bookshelf == "living room"
assert book.location_shelf == 2
assert book.description == "A classic fantasy tale"
assert book.notes == "First edition"
# Check authors were created and linked
authors = db.session.execute(db.select(Author)).scalars().all()
assert len(authors) == 1
author = authors[0]
assert author.name == "J.R.R. Tolkien"
assert book in author.books
assert author in book.authors
# Check genres were created and linked
genres = db.session.execute(db.select(Genre)).scalars().all()
assert len(genres) == 2
genre_names = {genre.name for genre in genres}
assert genre_names == {"Fantasy", "Adventure"}
for genre in genres:
assert book in genre.books
assert genre in book.genres
def test_book_add_minimal_fields(self, app, cli_runner):
"""Test book addition with only required fields."""
result = cli_runner.invoke(
cli, ["book", "add", "Minimal Book", "--owner", "alice"]
)
assert result.exit_code == 0
assert "Added book: Minimal Book (ID:" in result.output
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
assert book.title == "Minimal Book"
assert book.isbn == "" # Default empty string
assert book.publisher == ""
assert book.location_shelf is None # Default None
assert len(book.authors) == 0 # No authors provided
assert len(book.genres) == 0 # No genres provided
def test_book_add_missing_owner_fails(self, app, cli_runner):
"""Test that book addition fails when owner is not provided."""
result = cli_runner.invoke(
cli,
[
"book",
"add",
"Test Book",
# Missing --owner parameter
],
)
# Should fail with exit code 2 (Click validation error)
assert result.exit_code == 2
assert "Missing option '--owner'" in result.output
class TestBookListCommand:
"""Test the 'hxbooks book list' command."""
def test_book_list_empty(self, app, cli_runner):
"""Test listing books when database is empty."""
result = cli_runner.invoke(cli, ["book", "list"])
assert result.exit_code == 0
assert "No books found." in result.output
def test_book_list_with_books(self, app, cli_runner):
"""Test listing books in table format."""
# Add test data
cli_runner.invoke(
cli,
["book", "add", "Book One", "--owner", "alice", "--authors", "Author A"],
)
cli_runner.invoke(
cli, ["book", "add", "Book Two", "--owner", "bob", "--authors", "Author B"]
)
result = cli_runner.invoke(cli, ["book", "list"])
assert result.exit_code == 0
assert "Book One" in result.output
assert "Book Two" in result.output
assert "Author A" in result.output
assert "Author B" in result.output
assert "alice" in result.output
assert "bob" in result.output
def test_book_list_json_format(self, app, cli_runner):
"""Test listing books in JSON format."""
# Add test data
cli_runner.invoke(
cli,
[
"book",
"add",
"Test Book",
"--owner",
"alice",
"--authors",
"Test Author",
"--isbn",
"1234567890",
],
)
result = cli_runner.invoke(cli, ["book", "list", "--format", "json"])
assert result.exit_code == 0
books_data = json.loads(result.output)
assert len(books_data) == 1
book = books_data[0]
assert book["title"] == "Test Book"
assert book["authors"] == ["Test Author"]
assert book["owner"] == "alice"
assert book["isbn"] == "1234567890"
def test_book_list_filter_by_owner(self, app, cli_runner):
"""Test filtering books by owner."""
# Add books for different owners
cli_runner.invoke(cli, ["book", "add", "Alice Book", "--owner", "alice"])
cli_runner.invoke(cli, ["book", "add", "Bob Book", "--owner", "bob"])
result = cli_runner.invoke(cli, ["book", "list", "--owner", "alice"])
assert result.exit_code == 0
assert "Alice Book" in result.output
assert "Bob Book" not in result.output
def test_book_list_filter_by_location(self, app, cli_runner):
"""Test filtering books by location."""
# Add books in different locations
cli_runner.invoke(
cli,
[
"book",
"add",
"Home Book",
"--owner",
"alice",
"--place",
"home",
"--bookshelf",
"living",
"--shelf",
"1",
],
)
cli_runner.invoke(
cli,
[
"book",
"add",
"Office Book",
"--owner",
"alice",
"--place",
"office",
"--bookshelf",
"work",
"--shelf",
"2",
],
)
result = cli_runner.invoke(
cli,
[
"book",
"list",
"--place",
"home",
"--bookshelf",
"living",
"--shelf",
"1",
],
)
assert result.exit_code == 0
assert "Home Book" in result.output
assert "Office Book" not in result.output
class TestBookSearchCommand:
"""Test the 'hxbooks book search' command."""
def test_book_search_basic(self, app, cli_runner):
"""Test basic book search functionality."""
# Add test books
cli_runner.invoke(
cli,
[
"book",
"add",
"The Hobbit",
"--owner",
"alice",
"--authors",
"Tolkien",
"--genres",
"Fantasy",
],
)
cli_runner.invoke(
cli,
[
"book",
"add",
"Dune",
"--owner",
"alice",
"--authors",
"Herbert",
"--genres",
"Sci-Fi",
],
)
result = cli_runner.invoke(cli, ["book", "search", "Hobbit"])
assert result.exit_code == 0
assert "The Hobbit" in result.output
assert "Dune" not in result.output
def test_book_search_no_results(self, app, cli_runner):
"""Test search with no matching results."""
result = cli_runner.invoke(cli, ["book", "search", "nonexistent"])
assert result.exit_code == 0
assert "No books found." in result.output
def test_book_search_json_format(self, app, cli_runner):
"""Test book search with JSON output."""
cli_runner.invoke(
cli,
[
"book",
"add",
"Test Book",
"--owner",
"alice",
"--authors",
"Test Author",
],
)
result = cli_runner.invoke(cli, ["book", "search", "Test", "--format", "json"])
assert result.exit_code == 0
search_results = json.loads(result.output)
assert len(search_results) >= 1
# Results format depends on BookService.search_books_advanced implementation
@pytest.mark.parametrize(
"query,expected_titles",
[
# String field filters
("title:Hobbit", ["The Hobbit"]),
("author:Tolkien", ["The Hobbit", "The Fellowship"]),
("genre:Fantasy", ["The Hobbit", "The Fellowship"]),
("owner:alice", ["The Hobbit", "The Fellowship", "Dune"]),
("place:home", ["The Hobbit", "Programming Book"]),
("bookshelf:fantasy", ["The Hobbit", "The Fellowship"]),
# Numeric field filters
pytest.param(
"rating>=4",
["The Hobbit", "Programming Book"],
marks=pytest.mark.xfail(reason="Rating filter not implemented yet"),
),
pytest.param(
"rating=3",
["Dune"],
marks=pytest.mark.xfail(reason="Rating filter not implemented yet"),
),
("shelf>1", ["The Fellowship", "Programming Book"]),
(
"year>=1954",
["The Hobbit", "The Fellowship", "Dune", "Programming Book"],
),
# Date field filters
(
"added>=2026-03-15",
["The Hobbit", "The Fellowship", "Dune", "Programming Book"],
),
("bought<2026-01-01", ["Programming Book"]),
# Negation
("-genre:Fantasy", ["Dune", "Programming Book"]),
("-owner:bob", ["The Hobbit", "The Fellowship", "Dune"]),
# Complex query with multiple filters
("-genre:Fantasy owner:alice", ["Dune"]),
],
)
def test_book_search_advanced_queries(
self, app, cli_runner, query, expected_titles
):
"""Test advanced search queries with various field filters."""
# Set up comprehensive test data
self._setup_search_test_data(app, cli_runner)
# Execute the search query
result = cli_runner.invoke(
cli, ["book", "search", "--format", "json", "--", query]
)
assert result.exit_code == 0, f"Search query '{query}' failed: {result.output}"
# Parse results and extract titles
search_results = json.loads(result.output)
actual_titles = [book["title"] for book in search_results]
# Verify expected titles are present (order doesn't matter)
assert set(expected_titles) == set(actual_titles), (
f"Query '{query}' expected {expected_titles}, got {actual_titles}"
)
def _setup_search_test_data(self, app, cli_runner):
"""Set up comprehensive test data for advanced search testing."""
# Book 1: The Hobbit - Fantasy, high rating, shelf 1, home
cli_runner.invoke(
cli,
[
"book",
"add",
"The Hobbit",
"--owner",
"alice",
"--authors",
"J.R.R. Tolkien",
"--genres",
"Fantasy,Adventure",
"--place",
"home",
"--bookshelf",
"fantasy",
"--shelf",
"1",
"--publisher",
"Allen & Unwin",
],
)
# Book 2: The Fellowship - Fantasy, high rating, shelf 2, office
cli_runner.invoke(
cli,
[
"book",
"add",
"The Fellowship",
"--owner",
"alice",
"--authors",
"J.R.R. Tolkien",
"--genres",
"Fantasy,Epic",
"--place",
"office",
"--bookshelf",
"fantasy",
"--shelf",
"2",
"--publisher",
"Allen & Unwin",
],
)
# Book 3: Dune - Sci-Fi, medium rating, shelf 1, office
cli_runner.invoke(
cli,
[
"book",
"add",
"Dune",
"--owner",
"alice",
"--authors",
"Frank Herbert",
"--genres",
"Science Fiction",
"--place",
"office",
"--bookshelf",
"scifi",
"--shelf",
"1",
"--publisher",
"Chilton Books",
],
)
# Book 4: Programming Book - Tech, high rating, shelf 2, home, different owner
cli_runner.invoke(
cli,
[
"book",
"add",
"Programming Book",
"--owner",
"bob",
"--authors",
"Tech Author",
"--genres",
"Technology,Programming",
"--place",
"home",
"--bookshelf",
"tech",
"--shelf",
"2",
"--publisher",
"Tech Press",
],
)
# Add some readings and ratings to test rating filters
with app.app_context():
# Get book IDs
books = (
db.session.execute(db.select(Book).order_by(Book.id)).scalars().all()
)
hobbit_id = next(b.id for b in books if b.title == "The Hobbit")
fellowship_id = next(b.id for b in books if b.title == "The Fellowship")
dune_id = next(b.id for b in books if b.title == "Dune")
prog_id = next(b.id for b in books if b.title == "Programming Book")
# Start and finish reading sessions with ratings
cli_runner.invoke(cli, ["reading", "start", str(hobbit_id), "--owner", "alice"])
cli_runner.invoke(cli, ["reading", "start", str(dune_id), "--owner", "alice"])
cli_runner.invoke(cli, ["reading", "start", str(prog_id), "--owner", "bob"])
with app.app_context():
# Get reading session IDs
readings = (
db.session.execute(db.select(Reading).order_by(Reading.id))
.scalars()
.all()
)
hobbit_reading = next(r for r in readings if r.book_id == hobbit_id)
dune_reading = next(r for r in readings if r.book_id == dune_id)
prog_reading = next(r for r in readings if r.book_id == prog_id)
# Finish with different ratings
cli_runner.invoke(
cli, ["reading", "finish", str(hobbit_reading.id), "--rating", "5"]
)
cli_runner.invoke(
cli, ["reading", "finish", str(dune_reading.id), "--rating", "3"]
)
cli_runner.invoke(
cli, ["reading", "finish", str(prog_reading.id), "--rating", "4"]
)
# Update one book with bought_date for date filter testing
with app.app_context():
prog_book = db.session.get(Book, prog_id)
prog_book.bought_date = date(2025, 12, 1) # Before 2026-01-01
prog_book.first_published = 2000
hobbit_book = db.session.get(Book, hobbit_id)
hobbit_book.first_published = 1937
fellowship_book = db.session.get(Book, fellowship_id)
fellowship_book.first_published = 1954
dune_book = db.session.get(Book, dune_id)
dune_book.first_published = 1965
db.session.commit()
class TestReadingCommands:
"""Test reading-related CLI commands."""
def test_reading_start_basic(self, app, cli_runner):
"""Test starting a reading session."""
# Add a book first
result = cli_runner.invoke(
cli, ["book", "add", "Test Book", "--owner", "alice"]
)
assert result.exit_code == 0
# Extract book ID from output
import re
book_id_match = re.search(r"ID: (\d+)", result.output)
assert book_id_match
book_id = book_id_match.group(1)
# Start reading session
result = cli_runner.invoke(
cli, ["reading", "start", book_id, "--owner", "alice"]
)
assert result.exit_code == 0
assert f"Started reading session" in result.output
assert f"for book {book_id}" in result.output
def test_reading_finish_with_rating(self, app, cli_runner):
"""Test finishing a reading session with rating."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "Test Book", "--owner", "alice"])
with app.app_context():
# Get the book ID from database
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["reading", "start", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
# Extract reading session ID
import re
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
assert reading_id_match
reading_id = reading_id_match.group(1)
# Finish reading with rating
result = cli_runner.invoke(
cli,
[
"reading",
"finish",
reading_id,
"--rating",
"4",
"--comments",
"Great book!",
],
)
assert result.exit_code == 0
assert "Finished reading: Test Book" in result.output
assert "Rating: 4/5" in result.output
def test_reading_drop(self, app, cli_runner):
"""Test dropping a reading session."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "Boring Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["reading", "start", str(book_id), "--owner", "alice"]
)
import re
reading_id_match = re.search(r"Started reading session (\d+)", result.output)
reading_id = reading_id_match.group(1)
# Drop the reading
result = cli_runner.invoke(
cli, ["reading", "drop", reading_id, "--comments", "Too boring"]
)
assert result.exit_code == 0
assert "Dropped reading: Boring Book" in result.output
def test_reading_list_current(self, app, cli_runner):
"""Test listing current (unfinished) readings."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "Current Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["reading", "list", "--owner", "alice", "--current"]
)
assert result.exit_code == 0, f"CLI command failed with output: {result.output}"
assert "Current Book" in result.output
assert "Reading" in result.output
def test_reading_list_json_format(self, app, cli_runner):
"""Test listing readings in JSON format."""
# Add book and start reading
cli_runner.invoke(cli, ["book", "add", "JSON Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["reading", "list", "--owner", "alice", "--format", "json"]
)
assert result.exit_code == 0
readings_data = json.loads(result.output)
assert len(readings_data) == 1
reading = readings_data[0]
assert reading["book_title"] == "JSON Book"
assert reading["finished"] is False
class TestWishlistCommands:
"""Test wishlist-related CLI commands."""
def test_wishlist_add(self, app, cli_runner):
"""Test adding a book to wishlist."""
# Add a book first
cli_runner.invoke(cli, ["book", "add", "Desired Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["wishlist", "add", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
assert "Added 'Desired Book' to wishlist" in result.output
def test_wishlist_remove(self, app, cli_runner):
"""Test removing a book from wishlist."""
# Add book and add to wishlist
cli_runner.invoke(cli, ["book", "add", "Unwanted Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["wishlist", "remove", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
assert f"Removed book {book_id} from wishlist" in result.output
def test_wishlist_remove_not_in_list(self, app, cli_runner):
"""Test removing a book that's not in wishlist."""
# Add book but don't add to wishlist
cli_runner.invoke(cli, ["book", "add", "Not Wished Book", "--owner", "alice"])
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
result = cli_runner.invoke(
cli, ["wishlist", "remove", str(book_id), "--owner", "alice"]
)
assert result.exit_code == 0
assert f"Book {book_id} was not in wishlist" in result.output
def test_wishlist_list_empty(self, app, cli_runner):
"""Test listing empty wishlist."""
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
assert result.exit_code == 0
assert "Wishlist is empty." in result.output
def test_wishlist_list_with_items(self, app, cli_runner):
"""Test listing wishlist with items."""
# Add books and add to wishlist
cli_runner.invoke(
cli,
[
"book",
"add",
"Wished Book 1",
"--owner",
"alice",
"--authors",
"Author One",
],
)
cli_runner.invoke(
cli,
[
"book",
"add",
"Wished Book 2",
"--owner",
"alice",
"--authors",
"Author Two",
],
)
with app.app_context():
books = db.session.execute(db.select(Book)).scalars().all()
for book in books:
result = cli_runner.invoke(
cli, ["wishlist", "add", str(book.id), "--owner", "alice"]
)
assert result.exit_code == 0
result = cli_runner.invoke(cli, ["wishlist", "list", "--owner", "alice"])
assert result.exit_code == 0
assert "Wished Book 1" in result.output
assert "Wished Book 2" in result.output
assert "Author One" in result.output
assert "Author Two" in result.output
def test_wishlist_list_json_format(self, app, cli_runner):
"""Test listing wishlist in JSON format."""
cli_runner.invoke(
cli,
[
"book",
"add",
"JSON Wished Book",
"--owner",
"alice",
"--authors",
"JSON Author",
],
)
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "alice"])
result = cli_runner.invoke(
cli, ["wishlist", "list", "--owner", "alice", "--format", "json"]
)
assert result.exit_code == 0
wishlist_data = json.loads(result.output)
assert len(wishlist_data) == 1
item = wishlist_data[0]
assert item["title"] == "JSON Wished Book"
assert item["authors"] == ["JSON Author"]
class TestDatabaseCommands:
"""Test database management CLI commands."""
def test_db_init(self, app, cli_runner):
"""Test database initialization."""
result = cli_runner.invoke(cli, ["db", "init"])
assert result.exit_code == 0
assert "Database initialized." in result.output
def test_db_seed(self, app, cli_runner):
"""Test database seeding with sample data."""
result = cli_runner.invoke(cli, ["db", "seed", "--owner", "test_owner"])
assert result.exit_code == 0
assert "Created: The Hobbit" in result.output
assert "Created: Dune" in result.output
assert "Created: The Pragmatic Programmer" in result.output
assert "Created 3 sample books for user 'test_owner'" in result.output
# Verify books were actually created
with app.app_context():
books = db.session.execute(db.select(Book)).scalars().all()
assert len(books) == 3
titles = {book.title for book in books}
assert "The Hobbit" in titles
assert "Dune" in titles
assert "The Pragmatic Programmer" in titles
def test_db_status_empty(self, app, cli_runner):
"""Test database status with empty database."""
result = cli_runner.invoke(cli, ["db", "status"])
assert result.exit_code == 0
assert "Database Statistics:" in result.output
assert "Books: 0" in result.output
assert "Authors: 0" in result.output
assert "Genres: 0" in result.output
assert "Users: 0" in result.output
assert "Reading sessions: 0" in result.output
assert "Wishlist items: 0" in result.output
def test_db_status_with_data(self, app, cli_runner):
"""Test database status with sample data."""
# Add some test data
cli_runner.invoke(
cli,
[
"book",
"add",
"Status Book",
"--owner",
"alice",
"--authors",
"Status Author",
"--genres",
"Status Genre",
],
)
with app.app_context():
book = db.session.execute(db.select(Book)).scalar_one()
book_id = book.id
# Add reading and wishlist entries
cli_runner.invoke(cli, ["reading", "start", str(book_id), "--owner", "alice"])
cli_runner.invoke(cli, ["wishlist", "add", str(book_id), "--owner", "bob"])
result = cli_runner.invoke(cli, ["db", "status"])
assert result.exit_code == 0
assert "Books: 1" in result.output
assert "Authors: 1" in result.output
assert "Genres: 1" in result.output
assert "Users: 2" in result.output # alice and bob
assert "Reading sessions: 1" in result.output
assert "Wishlist items: 1" in result.output
class TestErrorScenarios:
"""Test error handling and edge cases."""
def test_reading_start_invalid_book_id(self, app, cli_runner):
"""Test starting reading with non-existent book ID."""
result = cli_runner.invoke(cli, ["reading", "start", "999", "--owner", "alice"])
assert result.exit_code == 1
assert "Error starting reading:" in result.output
def test_wishlist_add_invalid_book_id(self, app, cli_runner):
"""Test adding non-existent book to wishlist."""
result = cli_runner.invoke(cli, ["wishlist", "add", "999", "--owner", "alice"])
assert result.exit_code == 1
assert "Error adding to wishlist:" in result.output
def test_reading_finish_invalid_reading_id(self, app, cli_runner):
"""Test finishing non-existent reading session."""
result = cli_runner.invoke(cli, ["reading", "finish", "999"])
assert result.exit_code == 1
assert "Error finishing reading:" in result.output

405
tests/test_search.py Normal file
View File

@@ -0,0 +1,405 @@
"""
Query parser tests for HXBooks search functionality.
Tests the QueryParser class methods for type conversion, operator parsing,
field filters, and edge case handling.
"""
from datetime import date
from typing import List
import pytest
from hxbooks.search import (
ComparisonOperator,
Field,
FieldFilter,
QueryParser,
SearchQuery,
)
@pytest.fixture
def parser() -> QueryParser:
"""Create a QueryParser instance for testing."""
return QueryParser()
class TestQueryParser:
"""Test the QueryParser class functionality."""
def test_parse_empty_query(self, parser: QueryParser):
"""Test parsing an empty query string."""
result = parser.parse("")
assert result.text_terms == []
assert result.field_filters == []
def test_parse_whitespace_only(self, parser: QueryParser):
"""Test parsing a query with only whitespace."""
result = parser.parse(" \t\n ")
assert result.text_terms == []
assert result.field_filters == []
def test_parse_simple_text_terms(self, parser: QueryParser):
"""Test parsing simple text search terms."""
result = parser.parse("hobbit tolkien")
assert result.text_terms == ["hobbit", "tolkien"]
assert result.field_filters == []
def test_parse_quoted_text_terms(self, parser: QueryParser):
"""Test parsing quoted text search terms."""
result = parser.parse('"the hobbit" tolkien')
assert result.text_terms == ["the hobbit", "tolkien"]
assert result.field_filters == []
def test_parse_quoted_text_with_spaces(self, parser: QueryParser):
"""Test parsing quoted text containing multiple spaces."""
result = parser.parse('"lord of the rings"')
assert result.text_terms == ["lord of the rings"]
assert result.field_filters == []
class TestFieldFilters:
"""Test field filter parsing."""
def test_parse_title_filter(self, parser: QueryParser):
"""Test parsing title field filter."""
result = parser.parse("title:hobbit")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.TITLE
assert filter.operator == ComparisonOperator.EQUALS
assert filter.value == "hobbit"
assert filter.negated is False
def test_parse_quoted_title_filter(self, parser: QueryParser):
"""Test parsing quoted title field filter."""
result = parser.parse('title:"the hobbit"')
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.TITLE
assert filter.value == "the hobbit"
def test_parse_author_filter(self, parser: QueryParser):
"""Test parsing author field filter."""
result = parser.parse("author:tolkien")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.AUTHOR
assert filter.value == "tolkien"
def test_parse_negated_filter(self, parser: QueryParser):
"""Test parsing negated field filter."""
result = parser.parse("-genre:romance")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.GENRE
assert filter.value == "romance"
assert filter.negated is True
def test_parse_multiple_filters(self, parser: QueryParser):
"""Test parsing multiple field filters."""
result = parser.parse("author:tolkien genre:fantasy")
assert len(result.field_filters) == 2
author_filter = next(f for f in result.field_filters if f.field == Field.AUTHOR)
assert author_filter.value == "tolkien"
genre_filter = next(f for f in result.field_filters if f.field == Field.GENRE)
assert genre_filter.value == "fantasy"
def test_parse_mixed_filters_and_text(self, parser: QueryParser):
"""Test parsing mix of field filters and text terms."""
result = parser.parse('epic author:tolkien "middle earth"')
assert "epic" in result.text_terms
assert "middle earth" in result.text_terms
assert len(result.field_filters) == 1
assert result.field_filters[0].field == Field.AUTHOR
class TestComparisonOperators:
"""Test comparison operator parsing."""
@pytest.mark.parametrize(
"operator_str,expected_operator",
[
(">=", ComparisonOperator.GREATER_EQUAL),
("<=", ComparisonOperator.LESS_EQUAL),
(">", ComparisonOperator.GREATER),
("<", ComparisonOperator.LESS),
("=", ComparisonOperator.EQUALS),
("!=", ComparisonOperator.NOT_EQUALS),
(":", ComparisonOperator.EQUALS), # : defaults to equals
],
)
def test_parse_comparison_operators(
self, parser: QueryParser, operator_str, expected_operator
):
"""Test parsing all supported comparison operators."""
query = f"rating{operator_str}4"
result = parser.parse(query)
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.RATING
assert filter.operator == expected_operator
assert filter.value == 4
def test_parse_date_comparison(self, parser: QueryParser):
"""Test parsing date comparison operators."""
result = parser.parse("added>=2026-03-15")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.ADDED_DATE
assert filter.operator == ComparisonOperator.GREATER_EQUAL
assert filter.value == date(2026, 3, 15)
def test_parse_numeric_comparison(self, parser: QueryParser):
"""Test parsing numeric comparison operators."""
result = parser.parse("shelf>2")
assert len(result.field_filters) == 1
filter = result.field_filters[0]
assert filter.field == Field.SHELF
assert filter.operator == ComparisonOperator.GREATER
assert filter.value == 2
class TestTypeConversion:
"""Test the _convert_value method for different field types."""
def test_convert_date_field_valid(self, parser: QueryParser):
"""Test converting valid date strings for date fields."""
result = parser._convert_value(Field.BOUGHT_DATE, "2026-03-15")
assert result == date(2026, 3, 15)
result = parser._convert_value(Field.READ_DATE, "2025-12-31")
assert result == date(2025, 12, 31)
result = parser._convert_value(Field.ADDED_DATE, "2024-01-01")
assert result == date(2024, 1, 1)
def test_convert_date_field_invalid(self, parser: QueryParser):
"""Test converting invalid date strings falls back to string."""
result = parser._convert_value(Field.BOUGHT_DATE, "invalid-date")
assert result == "invalid-date"
result = parser._convert_value(
Field.READ_DATE, "2026-13-45"
) # Invalid month/day
assert result == "2026-13-45"
result = parser._convert_value(Field.ADDED_DATE, "not-a-date")
assert result == "not-a-date"
def test_convert_numeric_field_integers(self, parser: QueryParser):
"""Test converting integer strings for numeric fields."""
result = parser._convert_value(Field.RATING, "5")
assert result == 5
assert isinstance(result, int)
result = parser._convert_value(Field.SHELF, "10")
assert result == 10
result = parser._convert_value(Field.YEAR, "2026")
assert result == 2026
def test_convert_numeric_field_floats(self, parser: QueryParser):
"""Test converting float strings for numeric fields."""
result = parser._convert_value(Field.RATING, "4.5")
assert result == 4.5
assert isinstance(result, float)
result = parser._convert_value(Field.SHELF, "2.0")
assert result == 2.0
def test_convert_numeric_field_invalid(self, parser: QueryParser):
"""Test converting invalid numeric strings falls back to string."""
result = parser._convert_value(Field.RATING, "not-a-number")
assert result == "not-a-number"
result = parser._convert_value(Field.SHELF, "abc")
assert result == "abc"
result = parser._convert_value(Field.YEAR, "twenty-twenty-six")
assert result == "twenty-twenty-six"
def test_convert_string_fields(self, parser: QueryParser):
"""Test converting values for string fields returns as-is."""
result = parser._convert_value(Field.TITLE, "The Hobbit")
assert result == "The Hobbit"
result = parser._convert_value(Field.AUTHOR, "Tolkien")
assert result == "Tolkien"
result = parser._convert_value(Field.GENRE, "Fantasy")
assert result == "Fantasy"
# Even things that look like dates/numbers should stay as strings for string fields
result = parser._convert_value(Field.TITLE, "2026-03-15")
assert result == "2026-03-15"
assert isinstance(result, str)
result = parser._convert_value(Field.AUTHOR, "123")
assert result == "123"
assert isinstance(result, str)
class TestParsingEdgeCases:
"""Test edge cases and error handling in query parsing."""
def test_parse_invalid_field_name(self, parser: QueryParser):
"""Test parsing with invalid field names falls back to text search."""
result = parser.parse("invalid_field:value")
# Should fall back to treating the whole thing as text
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
def test_parse_mixed_quotes_and_operators(self, parser: QueryParser):
"""Test parsing complex queries with quotes and operators."""
result = parser.parse('title:"The Lord" author:tolkien rating>=4')
# Should have both field filters
title_filter = next(
(f for f in result.field_filters if f.field == Field.TITLE), None
)
author_filter = next(
(f for f in result.field_filters if f.field == Field.AUTHOR), None
)
rating_filter = next(
(f for f in result.field_filters if f.field == Field.RATING), None
)
assert title_filter is not None
assert title_filter.value == "The Lord"
assert author_filter is not None
assert author_filter.value == "tolkien"
assert rating_filter is not None
assert rating_filter.value == 4
assert rating_filter.operator == ComparisonOperator.GREATER_EQUAL
def test_parse_escaped_quotes(self, parser: QueryParser):
"""Test parsing strings with escaped quotes."""
result = parser.parse(r'title:"She said \"hello\""')
if result.field_filters:
# If parsing succeeds, check the escaped quote handling
filter = result.field_filters[0]
assert "hello" in filter.value
# If parsing fails, it should fall back gracefully
def test_parse_special_characters(self, parser: QueryParser):
"""Test parsing queries with special characters."""
result = parser.parse("title:C++ author:Stroustrup")
# Should handle the + characters gracefully
assert len(result.field_filters) >= 1 or len(result.text_terms) >= 1
def test_parse_very_long_query(self, parser: QueryParser):
"""Test parsing very long query strings."""
long_value = "a" * 1000
result = parser.parse(f"title:{long_value}")
# Should handle long strings without crashing
assert isinstance(result, SearchQuery)
def test_parse_unicode_characters(self, parser: QueryParser):
"""Test parsing queries with unicode characters."""
result = parser.parse("title:Café author:José")
# Should handle unicode gracefully
assert isinstance(result, SearchQuery)
def test_fallback_behavior_on_parse_error(self, parser: QueryParser):
"""Test that invalid syntax falls back to text search."""
# Construct a query that should cause parse errors
invalid_queries = [
"(((", # Unmatched parentheses
"field::", # Double colon
":", # Just a colon
">=<=", # Invalid operator combination
]
for query in invalid_queries:
result = parser.parse(query)
# Should not crash and should return some kind of result
assert isinstance(result, SearchQuery)
# Most likely falls back to text terms
assert len(result.text_terms) >= 1 or len(result.field_filters) == 0
class TestComplexQueries:
"""Test parsing of complex, real-world query examples."""
def test_parse_realistic_book_search(self, parser: QueryParser):
"""Test parsing realistic book search queries."""
result = parser.parse(
'author:tolkien genre:fantasy -genre:romance rating>=4 "middle earth"'
)
# Should have multiple field filters and text terms
assert len(result.field_filters) >= 3
assert "middle earth" in result.text_terms
# Check specific filters
tolkien_filter = next(
(f for f in result.field_filters if f.field == Field.AUTHOR), None
)
assert tolkien_filter is not None
assert tolkien_filter.value == "tolkien"
fantasy_filter = next(
(
f
for f in result.field_filters
if f.field == Field.GENRE and not f.negated
),
None,
)
assert fantasy_filter is not None
assert fantasy_filter.value == "fantasy"
romance_filter = next(
(f for f in result.field_filters if f.field == Field.GENRE and f.negated),
None,
)
assert romance_filter is not None
assert romance_filter.value == "romance"
assert romance_filter.negated is True
def test_parse_location_and_date_filters(self, parser: QueryParser):
"""Test parsing location and date-based queries."""
result = parser.parse("place:home bookshelf:fantasy shelf>=2 added>=2026-01-01")
assert len(result.field_filters) == 4
place_filter = next(
(f for f in result.field_filters if f.field == Field.PLACE), None
)
assert place_filter.value == "home"
shelf_filter = next(
(f for f in result.field_filters if f.field == Field.SHELF), None
)
assert shelf_filter.value == 2
assert shelf_filter.operator == ComparisonOperator.GREATER_EQUAL
added_filter = next(
(f for f in result.field_filters if f.field == Field.ADDED_DATE), None
)
assert added_filter.value == date(2026, 1, 1)
assert added_filter.operator == ComparisonOperator.GREATER_EQUAL
def test_parse_mixed_types_comprehensive(self, parser: QueryParser):
"""Test parsing query with all major field types."""
query = 'title:"Complex Book" author:Author year=2020 rating>=4 bought<=2025-12-31 -genre:boring epic adventure'
result = parser.parse(query)
# Should have a good mix of field filters and text terms
assert len(result.field_filters) >= 5
assert len(result.text_terms) >= 2
# Verify we got the expected mix of string, numeric, and date fields
field_types = {f.field for f in result.field_filters}
assert Field.TITLE in field_types
assert Field.AUTHOR in field_types
assert Field.YEAR in field_types
assert Field.RATING in field_types
assert Field.BOUGHT_DATE in field_types
assert Field.GENRE in field_types