import zipfile from datetime import date from pathlib import Path from types import SimpleNamespace import pytest from chitai.services.calibre import ( CalibreLibrary, CalibreLibraryError, extract_calibre_archive, format_series_index, parse_date, strip_html, unescape_author, ) from tests.calibre_fixtures import UNDEFINED_DATE, CalibreFixture EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf") @pytest.fixture(name="library_root") def fx_library_root(tmp_path: Path) -> Path: """A small Calibre library covering the rows that are easy to read wrongly.""" fixture = CalibreFixture(tmp_path / "Calibre Library") fixture.add_pages_table() fixture.add_book( 1, "The Metamorphosis", authors=["Franz Kafka"], pubdate="1915-10-15 00:00:00+00:00", tags=["Fiction", "Absurdist"], publisher="Kurt Wolff Verlag", languages=["deu", "eng"], comment="

A travelling salesman.

He wakes up changed.

", identifiers={"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"}, uuid="11111111-2222-3333-4444-555555555555", pages=201, cover=True, formats={"EPUB": EPUB}, ) # Volume seven of a series, and no publication date — the two values most likely to # be carried through verbatim when they should not be. fixture.add_book( 2, "Persepolis Rising", # Calibre escapes the comma and nothing else, so the space after it is stored # as-is: `Corey, Jr.` is written `Corey| Jr.`. authors=["Corey| Jr., James S. A."], series="The Expanse", series_index=7.0, formats={"EPUB": EPUB}, ) # A novella between two novels: a fractional position is real and must survive. fixture.add_book( 3, "Strange Dogs", series="The Expanse", series_index=6.5, formats={"PDF": PDF} ) # Every row Calibre will happily hold and Chitai cannot use: no files at all. fixture.add_book(4, "Metadata Only") # A catalogue row whose file is not on disk. fixture.add_book(5, "Lost Book") fixture.add_missing_format(5, "EPUB", "Lost Book - Unknown") return fixture.commit() async def test_reads_a_book_whole(library_root: Path) -> None: async with CalibreLibrary(library_root) as library: assert await library.count() == 5 books = await library.books() book = books[0] assert book.calibre_id == 1 assert book.title == "The Metamorphosis" assert book.authors == ["Franz Kafka"] assert book.published_date == date(1915, 10, 15) assert book.tags == ["Absurdist", "Fiction"] assert book.publisher == "Kurt Wolff Verlag" assert book.pages == 201 assert book.uuid == "11111111-2222-3333-4444-555555555555" # One language, and the one Calibre put first. assert book.language == "deu" # Reported as Calibre wrote them: folding `amazon` onto `asin` is the importer's # job, not the reader's. assert book.identifiers == {"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"} assert book.cover is not None assert book.cover.is_file() assert len(book.files) == 1 assert book.files[0].format == "EPUB" assert book.files[0].path.is_file() # The stem is Calibre's, truncated and sanitised — never the title. assert book.files[0].path.name != f"{book.title}.epub" async def test_the_undefined_date_is_not_a_date(library_root: Path) -> None: """`0101-01-01` parses fine, which is exactly the problem.""" async with CalibreLibrary(library_root) as library: books = {book.calibre_id: book for book in await library.books()} assert books[2].published_date is None async def test_series_position_is_a_plain_string(library_root: Path) -> None: async with CalibreLibrary(library_root) as library: books = {book.calibre_id: book for book in await library.books()} assert books[2].series == "The Expanse" assert books[2].series_position == "7" assert books[3].series_position == "6.5" # `series_index` defaults to 1.0 for every book, so a position without a series # would invent a volume one out of nothing. assert books[1].series is None assert books[1].series_position is None async def test_author_commas_are_unescaped(library_root: Path) -> None: async with CalibreLibrary(library_root) as library: books = {book.calibre_id: book for book in await library.books()} assert books[2].authors == ["Corey, Jr., James S. A."] async def test_comments_come_back_as_text(library_root: Path) -> None: async with CalibreLibrary(library_root) as library: books = {book.calibre_id: book for book in await library.books()} assert books[1].description == "A travelling salesman.\nHe wakes up changed." assert books[2].description is None async def test_files_are_reported_whether_or_not_they_exist(library_root: Path) -> None: """ The reader says what the catalogue says. Whether the bytes are there is a question for whoever is about to copy them, which stats them anyway. """ async with CalibreLibrary(library_root) as library: books = {book.calibre_id: book for book in await library.books()} assert books[4].files == [] assert len(books[5].files) == 1 assert not books[5].files[0].path.exists() async def test_a_library_without_the_pages_table_still_reads(tmp_path: Path) -> None: """`books_pages_link` is recent; an older library simply does not have it.""" fixture = CalibreFixture(tmp_path / "Old Library") fixture.add_book(1, "Old Book", formats={"EPUB": EPUB}) root = fixture.commit() async with CalibreLibrary(root) as library: books = await library.books() assert books[0].pages is None async def test_the_original_is_never_opened(library_root: Path) -> None: """ The catalogue is copied before it is read, and the copy goes away afterwards. Calibre may be running and writing; this is what keeps a live library out of it. """ before = (library_root / "metadata.db").read_bytes() library = CalibreLibrary(library_root) await library.open() workspace = library._workspace assert workspace is not None and (workspace / "metadata.db").is_file() await library.close() assert not workspace.exists() assert (library_root / "metadata.db").read_bytes() == before async def test_closing_twice_is_harmless(library_root: Path) -> None: library = CalibreLibrary(library_root) await library.open() await library.close() await library.close() async def test_a_directory_that_is_not_a_calibre_library(tmp_path: Path) -> None: with pytest.raises(CalibreLibraryError, match="not a Calibre library"): await CalibreLibrary(tmp_path).open() @pytest.mark.parametrize( ("stored", "expected"), [ ("2017-12-04 04:00:00+00:00", date(2017, 12, 4)), ("2001-07-02 00:00:00+00:00", date(2001, 7, 2)), ("1999-01-31", date(1999, 1, 31)), # Calibre's sentinel, and anything else implausibly early. (UNDEFINED_DATE, None), ("0101-01-01", None), (None, None), ("", None), ("not a date", None), ], ) def test_parse_date(stored: str | None, expected: date | None) -> None: assert parse_date(stored) == expected @pytest.mark.parametrize( ("index", "expected"), [ (7.0, "7"), (1.0, "1"), (6.5, "6.5"), (0.0, "0"), (12.25, "12.25"), (None, None), ], ) def test_format_series_index(index: float | None, expected: str | None) -> None: assert format_series_index(index) == expected @pytest.mark.parametrize( ("stored", "expected"), [ ("Doyle| Sir Arthur Conan", "Doyle, Sir Arthur Conan"), ("Franz Kafka", "Franz Kafka"), (" Herman Melville ", "Herman Melville"), ], ) def test_unescape_author(stored: str, expected: str) -> None: assert unescape_author(stored) == expected @pytest.mark.parametrize( ("html", "expected"), [ ("

One.

Two.

", "One.\nTwo."), ("Plain text", "Plain text"), ("
A
B
", "A\nB"), ("

Café & bar

", "Café & bar"), ("", "One\nTwo"), # Markup carrying no text at all is nothing, not an empty description. ("

", None), ("", None), (None, None), ], ) def test_strip_html(html: str | None, expected: str | None) -> None: assert strip_html(html) == expected class TestArchives: """A Calibre library that arrives zipped rather than as a path.""" def zipped(self, root: Path, into: Path, prefix: str = "") -> Path: """Zip a directory the way a file manager would.""" archive = into / "library.zip" with zipfile.ZipFile(archive, "w") as writing: for path in sorted(root.rglob("*")): if path.is_file(): writing.write(path, f"{prefix}{path.relative_to(root)}") return archive async def test_a_library_zipped_at_its_root( self, library_root: Path, tmp_path: Path ) -> None: archive = self.zipped(library_root, tmp_path) destination = tmp_path / "unpacked" destination.mkdir() catalogue = await extract_calibre_archive(archive, destination) assert catalogue == destination async with CalibreLibrary(catalogue) as library: assert await library.count() == 5 async def test_a_library_zipped_inside_a_folder( self, library_root: Path, tmp_path: Path ) -> None: """Zipping the folder itself is at least as common as zipping its contents.""" archive = self.zipped(library_root, tmp_path, prefix="Calibre Library/") destination = tmp_path / "unpacked" destination.mkdir() catalogue = await extract_calibre_archive(archive, destination) assert catalogue == destination / "Calibre Library" async with CalibreLibrary(catalogue) as library: assert await library.count() == 5 async def test_an_entry_pointing_outside_the_archive_is_refused( self, tmp_path: Path ) -> None: """ Zip slip. `ZipFile.extract` sanitises names itself, but relying on that silently is how the next person to change the extraction call reintroduces it. """ archive = tmp_path / "hostile.zip" with zipfile.ZipFile(archive, "w") as writing: writing.writestr("metadata.db", "not really") writing.writestr("../../escaped.txt", "gotcha") destination = tmp_path / "unpacked" destination.mkdir() with pytest.raises(CalibreLibraryError, match="outside itself"): await extract_calibre_archive(archive, destination) assert not (tmp_path.parent / "escaped.txt").exists() async def test_something_that_is_not_a_zip(self, tmp_path: Path) -> None: archive = tmp_path / "not.zip" archive.write_bytes(b"PK-ish, but no") destination = tmp_path / "unpacked" destination.mkdir() with pytest.raises(CalibreLibraryError, match="not a zip file"): await extract_calibre_archive(archive, destination) async def test_an_archive_with_no_catalogue(self, tmp_path: Path) -> None: archive = tmp_path / "books.zip" with zipfile.ZipFile(archive, "w") as writing: writing.writestr("Some Book.epub", "content") destination = tmp_path / "unpacked" destination.mkdir() with pytest.raises(CalibreLibraryError, match="no metadata.db"): await extract_calibre_archive(archive, destination) # Refused before anything was written. assert list(destination.iterdir()) == [] async def test_a_catalogue_buried_too_deep(self, tmp_path: Path) -> None: """Somebody's whole backup tree is not a library, however much it contains one.""" archive = tmp_path / "backup.zip" with zipfile.ZipFile(archive, "w") as writing: writing.writestr("backups/2026/january/library/metadata.db", "not really") destination = tmp_path / "unpacked" destination.mkdir() with pytest.raises(CalibreLibraryError, match="within 3 levels"): await extract_calibre_archive(archive, destination) async def test_an_archive_too_big_for_the_disk( self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """ Checked before writing, not discovered part-way through. A full disk takes the whole application down, and the size is in the archive already. """ archive = tmp_path / "huge.zip" with zipfile.ZipFile(archive, "w") as writing: writing.writestr("metadata.db", "not really") destination = tmp_path / "unpacked" destination.mkdir() monkeypatch.setattr( "chitai.services.calibre.shutil.disk_usage", lambda _path: SimpleNamespace(total=1024, used=1024, free=0), ) with pytest.raises(CalibreLibraryError, match="only"): await extract_calibre_archive(archive, destination) assert list(destination.iterdir()) == []