""" CLI command tests for HXBooks. Tests all CLI commands for correct behavior, database integration, and output formatting. """ import json import re from datetime import date import pytest from click.testing import CliRunner from flask import Flask from hxbooks.cli import cli from hxbooks.db import db from hxbooks.models import Author, Book, Genre, Reading, User class TestBookAddCommand: """Test the 'hxbooks book add' command.""" def test_book_add_basic(self, app: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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 class TestBookListCommand: """Test the 'hxbooks book list' command.""" def test_book_list_empty(self, app: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner ) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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,username,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 ("rating>=4", "", ["The Hobbit", "Programming Book"]), ("rating=3", "", ["Dune"]), ("shelf>1", "", ["The Fellowship", "Programming Book"]), ("year>=1954", "", ["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"]), # User-specific queries ("rating>=4", "alice", ["The Hobbit"]), ("is:reading", "alice", ["The Fellowship"]), ("is:read", "alice", ["The Hobbit", "Dune"]), ("is:wished", "alice", ["Programming Book"]), ], ) def test_book_search_advanced_queries( self, app: Flask, cli_runner: CliRunner, query: str, username: str, expected_titles: list[str], ) -> None: """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", "--username", username, "--", 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: Flask, cli_runner: CliRunner) -> None: """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"]) cli_runner.invoke( cli, ["reading", "start", str(fellowship_id), "--owner", "alice"] ) 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) assert prog_book is not None 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) assert hobbit_book is not None hobbit_book.first_published = 1937 fellowship_book = db.session.get(Book, fellowship_id) assert fellowship_book is not None fellowship_book.first_published = 1954 dune_book = db.session.get(Book, dune_id) assert dune_book is not None dune_book.first_published = 1965 db.session.commit() # Add a book to wishlist cli_runner.invoke( cli, ["wishlist", "add", str(prog_id), "--owner", "alice"], ) class TestReadingCommands: """Test reading-related CLI commands.""" def test_reading_start_basic(self, app: Flask, cli_runner: CliRunner) -> None: """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 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 "Started reading session" in result.output assert f"for book {book_id}" in result.output def test_reading_finish_with_rating( self, app: Flask, cli_runner: CliRunner ) -> None: """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 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: Flask, cli_runner: CliRunner) -> None: """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"] ) reading_id_match = re.search(r"Started reading session (\d+)", result.output) assert reading_id_match is not None 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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner ) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner) -> None: """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: Flask, cli_runner: CliRunner ) -> None: """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: Flask, cli_runner: CliRunner ) -> None: """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: Flask, cli_runner: CliRunner ) -> None: """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