From 5ec5a4d3349f2c994585ee864f270aca09a4ae8f Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 17 Aug 2026 20:56:24 -0400 Subject: [PATCH] chore: bring the test suite up to ruff's standards Mostly automatic: empty f-strings, unused imports, formatting. The manual half was duplicated import blocks stranded mid-file and `== None` assertions, which become `is None` here because they compare plain attributes -- unlike the identical rule in filters/book.py, where they build SQL. Two unused variables are the tests never checking the disk in either delete_files direction. Left in place under a noqa so the gap stays visible. --- TODO.md | 18 ++++ backend/tests/conftest.py | 3 +- backend/tests/integration/test_book.py | 77 ++++++++------ backend/tests/integration/test_bookshelf.py | 2 - .../tests/integration/test_calibre_import.py | 11 +- backend/tests/integration/test_file_hash.py | 15 ++- backend/tests/integration/test_library.py | 2 +- backend/tests/unit/test_filesystem_library.py | 37 +++++-- backend/tests/unit/test_matching.py | 18 +++- backend/tests/unit/test_metadata_extractor.py | 18 +++- .../unit/test_services/test_book_service.py | 100 +++++++++++++----- .../test_services/test_bookshelf_service.py | 9 +- .../test_calibre_import_service.py | 14 ++- .../test_services/test_library_service.py | 14 +-- .../unit/test_services/test_user_service.py | 10 +- 15 files changed, 232 insertions(+), 116 deletions(-) diff --git a/TODO.md b/TODO.md index 59f281b..098e6b3 100644 --- a/TODO.md +++ b/TODO.md @@ -114,6 +114,24 @@ CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8 `litestar run` is the CLI development runner. Production should invoke uvicorn or granian directly, with a worker count. +### The delete_files flag is untested in both directions + +`backend/tests/integration/test_book.py` — `test_remove_file_with_delete_files_false_keeps_filesystem_file` +and `test_remove_file_with_delete_files_true_removes_filesystem_file` + +Both tests capture the file's path and then assert only `response.status_code == 204`. +Neither looks at the disk. So the flag that decides whether removing a file from a book +also **erases it from the filesystem** is covered in name only, in both directions. + +Ruff surfaced this as two `F841` unused variables; the variables carry a `# noqa: F841` +and a comment rather than being deleted, so the gap stays visible. Remove the noqa when +the assertions land. + +The reason it is not a two-line fix: `FileMetadata.path` is stored relative to `book.path`, +so the test has to resolve it against the library root to know what to stat. That +resolution is the same thing `BookService.get_files` is recorded as getting wrong (see +`backend/AGENTS.md`), so it is worth settling once and using in both places. + ### No type checker on the backend Formatting, linting and tests now run in CI (`.gitea/workflows/ci.yml`) and block, and the diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index c910385..f57ac0b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -2,6 +2,8 @@ from pathlib import Path from uuid import uuid4 import pytest +from chitai import services + from advanced_alchemy.base import UUIDAuditBase from litestar.testing import AsyncTestClient from sqlalchemy import text @@ -155,7 +157,6 @@ async def other_authenticated_client( # Service fixtures -from chitai import services @pytest.fixture diff --git a/backend/tests/integration/test_book.py b/backend/tests/integration/test_book.py index 574300b..b681237 100644 --- a/backend/tests/integration/test_book.py +++ b/backend/tests/integration/test_book.py @@ -1,6 +1,7 @@ import pytest from httpx import AsyncClient from pathlib import Path +from litestar.status_codes import HTTP_400_BAD_REQUEST @pytest.mark.parametrize( @@ -218,7 +219,9 @@ async def test_list_books_by_id(populated_authenticated_client: AsyncClient) -> 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") + 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] @@ -228,7 +231,7 @@ async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> No """Test retrieving a specific book by ID.""" # Retrieve the book - response = await populated_authenticated_client.get(f"/books/1") + response = await populated_authenticated_client.get("/books/1") assert response.status_code == 200 book_data = response.json() @@ -300,13 +303,13 @@ async def test_delete_book_metadata_only( # Delete book without deleting files response = await populated_authenticated_client.delete( - f"/books?book_ids=3&delete_files=false&library_id=1" + "/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") + get_response = await populated_authenticated_client.get("/books/3") assert get_response.status_code == 404 @@ -317,7 +320,7 @@ async def test_delete_book_with_files( # Delete book and files response = await populated_authenticated_client.delete( - f"/books?book_ids=3&delete_files=true&library_id=1" + "/books?book_ids=3&delete_files=true&library_id=1" ) assert response.status_code == 204 @@ -330,7 +333,7 @@ async def test_delete_specific_book_files( # Delete specific file response = await populated_authenticated_client.delete( - f"/books/1/files?file_ids=1", + "/books/1/files?file_ids=1", ) assert response.status_code == 204 @@ -347,7 +350,7 @@ async def test_update_reading_progress( } response = await populated_authenticated_client.post( - f"/books/progress/1", + "/books/progress/1", json=progress_data, ) @@ -423,7 +426,9 @@ async def test_create_books_groups_formats_within_one_folder( ) -> 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() + pdf = Path( + "tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf" + ).read_bytes() files = [ ("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")), @@ -534,7 +539,9 @@ class TestDuplicateHandling: "files", ( "war.epub", - Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), + Path( + "tests/data_files/The Art of War - Sun Tzu.epub" + ).read_bytes(), "application/epub+zip", ), ) @@ -550,7 +557,9 @@ class TestDuplicateHandling: "files", ( "war.epub", - Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(), + Path( + "tests/data_files/The Art of War - Sun Tzu.epub" + ).read_bytes(), "application/epub+zip", ), ) @@ -736,7 +745,9 @@ class TestDuplicateBooks: 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 + 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() == [] @@ -786,16 +797,6 @@ class TestDuplicateBooks: # 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( @@ -849,8 +850,10 @@ class TestMetadataUpdates: ( "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"}, + lambda data: ( + {a["name"] for a in data["authors"]} + == {"New Author 1", "New Author 2"} + ), ), ( "authors", # Clear authors @@ -860,8 +863,9 @@ class TestMetadataUpdates: ( "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"}, + lambda data: ( + {t["name"] for t in data["tags"]} == {"Tag 1", "Tag 2", "Tag 3"} + ), ), ( "tags", # Clear tags @@ -881,8 +885,10 @@ class TestMetadataUpdates: ( "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"}, + lambda data: ( + data["identifiers"] + == {"isbn-13": "978-1234567890", "doi": "10.example/id"} + ), ), ( "identifiers", # Clear identifiers @@ -1053,7 +1059,7 @@ class TestMetadataUpdates: result = response.json() - assert result[updated_field] == None + assert result[updated_field] is None @pytest.mark.parametrize( ("updated_field"), @@ -1215,7 +1221,9 @@ class TestFileManagement: pytest.skip("Book has no files") file_id = book_data["files"][0]["id"] - filename = book_data["files"][0].get("path") + # TODO: this test asserts only the 204 and never checks the disk, so the flag it is + # named for is untested. See TODO.md; drop the noqa when the assertion lands. + filename = book_data["files"][0].get("path") # noqa: F841 # Remove file without deleting from filesystem response = await populated_authenticated_client.delete( @@ -1240,7 +1248,8 @@ class TestFileManagement: book_data = add_response.json() file_id = book_data["files"][-1]["id"] - file_path = book_data["files"][-1].get("path") + # TODO: as above -- the file is never checked for removal from disk. + file_path = book_data["files"][-1].get("path") # noqa: F841 # Remove file with deletion from filesystem response = await populated_authenticated_client.delete( @@ -1295,7 +1304,9 @@ class TestUnnameableFormats: self, authenticated_client: AsyncClient ) -> None: response = await authenticated_client.post( - "/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1} + "/books?library_id=1", + files=self.upload("Dune.mobi"), + data={"library_id": 1}, ) assert response.status_code == 201 @@ -1323,9 +1334,7 @@ class TestUnnameableFormats: 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: + 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", diff --git a/backend/tests/integration/test_bookshelf.py b/backend/tests/integration/test_bookshelf.py index 6822165..b8ae379 100644 --- a/backend/tests/integration/test_bookshelf.py +++ b/backend/tests/integration/test_bookshelf.py @@ -1,4 +1,3 @@ -import pytest from httpx import AsyncClient @@ -200,7 +199,6 @@ async def test_remove_books_from_shelf( "/books", params={"shelves": shelf_id} ) - assert books_response.status_code == 200 assert books_response.json()["total"] == 2 diff --git a/backend/tests/integration/test_calibre_import.py b/backend/tests/integration/test_calibre_import.py index 6e46f61..001523f 100644 --- a/backend/tests/integration/test_calibre_import.py +++ b/backend/tests/integration/test_calibre_import.py @@ -42,7 +42,9 @@ def fx_source(tmp_path: Path) -> Path: cover=True, formats={"EPUB": EPUB}, ) - fixture.add_book(2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB}) + fixture.add_book( + 2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB} + ) fixture.add_book(3, "Metadata Only", authors=["Nobody"]) return fixture.commit() @@ -95,7 +97,8 @@ async def test_an_uploaded_library_imports( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: status, job = await upload( - authenticated_client, zip_of(source, tmp_path / "out", prefix="Calibre Library/") + authenticated_client, + zip_of(source, tmp_path / "out", prefix="Calibre Library/"), ) assert status == 202 @@ -278,7 +281,9 @@ async def test_a_second_copy_is_counted_as_a_possible_duplicate( padded.write_bytes(EPUB.read_bytes() + b"\0" * 64) fixture = CalibreFixture(tmp_path / "calibre") - fixture.add_book(1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}) + fixture.add_book( + 1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB} + ) fixture.add_book( 2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded} ) diff --git a/backend/tests/integration/test_file_hash.py b/backend/tests/integration/test_file_hash.py index 9a32c6d..68f08c4 100644 --- a/backend/tests/integration/test_file_hash.py +++ b/backend/tests/integration/test_file_hash.py @@ -8,7 +8,9 @@ from pathlib import Path # Known KOReader hashes for test files TEST_FILES = { "Moby Dick; Or, The Whale - Herman Melville.epub": { - "path": Path("tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub"), + "path": Path( + "tests/data_files/Moby Dick; Or, The Whale - Herman Melville.epub" + ), "hash": "ceeef909ec65653ba77e1380dff998fb", "content_type": "application/epub+zip", }, @@ -59,7 +61,9 @@ async def test_add_file_to_book_generates_correct_hash( first_book = TEST_FILES["Moby Dick; Or, The Whale - Herman Melville.epub"] first_content = first_book["path"].read_bytes() - files = [("files", (first_book["path"].name, first_content, first_book["content_type"]))] + files = [ + ("files", (first_book["path"].name, first_content, first_book["content_type"])) + ] data = {"library_id": "1"} create_response = await authenticated_client.post( @@ -75,7 +79,12 @@ async def test_add_file_to_book_generates_correct_hash( second_book = TEST_FILES["Calculus Made Easy - Silvanus Thompson.pdf"] second_content = second_book["path"].read_bytes() - add_files = [("data", (second_book["path"].name, second_content, second_book["content_type"]))] + add_files = [ + ( + "data", + (second_book["path"].name, second_content, second_book["content_type"]), + ) + ] add_response = await authenticated_client.post( f"/books/{book_id}/files", diff --git a/backend/tests/integration/test_library.py b/backend/tests/integration/test_library.py index e2908ec..30a01db 100644 --- a/backend/tests/integration/test_library.py +++ b/backend/tests/integration/test_library.py @@ -40,5 +40,5 @@ async def test_create_library( assert result["name"] == "Test Library" assert result["root_path"] == f"{tmp_path}/books" assert result["path_template"] == "{author}/{title}" - assert result["read_only"] == False + assert result["read_only"] is False assert result["description"] is None diff --git a/backend/tests/unit/test_filesystem_library.py b/backend/tests/unit/test_filesystem_library.py index 8e6edca..bdad196 100644 --- a/backend/tests/unit/test_filesystem_library.py +++ b/backend/tests/unit/test_filesystem_library.py @@ -2,7 +2,10 @@ from pathlib import Path -from chitai.services.filesystem_library import BookPathGenerator, sanitize_path_component +from chitai.services.filesystem_library import ( + BookPathGenerator, + sanitize_path_component, +) ROOT = Path("/library") @@ -23,12 +26,15 @@ def test_a_book_with_no_authors() -> None: def test_a_series_adds_a_level_and_pads_the_position() -> None: - assert path_for( - title="Persepolis Rising", - authors=["James S. A. Corey"], - series="The Expanse", - series_position="7", - ) == ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising" + assert ( + path_for( + title="Persepolis Rising", + authors=["James S. A. Corey"], + series="The Expanse", + series_position="7", + ) + == ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising" + ) def test_a_slash_in_a_title_does_not_add_a_directory() -> None: @@ -43,16 +49,25 @@ def test_a_slash_in_a_title_does_not_add_a_directory() -> None: generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"]) assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC" - assert generated.relative_to(ROOT).parts == ("Murray Engleheart", "Back in Black: AC_DC") + assert generated.relative_to(ROOT).parts == ( + "Murray Engleheart", + "Back in Black: AC_DC", + ) def test_a_slash_in_an_author_or_series_is_handled_too() -> None: assert path_for(title="Split", authors=["A/B Collective"]) == ( ROOT / "A_B Collective" / "Split" ) - assert path_for( - title="Volume One", authors=["Someone"], series="Either/Or", series_position="1" - ) == ROOT / "Someone" / "Either_Or" / "01 - Volume One" + assert ( + path_for( + title="Volume One", + authors=["Someone"], + series="Either/Or", + series_position="1", + ) + == ROOT / "Someone" / "Either_Or" / "01 - Volume One" + ) def test_control_characters_are_removed() -> None: diff --git a/backend/tests/unit/test_matching.py b/backend/tests/unit/test_matching.py index fd707ec..4ad6f6b 100644 --- a/backend/tests/unit/test_matching.py +++ b/backend/tests/unit/test_matching.py @@ -54,7 +54,8 @@ class TestNormalizeTitle: assert normalize_title("The") == "the" @pytest.mark.parametrize( - "title", ["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"] + "title", + ["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"], ) def test_a_number_is_not_an_edition(self, title: str) -> None: """Edition stripping keys on the `e`; a bare number is part of the title.""" @@ -163,8 +164,13 @@ class TestNormalizeIdentifier: def test_uuids_are_refused(self) -> None: """Generated per build, so they only re-find what the hash check catches.""" - assert normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None - assert normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None + assert ( + normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None + ) + assert ( + normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") + is None + ) def test_other_schemes_keep_their_own_key(self) -> None: assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda" @@ -187,7 +193,9 @@ class TestIsbnConversion: assert isbn10_to_isbn13("043942089X") == "9780439420891" @pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"]) - def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None: + def test_anything_that_is_not_an_isbn_10_converts_to_nothing( + self, isbn: str + ) -> None: assert isbn10_to_isbn13(isbn) is None @@ -219,7 +227,7 @@ class TestParseIdentifier: assert parse_identifier("B000FC0PDA", "mobi-asin") == ("asin", "B000FC0PDA") def test_an_unrecognised_prefix_is_part_of_the_value(self) -> None: - """"http://example.com/book" is not an identifier called "http".""" + """ "http://example.com/book" is not an identifier called "http".""" assert parse_identifier("http://www.gutenberg.org/5200") == ( "id", "http://www.gutenberg.org/5200", diff --git a/backend/tests/unit/test_metadata_extractor.py b/backend/tests/unit/test_metadata_extractor.py index 7070776..e510297 100644 --- a/backend/tests/unit/test_metadata_extractor.py +++ b/backend/tests/unit/test_metadata_extractor.py @@ -22,7 +22,6 @@ class TestEpubExtractor: assert metadata["published_date"] == date(year=2001, month=7, day=1) - EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") @@ -31,7 +30,9 @@ PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") class TestIdentifierMerging: """A book's formats each contribute identifiers; none of them replaces the rest.""" - async def test_every_format_contributes(self, monkeypatch: pytest.MonkeyPatch) -> None: + async def test_every_format_contributes( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: """ Identifiers are a collection, not a single value. @@ -47,7 +48,9 @@ class TestIdentifierMerging: } async def pdf(_file): - return {"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}} + return { + "identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"} + } monkeypatch.setattr(EpubExtractor, "extract_metadata", epub) monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf) @@ -133,7 +136,9 @@ class TestSplitEdition: ), ], ) - def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None: + def test_editions_are_split_out( + self, title: str, stripped: str, edition: int + ) -> None: assert split_edition(title) == (stripped, edition) @pytest.mark.parametrize( @@ -171,7 +176,10 @@ class TestEditionFromFiles: """The PDF fixture calls itself a 2nd edition in its own metadata title.""" metadata = await Extractor.extract_metadata([PDF]) - assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy" + assert ( + metadata["title"] + == "The Project Gutenberg eBook #33283: Calculus Made Easy" + ) assert metadata["edition"] == 2 diff --git a/backend/tests/unit/test_services/test_book_service.py b/backend/tests/unit/test_services/test_book_service.py index bda3c37..be8f59e 100644 --- a/backend/tests/unit/test_services/test_book_service.py +++ b/backend/tests/unit/test_services/test_book_service.py @@ -25,7 +25,9 @@ PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf" def upload(path: Path, name: str | None = None) -> UploadFile: """An uploaded file carrying the bytes of one of the test fixtures.""" return UploadFile( - content_type="application/pdf" if path.suffix == ".pdf" else "application/epub+zip", + content_type="application/pdf" + if path.suffix == ".pdf" + else "application/epub+zip", filename=name or path.name, file_data=path.read_bytes(), ) @@ -67,7 +69,7 @@ class TestBookServiceCRUD: # Add path manually as it won't be generated (not using the create function, but manually inserting into db) book.path = f"{test_library.root_path}/J.R.R Tolkien/The Fellowship of the Ring" - await aios.makedirs(book.path) # type: ignore[arg-type] + await aios.makedirs(book.path) # type: ignore[arg-type] books_service.repository.session.add(book) await books_service.repository.session.commit() @@ -538,9 +540,7 @@ class TestBookPathCollisions: assert original.path != forced.path - paths = { - Path(book.path) / book.files[0].path for book in (original, forced) - } + paths = {Path(book.path) / book.files[0].path for book in (original, forced)} assert len(paths) == 2 assert all(path.is_file() for path in paths) @@ -581,7 +581,10 @@ class TestBookPathCollisions: # Renamed onto the first book's author and title. await books_service.update_book( second.books[0].id, - {"title": original.title, "authors": [author.name for author in original.authors]}, + { + "title": original.title, + "authors": [author.name for author in original.authors], + }, test_library, ) @@ -743,7 +746,8 @@ class TestDuplicateBooks: ) matches = await books_service.find_duplicate_books( - {"title": "Building Microservices", "authors": ["Newman, Sam;"]}, test_library + {"title": "Building Microservices", "authors": ["Newman, Sam;"]}, + test_library, ) assert [match.book_id for match in matches] == [stored.id] @@ -834,16 +838,22 @@ class TestDuplicateBooks: "series": "Foundation", } - assert await books_service.find_duplicate_books( - incoming | {"series_position": "2"}, test_library - ) == [] + assert ( + await books_service.find_duplicate_books( + incoming | {"series_position": "2"}, test_library + ) + == [] + ) # The same volume, written a little differently, still matches. - assert len( - await books_service.find_duplicate_books( - incoming | {"series_position": "1.0"}, test_library + assert ( + len( + await books_service.find_duplicate_books( + incoming | {"series_position": "1.0"}, test_library + ) ) - ) == 1 + == 1 + ) async def test_a_book_is_not_its_own_duplicate( self, books_service: BookService, test_library: m.Library @@ -909,7 +919,10 @@ class TestAuthorNames: existing row and then collides with it on the unique index. """ first = await store_book( - books_service, test_library, title="Building Microservices", authors=["Sam Newman"] + books_service, + test_library, + title="Building Microservices", + authors=["Sam Newman"], ) second = await store_book( books_service, @@ -953,7 +966,9 @@ class TestAuthorNames: "Franz Kafka.epub" is not a person. """ result = await books_service.create_many_from_files( - BooksCreateFromFiles(files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]), + BooksCreateFromFiles( + files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")] + ), test_library, ) @@ -1014,7 +1029,10 @@ class TestMergeBooks: ) -> None: """The survivor keeps its own fields unless the caller says otherwise.""" keep = await store_book( - books_service, test_library, title="Building Microservices", authors=["Sam Newman"] + books_service, + test_library, + title="Building Microservices", + authors=["Sam Newman"], ) fold = await store_book( books_service, @@ -1030,7 +1048,11 @@ class TestMergeBooks: assert merged.publisher is None other = await store_book( - books_service, test_library, title="Monolith", authors=["Sam Newman"], edition=3 + books_service, + test_library, + title="Monolith", + authors=["Sam Newman"], + edition=3, ) merged = await books_service.merge_books( keep.id, [other.id], test_library, metadata={"edition": 3} @@ -1096,10 +1118,14 @@ class TestMergeBooks: await books_service.merge_books(keep.id, [fold.id], test_library) rows = ( - await books_service.repository.session.execute( - select(m.BookProgress).where(m.BookProgress.book_id == keep.id) + ( + await books_service.repository.session.execute( + select(m.BookProgress).where(m.BookProgress.book_id == keep.id) + ) ) - ).scalars().all() + .scalars() + .all() + ) assert [row.percentage for row in rows] == [0.6] @@ -1112,13 +1138,23 @@ class TestMergeBooks: ) -> None: """Both books on one shelf must not leave the survivor linked to it twice.""" keep = await store_book( - books_service, test_library, title="A", authors=["X"], tags=["Shared", "Only Keep"] + books_service, + test_library, + title="A", + authors=["X"], + tags=["Shared", "Only Keep"], ) fold = await store_book( - books_service, test_library, title="B", authors=["X"], tags=["Shared", "Only Fold"] + books_service, + test_library, + title="B", + authors=["X"], + tags=["Shared", "Only Fold"], ) - shelf = m.BookList(title="Later", user_id=test_user.id, library_id=test_library.id) + shelf = m.BookList( + title="Later", user_id=test_user.id, library_id=test_library.id + ) session.add(shelf) await session.commit() session.add_all( @@ -1131,13 +1167,21 @@ class TestMergeBooks: merged = await books_service.merge_books(keep.id, [fold.id], test_library) - assert sorted(tag.name for tag in merged.tags) == ["Only Fold", "Only Keep", "Shared"] + assert sorted(tag.name for tag in merged.tags) == [ + "Only Fold", + "Only Keep", + "Shared", + ] links = ( - await books_service.repository.session.execute( - select(m.BookListLink).where(m.BookListLink.book_id == keep.id) + ( + await books_service.repository.session.execute( + select(m.BookListLink).where(m.BookListLink.book_id == keep.id) + ) ) - ).scalars().all() + .scalars() + .all() + ) assert len(links) == 1 async def test_an_identifier_moves_only_under_a_name_the_survivor_lacks( diff --git a/backend/tests/unit/test_services/test_bookshelf_service.py b/backend/tests/unit/test_services/test_bookshelf_service.py index 919e9bb..5cc6e0b 100644 --- a/backend/tests/unit/test_services/test_bookshelf_service.py +++ b/backend/tests/unit/test_services/test_bookshelf_service.py @@ -2,19 +2,12 @@ import pytest +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from chitai.services import ShelfService from chitai.database import models as m - - -import pytest -from sqlalchemy import select - -from chitai.services.bookshelf import ShelfService -from chitai.services import BookService from chitai.database.models.book_list import BookList, BookListLink -from chitai.database import models as m @pytest.fixture diff --git a/backend/tests/unit/test_services/test_calibre_import_service.py b/backend/tests/unit/test_services/test_calibre_import_service.py index 87f2617..3de7724 100644 --- a/backend/tests/unit/test_services/test_calibre_import_service.py +++ b/backend/tests/unit/test_services/test_calibre_import_service.py @@ -201,7 +201,9 @@ async def test_importing_twice_creates_nothing( ] held_by = [ - skipped.book_id for skipped in result.skipped if skipped.reason == "already stored" + skipped.book_id + for skipped in result.skipped + if skipped.reason == "already stored" ] assert all(book_id is not None for book_id in held_by) @@ -223,7 +225,9 @@ async def test_a_file_the_catalogue_lists_but_disk_does_not( await source.close() assert len(result.created) == 1 - assert [(s.calibre_id, s.reason) for s in result.skipped] == [(2, "no files on disk")] + assert [(s.calibre_id, s.reason) for s in result.skipped] == [ + (2, "no files on disk") + ] async def test_one_broken_book_does_not_stop_the_import( @@ -312,7 +316,11 @@ async def test_shared_authors_and_tags_are_one_row_each( 1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB} ) fixture.add_book( - 2, "Two", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": OTHER_EPUB} + 2, + "Two", + authors=["Franz Kafka"], + tags=["Fiction"], + formats={"EPUB": OTHER_EPUB}, ) root = fixture.commit() diff --git a/backend/tests/unit/test_services/test_library_service.py b/backend/tests/unit/test_services/test_library_service.py index b7af592..fb65147 100644 --- a/backend/tests/unit/test_services/test_library_service.py +++ b/backend/tests/unit/test_services/test_library_service.py @@ -34,8 +34,8 @@ class TestLibraryServiceCRUD: assert library.name == "Test Library" assert library.root_path == library_path assert library.path_template == "{author}/{title}" - assert library.description == None - assert library.read_only == False + assert library.description is None + assert library.read_only is False # Check if directory was created assert Path(library.root_path).is_dir() @@ -56,8 +56,8 @@ class TestLibraryServiceCRUD: read_only=False, ) - with pytest.raises(PermissionError) as exc_info: - library = await library_service.create(library_data) + with pytest.raises(PermissionError): + await library_service.create(library_data) # Check if directory was created assert not Path(library_path).exists() @@ -86,8 +86,8 @@ class TestLibraryServiceCRUD: assert library.name == "Test Library" assert library.root_path == library_path assert library.path_template == "{author}/{title}" - assert library.description == None - assert library.read_only == True + assert library.description is None + assert library.read_only is True async def test_create_library_read_only_nonexistent_path( self, library_service: LibraryService, tmp_path: Path @@ -138,7 +138,7 @@ class TestLibraryServiceCRUD: assert library.root_path == "./books" assert library.path_template == "{author}/{title}" assert library.description is None - assert library.read_only == False + assert library.read_only is False # async def test_delete_library_keep_files( # self, session: AsyncSession, library_service: LibraryService diff --git a/backend/tests/unit/test_services/test_user_service.py b/backend/tests/unit/test_services/test_user_service.py index d83c26f..e444bfd 100644 --- a/backend/tests/unit/test_services/test_user_service.py +++ b/backend/tests/unit/test_services/test_user_service.py @@ -21,7 +21,7 @@ class TestUserServiceAuthentication: # Create a user with a known password password = "password123" - user = m.User(email=f"test@example.com", password=password) + user = m.User(email="test@example.com", password=password) session.add(user) await session.commit() @@ -52,7 +52,7 @@ class TestUserServiceAuthentication: # Create user password = "password123" - user = m.User(email=f"test@example.com", password=password) + user = m.User(email="test@example.com", password=password) session.add(user) await session.commit() @@ -85,7 +85,7 @@ class TestUserServiceCRUD: ) -> None: """Test getting user by email.""" - user = m.User(email=f"test@example.com", password="password123") + user = m.User(email="test@example.com", password="password123") session.add(user) await session.commit() @@ -102,12 +102,12 @@ class TestUserServiceCRUD: """Test creating a new user with a duplicate email.""" # Create first user - user = m.User(email=f"test@example.com", password="password123") + user = m.User(email="test@example.com", password="password123") session.add(user) await session.commit() # Create second user - user = m.User(email=f"test@example.com", password="password12345") + user = m.User(email="test@example.com", password="password12345") with pytest.raises(IntegrityError) as exc_info: session.add(user)