import pytest from httpx import AsyncClient from pathlib import Path @pytest.mark.parametrize( ("path_to_book", "library_id", "title", "authors"), [ ( Path("tests/data_files/Metamorphosis - Franz Kafka.epub"), 1, "Metamorphosis", ["Franz Kafka"], ), ( Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), 1, "Moby Dick; Or, The Whale", ["Herman Melville"], ), ( Path( "tests/data_files/Relativity: The Special and General Theory - Albert Einstein.epub" ), 2, "Relativity : the Special and General Theory", ["Albert Einstein"], ), ( Path( "/home/patrick/projects/chitai-api/tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub" ), 2, "On the Origin of Species By Means of Natural Selection / Or, the Preservation of Favoured Races in the Struggle for Life", ["Charles Darwin"], ), ( Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"), 2, "The Project Gutenberg eBook #33283: Calculus Made Easy, 2nd Edition", ["Silvanus Phillips Thompson"], ), ], ) async def test_upload_book_without_data( authenticated_client: AsyncClient, path_to_book: Path, library_id: int, title: str, authors: list[str], ) -> None: """Test uploading a book file. Book information should be extracted from file.""" # Read file contents file_content = path_to_book.read_bytes() # Prepare multipart form data files = [("files", (path_to_book.name, file_content, "application/epub+zip"))] # The rest of the book data will be parsed from file data = { "library_id": library_id, } response = await authenticated_client.post( f"/books?library_id={library_id}", files=files, data=data, ) assert response.status_code == 201 book_data = response.json() assert book_data["id"] is not None assert book_data["title"] == title assert book_data["library_id"] == library_id # Check if authors were properly parsed book_authors = [author["name"] for author in book_data["authors"]] assert book_authors == authors @pytest.mark.parametrize( ("path_to_book", "library_id", "title", "authors", "tags"), [ ( Path("tests/data_files/Metamorphosis - Franz Kafka.epub"), 1, "The Metamorphosis", ["Franz Kafka"], ["Psychological fiction"], ), ( Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), 1, "Moby Dick", ["Herman Melville"], ["Classic Literature", "Whaling"], ), ( Path( "tests/data_files/Relativity: The Special and General Theory - Albert Einstein.epub" ), 2, "Relativity: the Special and General Theory", ["Albert Einstein"], ["Physics", "Mathematics"], ), ( Path( "/home/patrick/projects/chitai-api/tests/data_files/On The Origin of Species By Means of Natural Selection - Charles Darwin.epub" ), 2, "On the Origin of Species By Means of Natural Selection", ["Charles Darwin"], ["Biology"], ), ( Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"), 2, "Calculus Made Easy", ["Silvanus Thompson"], ["Mathematics"], ), ], ) async def test_upload_book_with_data( authenticated_client: AsyncClient, path_to_book: Path, library_id: int, title: str, authors: list[str], tags: list[str], ) -> None: """Test uploading a book file with some book information provided.""" # Read file contents file_content = path_to_book.read_bytes() # Prepare multipart form data files = [("files", (path_to_book.name, file_content, "application/epub+zip"))] # The rest of the book data will be parsed from file data = {"library_id": library_id, "title": title, "authors": authors, "tags": tags} response = await authenticated_client.post( f"/books?library_id={library_id}", files=files, data=data, ) assert response.status_code == 201 book_data = response.json() assert book_data["id"] is not None assert book_data["title"] == title assert book_data["library_id"] == library_id book_authors = [author["name"] for author in book_data["authors"]] assert book_authors == authors book_tags = [tag["name"] for tag in book_data["tags"]] assert book_tags == tags @pytest.mark.parametrize( ("path_to_book", "library_id"), [ (Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), 1), (Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"), 2), ], ) async def test_get_book_file( authenticated_client: AsyncClient, path_to_book: Path, library_id: int ) -> None: """Test uploading a book then downloading the book file.""" # Read file contents file_content = path_to_book.read_bytes() # Prepare multipart form data files = [("files", (path_to_book.name, file_content, "application/epub+zip"))] # The rest of the book data will be parsed from file data = { "library_id": library_id, } create_response = await authenticated_client.post( f"/books?library_id={library_id}", files=files, data=data, ) assert create_response.status_code == 201 book_data = create_response.json() # Retrieve the book file book_id = book_data["id"] file_id = book_data["files"][0]["id"] file_response = await authenticated_client.get( f"/books/download/{book_id}/{file_id}" ) assert file_response.status_code == 200 downloaded_content = file_response.content assert len(downloaded_content) == len(file_content) assert downloaded_content == file_content async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> None: """Test retrieving a specific book by ID.""" # Retrieve the book response = await populated_authenticated_client.get(f"/books/1") assert response.status_code == 200 book_data = response.json() assert book_data["id"] == 1 assert book_data["title"] == "The Adventures of Sherlock Holmes" assert len(book_data["authors"]) == 1 assert book_data["authors"][0]["name"] == "Arthur Conan Doyle" assert len(book_data["tags"]) == 1 assert book_data["tags"][0]["name"] == "Mystery" async def test_list_books(populated_authenticated_client: AsyncClient) -> None: """Test listing books with pagination and filters.""" response = await populated_authenticated_client.get( "/books?library_id=1&pageSize=10&page=1" ) assert response.status_code == 200 data = response.json() assert "items" in data # Pagination structure assert isinstance(data.get("items"), list) assert data["total"] == 3 # There should be 3 books in this library async def test_list_books_with_tag_filter( populated_authenticated_client: AsyncClient, ) -> None: """Test listing books filtered by tags.""" response = await populated_authenticated_client.get("/books?library_id=1&tags=1") assert response.status_code == 200 result = response.json() assert len(result["items"]) == 2 # Two books with the "Mystery" tag async def test_list_books_with_author_filter( populated_authenticated_client: AsyncClient, ) -> None: """Test listing books filtered by authors.""" response = await populated_authenticated_client.get("/books?library_id=1&authors=1") assert response.status_code == 200 result = response.json() assert len(result["items"]) == 1 # One book by "Arthur Conan Doyle" async def test_list_books_with_search( populated_authenticated_client: AsyncClient, ) -> None: """Test listing books with title search.""" response = await populated_authenticated_client.get( "/books?library_id=1&searchString=frankenstein" ) assert response.status_code == 200 result = response.json() assert len(result["items"]) == 1 # One matching book async def test_delete_book_metadata_only( populated_authenticated_client: AsyncClient, ) -> None: """Test deleting a book record without deleting files.""" # Delete book without deleting files response = await populated_authenticated_client.delete( f"/books?book_ids=3&delete_files=false&library_id=1" ) assert response.status_code == 204 # Verify book is deleted get_response = await populated_authenticated_client.get(f"/books/3") assert get_response.status_code == 404 async def test_delete_book_with_files( populated_authenticated_client: AsyncClient, ) -> None: """Test deleting a book and its associated files.""" # Delete book and files response = await populated_authenticated_client.delete( f"/books?book_ids=3&delete_files=true&library_id=1" ) assert response.status_code == 204 async def test_delete_specific_book_files( populated_authenticated_client: AsyncClient, ) -> None: """Test deleting specific files from a book.""" # Delete specific file response = await populated_authenticated_client.delete( f"/books/1/files?file_ids=1", ) assert response.status_code == 204 async def test_update_reading_progress( populated_authenticated_client: AsyncClient, ) -> None: """Test updating reading progress for a book.""" # Update progress progress_data = { "progress": 0.5, } response = await populated_authenticated_client.post( f"/books/progress/1", json=progress_data, ) assert response.status_code == 201 # Get the book and check if the progress is correct response = await populated_authenticated_client.get("/books/1") assert response.status_code == 200 book = response.json() assert book["progress"]["progress"] == 0.5 async def test_create_multiple_books_from_directory( authenticated_client: AsyncClient, ) -> None: """Test creating multiple books from a directory of files.""" path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") path2 = Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub") files = [ ("files", (path1.name, path1.read_bytes(), "application/epub+zip")), ("files", (path2.name, path2.read_bytes(), "application/epub+zip")), ] response = await authenticated_client.post( "/books/fromFiles?library_id=1", files=files, data={"library_id": 1}, ) assert response.status_code == 201 data = response.json() assert len(data.get("items") or data.get("data")) >= 1 # async def test_delete_book_metadata(authenticated_client: AsyncClient) -> None: # raise NotImplementedError() # async def test_delete_book_metadata_and_files( # authenticated_client: AsyncClient, # ) -> None: # raise NotImplementedError() # async def test_edit_book_metadata(authenticated_client: AsyncClient) -> None: # raise NotImplementedError() import pytest import aiofiles from httpx import AsyncClient from pathlib import Path from litestar.status_codes import HTTP_400_BAD_REQUEST import pytest from httpx import AsyncClient from datetime import date class TestMetadataUpdates: @pytest.mark.parametrize( ("updated_field", "new_value"), [ ("title", "New Title"), ("subtitle", "Updated Subtitle"), ("pages", 256), ("description", "An updated description"), ("edition", 2), ("language", "pl"), ("published_date", "1910-04-05"), ("series_position", "3"), ], ) async def test_update_single_scalar_field( self, populated_authenticated_client: AsyncClient, updated_field: str, new_value: str, ) -> None: """Test updating a single field without affecting others.""" # Get original book state original_response = await populated_authenticated_client.get("/books/1") assert original_response.status_code == 200 original_data = original_response.json() # Update field response = await populated_authenticated_client.patch( "/books/1", json={updated_field: str(new_value)}, ) assert response.status_code == 200 updated_data = response.json() # Verify updated field assert updated_data[updated_field] == new_value # Verify all other fields remain unchanged for field, original_value in original_data.items(): if field != updated_field and field not in ["updated_at", "created_at"]: assert updated_data[field] == original_value, ( f"Field {field} was unexpectedly changed" ) @pytest.mark.parametrize( ("updated_field", "new_values", "assertion_func"), [ ( "authors", # Update with new authors ["New Author 1", "New Author 2"], lambda data: {a["name"] for a in data["authors"]} == {"New Author 1", "New Author 2"}, ), ( "authors", # Clear authors [], lambda data: data["authors"] == [], ), ( "tags", # Update with new tags ["Tag 1", "Tag 2", "Tag 3"], lambda data: {t["name"] for t in data["tags"]} == {"Tag 1", "Tag 2", "Tag 3"}, ), ( "tags", # Clear tags [], lambda data: data["tags"] == [], ), ( "publisher", # Update with new publisher "Updated Publisher", lambda data: data["publisher"]["name"] == "Updated Publisher", ), ( "publisher", # Clear publisher None, lambda data: data["publisher"] is None, ), ( "identifiers", # Update with new identifiers {"isbn-13": "978-1234567890", "doi": "10.example/id"}, lambda data: data["identifiers"] == {"isbn-13": "978-1234567890", "doi": "10.example/id"}, ), ( "identifiers", # Clear identifiers {}, lambda data: data["identifiers"] == {}, ), ( "series", # Update with new series "Updated Series", lambda data: data["series"]["title"] == "Updated Series", ), ( "series", # Clear series None, lambda data: data["series"] is None, ), ], ) async def test_update_relationship_fields( self, populated_authenticated_client: AsyncClient, updated_field: str, new_values: list[str], assertion_func, ) -> None: """Test updating relationship fields (authors, tags, etc.)""" # Get original book state original_response = await populated_authenticated_client.get("/books/1") assert original_response.status_code == 200 original_data = original_response.json() response = await populated_authenticated_client.patch( "/books/1", json={updated_field: new_values}, ) assert response.status_code == 200 # Verify updated field updated_data = response.json() assert assertion_func(updated_data) # Verify all other fields remain unchanged for field, original_value in original_data.items(): if field != updated_field and field not in ["updated_at", "created_at"]: assert updated_data[field] == original_value, ( f"Field {field} was unexpectedly changed" ) async def test_update_with_invalid_pages_value( self, populated_authenticated_client: AsyncClient ) -> None: """Test updating with invalid pages value (non-integer).""" data = { "pages": "not_a_number", } response = await populated_authenticated_client.patch( "/books/1", files=data, ) assert response.status_code == 400 async def test_update_with_invalid_edition_value( self, populated_authenticated_client: AsyncClient ) -> None: """Test updating with invalid edition value (non-integer).""" data = { "edition": "invalid_edition", } response = await populated_authenticated_client.patch( "/books/1", files=data, ) assert response.status_code == 400 async def test_update_with_invalid_date_format( self, populated_authenticated_client: AsyncClient ) -> None: """Test updating with invalid published_date format.""" data = { "published_date": "invalid-date", } response = await populated_authenticated_client.patch( "/books/1", files=data, ) assert response.status_code == 400 async def test_update_with_invalid_identifiers_json( self, populated_authenticated_client: AsyncClient ) -> None: """Test updating with invalid JSON in identifiers.""" data = { "identifiers": "not valid json", } response = await populated_authenticated_client.patch( "/books/1", files=data, ) assert response.status_code == 400 async def test_update_multiple_fields_at_once( self, populated_authenticated_client: AsyncClient ) -> None: """Test updating multiple fields simultaneously.""" data = { "title": "New Title", "description": "New description", "publisher": "New Publisher", "pages": 430, } response = await populated_authenticated_client.patch( "/books/1", json=data, ) assert response.status_code == 200 book_data = response.json() assert book_data["title"] == "New Title" assert book_data["description"] == "New description" assert book_data["publisher"]["name"] == "New Publisher" assert book_data["pages"] == 430 async def test_update_nonexistent_book( self, populated_authenticated_client: AsyncClient ) -> None: """Test updating a nonexistent book returns 404.""" data = { "title": "New Title", } response = await populated_authenticated_client.patch( "/books/99999", json=data, ) assert response.status_code == 404 assert "book does not exist" in response.text @pytest.mark.parametrize( ("updated_field"), [ ("subtitle"), ("pages"), ("description"), ("edition"), ("language"), ("published_date"), ("series_position"), ], ) async def test_update_clears_optional_field( self, populated_authenticated_client: AsyncClient, updated_field: str ) -> None: """Test that optional fields can be cleared by passing None or empty values.""" data = {updated_field: None} response = await populated_authenticated_client.patch( "/books/1", json=data, ) assert response.status_code == 200 result = response.json() assert result[updated_field] == None @pytest.mark.parametrize( ("updated_field"), [ ("title"), ], ) async def test_update_clears_required_field( self, populated_authenticated_client: AsyncClient, updated_field: str ) -> None: """Test that optional fields can be cleared by passing None or empty values.""" data = {updated_field: None} response = await populated_authenticated_client.patch( "/books/1", json=data, ) assert response.status_code == 400 async def test_update_cover_successfully( self, populated_authenticated_client: AsyncClient ) -> None: """Test successfully updating a book's cover image.""" path = Path("tests/data_files/cover.jpg") file_content = path.read_bytes() files = {"cover_image": (path.name, file_content, "image/jpeg")} # Get original book state original_response = await populated_authenticated_client.get("/books/1") assert original_response.status_code == 200 original_data = original_response.json() response = await populated_authenticated_client.put( "/books/1/cover", files=files, ) assert response.status_code == 200 book_data = response.json() assert book_data["id"] == 1 assert book_data["cover_image"] is not None assert book_data["cover_image"] != original_data["cover_image"] class TestFileManagement: async def test_add_multiple_files_to_book( self, populated_authenticated_client: AsyncClient ) -> None: """Test adding multiple files to a book in a single request.""" path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") path2 = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") file1_content = path1.read_bytes() file2_content = path2.read_bytes() files = [ ("files", (path1.name, file1_content, "application/epub+zip")), ("files", (path2.name, file2_content, "application/pdf")), ] response = await populated_authenticated_client.post( "/books/1/files", files=files, ) assert response.status_code == 201 book_data = response.json() assert len(book_data["files"]) >= 2 async def test_add_files_to_nonexistent_book( self, populated_authenticated_client: AsyncClient ) -> None: """Test adding files to a nonexistent book returns 404.""" path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") file_content = path.read_bytes() files = [("files", (path.name, file_content, "application/epub+zip"))] response = await populated_authenticated_client.post( "/books/99999/files?library_id=1", files=files, ) assert response.status_code == 404 async def test_remove_multiple_files_at_once( self, populated_authenticated_client: AsyncClient ) -> None: """Test removing multiple files from a book at once.""" # First add multiple files path1 = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") path2 = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") files = [ ("files", (path1.name, path1.read_bytes(), "application/epub+zip")), ("files", (path2.name, path2.read_bytes(), "application/pdf")), ] add_response = await populated_authenticated_client.post( "/books/1/files", files=files, ) assert add_response.status_code == 201 book_data = add_response.json() file_ids = [f["id"] for f in book_data["files"]] # Remove multiple files response = await populated_authenticated_client.delete( f"/books/1/files?file_ids={file_ids[0]}&file_ids={file_ids[1]}&delete_files=false", ) assert response.status_code == 204 # Verify files were removed book = await populated_authenticated_client.get("/books/1") book_data = book.json() assert len(book_data["files"]) < len(file_ids) async def test_remove_zero_files_raises_error( self, populated_authenticated_client: AsyncClient ) -> None: """Test error when trying to remove zero files.""" response = await populated_authenticated_client.delete( "/books/1/files?delete_files=false", ) assert response.status_code == HTTP_400_BAD_REQUEST assert "missing required query parameter 'file_ids'" in response.text.lower() async def test_remove_files_from_nonexistent_book( self, populated_authenticated_client: AsyncClient ) -> None: """Test removing files from a nonexistent book returns 404.""" response = await populated_authenticated_client.delete( "/books/99999/files?file_ids=1&delete_files=false&library_id=1", ) assert response.status_code == 404 async def test_remove_nonexistent_file_id_from_book( self, populated_authenticated_client: AsyncClient ) -> None: """Test removing a file_id that doesn't exist on that book.""" # Try to remove a file that doesn't belong to this book response = await populated_authenticated_client.delete( "/books/1/files?file_ids=99999&delete_files=false", ) # Should succeed (idempotent) or return 404, depending on implementation assert response.status_code in [204, 404] async def test_remove_file_with_delete_files_false_keeps_filesystem_file( self, populated_authenticated_client: AsyncClient ) -> None: """Test that removing with delete_files=false keeps file on disk.""" # Get the book to retrieve file info book_response = await populated_authenticated_client.get("/books/1") book_data = book_response.json() if not book_data["files"]: pytest.skip("Book has no files") file_id = book_data["files"][0]["id"] filename = book_data["files"][0].get("path") # Remove file without deleting from filesystem response = await populated_authenticated_client.delete( f"/books/1/files?file_ids={file_id}&delete_files=false", ) assert response.status_code == 204 async def test_remove_file_with_delete_files_true_removes_filesystem_file( self, populated_authenticated_client: AsyncClient ) -> None: """Test that removing with delete_files=true deletes from filesystem.""" # Add a new file first path = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") file_content = path.read_bytes() files = [("files", (path.name, file_content, "application/pdf"))] add_response = await populated_authenticated_client.post( "/books/1/files", files=files, ) assert add_response.status_code == 201 book_data = add_response.json() file_id = book_data["files"][-1]["id"] file_path = book_data["files"][-1].get("path") # Remove file with deletion from filesystem response = await populated_authenticated_client.delete( f"/books/1/files?file_ids={file_id}&delete_files=true", ) assert response.status_code == 204 async def test_remove_file_idempotent( self, populated_authenticated_client: AsyncClient ) -> None: """Test that removing the same file twice doesn't error on second attempt.""" book_response = await populated_authenticated_client.get("/books/1") book_data = book_response.json() if not book_data["files"]: pytest.skip("Book has no files") file_id = book_data["files"][0]["id"] # Remove file first time response1 = await populated_authenticated_client.delete( f"/books/1/files?file_ids={file_id}&delete_files=false", ) assert response1.status_code == 204 # Try to remove same file again response2 = await populated_authenticated_client.delete( f"/books/1/files?file_ids={file_id}&delete_files=false", ) # Should succeed (idempotent) assert response2.status_code == 204