fix: download every selected book, not just the last
ci / backend (push) Failing after 3m3s
ci / frontend (push) Successful in 2m2s

The frontend built book_ids with `set` rather than `append`, so the archive
only ever held one book. The proxy also sent an empty Content-Length, which
is not valid HTTP/1.1, and buffered the whole zip before forwarding a byte
of it.

Entries now carry the file's own name, suffixed when two books claim the
same one, and the books are resolved before the stream starts so a miss is
a 404 instead of a truncated archive under a 200.
This commit is contained in:
2026-09-14 11:04:47 -04:00
parent 412a73cedf
commit ead43b2562
6 changed files with 230 additions and 58 deletions
@@ -221,7 +221,9 @@ class TestBookServiceCRUD:
archive_bytes = b"".join(
[
chunk
async for chunk in books_service.get_files([book.id], test_library.id)
async for chunk in await books_service.get_files(
[book.id], test_library.id
)
]
)
@@ -231,13 +233,79 @@ class TestBookServiceCRUD:
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"}
# Entries carry the file's own name, with no directory in front of it.
assert set(names) == set(contents)
for name, payload in contents.items():
entry = next(n for n in names if Path(n).name == name)
assert archive.read(entry) == payload
assert archive.read(name) == payload
async def test_get_files_suffixes_a_name_claimed_twice(
self, books_service: BookService, test_library: m.Library
) -> None:
"""Two books can each hold a `Dune.epub`; the archive must keep both.
Entries are named after the file, and a zip stores the same name twice
happily — leaving whichever copy was written last as the one that survives
extraction. The second is suffixed instead.
"""
payloads = {
"Herbert": b"herbert-payload-" * 512,
"Other": b"other-payload-" * 512,
}
book_ids = []
for author, payload in payloads.items():
book_data = BookCreate(
library_id=test_library.id,
title="Dune",
authors=[author],
pages=412,
)
book = await books_service.to_model_on_create(book_data.model_dump())
assert isinstance(book, m.Book)
book.path = f"{test_library.root_path}/{author}/Dune"
await aios.makedirs(book.path) # type: ignore[arg-type]
Path(book.path, "Dune.epub").write_bytes(payload)
book.files.append(
m.FileMetadata(
path="Dune.epub",
size=len(payload),
hash=f"hash-{author}",
content_type=None,
)
)
books_service.repository.session.add(book)
await books_service.repository.session.commit()
book_ids.append(book.id)
archive_bytes = b"".join(
[
chunk
async for chunk in await books_service.get_files(
book_ids, test_library.id
)
]
)
with zipfile.ZipFile(BytesIO(archive_bytes)) as archive:
assert archive.testzip() is None
assert sorted(archive.namelist()) == ["Dune (2).epub", "Dune.epub"]
stored = {archive.read(name) for name in archive.namelist()}
assert stored == set(payloads.values())
async def test_get_files_refuses_a_selection_with_nothing_in_it(
self, books_service: BookService, test_library: m.Library
) -> None:
"""A miss is a 404, raised before the response starts.
Resolving lazily inside the stream meant an empty selection produced a valid
22-byte archive under a 200, which tells the caller nothing went wrong.
"""
with pytest.raises(FileNotFoundError):
await books_service.get_files([424242], test_library.id)
@pytest.mark.asyncio