Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions src/api/openLibrary.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
36 changes: 36 additions & 0 deletions src/api/openLibrary.ts
Original file line number Diff line number Diff line change
@@ -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<Book[]> {
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 ?? [],
};
}
25 changes: 25 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading