Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/nperez0111/bookhive. Track your books, share your shelves, see what others are reading bookhive.buzz
atproto bluesky books bookshelf goodreads management-system
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377import { describe, it, expect, beforeEach, afterEach } from "bun:test";import { Database as DatabaseSync } from "bun:sqlite";import { Kysely, SqliteDialect } from "kysely";import { rm } from "node:fs/promises";import path from "node:path";
import { wrapBunSqliteForKysely } from "../bun-sqlite-kysely";import { migrateToLatest, type Database, type DatabaseSchema } from "../db";import { parseByteRange, streamPersonalBook } from "./personalLibrary";import { attachmentDisposition } from "./contentDisposition";import { canonicalDownloadFilename } from "./downloadFilename";
const DID = "did:plc:testuser";const HASH = "abc123";const now = "2026-08-02T12:00:00.000Z";
let db: Database;let filePath: string;
beforeEach(async () => { const sqlite = new DatabaseSync(":memory:"); sqlite.exec("PRAGMA journal_mode = WAL"); db = new Kysely<DatabaseSchema>({ dialect: new SqliteDialect({ database: wrapBunSqliteForKysely(sqlite) }), }); await migrateToLatest(db, sqlite);
filePath = path.join("/tmp", `personal-library-test-${HASH}.epub`); await Bun.write(filePath, "epub bytes");
await db .insertInto("personal_book") .values({ userDid: DID, contentHash: HASH, hiveId: null, filename: "book.epub", title: "A Personal Book", authors: "An Author", language: "en", format: "epub", mime: "application/epub+zip", filePath, coverPath: null, coverMime: null, sizeBytes: 10, createdAt: now, updatedAt: now, }) .execute();});
afterEach(async () => { await db.destroy(); await rm(filePath, { force: true });});
describe("streamPersonalBook", () => { it("streams the file with a strong ETag when there is no validator", async () => { const result = await streamPersonalBook(db, DID, HASH); expect(result).not.toBeNull(); expect(result!.status).toBe(200); expect(result!.headers["ETag"]).toBe(`"${HASH}"`); expect(result!.headers["Content-Type"]).toBe("application/epub+zip"); });
// The regression: these routes are excluded from hono's etag() middleware // because it buffers the whole body through a digest. That middleware was // also what turned If-None-Match into a 304 — setting the header alone does // not, so without this branch an e-reader re-downloads every book on every // sync. it("returns a 304 when If-None-Match matches the content hash", async () => { const result = await streamPersonalBook(db, DID, HASH, `"${HASH}"`); expect(result).not.toBeNull(); expect(result!.status).toBe(304); expect(result!.headers["ETag"]).toBe(`"${HASH}"`); expect(result!.stream).toBeNull(); });
it("handles the weak prefix, comma lists and wildcard clients send", async () => { for (const header of [`W/"${HASH}"`, `"other", "${HASH}"`, "*"]) { const result = await streamPersonalBook(db, DID, HASH, header); expect(result!.status).toBe(304); } });
it("still streams when the validator is for a different version", async () => { const result = await streamPersonalBook(db, DID, HASH, `"stale-hash"`); expect(result!.status).toBe(200); });
it("answers the conditional request without touching the file", async () => { // A 304 must not depend on the file still being readable — otherwise a // missing file turns a cheap revalidation into a 404 for a book the client // already has. await rm(filePath, { force: true }); const result = await streamPersonalBook(db, DID, HASH, `"${HASH}"`); expect(result!.status).toBe(304); });
it("returns null for another user's book", async () => { expect(await streamPersonalBook(db, "did:plc:someoneelse", HASH)).toBeNull(); });});
describe("range requests", () => { // "epub bytes" — 10 bytes, matching sizeBytes above. const SIZE = 10;
const read = async (stream: ReadableStream | null) => stream ? await new Response(stream).text() : null;
it("advertises Accept-Ranges on a plain download", async () => { const result = await streamPersonalBook(db, DID, HASH); expect(result!.headers["Accept-Ranges"]).toBe("bytes"); expect(result!.headers["Content-Length"]).toBe(String(SIZE)); });
it("advertises Accept-Ranges on the 304 too", async () => { // The client that needs to resume is precisely the one that has seen a // validator before, so the header has to survive revalidation. const result = await streamPersonalBook(db, DID, HASH, `"${HASH}"`); expect(result!.status).toBe(304); expect(result!.headers["Accept-Ranges"]).toBe("bytes"); });
it("serves a 206 with the right slice, Content-Range and Content-Length", async () => { const result = await streamPersonalBook(db, DID, HASH, null, { range: "bytes=5-8" }); expect(result!.status).toBe(206); expect(result!.headers["Content-Range"]).toBe(`bytes 5-8/${SIZE}`); expect(result!.headers["Content-Length"]).toBe("4"); expect(await read(result!.stream)).toBe("byte"); });
it("resumes an open-ended range to the end of the file", async () => { const result = await streamPersonalBook(db, DID, HASH, null, { range: "bytes=5-" }); expect(result!.status).toBe(206); expect(result!.headers["Content-Range"]).toBe(`bytes 5-9/${SIZE}`); expect(await read(result!.stream)).toBe("bytes"); });
it("serves a suffix range", async () => { const result = await streamPersonalBook(db, DID, HASH, null, { range: "bytes=-5" }); expect(result!.status).toBe(206); expect(result!.headers["Content-Range"]).toBe(`bytes 5-9/${SIZE}`); expect(await read(result!.stream)).toBe("bytes"); });
it("answers a past-the-end range with 416 and the real length", async () => { const result = await streamPersonalBook(db, DID, HASH, null, { range: "bytes=99-" }); expect(result!.status).toBe(416); expect(result!.stream).toBeNull(); expect(result!.headers["Content-Range"]).toBe(`bytes */${SIZE}`); });
it("ignores a Range whose If-Range no longer matches the file", async () => { // The file changed under the client, so the half it already holds is from a // different book; only the whole new representation is a correct answer. const result = await streamPersonalBook(db, DID, HASH, null, { range: "bytes=5-", ifRange: '"some-older-hash"', }); expect(result!.status).toBe(200); expect(result!.headers["Content-Length"]).toBe(String(SIZE)); expect(await read(result!.stream)).toBe("epub bytes"); });
it("honours a Range whose If-Range still matches", async () => { const result = await streamPersonalBook(db, DID, HASH, null, { range: "bytes=5-", ifRange: `"${HASH}"`, }); expect(result!.status).toBe(206); });});
describe("parseByteRange", () => { it("ignores anything it does not serve rather than failing the request", () => { // RFC 9110 lets a server ignore a Range it doesn't want to handle. A 200 // with the whole file is always a correct answer; a 416 is not. expect(parseByteRange(undefined, 10)).toBeNull(); expect(parseByteRange("items=0-5", 10)).toBeNull(); expect(parseByteRange("bytes=0-1,5-6", 10)).toBeNull(); // multipart/byteranges expect(parseByteRange("bytes=abc", 10)).toBeNull(); });
it("clamps an over-long end to the last byte", () => { expect(parseByteRange("bytes=2-999", 10)).toEqual({ start: 2, end: 9 }); });
it("treats an inverted or out-of-bounds range as unsatisfiable", () => { expect(parseByteRange("bytes=8-3", 10)).toBe("unsatisfiable"); expect(parseByteRange("bytes=10-", 10)).toBe("unsatisfiable"); expect(parseByteRange("bytes=-0", 10)).toBe("unsatisfiable"); expect(parseByteRange("bytes=0-", 0)).toBe("unsatisfiable"); });
it("caps a suffix longer than the file at the whole file", () => { expect(parseByteRange("bytes=-99", 10)).toEqual({ start: 0, end: 9 }); });});
describe("Content-Disposition", () => { it("carries both parameter forms", async () => { const result = await streamPersonalBook(db, DID, HASH); expect(result!.headers["Content-Disposition"]).toBe( `attachment; filename="book.epub"; filename*=UTF-8''book.epub`, ); });
// The regression: encodeURIComponent leaves `'` alone, and `'` is the // delimiter inside an RFC 8187 ext-value, so the old header truncated every // title with an apostrophe in it at the apostrophe. it("percent-encodes the characters encodeURIComponent leaves alone", () => { const name = "The Handmaid's Tale (Special)!~*.epub"; const header = attachmentDisposition(name, canonicalDownloadFilename(name, "epub")); expect(header).not.toContain("''The Handmaid'"); const extValue = header.split("filename*=UTF-8''")[1]!; // `!` and `~` are attr-char, so they are correctly left as-is; `'`, `(`, // `)` and `*` are not, and are exactly what the old encoding let through. expect(extValue).toBe("The%20Handmaid%27s%20Tale%20%28Special%29!~%2A.epub"); expect(decodeURIComponent(extValue)).toBe("The Handmaid's Tale (Special)!~*.epub"); });
it("keeps a plain ASCII filename for clients that ignore filename*", () => { // Without this a reader that can't parse filename* falls back to the URL's // last segment, which used to be the extension-less "download". const name = "Война и мир.epub"; const header = attachmentDisposition(name, canonicalDownloadFilename(name, "epub")); expect(header).toContain(`filename="`); const plain = /filename="([^"]*)"/.exec(header)![1]!; expect(plain).toMatch(/\.epub$/); expect(plain).toMatch(/^[\x20-\x7E]*$/); expect(decodeURIComponent(header.split("filename*=UTF-8''")[1]!)).toBe("Война и мир.epub"); });
it("escapes the quoted-string metacharacters", () => { const name = 'a"b\\c.epub'; const header = attachmentDisposition(name, canonicalDownloadFilename(name, "epub")); expect(/filename="([^"]*)"/.exec(header)![1]).toBe("a_b_c.epub"); });
// The plain parameter is a quoted-string. A caller passing something other // than `canonicalDownloadFilename` (which cannot produce either character) // must not be able to close the quote and append parameters of its own. it("escapes a quote or backslash reaching the plain filename", () => { const header = attachmentDisposition("x.epub", 'a".epub"; x=y\\z'); expect(/filename="((?:[^"\\]|\\.)*)"/.exec(header)![1]).toBe('a\\".epub\\"; x=y\\\\z'); expect(header).not.toContain("\r"); });
it("never emits an empty plain filename", () => { expect( /filename="([^"]*)"/.exec( attachmentDisposition("книга", canonicalDownloadFilename("книга", "epub")), )![1], ).not.toBe(""); });});
describe("serving a derived EPUB", () => { const epubPath = "/tmp/personal-library-test-derived.epub";
const linkEpub = async () => { await Bun.write(epubPath, "converted epub bytes"); await db .updateTable("personal_book") .set({ epubPath, epubSizeBytes: 20 }) .where("userDid", "=", DID) .where("contentHash", "=", HASH) .execute(); };
it("serves the converted bytes, not the original", async () => { await linkEpub(); const result = await streamPersonalBook(db, DID, HASH); expect(result!.status).toBe(200); expect(await new Response(result!.stream).text()).toBe("converted epub bytes"); expect(result!.headers["Content-Type"]).toBe("application/epub+zip"); });
// The trap: contentHash is the hash of the *original*. Reusing it bare would // tell a client holding the MOBI that it already has the EPUB, and it would // keep the stale copy forever. it("uses a distinct validator from the original representation", async () => { const before = await streamPersonalBook(db, DID, HASH); expect(before!.headers["ETag"]).toBe(`"${HASH}"`);
await linkEpub(); const after = await streamPersonalBook(db, DID, HASH); expect(after!.headers["ETag"]).toBe(`"${HASH}-epub"`);
// A client revalidating with the original's validator must be sent the // new bytes, not a 304. const revalidated = await streamPersonalBook(db, DID, HASH, `"${HASH}"`); expect(revalidated!.status).toBe(200); // ...and the EPUB's own validator still earns its 304. const fresh = await streamPersonalBook(db, DID, HASH, `"${HASH}-epub"`); expect(fresh!.status).toBe(304); });
// `epubPath` points at a second file the quota does not account for and that // nothing re-derives. If it goes missing, failing the download would lose the // user the original too — which is right there on disk. it("falls back to the original when the derived EPUB has gone missing", async () => { await db .updateTable("personal_book") .set({ format: "mobi", mime: "application/x-mobipocket-ebook" }) .where("contentHash", "=", HASH) .execute(); await linkEpub(); await rm(epubPath, { force: true });
const result = await streamPersonalBook(db, DID, HASH); expect(result!.status).toBe(200); expect(await new Response(result!.stream).text()).toBe("epub bytes"); // ...as the original representation throughout, not an EPUB label over // MOBI bytes: a client that cached under the `-epub` validator must not be // told these are the same thing. expect(result!.headers["ETag"]).toBe(`"${HASH}"`); expect(result!.headers["Content-Type"]).toBe("application/x-mobipocket-ebook"); });
it("names the download with the served extension", async () => { await linkEpub(); const result = await streamPersonalBook(db, DID, HASH); expect(result!.headers["Content-Disposition"]).toContain('filename="book.epub"'); });
// The regression: `filename*` was derived from the canonical ASCII name, so // both parameters carried the lossy form. A title with no ASCII form lost // itself entirely on conversion, while the same file served un-converted // kept its name. it("keeps the user's real filename in filename* when serving a conversion", async () => { await db .updateTable("personal_book") .set({ filename: "Война и мир.mobi", format: "mobi" }) .where("contentHash", "=", HASH) .execute(); await linkEpub();
const header = (await streamPersonalBook(db, DID, HASH))!.headers["Content-Disposition"]!; const extValue = header.split("filename*=UTF-8''")[1]!; // Only the extension moves to the served format; the stem survives intact. expect(decodeURIComponent(extValue)).toBe("Война и мир.epub"); // The ASCII fallback is still the lossy form — that is what it is for. expect(header).toContain('filename="book.epub"'); });
it("swaps only the extension for a Latin filename", async () => { await db .updateTable("personal_book") .set({ filename: "Dune Messiah.azw3", format: "mobi" }) .where("contentHash", "=", HASH) .execute(); await linkEpub();
const header = (await streamPersonalBook(db, DID, HASH))!.headers["Content-Disposition"]!; expect(decodeURIComponent(header.split("filename*=UTF-8''")[1]!)).toBe("Dune Messiah.epub"); expect(header).toContain('filename="Dune_Messiah.epub"'); });
it("ranges over the converted file's length, not the original's", async () => { await linkEpub(); const result = await streamPersonalBook(db, DID, HASH, null, { range: "bytes=0-8" }); expect(result!.status).toBe(206); expect(result!.headers["Content-Range"]).toBe("bytes 0-8/20"); expect(await new Response(result!.stream).text()).toBe("converted"); });
it("falls back to the original when no EPUB was derived", async () => { const result = await streamPersonalBook(db, DID, HASH); expect(await new Response(result!.stream).text()).toBe("epub bytes"); expect(result!.headers["ETag"]).toBe(`"${HASH}"`); });});