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.
This commit is contained in:
@@ -570,7 +570,7 @@ class TestDuplicateHandling:
|
||||
stored = book["files"][0]
|
||||
|
||||
response = await authenticated_client.post(
|
||||
"/books/duplicates?library_id=1",
|
||||
"/books/duplicate-files?library_id=1",
|
||||
json=[
|
||||
{
|
||||
"hash": stored["hash"],
|
||||
@@ -590,6 +590,117 @@ class TestDuplicateHandling:
|
||||
assert matches[0]["book_id"] == book["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDuplicateBooks:
|
||||
"""A second copy of a book is imported and reported, never refused."""
|
||||
|
||||
epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||
|
||||
def upload(self, name: str, pad: bool = False) -> list[tuple[str, tuple]]:
|
||||
"""
|
||||
The fixture, optionally padded so it is a different file and the same book.
|
||||
|
||||
Padding the archive changes its size and its sampled hash without disturbing
|
||||
the metadata, which is exactly the case the file-level check cannot see.
|
||||
"""
|
||||
data = self.epub_path.read_bytes() + (b"\0" * 64 if pad else b"")
|
||||
|
||||
return [("files", (name, data, "application/epub+zip"))]
|
||||
|
||||
async def test_a_second_edition_is_created_and_reported(
|
||||
self, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
first = await authenticated_client.post(
|
||||
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||
)
|
||||
original = first.json()["created"][0]
|
||||
|
||||
second = await authenticated_client.post(
|
||||
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
|
||||
)
|
||||
|
||||
assert second.status_code == 201
|
||||
body = second.json()
|
||||
|
||||
# Created, not skipped: a metadata match is a guess, and refusing a legitimate
|
||||
# second edition costs more than a note does.
|
||||
assert len(body["created"]) == 1
|
||||
assert body["skipped"] == []
|
||||
|
||||
assert len(body["possible_duplicates"]) == 1
|
||||
possible = body["possible_duplicates"][0]
|
||||
assert possible["book_id"] == body["created"][0]["id"]
|
||||
|
||||
candidate = possible["candidates"][0]
|
||||
assert candidate["book_id"] == original["id"]
|
||||
assert candidate["title"] == original["title"]
|
||||
assert candidate["authors"] == ["Franz Kafka"]
|
||||
assert "title-author" in candidate["matched_on"]
|
||||
|
||||
async def test_the_review_screen_groups_them(
|
||||
self, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
first = await authenticated_client.post(
|
||||
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||
)
|
||||
second = await authenticated_client.post(
|
||||
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
|
||||
)
|
||||
|
||||
book_ids = sorted(
|
||||
[first.json()["created"][0]["id"], second.json()["created"][0]["id"]]
|
||||
)
|
||||
|
||||
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
|
||||
|
||||
assert response.status_code == 200
|
||||
groups = response.json()
|
||||
assert len(groups) == 1
|
||||
assert [book["book_id"] for book in groups[0]["books"]] == book_ids
|
||||
|
||||
async def test_a_dismissed_group_stays_dismissed(
|
||||
self, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
first = await authenticated_client.post(
|
||||
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||
)
|
||||
second = await authenticated_client.post(
|
||||
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
|
||||
)
|
||||
|
||||
pair = {
|
||||
"book_a_id": first.json()["created"][0]["id"],
|
||||
"book_b_id": second.json()["created"][0]["id"],
|
||||
}
|
||||
|
||||
dismissed = await authenticated_client.post(
|
||||
"/books/duplicate-books/dismissals", json=pair
|
||||
)
|
||||
assert dismissed.status_code == 204
|
||||
|
||||
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
|
||||
assert response.json() == []
|
||||
|
||||
restored = await authenticated_client.delete(
|
||||
"/books/duplicate-books/dismissals"
|
||||
f"?book_a_id={pair['book_b_id']}&book_b_id={pair['book_a_id']}"
|
||||
)
|
||||
assert restored.status_code == 204
|
||||
|
||||
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
|
||||
assert len(response.json()) == 1
|
||||
|
||||
async def test_dismissing_an_unknown_book_is_refused(
|
||||
self, authenticated_client: AsyncClient
|
||||
) -> None:
|
||||
response = await authenticated_client.post(
|
||||
"/books/duplicate-books/dismissals",
|
||||
json={"book_a_id": 1, "book_b_id": 9999},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# NOTE: the multi-book ZIP download is covered at the service level, in
|
||||
# tests/unit/test_services/test_book_service.py. Driving `/books/download` through
|
||||
# AsyncTestClient hangs in fixture teardown: it is the only `Stream` endpoint in the
|
||||
|
||||
Reference in New Issue
Block a user