From b72e0daba92d39900df1b62deb4500379c3b495c Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Wed, 2 Sep 2026 14:25:45 -0600 Subject: [PATCH 1/2] feat: add OpenLibrary types and API client (#4) - src/types.ts: Book domain type and OpenLibrary search response types - src/api/openLibrary.ts: searchBooksBySubject with field-filtered requests, coverUrl helper, normalization with defensive defaults - src/api/openLibrary.test.ts: unit tests with stubbed fetch (no network) covering normalization, request params, error handling, cover urls Closes #4 --- src/api/openLibrary.test.ts | 80 +++++++++++++++++++++++++++++++++++++ src/api/openLibrary.ts | 36 +++++++++++++++++ src/types.ts | 25 ++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 src/api/openLibrary.test.ts create mode 100644 src/api/openLibrary.ts create mode 100644 src/types.ts diff --git a/src/api/openLibrary.test.ts b/src/api/openLibrary.test.ts new file mode 100644 index 0000000..3c2df49 --- /dev/null +++ b/src/api/openLibrary.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { BOOKS_PER_SUBJECT, coverUrl, searchBooksBySubject } from "./openLibrary"; + +const searchResponse = { + numFound: 2, + start: 0, + docs: [ + { + key: "/works/OL1", + title: "A Book", + author_name: ["Jane Doe"], + first_publish_year: 2001, + cover_i: 123, + subject: ["fantasy"], + }, + { + key: "/works/OL2", + title: "Bare Bones", + }, + ], +}; + +describe("searchBooksBySubject", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("normalizes docs into books with defaults for missing fields", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => searchResponse }), + ); + const books = await searchBooksBySubject("fantasy"); + expect(books).toHaveLength(2); + expect(books[0]).toEqual({ + key: "/works/OL1", + title: "A Book", + authors: ["Jane Doe"], + firstPublishYear: 2001, + coverId: 123, + subjects: ["fantasy"], + }); + expect(books[1]).toEqual({ + key: "/works/OL2", + title: "Bare Bones", + authors: [], + firstPublishYear: null, + coverId: null, + subjects: [], + }); + }); + + it("requests the configured limit and only the needed fields", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ docs: [] }) }); + vi.stubGlobal("fetch", fetchMock); + await searchBooksBySubject("history"); + const url = new URL(fetchMock.mock.calls[0][0] as string); + expect(url.searchParams.get("subject")).toBe("history"); + expect(url.searchParams.get("limit")).toBe(String(BOOKS_PER_SUBJECT)); + expect(url.searchParams.get("fields")).toContain("title"); + }); + + it("throws on a failed response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({}) }), + ); + await expect(searchBooksBySubject("fantasy")).rejects.toThrow("status 500"); + }); +}); + +describe("coverUrl", () => { + it("builds a cover url for a cover id", () => { + expect(coverUrl(123)).toBe("https://covers.openlibrary.org/b/id/123-L.jpg"); + }); + + it("returns null when there is no cover", () => { + expect(coverUrl(null)).toBeNull(); + }); +}); diff --git a/src/api/openLibrary.ts b/src/api/openLibrary.ts new file mode 100644 index 0000000..eafb841 --- /dev/null +++ b/src/api/openLibrary.ts @@ -0,0 +1,36 @@ +import type { Book, CoverSize, OpenLibrarySearchDoc, OpenLibrarySearchResponse } from "../types"; + +const SEARCH_URL = "https://openlibrary.org/search.json"; +const COVER_URL = "https://covers.openlibrary.org/b/id"; + +export const BOOKS_PER_SUBJECT = 10; + +const BOOK_FIELDS = "key,title,author_name,first_publish_year,cover_i,subject"; + +export async function searchBooksBySubject( + subject: string, + limit: number = BOOKS_PER_SUBJECT, +): Promise { + const url = `${SEARCH_URL}?subject=${encodeURIComponent(subject)}&limit=${limit}&fields=${BOOK_FIELDS}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`OpenLibrary search failed with status ${response.status}`); + } + const data: OpenLibrarySearchResponse = await response.json(); + return (data.docs ?? []).map(toBook); +} + +export function coverUrl(coverId: number | null, size: CoverSize = "L"): string | null { + return coverId === null ? null : `${COVER_URL}/${coverId}-${size}.jpg`; +} + +function toBook(doc: OpenLibrarySearchDoc): Book { + return { + key: doc.key, + title: doc.title, + authors: doc.author_name ?? [], + firstPublishYear: doc.first_publish_year ?? null, + coverId: doc.cover_i ?? null, + subjects: doc.subject ?? [], + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..be55fa3 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,25 @@ +export interface Book { + key: string; + title: string; + authors: string[]; + firstPublishYear: number | null; + coverId: number | null; + subjects: string[]; +} + +export interface OpenLibrarySearchDoc { + key: string; + title: string; + author_name?: string[]; + first_publish_year?: number; + cover_i?: number; + subject?: string[]; +} + +export interface OpenLibrarySearchResponse { + numFound?: number; + start?: number; + docs?: OpenLibrarySearchDoc[]; +} + +export type CoverSize = "S" | "M" | "L"; From 889b753981b91a10b02ca21f60b320a7f425ba12 Mon Sep 17 00:00:00 2001 From: Erik Hjelm Fjeldheim Date: Sat, 5 Sep 2026 14:10:23 -0600 Subject: [PATCH 2/2] docs: refresh roadmap status after PR #3 merge --- ROADMAP.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e4931c6..3e49dcc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -15,15 +15,12 @@ In progress → In review → Done); issues also carry `status:*` labels. | M4 — Quality | Tue 15/9 | Responsive CSS, accessibility, Vitest suite, manual browser/device checks | | M5 — Delivery | Thu 17/9 | Apache deployment, README, delivery artifacts (#5) | -## Status (updated 4/9) +## Status (updated 5/9) -- PR #3 — template, CI/CD pipeline, docs — **awaiting review** (any team member - can review it) -- #4 — types + API client — implemented and verified (8 tests) on - `feat/4-types-api-client` (pushed); PR opens right after #3 merges -- #11–#14 — M2 issues created and assigned: robertky #11 BookCard, erikhfj #12 +- PR #3 — **merged** (template, CI/CD pipeline, docs); issues #1/#2 closed +- This PR — closes #4 (types + API client, verified with 8 unit tests); unblocks M2 +- #11–#14 — M2 issues assigned and Ready: robertky #11 BookCard, erikhfj #12 useBooks + App wiring, evenkkl #13 NavigationControls, rachelks #14 BookJumpList -- #7–#10 — closed as not planned: duplicates of work already in #3/#4 - #6 — VM prepped (Apache, rsync, sudoers, runner v2.337.0); registration waits on repository admin from staff; guide: [docs/ci-runner.md](docs/ci-runner.md) - #5 — delivery artifacts (timeliste, VM, FeedbackFruits) — open