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
5 changes: 5 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
padding: 1.5rem;
}

.book-viewer {
max-width: 32rem;
margin: 0 auto;
}

.app-footer {
padding: 0.75rem;
text-align: center;
Expand Down
47 changes: 35 additions & 12 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,45 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { http, HttpResponse, delay } from "msw";
import { screen } from "@testing-library/react";
import App from "./App";
import { withProviders } from "./test/utils";
import { server } from "./test/server";
import { emptySearchHandler, failingSearchHandler } from "./test/handlers";

describe("App", () => {
it("renders the main heading", () => {
render(<App />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent("Digital Library");
it("shows a loading indicator while the request is in flight", async () => {
server.use(
http.get("https://openlibrary.org/search.json", async () => {
await delay(200);
return HttpResponse.json({ docs: [] });
}),
);
withProviders(<App />);
expect(screen.getByRole("status")).toHaveTextContent("Loading books");
});

it("renders a banner and main content region", () => {
render(<App />);
expect(screen.getByRole("banner")).toBeInTheDocument();
expect(screen.getByRole("main")).toBeInTheDocument();
it("renders the first book once loaded", async () => {
withProviders(<App />);
const heading = await screen.findByRole("heading", { name: "The Hobbit" });
expect(heading).toBeInTheDocument();
expect(screen.getByRole("region", { name: "Book viewer" })).toBeInTheDocument();
});

it("matches the snapshot", () => {
const { container } = render(<App />);
it("shows an error message when the request fails", async () => {
server.use(failingSearchHandler);
withProviders(<App />);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not load books");
});

it("shows an empty message when no books match the subject", async () => {
server.use(emptySearchHandler);
withProviders(<App />);
expect(await screen.findByText(/No books found/)).toBeInTheDocument();
});

it("matches the snapshot", async () => {
const { container } = withProviders(<App />);
await screen.findByRole("heading", { level: 2 });
expect(container).toMatchSnapshot();
});
});
18 changes: 17 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import { BookCard } from "./components/BookCard";
import { useBooks } from "./hooks/useBooks";
import "./App.css";

const SUBJECT = "fantasy";

function App() {
const { books, isLoading, isError } = useBooks(SUBJECT);
const current = books[0];

return (
<div className="app">
<header className="app-header">
<h1>Digital Library</h1>
<p>Browse reading material from OpenLibrary, one book at a time.</p>
</header>
<main className="app-main">
<p>Book browsing is coming soon.</p>
{isLoading && <p role="status">Loading books…</p>}
{isError && <p role="alert">Could not load books. Please try refreshing the page.</p>}
{!isLoading && !isError && books.length === 0 && (
<p>No books found for the subject “{SUBJECT}”.</p>
)}
{current && (
<section className="book-viewer" aria-label="Book viewer">
<BookCard book={current} />
</section>
)}
</main>
<footer className="app-footer">
<p>Data from the OpenLibrary API.</p>
Expand Down
47 changes: 44 additions & 3 deletions src/__snapshots__/App.test.tsx.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,50 @@ exports[`App > matches the snapshot 1`] = `
<main
class="app-main"
>
<p>
Book browsing is coming soon.
</p>
<section
aria-label="Book viewer"
class="book-viewer"
>
<article
class="book-card"
>
<div
class="book-card-cover"
>
<img
alt="Cover of The Hobbit"
src="https://covers.openlibrary.org/b/id/123-L.jpg"
/>
</div>
<div
class="book-card-content"
>
<h2>
The Hobbit
</h2>
<p
class="book-card-authors"
>
J. R. R. Tolkien
</p>
<p>
First published:
1937
</p>
<ul
aria-label="Subjects"
class="book-card-subjects"
>
<li>
fantasy
</li>
<li>
dragons
</li>
</ul>
</div>
</article>
</section>
</main>
<footer
class="app-footer"
Expand Down
32 changes: 32 additions & 0 deletions src/hooks/useBooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from "vitest";
import { waitFor } from "@testing-library/react";
import { useBooks } from "./useBooks";
import { renderHookWithProviders } from "../test/utils";
import { fixtureDocs, getSearchRequestCount, resetSearchRequestCount } from "../test/handlers";

describe("useBooks", () => {
beforeEach(() => {
resetSearchRequestCount();
});

it("returns books from the OpenLibrary search endpoint", async () => {
const { result } = renderHookWithProviders(() => useBooks("fantasy"));
await waitFor(() => expect(result.current.books).toHaveLength(fixtureDocs.length));
expect(result.current.books[0]?.title).toBe(fixtureDocs[0]?.title);
expect(result.current.isError).toBe(false);
});

it("keeps the cache warm per subject instead of refetching", async () => {
const { result, rerender } = renderHookWithProviders(
({ subject }: { subject: string }) => useBooks(subject),
{ initialProps: { subject: "fantasy" } },
);
await waitFor(() => expect(result.current.books).not.toHaveLength(0));

rerender({ subject: "fantasy" });
expect(getSearchRequestCount()).toBe(1);

rerender({ subject: "mystery" });
await waitFor(() => expect(getSearchRequestCount()).toBe(2));
});
});
22 changes: 22 additions & 0 deletions src/hooks/useBooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { searchBooksBySubject } from "../api/openLibrary";
import type { Book } from "../types";

interface UseBooksResult {
books: Book[];
isLoading: boolean;
isError: boolean;
}

export function useBooks(subject: string): UseBooksResult {
const query = useQuery({
queryKey: ["books", subject],
queryFn: () => searchBooksBySubject(subject),
});

return {
books: query.data ?? [],
isLoading: query.isLoading,
isError: query.isError,
};
}
53 changes: 53 additions & 0 deletions src/test/handlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { http, HttpResponse } from "msw";
import type { OpenLibrarySearchDoc } from "../types";

export const fixtureDocs: OpenLibrarySearchDoc[] = [
{
key: "/works/OL1",
title: "The Hobbit",
author_name: ["J. R. R. Tolkien"],
first_publish_year: 1937,
cover_i: 123,
subject: ["fantasy", "dragons"],
},
{
key: "/works/OL2",
title: "A Wizard of Earthsea",
author_name: ["Ursula K. Le Guin"],
first_publish_year: 1968,
subject: ["fantasy", "magic"],
},
{
key: "/works/OL3",
title: "The Left Hand of Darkness",
author_name: ["Ursula K. Le Guin"],
first_publish_year: 1969,
},
];

let searchRequestCount = 0;

export function getSearchRequestCount() {
return searchRequestCount;
}

export function resetSearchRequestCount() {
searchRequestCount = 0;
}

export const searchHandler = http.get("https://openlibrary.org/search.json", () => {
searchRequestCount += 1;
return HttpResponse.json({ numFound: fixtureDocs.length, start: 0, docs: fixtureDocs });
});

export const emptySearchHandler = http.get("https://openlibrary.org/search.json", () => {
searchRequestCount += 1;
return HttpResponse.json({ numFound: 0, start: 0, docs: [] });
});

export const failingSearchHandler = http.get(
"https://openlibrary.org/search.json",
() => new HttpResponse(JSON.stringify({ error: "boom" }), { status: 500 }),
);

export const handlers = [searchHandler];
4 changes: 4 additions & 0 deletions src/test/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);
8 changes: 7 additions & 1 deletion src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./server";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));

afterEach(() => {
cleanup();
server.resetHandlers();
});

afterAll(() => server.close());
32 changes: 32 additions & 0 deletions src/test/utils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { ReactElement, ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, renderHook } from "@testing-library/react";

export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: 0 },
},
});
}

function makeWrapper(client: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }): ReactElement {
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
}

export function withProviders(ui: ReactElement, client: QueryClient = createTestQueryClient()) {
return { ...render(ui, { wrapper: makeWrapper(client) }), client };
}

export function renderHookWithProviders<TResult, TProps = undefined>(
callback: (props: TProps) => TResult,
{ initialProps, client }: { initialProps?: TProps; client?: QueryClient } = {},
) {
const queryClient = client ?? createTestQueryClient();
return {
...renderHook(callback, { wrapper: makeWrapper(queryClient), initialProps }),
client: queryClient,
};
}