tests / book-search.test.ts
tests / book-search.test.ts
import { describe, it, expect, vi, afterEach } from "vitest";
import { fetchPage } from "../src/net/http";
import { parseOpenLibraryBooks, parseLibraryOfCongressBooks, parseOpenAccessBooks, parseGutenbergBooks, searchBooks } from "../src/net/book-search";
vi.mock("../src/net/http", () => ({ fetchPage: vi.fn() }));
afterEach(() => vi.resetAllMocks());
describe("book catalog discovery", () => {
it("parses Open Library works with bibliographic details", () => {
expect(parseOpenLibraryBooks({ docs: [{ key: "/works/OL123W", title: "A Book", author_name: ["A. Author"], first_publish_year: 2001 }, { key: "../../hostile", title: "Bad" }] })).toEqual([
{ title: "A Book", authors: ["A. Author"], year: "2001", url: "https://openlibrary.org/works/OL123W", catalog: "Open Library" },
]);
});
it("only links Library of Congress records on the official domain", () => {
const result = parseLibraryOfCongressBooks({ results: [{ id: "http://www.loc.gov/item/123/", title: "Book", contributor: ["Author"], date: "1999" }, { id: "https://loc.gov.evil.test/item/1", title: "Bad" }] });
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ url: "https://www.loc.gov/item/123/", authors: ["Author"], year: "1999" });
});
it("parses English open-access DSpace records and rejects other languages or unsafe handles", () => {
const item = { type: "item", handle: "20.500.12854/26303", name: "Book", metadata: [{ key: "dc.language.iso", value: "eng" }, { key: "dc.contributor.author", value: "A. Author" }, { key: "dc.date.issued", value: "2020" }] };
const invalid = { ...item, handle: "../../bad" };
const french = { ...item, metadata: [{ key: "dc.language.iso", value: "fra" }] };
const books = parseOpenAccessBooks([item, invalid, french], "directory.doabooks.org", "DOAB");
expect(books).toEqual([{ title: "Book", authors: ["A. Author"], year: "2020", url: "https://directory.doabooks.org/handle/20.500.12854/26303", catalog: "DOAB" }]);
});
it("accepts English Gutenberg IDs and builds safe ebook links", () => {
const books = parseGutenbergBooks({ results: [{ id: 123, title: "Book", languages: ["en"], authors: [{ name: "Author" }] }, { id: 124, title: "Livre", languages: ["fr"] }] });
expect(books).toEqual([{ title: "Book", authors: ["Author"], url: "https://www.gutenberg.org/ebooks/123", catalog: "Project Gutenberg (free ebook)" }]);
});
it("combines catalogs and tolerates an unavailable provider", async () => {
vi.mocked(fetchPage).mockImplementation(async (url) => {
if (url.includes("loc.gov")) throw new Error("offline");
return { html: JSON.stringify({ docs: [{ key: "/works/OL123W", title: "A Book", author_name: ["Author"] }] }), finalUrl: url, statusCode: 200 };
});
const books = await searchBooks("book history", new AbortController().signal);
expect(books).toHaveLength(1);
expect(books[0].catalog).toBe("Open Library");
expect(fetchPage).toHaveBeenCalledTimes(5);
});
it("returns already completed catalog results when another times out", async () => {
vi.mocked(fetchPage).mockImplementation(async (url) => {
if (url.includes("loc.gov")) return new Promise(() => {});
return { html: JSON.stringify({ docs: [{ key: "/works/OL123W", title: "A Book" }] }), finalUrl: url };
});
const books = await searchBooks("book history", new AbortController().signal, 20);
expect(books).toHaveLength(1);
});
it("retains results from different catalogs when the combined list exceeds the cap", async () => {
vi.mocked(fetchPage).mockImplementation(async (url) => {
const data = url.includes("gutendex")
? { results: Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: `Gutenberg ${i}`, languages: ["en"] })) }
: url.includes("openlibrary")
? { docs: Array.from({ length: 12 }, (_, i) => ({ key: `/works/OL${i + 1}W`, title: `Library ${i}` })) }
: url.includes("loc.gov")
? { results: Array.from({ length: 12 }, (_, i) => ({ id: `https://www.loc.gov/item/${i}`, title: `Congress ${i}` })) }
: [];
return { html: JSON.stringify(data), finalUrl: url };
});
const books = await searchBooks("history", new AbortController().signal);
expect(books).toHaveLength(30);
expect(new Set(books.map((book) => book.catalog)).size).toBe(3);
});
});
import { describe, it, expect, vi, afterEach } from "vitest";
import { fetchPage } from "../src/net/http";
import { parseOpenLibraryBooks, parseLibraryOfCongressBooks, parseOpenAccessBooks, parseGutenbergBooks, searchBooks } from "../src/net/book-search";
vi.mock("../src/net/http", () => ({ fetchPage: vi.fn() }));
afterEach(() => vi.resetAllMocks());
describe("book catalog discovery", () => {
it("parses Open Library works with bibliographic details", () => {
expect(parseOpenLibraryBooks({ docs: [{ key: "/works/OL123W", title: "A Book", author_name: ["A. Author"], first_publish_year: 2001 }, { key: "../../hostile", title: "Bad" }] })).toEqual([
{ title: "A Book", authors: ["A. Author"], year: "2001", url: "https://openlibrary.org/works/OL123W", catalog: "Open Library" },
]);
});
it("only links Library of Congress records on the official domain", () => {
const result = parseLibraryOfCongressBooks({ results: [{ id: "http://www.loc.gov/item/123/", title: "Book", contributor: ["Author"], date: "1999" }, { id: "https://loc.gov.evil.test/item/1", title: "Bad" }] });
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ url: "https://www.loc.gov/item/123/", authors: ["Author"], year: "1999" });
});
it("parses English open-access DSpace records and rejects other languages or unsafe handles", () => {
const item = { type: "item", handle: "20.500.12854/26303", name: "Book", metadata: [{ key: "dc.language.iso", value: "eng" }, { key: "dc.contributor.author", value: "A. Author" }, { key: "dc.date.issued", value: "2020" }] };
const invalid = { ...item, handle: "../../bad" };
const french = { ...item, metadata: [{ key: "dc.language.iso", value: "fra" }] };
const books = parseOpenAccessBooks([item, invalid, french], "directory.doabooks.org", "DOAB");
expect(books).toEqual([{ title: "Book", authors: ["A. Author"], year: "2020", url: "https://directory.doabooks.org/handle/20.500.12854/26303", catalog: "DOAB" }]);
});
it("accepts English Gutenberg IDs and builds safe ebook links", () => {
const books = parseGutenbergBooks({ results: [{ id: 123, title: "Book", languages: ["en"], authors: [{ name: "Author" }] }, { id: 124, title: "Livre", languages: ["fr"] }] });
expect(books).toEqual([{ title: "Book", authors: ["Author"], url: "https://www.gutenberg.org/ebooks/123", catalog: "Project Gutenberg (free ebook)" }]);
});
it("combines catalogs and tolerates an unavailable provider", async () => {
vi.mocked(fetchPage).mockImplementation(async (url) => {
if (url.includes("loc.gov")) throw new Error("offline");
return { html: JSON.stringify({ docs: [{ key: "/works/OL123W", title: "A Book", author_name: ["Author"] }] }), finalUrl: url, statusCode: 200 };
});
const books = await searchBooks("book history", new AbortController().signal);
expect(books).toHaveLength(1);
expect(books[0].catalog).toBe("Open Library");
expect(fetchPage).toHaveBeenCalledTimes(5);
});
it("returns already completed catalog results when another times out", async () => {
vi.mocked(fetchPage).mockImplementation(async (url) => {
if (url.includes("loc.gov")) return new Promise(() => {});
return { html: JSON.stringify({ docs: [{ key: "/works/OL123W", title: "A Book" }] }), finalUrl: url };
});
const books = await searchBooks("book history", new AbortController().signal, 20);
expect(books).toHaveLength(1);
});
it("retains results from different catalogs when the combined list exceeds the cap", async () => {
vi.mocked(fetchPage).mockImplementation(async (url) => {
const data = url.includes("gutendex")
? { results: Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: `Gutenberg ${i}`, languages: ["en"] })) }
: url.includes("openlibrary")
? { docs: Array.from({ length: 12 }, (_, i) => ({ key: `/works/OL${i + 1}W`, title: `Library ${i}` })) }
: url.includes("loc.gov")
? { results: Array.from({ length: 12 }, (_, i) => ({ id: `https://www.loc.gov/item/${i}`, title: `Congress ${i}` })) }
: [];
return { html: JSON.stringify(data), finalUrl: url };
});
const books = await searchBooks("history", new AbortController().signal);
expect(books).toHaveLength(30);
expect(new Set(books.map((book) => book.catalog)).size).toBe(3);
});
});