Rework core functionality, add CLI and tests
This commit is contained in:
70
tests/conftest.py
Normal file
70
tests/conftest.py
Normal 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
930
tests/test_cli.py
Normal 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
405
tests/test_search.py
Normal 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
|
||||
Reference in New Issue
Block a user