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( "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 ", 2nd Edition" is split off into `edition`, not kept in the title. "The Project Gutenberg eBook #33283: Calculus Made Easy", ["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( "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_list_books_by_id(populated_authenticated_client: AsyncClient) -> None: """ `?ids=` has to reach the database as integers. advanced_alchemy's stock id filter annotates the parameter as `list[str]` whatever the configured id type, so the ids arrived as strings and Postgres refused to compare a bigint primary key against them. Nothing called it until a screen needed to fetch a handful of books by id. """ response = await populated_authenticated_client.get("/books?ids=1&ids=2&pageSize=10") assert response.status_code == 200 assert sorted(book["id"] for book in response.json()["items"]) == [1, 2] 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 = { "percentage": 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"]["percentage"] == 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["created"]) == 2 assert data["skipped"] == [] async def test_create_books_from_parent_directory_keeps_embedded_title( authenticated_client: AsyncClient, ) -> None: """A folder name in the upload path must not override the file's own metadata. The browser sends webkitRelativePath, so picking the shelf above a book's folder submits one more path component than picking the folder itself. That extra level used to make the directory name win over the title inside the EPUB. """ source = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") files = [ ( "files", ( "Shelf/Metamorphosis - Franz Kafka/Metamorphosis.epub", source.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 books = response.json()["created"] assert len(books) == 1 assert books[0]["title"] == "Metamorphosis" async def test_create_books_groups_formats_within_one_folder( authenticated_client: AsyncClient, ) -> None: """Picking a book's own folder yields one book with both formats, not two books.""" epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes() pdf = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf").read_bytes() files = [ ("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")), ("files", ("Metamorphosis/Metamorphosis.pdf", pdf, "application/pdf")), ] response = await authenticated_client.post( "/books/fromFiles?library_id=1", files=files, data={"library_id": 1} ) assert response.status_code == 201 books = response.json()["created"] assert len(books) == 1 assert len(books[0]["files"]) == 2 class TestDuplicateHandling: """A file the library already holds must not be stored a second time.""" epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") def upload(self, name: str | None = None) -> list[tuple[str, tuple]]: return [ ( "files", ( name or self.epub_path.name, self.epub_path.read_bytes(), "application/epub+zip", ), ) ] async def test_bulk_upload_reports_skipped_files( self, authenticated_client: AsyncClient ) -> None: """Re-dropping a folder must import what is new and name what was not.""" first = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload() ) assert first.status_code == 201 created = first.json()["created"][0] second = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload() ) assert second.status_code == 201 result = second.json() assert result["created"] == [] assert len(result["skipped"]) == 1 skipped = result["skipped"][0] assert skipped["filename"] == self.epub_path.name assert skipped["book_id"] == created["id"] assert skipped["book_title"] == created["title"] async def test_bulk_upload_can_be_forced( self, authenticated_client: AsyncClient ) -> None: await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload() ) response = await authenticated_client.post( "/books/fromFiles?library_id=1&allow_duplicates=true", files=self.upload() ) assert response.status_code == 201 assert len(response.json()["created"]) == 1 assert response.json()["skipped"] == [] async def test_single_book_create_conflicts( self, authenticated_client: AsyncClient ) -> None: """Naming files deliberately earns a refusal rather than a silent drop.""" await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload() ) response = await authenticated_client.post( "/books?library_id=1", files=self.upload(), data={"library_id": 1} ) assert response.status_code == 409 assert response.json()["extra"][0]["filename"] == self.epub_path.name forced = await authenticated_client.post( "/books?library_id=1&allow_duplicates=true", files=self.upload(), data={"library_id": 1}, ) assert forced.status_code == 201 async def test_adding_another_books_file_conflicts( self, authenticated_client: AsyncClient ) -> None: created = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload() ) book_id = created.json()["created"][0]["id"] other = await authenticated_client.post( "/books?library_id=1", files=[ ( "files", ( "war.epub", Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), "application/epub+zip", ), ) ], data={"library_id": 1}, ) other_id = other.json()["id"] response = await authenticated_client.post( f"/books/{book_id}/files", files=[ ( "files", ( "war.epub", Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), "application/epub+zip", ), ) ], ) assert response.status_code == 409 assert response.json()["extra"][0]["book_id"] == other_id async def test_resending_a_books_own_file_changes_nothing( self, authenticated_client: AsyncClient ) -> None: created = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload() ) book_id = created.json()["created"][0]["id"] response = await authenticated_client.post( f"/books/{book_id}/files", files=self.upload() ) assert response.status_code == 201 assert len(response.json()["files"]) == 1 async def test_duplicates_can_be_checked_before_uploading( self, authenticated_client: AsyncClient ) -> None: """The pre-flight check answers from hashes alone, with no file sent.""" created = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload() ) book = created.json()["created"][0] stored = book["files"][0] response = await authenticated_client.post( "/books/duplicate-files?library_id=1", json=[ { "hash": stored["hash"], "size": stored["size"], "filename": "local-copy.epub", }, {"hash": stored["hash"], "size": stored["size"] + 1}, {"hash": "0" * 32, "size": 1234}, ], ) assert response.status_code == 200 matches = response.json() assert len(matches) == 1 assert matches[0]["filename"] == "local-copy.epub" assert matches[0]["book_id"] == book["id"] @pytest.mark.asyncio class TestDuplicateBooks: """A second copy of a book is imported and reported, never refused.""" epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") def upload(self, name: str, pad: bool = False) -> list[tuple[str, tuple]]: """ The fixture, optionally padded so it is a different file and the same book. Padding the archive changes its size and its sampled hash without disturbing the metadata, which is exactly the case the file-level check cannot see. """ data = self.epub_path.read_bytes() + (b"\0" * 64 if pad else b"") return [("files", (name, data, "application/epub+zip"))] async def test_a_second_edition_is_created_and_reported( self, authenticated_client: AsyncClient ) -> None: first = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("first.epub") ) original = first.json()["created"][0] second = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True) ) assert second.status_code == 201 body = second.json() # Created, not skipped: a metadata match is a guess, and refusing a legitimate # second edition costs more than a note does. assert len(body["created"]) == 1 assert body["skipped"] == [] assert len(body["possible_duplicates"]) == 1 possible = body["possible_duplicates"][0] assert possible["book_id"] == body["created"][0]["id"] candidate = possible["candidates"][0] assert candidate["book_id"] == original["id"] assert candidate["title"] == original["title"] assert candidate["authors"] == ["Franz Kafka"] assert "title-author" in candidate["matched_on"] async def test_the_review_screen_groups_them( self, authenticated_client: AsyncClient ) -> None: first = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("first.epub") ) second = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True) ) book_ids = sorted( [first.json()["created"][0]["id"], second.json()["created"][0]["id"]] ) response = await authenticated_client.get("/books/duplicate-books?library_id=1") assert response.status_code == 200 groups = response.json() assert len(groups) == 1 assert [book["book_id"] for book in groups[0]["books"]] == book_ids async def test_a_dismissed_group_stays_dismissed( self, authenticated_client: AsyncClient ) -> None: first = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("first.epub") ) second = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True) ) pair = { "book_a_id": first.json()["created"][0]["id"], "book_b_id": second.json()["created"][0]["id"], } dismissed = await authenticated_client.post( "/books/duplicate-books/dismissals", json=pair ) assert dismissed.status_code == 204 response = await authenticated_client.get("/books/duplicate-books?library_id=1") assert response.json() == [] restored = await authenticated_client.delete( "/books/duplicate-books/dismissals" f"?book_a_id={pair['book_b_id']}&book_b_id={pair['book_a_id']}" ) assert restored.status_code == 204 response = await authenticated_client.get("/books/duplicate-books?library_id=1") assert len(response.json()) == 1 async def test_two_books_merge_into_one( self, authenticated_client: AsyncClient ) -> None: """The survivor keeps its id and gains the other's file; the other is gone.""" first = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("first.epub") ) second = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True) ) keep = first.json()["created"][0] fold = second.json()["created"][0] response = await authenticated_client.post( "/books/merge?library_id=1", json={ "survivor_id": keep["id"], "merged_ids": [fold["id"]], "metadata": {"title": "Metamorphosis", "edition": 2}, }, ) assert response.status_code == 201 merged = response.json() assert merged["id"] == keep["id"] assert merged["edition"] == 2 assert len(merged["files"]) == 2 # The folded record is gone, and the group it formed with it. assert (await authenticated_client.get(f"/books/{fold['id']}")).status_code == 404 groups = await authenticated_client.get("/books/duplicate-books?library_id=1") assert groups.json() == [] async def test_merging_an_unknown_book_is_refused( self, authenticated_client: AsyncClient ) -> None: created = await authenticated_client.post( "/books/fromFiles?library_id=1", files=self.upload("first.epub") ) keep = created.json()["created"][0]["id"] response = await authenticated_client.post( "/books/merge?library_id=1", json={"survivor_id": keep, "merged_ids": [9999]}, ) assert response.status_code == 400 async def test_dismissing_an_unknown_book_is_refused( self, authenticated_client: AsyncClient ) -> None: response = await authenticated_client.post( "/books/duplicate-books/dismissals", json={"book_a_id": 1, "book_b_id": 9999}, ) assert response.status_code == 400 # NOTE: the multi-book ZIP download is covered at the service level, in # tests/unit/test_services/test_book_service.py. Driving `/books/download` through # AsyncTestClient hangs in fixture teardown: it is the only `Stream` endpoint in the # app, and the test transport never sends the `http.disconnect` that Litestar's # streaming response waits on, so the app's lifespan shutdown never completes. # 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 @pytest.mark.asyncio class TestUnnameableFormats: """ A file whose format nothing can name must still round-trip. `mimetypes.guess_type` answers None for `.mobi`, `.azw`, `.fb2` and `.lit`, which is most of what a library imported from elsewhere carries alongside its EPUBs. `FileMetadataRead.content_type` used to be a required string, so such a book was created and then failed serialisation on its way back out — a 500 on a book the reader can otherwise download. """ def upload(self, name: str) -> list[tuple[str, tuple]]: # `application/octet-stream` is what a browser posts for these, and it is not # an answer — the extension is what names the format. return [("files", (name, b"BOOKMOBI\x00 payload", "application/octet-stream"))] async def test_a_mobi_is_named_from_its_extension( self, authenticated_client: AsyncClient ) -> None: response = await authenticated_client.post( "/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1} ) assert response.status_code == 201 book = response.json() assert book["files"][0]["content_type"] == "application/x-mobipocket-ebook" detail = await authenticated_client.get(f"/books/{book['id']}") assert detail.status_code == 200 async def test_an_unknown_extension_stores_no_content_type( self, authenticated_client: AsyncClient ) -> None: """Null, not a placeholder — and the book still serialises either way.""" response = await authenticated_client.post( "/books?library_id=1", files=self.upload("Notes.xyzzy"), data={"library_id": 1}, ) assert response.status_code == 201 book = response.json() assert book["files"][0]["content_type"] is None detail = await authenticated_client.get(f"/books/{book['id']}") assert detail.status_code == 200 assert detail.json()["files"][0]["content_type"] is None async def test_the_file_downloads( self, authenticated_client: AsyncClient ) -> None: """Litestar supplies its own media type when the row carries none.""" created = await authenticated_client.post( "/books?library_id=1", files=self.upload("Notes.xyzzy"), data={"library_id": 1}, ) book = created.json() response = await authenticated_client.get( f"/books/download/{book['id']}/{book['files'][0]['id']}" ) assert response.status_code == 200 assert response.headers["content-type"] == "application/octet-stream" async def test_the_opds_feed_survives_a_null_content_type( self, authenticated_client: AsyncClient ) -> None: """ The one place the type has to be a string. `Link.type` is required, so a null fails the whole feed rather than one entry. OPDS clients speak Basic, not the JWT the rest of the API uses. """ await authenticated_client.post( "/books?library_id=1", files=self.upload("Notes.xyzzy"), data={"library_id": 1}, ) feed = await authenticated_client.get( "/opds/acquisition?feed_id=all&feed_title=All+Books", auth=("user1@example.com", "password123"), ) assert feed.status_code == 200 assert 'type="application/octet-stream"' in feed.text