feat: canonicalise author names

Extractors wrote whatever the file said, so one person held several rows:
"Sam Newman" beside "Newman, Sam;" beside "Sam Newman.epub", the last because
the upload path never stripped the file extension. Tidy on write, in the
validator and in the uniqueness lookup alike, and merge the rows that collide.
This commit is contained in:
2026-08-15 21:56:35 -04:00
parent 373c96d6d6
commit 8ee406533c
6 changed files with 328 additions and 9 deletions
@@ -895,6 +895,72 @@ class TestDuplicateBooks:
assert "title-author" in possible.candidates[0].matched_on
@pytest.mark.asyncio
class TestAuthorNames:
"""One person is one row, however the file happened to spell them."""
async def test_a_variant_spelling_reuses_the_existing_author(
self, books_service: BookService, test_library: m.Library
) -> None:
"""
`as_unique_async` looks a name up before inserting it, so the lookup and the
insert have to tidy identically. If they disagree, every variant misses the
existing row and then collides with it on the unique index.
"""
first = await store_book(
books_service, test_library, title="Building Microservices", authors=["Sam Newman"]
)
second = await store_book(
books_service,
test_library,
title="Monolith to Microservices",
authors=["Newman, Sam;"],
)
assert [author.name for author in first.authors] == ["Sam Newman"]
assert [author.name for author in second.authors] == ["Sam Newman"]
assert first.authors[0].id == second.authors[0].id
@pytest.mark.parametrize(
("written", "stored"),
[
("Newman, Sam;", "Sam Newman"),
("Sam Newman.epub", "Sam Newman"),
(" Sam Newman ", "Sam Newman"),
# Two people in one string is left exactly as it was found.
("Dave Thomas, Andy Hunt", "Dave Thomas, Andy Hunt"),
],
)
async def test_the_stored_name_is_the_tidy_one(
self,
books_service: BookService,
test_library: m.Library,
written: str,
stored: str,
) -> None:
book = await store_book(
books_service, test_library, title="Some Book", authors=[written]
)
assert [author.name for author in book.authors] == [stored]
async def test_an_upload_does_not_keep_the_file_extension(
self, books_service: BookService, test_library: m.Library
) -> None:
"""
A filename is the fallback when the file declares no author of its own, and
"Franz Kafka.epub" is not a person.
"""
result = await books_service.create_many_from_files(
BooksCreateFromFiles(files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]),
test_library,
)
book = await books_service.get(result.books[0].id)
for author in book.authors:
assert not author.name.endswith((".epub", ".pdf", ".mobi"))
@pytest.mark.asyncio
class TestDuplicateBookGroups:
"""The pass over a library someone already has."""