feat: import a Calibre library
Reads metadata.db and copies the books into a library — from a zip uploaded on the library settings page, or from a path with `litestar calibre-import`. The source is never touched, and re-running only picks up what is new. Also names the formats mimetypes does not know: a Calibre library is full of MOBI and AZW3, and a null content type used to fail the book endpoint.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
Build a Calibre library on disk, for tests to read.
|
||||
|
||||
Generated rather than committed as a binary `metadata.db`, because the rows worth
|
||||
testing are the awkward ones — the year-101 pubdate, a `|` in an author name, a REAL
|
||||
series index, HTML in a comment — and those are clearer written out in Python than
|
||||
hidden inside a blob.
|
||||
|
||||
The schema below is Calibre's own, copied from a real library's `sqlite_master`, reduced
|
||||
to the tables the reader touches. `books_pages_link` is created separately by
|
||||
`add_pages`: it is recent, and a library made by an older Calibre will not have it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT 'Unknown' COLLATE NOCASE,
|
||||
sort TEXT COLLATE NOCASE,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
pubdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
series_index REAL NOT NULL DEFAULT 1.0,
|
||||
author_sort TEXT COLLATE NOCASE,
|
||||
path TEXT NOT NULL DEFAULT '',
|
||||
uuid TEXT,
|
||||
has_cover BOOL DEFAULT 0,
|
||||
last_modified TIMESTAMP NOT NULL DEFAULT '2000-01-01 00:00:00+00:00'
|
||||
);
|
||||
CREATE TABLE authors (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
|
||||
);
|
||||
CREATE TABLE books_authors_link (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, author INTEGER NOT NULL,
|
||||
UNIQUE(book, author)
|
||||
);
|
||||
CREATE TABLE publishers (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
|
||||
);
|
||||
CREATE TABLE books_publishers_link (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, publisher INTEGER NOT NULL,
|
||||
UNIQUE(book)
|
||||
);
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||
link TEXT NOT NULL DEFAULT '', UNIQUE (name)
|
||||
);
|
||||
CREATE TABLE books_tags_link (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, tag INTEGER NOT NULL,
|
||||
UNIQUE(book, tag)
|
||||
);
|
||||
CREATE TABLE series (
|
||||
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE (name)
|
||||
);
|
||||
CREATE TABLE books_series_link (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, series INTEGER NOT NULL,
|
||||
UNIQUE(book)
|
||||
);
|
||||
CREATE TABLE languages (
|
||||
id INTEGER PRIMARY KEY, lang_code TEXT NOT NULL COLLATE NOCASE,
|
||||
link TEXT NOT NULL DEFAULT '', UNIQUE(lang_code)
|
||||
);
|
||||
CREATE TABLE books_languages_link (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, lang_code INTEGER NOT NULL,
|
||||
item_order INTEGER NOT NULL DEFAULT 0, UNIQUE(book, lang_code)
|
||||
);
|
||||
CREATE TABLE comments (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
|
||||
text TEXT NOT NULL COLLATE NOCASE, UNIQUE(book)
|
||||
);
|
||||
CREATE TABLE identifiers (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'isbn' COLLATE NOCASE,
|
||||
val TEXT NOT NULL COLLATE NOCASE, UNIQUE(book, type)
|
||||
);
|
||||
CREATE TABLE data (
|
||||
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
|
||||
format TEXT NOT NULL COLLATE NOCASE, uncompressed_size INTEGER NOT NULL,
|
||||
name TEXT NOT NULL, UNIQUE(book, format)
|
||||
);
|
||||
"""
|
||||
|
||||
# Calibre's own "no date". Stored, never null, and a valid date — which is exactly why
|
||||
# it has to be recognised rather than parsed.
|
||||
UNDEFINED_DATE = "0101-01-01 00:00:00+00:00"
|
||||
|
||||
# What a `cover.jpg` that PIL cannot read looks like. Real libraries hold these, from
|
||||
# an interrupted download or a failed conversion.
|
||||
CORRUPT_COVER = b"\xff\xd8\xff\xe0 not really a jpeg"
|
||||
|
||||
|
||||
def write_cover(path: Path) -> None:
|
||||
"""
|
||||
Write a real, readable JPEG.
|
||||
|
||||
Generated with PIL rather than embedded as a hex blob: a hand-rolled JPEG that is
|
||||
subtly malformed fails inside the import as an unrelated error, which is exactly the
|
||||
confusion this avoids.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
Image.new("RGB", (2, 3), (10, 20, 30)).save(path, "JPEG")
|
||||
|
||||
|
||||
class CalibreFixture:
|
||||
"""A Calibre library being assembled under `root`."""
|
||||
|
||||
def __init__(self, root: Path) -> None:
|
||||
self.root = root
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.connection = sqlite3.connect(self.root / "metadata.db")
|
||||
self.connection.executescript(SCHEMA)
|
||||
|
||||
def add_pages_table(self) -> None:
|
||||
"""Add `books_pages_link`, which only a recent Calibre creates."""
|
||||
self.connection.executescript(
|
||||
"""
|
||||
CREATE TABLE books_pages_link (
|
||||
book INTEGER PRIMARY KEY,
|
||||
pages INTEGER DEFAULT 0 NOT NULL,
|
||||
algorithm INTEGER DEFAULT 0 NOT NULL,
|
||||
format TEXT DEFAULT '' NOT NULL COLLATE NOCASE,
|
||||
format_size INTEGER DEFAULT 0 NOT NULL,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
needs_scan INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def add_book(
|
||||
self,
|
||||
book_id: int,
|
||||
title: str,
|
||||
*,
|
||||
authors: list[str] | None = None,
|
||||
pubdate: str = UNDEFINED_DATE,
|
||||
series: str | None = None,
|
||||
series_index: float = 1.0,
|
||||
tags: list[str] | None = None,
|
||||
publisher: str | None = None,
|
||||
languages: list[str] | None = None,
|
||||
comment: str | None = None,
|
||||
identifiers: dict[str, str] | None = None,
|
||||
uuid: str | None = None,
|
||||
pages: int | None = None,
|
||||
cover: bool = False,
|
||||
corrupt_cover: bool = False,
|
||||
formats: dict[str, Path] | None = None,
|
||||
directory: str | None = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Add one book, with its files laid out the way Calibre lays them out.
|
||||
|
||||
Args:
|
||||
formats: Format name (`EPUB`) to a real file to copy in. Its on-disk stem is
|
||||
Calibre's, not the title — that is the point of the `data` table.
|
||||
directory: Override the `books.path` value, for testing a row whose
|
||||
directory is not where the convention would put it.
|
||||
|
||||
Returns:
|
||||
The book's directory.
|
||||
"""
|
||||
author_names = authors or ["Unknown"]
|
||||
relative = directory or f"{author_names[0]}/{title} ({book_id})"
|
||||
book_directory = self.root / relative
|
||||
book_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.connection.execute(
|
||||
"INSERT INTO books (id, title, pubdate, series_index, path, uuid, has_cover) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
book_id,
|
||||
title,
|
||||
pubdate,
|
||||
series_index,
|
||||
relative,
|
||||
uuid or f"uuid-{book_id}",
|
||||
int(cover or corrupt_cover),
|
||||
),
|
||||
)
|
||||
|
||||
for name in author_names:
|
||||
self._link("authors", "books_authors_link", "author", book_id, name)
|
||||
|
||||
for name in tags or []:
|
||||
self._link("tags", "books_tags_link", "tag", book_id, name)
|
||||
|
||||
if series:
|
||||
self._link("series", "books_series_link", "series", book_id, series)
|
||||
|
||||
if publisher:
|
||||
self._link(
|
||||
"publishers", "books_publishers_link", "publisher", book_id, publisher
|
||||
)
|
||||
|
||||
for order, code in enumerate(languages or []):
|
||||
language_id = self._lookup("languages", "lang_code", code)
|
||||
self.connection.execute(
|
||||
"INSERT INTO books_languages_link (book, lang_code, item_order) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(book_id, language_id, order),
|
||||
)
|
||||
|
||||
if comment is not None:
|
||||
self.connection.execute(
|
||||
"INSERT INTO comments (book, text) VALUES (?, ?)", (book_id, comment)
|
||||
)
|
||||
|
||||
for name, value in (identifiers or {}).items():
|
||||
self.connection.execute(
|
||||
"INSERT INTO identifiers (book, type, val) VALUES (?, ?, ?)",
|
||||
(book_id, name, value),
|
||||
)
|
||||
|
||||
if pages is not None:
|
||||
self.connection.execute(
|
||||
"INSERT INTO books_pages_link (book, pages) VALUES (?, ?)",
|
||||
(book_id, pages),
|
||||
)
|
||||
|
||||
if corrupt_cover:
|
||||
(book_directory / "cover.jpg").write_bytes(CORRUPT_COVER)
|
||||
elif cover:
|
||||
write_cover(book_directory / "cover.jpg")
|
||||
|
||||
for format, origin in (formats or {}).items():
|
||||
# Calibre's on-disk stem: sanitised, truncated, and not the title.
|
||||
stem = f"{title[:40]} - {author_names[0]}".replace(":", "_")
|
||||
destination = book_directory / f"{stem}.{format.lower()}"
|
||||
shutil.copy(origin, destination)
|
||||
|
||||
self.connection.execute(
|
||||
"INSERT INTO data (book, format, uncompressed_size, name) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(book_id, format, destination.stat().st_size, stem),
|
||||
)
|
||||
|
||||
return book_directory
|
||||
|
||||
def add_missing_format(self, book_id: int, format: str, stem: str) -> None:
|
||||
"""Record a file in the catalogue without putting one on disk."""
|
||||
self.connection.execute(
|
||||
"INSERT INTO data (book, format, uncompressed_size, name) VALUES (?, ?, ?, ?)",
|
||||
(book_id, format, 1234, stem),
|
||||
)
|
||||
|
||||
def _link(
|
||||
self, table: str, link_table: str, column: str, book_id: int, name: str
|
||||
) -> None:
|
||||
item_id = self._lookup(table, "name", name)
|
||||
self.connection.execute(
|
||||
f"INSERT INTO {link_table} (book, {column}) VALUES (?, ?)",
|
||||
(book_id, item_id),
|
||||
)
|
||||
|
||||
def _lookup(self, table: str, column: str, value: str) -> int:
|
||||
row = self.connection.execute(
|
||||
f"SELECT id FROM {table} WHERE {column} = ?", (value,)
|
||||
).fetchone()
|
||||
|
||||
if row:
|
||||
return int(row[0])
|
||||
|
||||
cursor = self.connection.execute(
|
||||
f"INSERT INTO {table} ({column}) VALUES (?)", (value,)
|
||||
)
|
||||
return int(cursor.lastrowid or 0)
|
||||
|
||||
def commit(self) -> Path:
|
||||
"""Finish writing and return the library root."""
|
||||
self.connection.commit()
|
||||
self.connection.close()
|
||||
return self.root
|
||||
@@ -1272,3 +1272,94 @@ class TestFileManagement:
|
||||
)
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""
|
||||
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
|
||||
@@ -0,0 +1,392 @@
|
||||
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="<p>A travelling salesman.</p><p>He wakes up <i>changed</i>.</p>",
|
||||
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"),
|
||||
[
|
||||
("<p>One.</p><p>Two.</p>", "One.\nTwo."),
|
||||
("Plain text", "Plain text"),
|
||||
("<div>A<br>B</div>", "A\nB"),
|
||||
("<p>Café & bar</p>", "Café & bar"),
|
||||
("<ul><li>One</li><li>Two</li></ul>", "One\nTwo"),
|
||||
# Markup carrying no text at all is nothing, not an empty description.
|
||||
("<p></p>", 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()) == []
|
||||
@@ -0,0 +1,64 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chitai.services.utils import guess_content_type
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "expected"),
|
||||
[
|
||||
# What `mimetypes` already knows, kept here so a host with a thin
|
||||
# /etc/mime.types cannot change the answer without a test noticing.
|
||||
("Frankenstein.epub", "application/epub+zip"),
|
||||
("Calculus.pdf", "application/pdf"),
|
||||
("Persepolis.azw3", "application/vnd.amazon.mobi8-ebook"),
|
||||
("Watchmen.cbz", "application/vnd.comicbook+zip"),
|
||||
# What it does not, and where a Calibre library's older formats live.
|
||||
("Dune.mobi", "application/x-mobipocket-ebook"),
|
||||
("Dune.prc", "application/x-mobipocket-ebook"),
|
||||
("Dune.azw", "application/vnd.amazon.ebook"),
|
||||
("Voyna i Mir.fb2", "application/x-fictionbook+xml"),
|
||||
("Voyna i Mir.fbz", "application/x-zip-compressed-fb2"),
|
||||
("Reader.lit", "application/x-ms-reader"),
|
||||
("Reader.lrf", "application/x-sony-bbeb"),
|
||||
("Watchmen.cb7", "application/x-cb7"),
|
||||
# Case is not part of the answer, and Calibre writes formats uppercase.
|
||||
("Dune.MOBI", "application/x-mobipocket-ebook"),
|
||||
# Nothing can name these, and None is the answer rather than a placeholder.
|
||||
("Notes.xyzzy", None),
|
||||
("README", None),
|
||||
],
|
||||
)
|
||||
def test_guess_content_type(filename: str, expected: str | None) -> None:
|
||||
assert guess_content_type(Path(filename)) == expected
|
||||
# A str and a Path must agree, and an upload's `filename` carries its relative
|
||||
# path, so a name with directories in front of it has to resolve the same way.
|
||||
assert guess_content_type(filename) == expected
|
||||
assert guess_content_type(f"Some Author/Some Book/{filename}") == expected
|
||||
|
||||
|
||||
def test_fallback_is_used_only_when_the_extension_says_nothing() -> None:
|
||||
"""A client's claim fills a gap; it never overrides the name."""
|
||||
assert (
|
||||
guess_content_type(Path("Dune.mobi"), fallback="application/pdf")
|
||||
== "application/x-mobipocket-ebook"
|
||||
)
|
||||
assert (
|
||||
guess_content_type(Path("Notes.xyzzy"), fallback="application/epub+zip")
|
||||
== "application/epub+zip"
|
||||
)
|
||||
|
||||
|
||||
def test_an_unspecified_fallback_is_not_an_answer() -> None:
|
||||
"""
|
||||
`application/octet-stream` from a client is it saying it does not know.
|
||||
|
||||
Browsers post exactly that for every extension they do not recognise, which is most
|
||||
ebook formats. Storing it would be indistinguishable from having determined a
|
||||
format, so it is discarded and the column keeps its null.
|
||||
"""
|
||||
assert (
|
||||
guess_content_type(Path("Notes.xyzzy"), fallback="application/octet-stream")
|
||||
is None
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for BookPathGenerator."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from chitai.services.filesystem_library import BookPathGenerator, sanitize_path_component
|
||||
|
||||
|
||||
ROOT = Path("/library")
|
||||
|
||||
|
||||
def path_for(**book) -> Path:
|
||||
return BookPathGenerator(ROOT).generate_path(book)
|
||||
|
||||
|
||||
def test_author_and_title() -> None:
|
||||
assert path_for(title="Dune", authors=["Frank Herbert"]) == (
|
||||
ROOT / "Frank Herbert" / "Dune"
|
||||
)
|
||||
|
||||
|
||||
def test_a_book_with_no_authors() -> None:
|
||||
assert path_for(title="Beowulf", authors=[]) == ROOT / "Unknown" / "Beowulf"
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
|
||||
"""
|
||||
The separators in the path come from the template, never from the metadata.
|
||||
|
||||
A title with a slash in it — "AC/DC", "Him/Her" — would otherwise put the book one
|
||||
level below where `book.path` says it is, which is what deletes, moves and file
|
||||
lookups all act on. Calibre keeps the real title in its database and strips this
|
||||
from its own directory names, so an import is where they surface.
|
||||
"""
|
||||
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")
|
||||
|
||||
|
||||
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"
|
||||
|
||||
|
||||
def test_control_characters_are_removed() -> None:
|
||||
assert path_for(title="Line\nBreak", authors=["Someone"]) == (
|
||||
ROOT / "Someone" / "Line_Break"
|
||||
)
|
||||
|
||||
|
||||
def test_sanitize_path_component() -> None:
|
||||
assert sanitize_path_component("AC/DC") == "AC_DC"
|
||||
assert sanitize_path_component("back\\slash") == "back_slash"
|
||||
assert sanitize_path_component(" padded ") == "padded"
|
||||
# Colons and other punctuation are legal in a path and are left alone.
|
||||
assert sanitize_path_component("Title: Subtitle") == "Title: Subtitle"
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Tests for importing a Calibre library through BookService."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chitai.config import settings
|
||||
from chitai.database import models as m
|
||||
from chitai.services import BookService
|
||||
from chitai.services.calibre import CalibreLibrary
|
||||
|
||||
from tests.calibre_fixtures import CalibreFixture
|
||||
|
||||
|
||||
DATA_FILES = Path("tests/data_files")
|
||||
EPUB = DATA_FILES / "Metamorphosis - Franz Kafka.epub"
|
||||
OTHER_EPUB = DATA_FILES / "The Art of War - Sun Tzu.epub"
|
||||
PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
|
||||
|
||||
|
||||
@pytest.fixture(name="calibre_root")
|
||||
def fx_calibre_root(tmp_path: Path) -> Path:
|
||||
"""Three books: one plain, one in two formats, one Chitai cannot use."""
|
||||
fixture = CalibreFixture(tmp_path / "source")
|
||||
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"],
|
||||
comment="<p>He wakes up <i>changed</i>.</p>",
|
||||
identifiers={"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"},
|
||||
uuid="11111111-2222-3333-4444-555555555555",
|
||||
pages=201,
|
||||
cover=True,
|
||||
formats={"EPUB": EPUB},
|
||||
)
|
||||
|
||||
fixture.add_book(
|
||||
2,
|
||||
"The Art of War",
|
||||
authors=["Sun Tzu"],
|
||||
series="Classics",
|
||||
series_index=3.0,
|
||||
formats={"EPUB": OTHER_EPUB, "PDF": PDF},
|
||||
)
|
||||
|
||||
fixture.add_book(3, "Metadata Only", authors=["Nobody"])
|
||||
|
||||
return fixture.commit()
|
||||
|
||||
|
||||
async def library_of(root: Path) -> CalibreLibrary:
|
||||
source = CalibreLibrary(root)
|
||||
await source.open()
|
||||
return source
|
||||
|
||||
|
||||
async def test_imports_a_catalogue(
|
||||
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||
) -> None:
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert result.total == 3
|
||||
assert len(result.created) == 2
|
||||
|
||||
# The book with no files is left out: a record with nothing to read, and a directory
|
||||
# to match, is worse than not importing it.
|
||||
assert [skipped.calibre_id for skipped in result.skipped] == [3]
|
||||
assert result.skipped[0].reason == "no files in the catalogue"
|
||||
assert result.failed == []
|
||||
|
||||
book = await books_service.get(result.created[0])
|
||||
|
||||
assert book.title == "The Metamorphosis"
|
||||
assert [author.name for author in book.authors] == ["Franz Kafka"]
|
||||
assert sorted(tag.name for tag in book.tags) == ["Absurdist", "Fiction"]
|
||||
assert book.publisher is not None and book.publisher.name == "Kurt Wolff Verlag"
|
||||
assert book.published_date is not None and book.published_date.year == 1915
|
||||
assert book.language == "deu"
|
||||
assert book.pages == 201
|
||||
assert book.cover_image is not None
|
||||
|
||||
# The HTML is gone; `Book.description` is rendered as text.
|
||||
assert book.description == "He wakes up changed."
|
||||
|
||||
|
||||
async def test_two_formats_are_one_book(
|
||||
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||
) -> None:
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
book = await books_service.get(result.created[1])
|
||||
|
||||
assert book.title == "The Art of War"
|
||||
assert sorted(Path(file.path).suffix for file in book.files) == [".epub", ".pdf"]
|
||||
|
||||
# A REAL series index reaches the column as the string everything else writes.
|
||||
assert book.series is not None and book.series.title == "Classics"
|
||||
assert book.series_position == "3"
|
||||
|
||||
|
||||
async def test_identifiers_are_folded_onto_chitai_schemes(
|
||||
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||
) -> None:
|
||||
"""
|
||||
`amazon` becomes `asin`, a hyphenated ISBN survives, and the Calibre uuid is kept.
|
||||
|
||||
The uuid is deliberately not stored under `uuid`, which duplicate matching ignores
|
||||
because an EPUB regenerates one per build. Calibre's is stable, so it is the durable
|
||||
link back to the row it came from.
|
||||
"""
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
book = await books_service.get(result.created[0])
|
||||
identifiers = {identifier.name: identifier.value for identifier in book.identifiers}
|
||||
|
||||
assert identifiers["asin"] == "B01N5IB20Q"
|
||||
assert identifiers["isbn-13"] == "9780486290300"
|
||||
assert identifiers["calibre-uuid"] == "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
matching = {
|
||||
identifier.name: identifier.normalized_value for identifier in book.identifiers
|
||||
}
|
||||
|
||||
# Stored under its own name, matched under one scheme for both ISBN forms.
|
||||
assert matching["isbn-13"] == "isbn:9780486290300"
|
||||
|
||||
# And the uuid carries a real matching key, which is the whole reason it is not
|
||||
# filed under `uuid`.
|
||||
assert matching["calibre-uuid"] is not None
|
||||
|
||||
|
||||
async def test_the_source_library_is_left_alone(
|
||||
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||
) -> None:
|
||||
"""Files are copied. Moving them would leave `metadata.db` pointing at nothing."""
|
||||
before = {
|
||||
path: path.stat().st_mtime_ns
|
||||
for path in sorted(calibre_root.rglob("*"))
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
after = {
|
||||
path: path.stat().st_mtime_ns
|
||||
for path in sorted(calibre_root.rglob("*"))
|
||||
if path.is_file()
|
||||
}
|
||||
|
||||
assert after == before
|
||||
|
||||
# And the copies are really there, under the library's own layout.
|
||||
for book_id in result.created:
|
||||
book = await books_service.get(book_id)
|
||||
for file in book.files:
|
||||
assert (Path(book.path or "") / file.path).is_file()
|
||||
|
||||
|
||||
async def test_importing_twice_creates_nothing(
|
||||
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||
) -> None:
|
||||
"""
|
||||
Re-running is safe with no bookkeeping: the bytes are recognised wherever they sit.
|
||||
|
||||
This is what makes an interrupted import resumable by simply running it again.
|
||||
"""
|
||||
for _ in range(2):
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert result.created == []
|
||||
assert sorted(skipped.reason for skipped in result.skipped) == [
|
||||
"already stored",
|
||||
"already stored",
|
||||
"no files in the catalogue",
|
||||
]
|
||||
|
||||
held_by = [
|
||||
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)
|
||||
|
||||
|
||||
async def test_a_file_the_catalogue_lists_but_disk_does_not(
|
||||
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||
) -> None:
|
||||
"""Calibre keeps the row when a file is moved away behind its back."""
|
||||
fixture = CalibreFixture(tmp_path / "source")
|
||||
fixture.add_book(1, "Present", authors=["A"], formats={"EPUB": EPUB})
|
||||
fixture.add_book(2, "Absent", authors=["B"])
|
||||
fixture.add_missing_format(2, "EPUB", "Absent - B")
|
||||
root = fixture.commit()
|
||||
|
||||
source = await library_of(root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert len(result.created) == 1
|
||||
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(
|
||||
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||
) -> None:
|
||||
"""
|
||||
A failure is recorded and the run continues, leaving no files behind for it.
|
||||
|
||||
An orphaned directory would make the next attempt reserve `title (2)` and look as
|
||||
though it had worked.
|
||||
"""
|
||||
fixture = CalibreFixture(tmp_path / "source")
|
||||
fixture.add_book(1, "First", authors=["A"], formats={"EPUB": EPUB})
|
||||
fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB})
|
||||
fixture.add_book(3, "Third", authors=["C"], formats={"PDF": PDF})
|
||||
root = fixture.commit()
|
||||
|
||||
original = books_service.create
|
||||
|
||||
async def fail_on_the_second(data, *args, **kwargs):
|
||||
if isinstance(data, dict) and data.get("title") == "Doomed":
|
||||
raise RuntimeError("no room on the shelf")
|
||||
return await original(data, *args, **kwargs)
|
||||
|
||||
books_service.create = fail_on_the_second # type: ignore[method-assign]
|
||||
|
||||
source = await library_of(root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
books_service.create = original # type: ignore[method-assign]
|
||||
|
||||
assert len(result.created) == 2
|
||||
assert len(result.failed) == 1
|
||||
assert result.failed[0].calibre_id == 2
|
||||
assert "no room on the shelf" in result.failed[0].reason
|
||||
|
||||
# Nothing of the failed book was left in the library. Checked against the path the
|
||||
# template would have produced, rather than by walking the root — the Calibre source
|
||||
# sits under it in these tests, and its own files are meant to still be there.
|
||||
assert not (Path(test_library.root_path) / "B").exists()
|
||||
|
||||
# And the books either side of it are where they should be.
|
||||
for book_id in result.created:
|
||||
book = await books_service.get(book_id)
|
||||
for file in book.files:
|
||||
assert (Path(book.path or "") / file.path).is_file()
|
||||
|
||||
|
||||
async def test_a_cover_that_cannot_be_read_is_not_fatal(
|
||||
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||
) -> None:
|
||||
"""
|
||||
A truncated `cover.jpg` costs the cover, not the book.
|
||||
|
||||
Real libraries hold them, from an interrupted download or a failed conversion, and
|
||||
the cover is the one thing in the directory that can be replaced from the book page.
|
||||
"""
|
||||
fixture = CalibreFixture(tmp_path / "source")
|
||||
fixture.add_book(
|
||||
1, "Unreadable Cover", authors=["A"], corrupt_cover=True, formats={"EPUB": EPUB}
|
||||
)
|
||||
root = fixture.commit()
|
||||
|
||||
source = await library_of(root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert result.failed == []
|
||||
assert len(result.created) == 1
|
||||
|
||||
book = await books_service.get(result.created[0])
|
||||
assert book.cover_image is None
|
||||
assert len(book.files) == 1
|
||||
|
||||
|
||||
async def test_shared_authors_and_tags_are_one_row_each(
|
||||
books_service: BookService, test_library: m.Library, tmp_path: Path, session
|
||||
) -> None:
|
||||
"""Two books by one author must not produce two `Author` rows."""
|
||||
fixture = CalibreFixture(tmp_path / "source")
|
||||
fixture.add_book(
|
||||
1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB}
|
||||
)
|
||||
fixture.add_book(
|
||||
2, "Two", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": OTHER_EPUB}
|
||||
)
|
||||
root = fixture.commit()
|
||||
|
||||
source = await library_of(root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert len(result.created) == 2
|
||||
|
||||
first, second = [await books_service.get(book_id) for book_id in result.created]
|
||||
|
||||
assert first.authors[0].id == second.authors[0].id
|
||||
assert first.tags[0].id == second.tags[0].id
|
||||
|
||||
|
||||
async def test_a_second_copy_is_reported_not_refused(
|
||||
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||
) -> None:
|
||||
"""
|
||||
Two catalogue rows for one book, with different bytes, both import.
|
||||
|
||||
File-level dedupe cannot see it — the archives differ — so book-level detection
|
||||
reports the pair and leaves the decision to the reader.
|
||||
"""
|
||||
padded = tmp_path / "padded.epub"
|
||||
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
|
||||
|
||||
fixture = CalibreFixture(tmp_path / "source")
|
||||
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()
|
||||
|
||||
source = await library_of(root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert len(result.created) == 2
|
||||
assert len(result.possible_duplicates) == 1
|
||||
assert result.possible_duplicates[0].candidates[0].book_id == result.created[0]
|
||||
|
||||
|
||||
async def test_allow_duplicates_stores_the_same_bytes_again(
|
||||
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||
) -> None:
|
||||
for allow in (False, True):
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(
|
||||
source, test_library, allow_duplicates=allow
|
||||
)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert len(result.created) == 2
|
||||
|
||||
|
||||
async def test_duplicate_scope_off_imports_everything(
|
||||
books_service: BookService,
|
||||
test_library: m.Library,
|
||||
calibre_root: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "duplicate_scope", "off")
|
||||
|
||||
for _ in range(2):
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
result = await books_service.create_many_from_calibre(source, test_library)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert len(result.created) == 2
|
||||
|
||||
|
||||
async def test_progress_is_reported_per_book(
|
||||
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||
) -> None:
|
||||
"""The import is long enough that its progress is the only thing worth watching."""
|
||||
seen = []
|
||||
|
||||
source = await library_of(calibre_root)
|
||||
try:
|
||||
await books_service.create_many_from_calibre(
|
||||
source, test_library, on_progress=seen.append
|
||||
)
|
||||
finally:
|
||||
await source.close()
|
||||
|
||||
assert [progress.processed for progress in seen] == [1, 2, 3]
|
||||
assert all(progress.total == 3 for progress in seen)
|
||||
assert [progress.outcome for progress in seen] == ["created", "created", "skipped"]
|
||||
assert seen[0].title == "The Metamorphosis"
|
||||
Reference in New Issue
Block a user