Files
chitai/backend/tests/unit/test_services/test_book_service.py
T
patrick 5047277845 feat: detect books that may already be in the library
Match on a shared identifier, or on a normalised title credited to a shared
author, and report candidates rather than refusing anything — a metadata match
is a guess, and a second edition is not a mistake. Adds a library-wide review
pass, dismissals, and renames the fingerprint pre-flight to duplicate-files.
2026-08-15 21:55:13 -04:00

998 lines
36 KiB
Python

"""Tests for BookService"""
import zipfile
from io import BytesIO
from pathlib import Path
import pytest
import aiofiles.os as aios
from litestar.datastructures import UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from chitai.config import DuplicateScope, settings
from chitai.schemas import BookCreate, BooksCreateFromFiles
from chitai.services import BookService
from chitai.services.book import DuplicateFilesError
from chitai.database import models as m
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"
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",
filename=name or path.name,
file_data=path.read_bytes(),
)
def edition(path: Path, name: str) -> UploadFile:
"""
Another edition of one of the fixtures: same book, different bytes.
Padding the archive changes the size and the sampled hash without disturbing
anything a reader or a metadata extractor sees, which is the case file-level
dedupe cannot answer and book-level detection exists for.
"""
return UploadFile(
content_type="application/epub+zip",
filename=name,
file_data=path.read_bytes() + b"\0" * 64,
)
@pytest.mark.asyncio
class TestBookServiceCRUD:
"""Test CRUD operation for libraries."""
async def test_update_book(
self, books_service: BookService, test_library: m.Library
) -> None:
book_data = BookCreate(
library_id=1,
title="Fellowship of the Ring",
authors=["J.R.R Tolkien"],
tags=["Fantasy"],
identifiers={"isbn-13": "9780261102354"},
pages=427,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
# 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]
books_service.repository.session.add(book)
await books_service.repository.session.commit()
await books_service.repository.session.refresh(book)
await books_service.update_book(
book.id,
{
"title": "The Fellowship of the Ring",
"identifiers": {"isbn-10": "9780261102354"},
"edition": 3,
"publisher": "Tolkien Estate",
"series": "The Lord of the Rings",
"series_position": "1",
"tags": ["Fantasy", "Adventure"],
},
test_library,
)
updated_book = await books_service.get(book.id)
# Assert updated information is correct
assert updated_book.title == "The Fellowship of the Ring"
assert (
updated_book.path
== f"{test_library.root_path}/J.R.R Tolkien/The Lord of the Rings/01 - The Fellowship of the Ring"
)
assert len(updated_book.identifiers)
assert updated_book.identifiers[0].value == "9780261102354"
assert updated_book.edition == 3
assert updated_book.publisher is not None
assert updated_book.publisher.name == "Tolkien Estate"
assert len(updated_book.tags) == 2
async def test_update_book_reuses_existing_links(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Re-submitting a relationship a book already has must not duplicate its link.
The link tables are unique on (book_id, tag_id) / (book_id, author_id), and
identifiers on (name, book_id), so replacing a collection wholesale used to
insert a row that collided with the one it was replacing.
"""
book_data = BookCreate(
library_id=1,
title="The Two Towers",
authors=["J.R.R Tolkien"],
tags=["Fantasy"],
identifiers={"isbn-13": "9780261102358"},
pages=352,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
# Matches what the default template generates, so these updates move nothing.
book.path = f"{test_library.root_path}/J.R.R Tolkien/The Two Towers"
await aios.makedirs(book.path) # type: ignore[arg-type]
books_service.repository.session.add(book)
await books_service.repository.session.commit()
await books_service.repository.session.refresh(book)
# Through the service, so the link collections come back eagerly loaded.
created_book = await books_service.get(book.id)
original_tag_link_id = created_book.tag_links[0].id
# Every collection resubmitted unchanged.
await books_service.update_book(
book.id,
{
"authors": ["J.R.R Tolkien"],
"tags": ["Fantasy"],
"identifiers": {"isbn-13": "9780261102358"},
},
test_library,
)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Fantasy"]
assert [author.name for author in updated_book.authors] == ["J.R.R Tolkien"]
assert len(updated_book.identifiers) == 1
# The existing link is reused, not deleted and reinserted.
assert updated_book.tag_links[0].id == original_tag_link_id
# Keeping one tag while adding another.
await books_service.update_book(
book.id, {"tags": ["Fantasy", "Adventure"]}, test_library
)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Fantasy", "Adventure"]
# Dropping one while keeping the other.
await books_service.update_book(book.id, {"tags": ["Adventure"]}, test_library)
updated_book = await books_service.get(book.id)
assert [tag.name for tag in updated_book.tags] == ["Adventure"]
# A name the book already carries has its value updated in place.
await books_service.update_book(
book.id, {"identifiers": {"isbn-13": "9780261102999"}}, test_library
)
updated_book = await books_service.get(book.id)
assert len(updated_book.identifiers) == 1
assert updated_book.identifiers[0].value == "9780261102999"
async def test_get_files_zips_every_book_file(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The multi-book download must contain the real bytes of each file.
`file.path` holds a bare filename relative to `book.path`, so resolving it on
its own matched nothing on disk and yielded a valid but empty archive.
"""
contents = {
"Dracula.epub": b"epub-payload-" * 512,
"Dracula.pdf": b"pdf-payload-" * 512,
}
book_data = BookCreate(
library_id=test_library.id,
title="Dracula",
authors=["Bram Stoker"],
pages=418,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
book.path = f"{test_library.root_path}/Bram Stoker/Dracula"
await aios.makedirs(book.path) # type: ignore[arg-type]
for name, payload in contents.items():
Path(book.path, name).write_bytes(payload)
book.files.append(
m.FileMetadata(
path=name,
size=len(payload),
hash=f"hash-{name}",
content_type=None,
)
)
books_service.repository.session.add(book)
await books_service.repository.session.commit()
archive_bytes = b"".join(
[
chunk
async for chunk in books_service.get_files([book.id], test_library.id)
]
)
with zipfile.ZipFile(BytesIO(archive_bytes)) as archive:
assert archive.testzip() is None
names = archive.namelist()
assert len(names) == len(contents)
# Entries are namespaced by the book's directory, so two books sharing a
# filename cannot overwrite one another.
assert {Path(name).parent.name for name in names} == {"Dracula"}
for name, payload in contents.items():
entry = next(n for n in names if Path(n).name == name)
assert archive.read(entry) == payload
@pytest.mark.asyncio
class TestBookServiceDuplicates:
"""Files that are already stored must not be stored again."""
async def test_duplicate_upload_is_skipped_and_reported(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The second import of a file creates nothing and names where it already is."""
first = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
assert len(first.books) == 1
assert first.duplicates == []
second = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
assert second.books == []
assert len(second.duplicates) == 1
duplicate = second.duplicates[0]
assert duplicate.filename == EPUB.name
assert duplicate.book_id == first.books[0].id
assert duplicate.book_title == first.books[0].title
async def test_new_format_beside_a_duplicate_is_still_imported(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A folder is not all-or-nothing: the file that is new must still land."""
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB, "Metamorphosis/book.epub")]),
test_library,
)
result = await books_service.create_many_from_files(
BooksCreateFromFiles(
files=[
upload(EPUB, "Metamorphosis/book.epub"),
upload(PDF, "Metamorphosis/book.pdf"),
]
),
test_library,
)
assert len(result.books) == 1
assert [d.filename for d in result.duplicates] == ["Metamorphosis/book.epub"]
book = await books_service.get(result.books[0].id)
assert [file.path for file in book.files] == ["book.pdf"]
async def test_duplicate_within_one_upload_is_caught(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The same bytes twice in one request has no row to match against yet."""
result = await books_service.create_many_from_files(
BooksCreateFromFiles(
files=[upload(EPUB, "first.epub"), upload(EPUB, "second.epub")]
),
test_library,
)
assert len(result.books) == 1
assert len(result.duplicates) == 1
assert result.duplicates[0].filename == "second.epub"
# Nothing in the database holds it yet, so there is no book to point at.
assert result.duplicates[0].book_id is None
async def test_allow_duplicates_stores_the_file_anyway(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The escape hatch has to work: the hash is not proof of identity."""
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]),
test_library,
allow_duplicates=True,
)
assert len(result.books) == 1
assert result.duplicates == []
async def test_a_different_file_is_not_a_duplicate(
self, books_service: BookService, test_library: m.Library
) -> None:
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB), upload(OTHER_EPUB)]), test_library
)
assert len(result.books) == 2
assert result.duplicates == []
async def test_matching_hash_with_a_different_size_is_not_a_duplicate(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The hash samples 12 KiB, so the size is what makes a match trustworthy."""
created = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
stored = (await books_service.get(created.books[0].id)).files[0]
matches = await books_service.find_duplicate_files(
[(stored.hash, stored.size), (stored.hash, stored.size + 1)], test_library
)
assert (stored.hash, stored.size) in matches
assert (stored.hash, stored.size + 1) not in matches
async def test_create_book_refuses_and_writes_nothing(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A single-book create names its files, so it is refused rather than trimmed."""
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
before = sorted(Path(test_library.root_path).rglob("*"))
with pytest.raises(DuplicateFilesError) as excinfo:
await books_service.create_book(
BookCreate(
library_id=test_library.id,
title="Metamorphosis",
authors=["Franz Kafka"],
files=[upload(EPUB)],
).model_dump(),
test_library,
)
assert len(excinfo.value.duplicates) == 1
assert sorted(Path(test_library.root_path).rglob("*")) == before
async def test_scope_decides_whether_libraries_share(
self,
books_service: BookService,
test_library: m.Library,
session: AsyncSession,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A second library is a separate collection by default, and not under `global`."""
other = m.Library(
name="Second Library",
slug="second-library",
root_path=str(tmp_path / "second"),
path_template=test_library.path_template,
read_only=False,
)
session.add(other)
await session.commit()
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), other
)
assert len(result.books) == 1
assert result.duplicates == []
monkeypatch.setattr(settings, "duplicate_scope", DuplicateScope.GLOBAL)
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), other
)
assert result.books == []
assert len(result.duplicates) == 1
async def test_scope_off_disables_detection(
self,
books_service: BookService,
test_library: m.Library,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "duplicate_scope", DuplicateScope.OFF)
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB), upload(EPUB, "copy.epub")]),
test_library,
)
assert len(result.books) == 2
assert result.duplicates == []
async def test_re_adding_a_file_to_its_own_book_does_nothing(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Asking for a state that already holds is not a conflict."""
created = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
book_id = created.books[0].id
await books_service.add_files(book_id, [upload(EPUB)], test_library)
book = await books_service.get(book_id)
assert len(book.files) == 1
async def test_adding_another_books_file_is_refused(
self, books_service: BookService, test_library: m.Library
) -> None:
created = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB), upload(OTHER_EPUB)]), test_library
)
first, second = created.books
with pytest.raises(DuplicateFilesError) as excinfo:
await books_service.add_files(first.id, [upload(OTHER_EPUB)], test_library)
assert excinfo.value.duplicates[0].book_id == second.id
assert len((await books_service.get(first.id)).files) == 1
async def test_consume_duplicate_is_moved_aside(
self,
books_service: BookService,
test_library: m.Library,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The watcher cannot ask, so a refused file is parked rather than dropped."""
quarantine = tmp_path / "duplicates"
monkeypatch.setattr(settings, "duplicate_path", str(quarantine))
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
consume = tmp_path / "consume"
await aios.makedirs(consume)
dropped = consume / EPUB.name
dropped.write_bytes(EPUB.read_bytes())
result = await books_service.create_many_from_existing_files(
[dropped], consume, test_library
)
assert result.books == []
assert len(result.duplicates) == 1
assert not dropped.exists()
assert (quarantine / test_library.slug / EPUB.name).is_file()
async def test_consume_imports_what_is_new(
self,
books_service: BookService,
test_library: m.Library,
tmp_path: Path,
) -> None:
"""The screening must not disturb the ordinary consume import."""
consume = tmp_path / "consume"
await aios.makedirs(consume / "Metamorphosis")
dropped = consume / "Metamorphosis" / EPUB.name
dropped.write_bytes(EPUB.read_bytes())
result = await books_service.create_many_from_existing_files(
[dropped], consume, test_library
)
assert len(result.books) == 1
assert result.duplicates == []
book = await books_service.get(result.books[0].id)
assert (Path(book.path) / book.files[0].path).is_file()
@pytest.mark.asyncio
class TestBookPathCollisions:
"""Two books must never share a directory, whatever their metadata says."""
async def test_forced_duplicate_gets_its_own_copy(
self, books_service: BookService, test_library: m.Library
) -> None:
"""`allow_duplicates` must add a book, not overwrite the one already there.
The path comes from the metadata alone, so a forced duplicate generates the
same directory and the same filename. Writing it lands on top of the original:
one file on disk, two books pointing at it, and deleting either takes both.
"""
first = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
second = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]),
test_library,
allow_duplicates=True,
)
original = await books_service.get(first.books[0].id)
forced = await books_service.get(second.books[0].id)
assert original.path != forced.path
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)
async def test_deleting_a_forced_duplicate_keeps_the_original(
self, books_service: BookService, test_library: m.Library
) -> None:
first = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
second = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]),
test_library,
allow_duplicates=True,
)
original = await books_service.get(first.books[0].id)
kept = Path(original.path) / original.files[0].path
await books_service.delete_books(
[second.books[0].id], test_library, delete_files=True
)
assert kept.is_file()
async def test_editing_metadata_cannot_merge_into_another_book(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A rename that collides must step aside rather than move in on top."""
first = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
second = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(OTHER_EPUB)]), test_library
)
original = await books_service.get(first.books[0].id)
# 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]},
test_library,
)
moved = await books_service.get(second.books[0].id)
assert moved.path != original.path
assert (Path(original.path) / original.files[0].path).is_file()
assert (Path(moved.path) / moved.files[0].path).is_file()
async def test_adding_a_file_lands_in_the_books_own_directory(
self, books_service: BookService, test_library: m.Library
) -> None:
"""`add_files` must follow the book, not the template."""
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
forced = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]),
test_library,
allow_duplicates=True,
)
book_id = forced.books[0].id
await books_service.add_files(book_id, [upload(PDF)], test_library)
book = await books_service.get(book_id)
assert len(book.files) == 2
for file in book.files:
assert (Path(book.path) / file.path).is_file()
@pytest.mark.asyncio
class TestMissingFiles:
"""A row whose bytes are gone must not stand in for the file itself."""
async def test_a_missing_file_is_not_a_duplicate(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Otherwise the library refuses to take back a file it can no longer open."""
created = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
book = await books_service.get(created.books[0].id)
(Path(book.path) / book.files[0].path).unlink()
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
assert len(result.books) == 1
assert result.duplicates == []
restored = await books_service.get(result.books[0].id)
assert (Path(restored.path) / restored.files[0].path).is_file()
async def test_re_adding_a_missing_file_puts_it_back(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The book already has a row for it, so the bytes go back where it says."""
created = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
book_id = created.books[0].id
book = await books_service.get(book_id)
path = Path(book.path) / book.files[0].path
path.unlink()
await books_service.add_files(book_id, [upload(EPUB)], test_library)
book = await books_service.get(book_id)
assert len(book.files) == 1
assert path.is_file()
assert path.read_bytes() == EPUB.read_bytes()
async def store_book(
books_service: BookService, library: m.Library, **metadata
) -> m.Book:
"""
A book in the database carrying exactly the metadata given, and no files.
Book-level matching reads metadata only, so going through the file pipeline would
just make every case depend on what a fixture EPUB happens to declare.
"""
book = await books_service.to_model_on_create(
BookCreate(library_id=library.id, **metadata).model_dump()
)
books_service.repository.session.add(book)
await books_service.repository.session.commit()
return book
@pytest.mark.asyncio
class TestDuplicateBooks:
"""The same book arriving as different bytes: reported, never refused."""
async def test_an_identifier_alone_is_enough(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Two printings of one edition agree on the ISBN and nothing else."""
stored = await store_book(
books_service,
test_library,
title="Metamorphosis",
authors=["Franz Kafka"],
identifiers={"isbn-13": "9780486282114"},
)
matches = await books_service.find_duplicate_books(
{
"title": "Die Verwandlung",
"authors": ["Someone Else"],
# The ISBN-10 of the same edition, written with its hyphens.
"identifiers": {"isbn-10": "0-486-28211-2"},
},
test_library,
)
assert [match.book_id for match in matches] == [stored.id]
assert matches[0].matched_on == ["identifier"]
assert matches[0].authors == ["Franz Kafka"]
async def test_a_title_and_a_shared_author_are_enough(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A re-scan carries no identifier at all, only what is on the cover."""
stored = await store_book(
books_service,
test_library,
title="The Metamorphosis",
authors=["Kafka, Franz"],
)
matches = await books_service.find_duplicate_books(
{"title": "Metamorphosis", "authors": ["Franz Kafka"]}, test_library
)
assert [match.book_id for match in matches] == [stored.id]
assert matches[0].matched_on == ["title-author"]
async def test_an_author_left_in_the_title_still_matches(
self, books_service: BookService, test_library: m.Library
) -> None:
"""
Files are named `Title - Author.epub`, and that name often became the title.
One copy stored as "Building Microservices" and another as "Building
Microservices Sam Newman" are the same book, and comparing the title columns
as they stand would never say so.
"""
stored = await store_book(
books_service,
test_library,
title="Building Microservices - Sam Newman",
authors=["Sam Newman"],
)
matches = await books_service.find_duplicate_books(
{"title": "Building Microservices", "authors": ["Newman, Sam;"]}, test_library
)
assert [match.book_id for match in matches] == [stored.id]
assert matches[0].matched_on == ["title-author"]
async def test_the_author_in_the_title_works_the_other_way_round(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Whichever copy arrived first, the comparison has to reach the other."""
stored = await store_book(
books_service,
test_library,
title="Building Microservices",
authors=["Newman, Sam;"],
)
matches = await books_service.find_duplicate_books(
{"title": "Building Microservices - Sam Newman", "authors": ["Sam Newman"]},
test_library,
)
assert [match.book_id for match in matches] == [stored.id]
async def test_a_shared_author_is_still_required(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The title variants must not become a way around the author requirement."""
await store_book(
books_service,
test_library,
title="Building Microservices - Sam Newman",
authors=["Sam Newman"],
)
assert (
await books_service.find_duplicate_books(
{"title": "Building Microservices", "authors": ["Martin Fowler"]},
test_library,
)
== []
)
async def test_a_title_without_a_shared_author_is_not_enough(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Otherwise every book the extractors gave up on matches every other one."""
await store_book(
books_service, test_library, title="Metamorphosis", authors=["Franz Kafka"]
)
assert (
await books_service.find_duplicate_books(
{"title": "Metamorphosis", "authors": ["Peter Kuper"]}, test_library
)
== []
)
async def test_a_book_with_no_authors_can_only_match_on_an_identifier(
self, books_service: BookService, test_library: m.Library
) -> None:
await store_book(
books_service, test_library, title="Unknown", authors=["Franz Kafka"]
)
assert (
await books_service.find_duplicate_books(
{"title": "Unknown", "authors": []}, test_library
)
== []
)
async def test_another_volume_of_a_series_is_not_a_duplicate(
self, books_service: BookService, test_library: m.Library
) -> None:
"""The position is the library saying outright that these are two books."""
await store_book(
books_service,
test_library,
title="Foundation",
authors=["Isaac Asimov"],
series="Foundation",
series_position="1",
)
incoming = {
"title": "Foundation",
"authors": ["Isaac Asimov"],
"series": "Foundation",
}
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
)
) == 1
async def test_a_book_is_not_its_own_duplicate(
self, books_service: BookService, test_library: m.Library
) -> None:
stored = await store_book(
books_service, test_library, title="Metamorphosis", authors=["Franz Kafka"]
)
assert (
await books_service.find_duplicate_books(
stored, test_library, exclude_book_id=stored.id
)
== []
)
async def test_scope_off_reports_nothing(
self,
books_service: BookService,
test_library: m.Library,
monkeypatch: pytest.MonkeyPatch,
) -> None:
await store_book(
books_service, test_library, title="Metamorphosis", authors=["Franz Kafka"]
)
monkeypatch.setattr(settings, "duplicate_scope", DuplicateScope.OFF)
incoming = {"title": "Metamorphosis", "authors": ["Franz Kafka"]}
assert await books_service.find_duplicate_books(incoming, test_library) == []
assert await books_service.find_duplicate_book_groups(test_library) == []
async def test_an_import_reports_the_copy_it_just_made(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Different bytes, same book — created, and said so."""
await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB)]), test_library
)
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[edition(EPUB, "second.epub")]), test_library
)
assert len(result.books) == 1
assert result.duplicates == []
assert len(result.possible_duplicates) == 1
possible = result.possible_duplicates[0]
assert possible.book_id == result.books[0].id
assert "title-author" in possible.candidates[0].matched_on
@pytest.mark.asyncio
class TestDuplicateBookGroups:
"""The pass over a library someone already has."""
async def test_groups_join_transitively(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A and B by ISBN, B and C by title and author, all three in one group."""
first = await store_book(
books_service,
test_library,
title="Metamorphosis",
authors=["Franz Kafka"],
identifiers={"isbn-13": "9780486282114"},
)
second = await store_book(
books_service,
test_library,
title="The Trial",
authors=["Franz Kafka"],
identifiers={"isbn-10": "0486282112"},
)
third = await store_book(
books_service, test_library, title="Trial", authors=["Kafka, Franz"]
)
groups = await books_service.find_duplicate_book_groups(test_library)
assert len(groups) == 1
assert [book.book_id for book in groups[0]] == [first.id, second.id, third.id]
reasons = {book.book_id: book.matched_on for book in groups[0]}
assert reasons[first.id] == ["identifier"]
assert reasons[second.id] == ["identifier", "title-author"]
assert reasons[third.id] == ["title-author"]
async def test_a_lone_book_is_not_a_group(
self, books_service: BookService, test_library: m.Library
) -> None:
await store_book(
books_service, test_library, title="Metamorphosis", authors=["Franz Kafka"]
)
assert await books_service.find_duplicate_book_groups(test_library) == []
async def test_a_dismissed_pair_is_left_out(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Disagreeing with one pairing must not break a group standing on another."""
first = await store_book(
books_service,
test_library,
title="Metamorphosis",
authors=["Franz Kafka"],
identifiers={"isbn-13": "9780486282114"},
)
second = await store_book(
books_service,
test_library,
title="The Trial",
authors=["Franz Kafka"],
identifiers={"isbn-10": "0486282112"},
)
third = await store_book(
books_service, test_library, title="Trial", authors=["Kafka, Franz"]
)
await books_service.dismiss_duplicates(second.id, first.id)
groups = await books_service.find_duplicate_book_groups(test_library)
assert [[book.book_id for book in group] for group in groups] == [
[second.id, third.id]
]
await books_service.restore_duplicates(first.id, second.id)
groups = await books_service.find_duplicate_book_groups(test_library)
assert [[book.book_id for book in group] for group in groups] == [
[first.id, second.id, third.id]
]
async def test_a_book_cannot_be_dismissed_against_itself(
self, books_service: BookService, test_library: m.Library
) -> None:
stored = await store_book(
books_service, test_library, title="Metamorphosis", authors=["Franz Kafka"]
)
with pytest.raises(ValueError):
await books_service.dismiss_duplicates(stored.id, stored.id)
async def test_dismissing_an_unknown_book_is_refused(
self, books_service: BookService, test_library: m.Library
) -> None:
stored = await store_book(
books_service, test_library, title="Metamorphosis", authors=["Franz Kafka"]
)
with pytest.raises(ValueError):
await books_service.dismiss_duplicates(stored.id, stored.id + 10_000)