""" Tests for the Calibre import endpoints. The API takes a zipped library and nothing else — a desktop Calibre install is usually not on the server, and importing from a path the server can already see stays a server-side operation (`litestar calibre-import`). """ import asyncio import tempfile import zipfile from pathlib import Path import pytest from httpx import AsyncClient from chitai.services.calibre_import import registry from tests.calibre_fixtures import CalibreFixture EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub") OTHER_EPUB = Path("tests/data_files/The Art of War - Sun Tzu.epub") @pytest.fixture(autouse=True) def _clear_registry(): """The registry is a module-level singleton, so it leaks between tests.""" registry._jobs.clear() yield registry._jobs.clear() @pytest.fixture(name="source") def fx_source(tmp_path: Path) -> Path: fixture = CalibreFixture(tmp_path / "calibre") fixture.add_book( 1, "The Metamorphosis", authors=["Franz Kafka"], tags=["Fiction"], cover=True, formats={"EPUB": 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() def zip_of(root: Path, into: Path, prefix: str = "") -> Path: """Zip a directory the way a file manager would.""" into.mkdir(parents=True, exist_ok=True) 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 upload( client: AsyncClient, archive: Path, library_id: int = 1, allow_duplicates: bool = False, ) -> tuple[int, dict]: response = await client.post( f"/libraries/{library_id}/imports/calibre/upload", files=[("archive", (archive.name, archive.read_bytes(), "application/zip"))], data={"allow_duplicates": str(allow_duplicates).lower()}, ) return response.status_code, response.json() async def wait_for(client: AsyncClient, job_id: str) -> dict: """Poll until the job is no longer running, the way the screen does.""" for _ in range(200): response = await client.get(f"/libraries/imports/{job_id}") assert response.status_code == 200 job = response.json() if job["state"] != "running": return job await asyncio.sleep(0.05) raise AssertionError("the import never finished") 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/"), ) assert status == 202 assert job["state"] == "running" assert job["library_id"] == 1 # The archive's name, not the temp directory it was unpacked into. assert job["source"] == "library.zip" finished = await wait_for(authenticated_client, job["id"]) assert finished["state"] == "finished" assert finished["total"] == 3 assert finished["created"] == 2 assert finished["skipped"] == 1 assert finished["failed"] == 0 assert finished["error"] is None assert finished["current_title"] is None listed = await authenticated_client.get("/books?library_id=1") titles = [book["title"] for book in listed.json()["items"]] assert "The Metamorphosis" in titles assert "The Art of War" in titles async def test_a_library_zipped_without_a_wrapping_folder( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: """Zipping the contents is as common as zipping the folder.""" status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out")) assert status == 202 finished = await wait_for(authenticated_client, job["id"]) assert finished["created"] == 2 async def test_the_unpacked_copy_is_cleaned_up( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: """ An unpacked archive is a second copy of the whole library. The books worth keeping have been copied into the library by the time the job ends, so nothing is lost with it — and nothing will come back for it. """ status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out")) assert status == 202 workspace = registry.get(job["id"]).workspace assert workspace is not None await wait_for(authenticated_client, job["id"]) assert not workspace.exists() async def test_uploading_the_same_library_twice_imports_nothing_new( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: """Re-running is safe, which is what makes an interrupted import resumable.""" archive = zip_of(source, tmp_path / "out") for _ in range(2): _, job = await upload(authenticated_client, archive) finished = await wait_for(authenticated_client, job["id"]) assert finished["created"] == 0 assert finished["skipped"] == 3 async def test_allow_duplicates_stores_the_files_again( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: """ The one option the screen offers, and it has to reach the import. Without it the second pass skips everything, which is the previous test. """ archive = zip_of(source, tmp_path / "out") _, first = await upload(authenticated_client, archive) await wait_for(authenticated_client, first["id"]) _, second = await upload(authenticated_client, archive, allow_duplicates=True) finished = await wait_for(authenticated_client, second["id"]) assert finished["created"] == 2 assert finished["skipped"] == 1 # still the book with no files async def test_two_imports_into_one_library_conflict( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: archive = zip_of(source, tmp_path / "out") first_status, first = await upload(authenticated_client, archive) assert first_status == 202 second_status, second = await upload(authenticated_client, archive) assert second_status == 409 assert second["extra"]["job_id"] == first["id"] await wait_for(authenticated_client, first["id"]) async def test_a_finished_import_does_not_block_the_next_one( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: archive = zip_of(source, tmp_path / "out") _, first = await upload(authenticated_client, archive) await wait_for(authenticated_client, first["id"]) status, second = await upload(authenticated_client, archive) assert status == 202 await wait_for(authenticated_client, second["id"]) async def test_cancelling_stops_after_the_current_book( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: """ Cancelling is not aborting: a book abandoned mid-copy would leave files with no row. Whether this cancels before any book, after one, or after the lot is a race — the catalogue is three books long. What must hold either way is that the state is terminal and every book it did import is complete. """ _, job = await upload(authenticated_client, zip_of(source, tmp_path / "out")) cancelled = await authenticated_client.delete(f"/libraries/imports/{job['id']}") assert cancelled.status_code == 200 final = await wait_for(authenticated_client, job["id"]) assert final["state"] in {"cancelled", "finished"} listed = await authenticated_client.get("/books?library_id=1") for book in listed.json()["items"]: assert book["files"] async def test_failures_are_reported_on_the_job( authenticated_client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """One book failing is recorded and does not stop the run.""" fixture = CalibreFixture(tmp_path / "calibre") fixture.add_book(1, "Fine", authors=["A"], formats={"EPUB": EPUB}) fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB}) root = fixture.commit() from chitai.services.book import BookService original = BookService.create async def fail_on_the_second(self, data, *args, **kwargs): if isinstance(data, dict) and data.get("title") == "Doomed": raise RuntimeError("no room on the shelf") return await original(self, data, *args, **kwargs) monkeypatch.setattr(BookService, "create", fail_on_the_second) _, job = await upload(authenticated_client, zip_of(root, tmp_path / "out")) finished = await wait_for(authenticated_client, job["id"]) assert finished["state"] == "finished" assert finished["created"] == 1 assert finished["failed"] == 1 assert finished["failures"][0]["calibre_id"] == 2 assert "no room on the shelf" in finished["failures"][0]["reason"] async def test_a_second_copy_is_counted_as_a_possible_duplicate( authenticated_client: AsyncClient, tmp_path: Path ) -> None: """A count, not the records — the duplicates screen is what shows them.""" padded = tmp_path / "padded.epub" 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( 2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded} ) root = fixture.commit() _, job = await upload(authenticated_client, zip_of(root, tmp_path / "out")) finished = await wait_for(authenticated_client, job["id"]) assert finished["created"] == 2 assert finished["possible_duplicates"] == 1 async def test_polling_an_unknown_job(authenticated_client: AsyncClient) -> None: response = await authenticated_client.get("/libraries/imports/not-a-job") assert response.status_code == 404 async def test_cancelling_an_unknown_job(authenticated_client: AsyncClient) -> None: response = await authenticated_client.delete("/libraries/imports/not-a-job") assert response.status_code == 404 async def test_importing_into_a_library_that_does_not_exist( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: status, _ = await upload( authenticated_client, zip_of(source, tmp_path / "out"), library_id=999 ) assert status == 404 async def test_importing_into_a_read_only_library( authenticated_client: AsyncClient, source: Path, tmp_path: Path ) -> None: """A read-only library points at a tree Chitai does not own.""" root = tmp_path / "read-only" root.mkdir() created = await authenticated_client.post( "/libraries", json={"name": "Read Only", "root_path": str(root), "read_only": True}, ) assert created.status_code == 201 status, body = await upload( authenticated_client, zip_of(source, tmp_path / "out"), library_id=created.json()["id"], ) assert status == 400 assert "read-only" in body["detail"] async def test_an_import_needs_authentication( client: AsyncClient, source: Path, tmp_path: Path ) -> None: status, _ = await upload(client, zip_of(source, tmp_path / "out")) assert status == 401 class TestRefusedArchives: """Everything wrong with an archive is answered now, not as a job that fails later.""" async def test_a_hostile_archive( self, authenticated_client: AsyncClient, tmp_path: Path ) -> None: """Zip slip.""" archive = tmp_path / "hostile.zip" with zipfile.ZipFile(archive, "w") as writing: writing.writestr("metadata.db", "not really") writing.writestr("../../escaped.txt", "gotcha") status, body = await upload(authenticated_client, archive) assert status == 400 assert "outside itself" in body["detail"] assert registry._jobs == {} async def test_something_that_is_not_a_zip( self, authenticated_client: AsyncClient, tmp_path: Path ) -> None: archive = tmp_path / "notes.txt" archive.write_bytes(b"just some text") status, body = await upload(authenticated_client, archive) assert status == 400 assert "not a zip" in body["detail"] async def test_an_archive_with_no_catalogue( self, authenticated_client: AsyncClient, tmp_path: Path ) -> None: archive = tmp_path / "books.zip" with zipfile.ZipFile(archive, "w") as writing: writing.writestr("Some Book.epub", "content") status, body = await upload(authenticated_client, archive) assert status == 400 assert "no metadata.db" in body["detail"] async def test_a_refusal_leaves_no_temp_files( self, authenticated_client: AsyncClient, tmp_path: Path ) -> None: """Every refusal path removes the workspace it had already made.""" before = set(Path(tempfile.gettempdir()).glob("tmp*")) archive = tmp_path / "books.zip" with zipfile.ZipFile(archive, "w") as writing: writing.writestr("Some Book.epub", "content") await upload(authenticated_client, archive) assert set(Path(tempfile.gettempdir()).glob("tmp*")) == before