aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/pages')
-rw-r--r--src/client/pages/HomePage.test.tsx291
-rw-r--r--src/client/pages/HomePage.tsx117
2 files changed, 406 insertions, 2 deletions
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
new file mode 100644
index 0000000..f471924
--- /dev/null
+++ b/src/client/pages/HomePage.test.tsx
@@ -0,0 +1,291 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { Router } from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+import { apiClient } from "../api/client";
+import { AuthProvider } from "../stores";
+import { HomePage } from "./HomePage";
+
+vi.mock("../api/client", () => ({
+ apiClient: {
+ login: vi.fn(),
+ logout: vi.fn(),
+ isAuthenticated: vi.fn(),
+ getTokens: vi.fn(),
+ getAuthHeader: vi.fn(),
+ rpc: {
+ api: {
+ decks: {
+ $get: vi.fn(),
+ },
+ },
+ },
+ },
+ ApiClientError: class ApiClientError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "ApiClientError";
+ }
+ },
+}));
+
+// Helper to create mock responses compatible with Hono's ClientResponse
+// biome-ignore lint/suspicious/noExplicitAny: Test helper needs flexible typing
+function mockResponse(data: { ok: boolean; status?: number; json: () => Promise<any> }) {
+ return data as unknown as Awaited<ReturnType<typeof apiClient.rpc.api.decks.$get>>;
+}
+
+const mockDecks = [
+ {
+ id: "deck-1",
+ name: "Japanese Vocabulary",
+ description: "Common Japanese words",
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:00Z",
+ },
+ {
+ id: "deck-2",
+ name: "Spanish Verbs",
+ description: null,
+ newCardsPerDay: 10,
+ createdAt: "2024-01-02T00:00:00Z",
+ updatedAt: "2024-01-02T00:00:00Z",
+ },
+];
+
+function renderWithProviders(path = "/") {
+ const { hook } = memoryLocation({ path });
+ return render(
+ <Router hook={hook}>
+ <AuthProvider>
+ <HomePage />
+ </AuthProvider>
+ </Router>,
+ );
+}
+
+describe("HomePage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(apiClient.getTokens).mockReturnValue({
+ accessToken: "access-token",
+ refreshToken: "refresh-token",
+ });
+ vi.mocked(apiClient.isAuthenticated).mockReturnValue(true);
+ vi.mocked(apiClient.getAuthHeader).mockReturnValue({
+ Authorization: "Bearer access-token",
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+ });
+
+ it("renders page title and logout button", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ expect(screen.getByRole("heading", { name: "Kioku" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Logout" })).toBeDefined();
+ });
+
+ it("shows loading state while fetching decks", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockImplementation(
+ () => new Promise(() => {}), // Never resolves
+ );
+
+ renderWithProviders();
+
+ expect(screen.getByText("Loading decks...")).toBeDefined();
+ });
+
+ it("displays empty state when no decks exist", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByText("You don't have any decks yet.")).toBeDefined();
+ });
+ expect(
+ screen.getByText("Create your first deck to start learning!"),
+ ).toBeDefined();
+ });
+
+ it("displays list of decks", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: mockDecks }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Japanese Vocabulary" }),
+ ).toBeDefined();
+ });
+ expect(
+ screen.getByRole("heading", { name: "Spanish Verbs" }),
+ ).toBeDefined();
+ expect(screen.getByText("Common Japanese words")).toBeDefined();
+ });
+
+ it("displays error on API failure", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Internal server error" }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Internal server error",
+ );
+ });
+ });
+
+ it("displays generic error on unexpected failure", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockRejectedValue(
+ new Error("Network error"),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert").textContent).toContain(
+ "Failed to load decks. Please try again.",
+ );
+ });
+ });
+
+ it("allows retry after error", async () => {
+ const user = userEvent.setup();
+ vi.mocked(apiClient.rpc.api.decks.$get)
+ .mockResolvedValueOnce(
+ mockResponse({
+ ok: false,
+ status: 500,
+ json: async () => ({ error: "Server error" }),
+ }),
+ )
+ .mockResolvedValueOnce(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: mockDecks }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.getByRole("alert")).toBeDefined();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Retry" }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "Japanese Vocabulary" }),
+ ).toBeDefined();
+ });
+ });
+
+ it("calls logout when logout button is clicked", async () => {
+ const user = userEvent.setup();
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(screen.queryByText("Loading decks...")).toBeNull();
+ });
+
+ await user.click(screen.getByRole("button", { name: "Logout" }));
+
+ expect(apiClient.logout).toHaveBeenCalled();
+ });
+
+ it("does not show description if deck has none", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({
+ decks: [
+ {
+ id: "deck-1",
+ name: "No Description Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: "2024-01-01T00:00:00Z",
+ updatedAt: "2024-01-01T00:00:00Z",
+ },
+ ],
+ }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: "No Description Deck" }),
+ ).toBeDefined();
+ });
+
+ // The deck item should only contain the heading, no description paragraph
+ const deckItem = screen
+ .getByRole("heading", { name: "No Description Deck" })
+ .closest("li");
+ expect(deckItem?.querySelectorAll("p").length).toBe(0);
+ });
+
+ it("passes auth header when fetching decks", async () => {
+ vi.mocked(apiClient.rpc.api.decks.$get).mockResolvedValue(
+ mockResponse({
+ ok: true,
+ json: async () => ({ decks: [] }),
+ }),
+ );
+
+ renderWithProviders();
+
+ await waitFor(() => {
+ expect(apiClient.rpc.api.decks.$get).toHaveBeenCalledWith(undefined, {
+ headers: { Authorization: "Bearer access-token" },
+ });
+ });
+ });
+});
diff --git a/src/client/pages/HomePage.tsx b/src/client/pages/HomePage.tsx
index 1d65484..c9d0843 100644
--- a/src/client/pages/HomePage.tsx
+++ b/src/client/pages/HomePage.tsx
@@ -1,8 +1,121 @@
+import { useCallback, useEffect, useState } from "react";
+import { ApiClientError, apiClient } from "../api";
+import { useAuth } from "../stores";
+
+interface Deck {
+ id: string;
+ name: string;
+ description: string | null;
+ newCardsPerDay: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
export function HomePage() {
+ const { logout } = useAuth();
+ const [decks, setDecks] = useState<Deck[]>([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchDecks = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const res = await apiClient.rpc.api.decks.$get(undefined, {
+ headers: apiClient.getAuthHeader(),
+ });
+
+ if (!res.ok) {
+ const errorBody = await res.json().catch(() => ({}));
+ throw new ApiClientError(
+ (errorBody as { error?: string }).error ||
+ `Request failed with status ${res.status}`,
+ res.status,
+ );
+ }
+
+ const data = await res.json();
+ setDecks(data.decks);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setError(err.message);
+ } else {
+ setError("Failed to load decks. Please try again.");
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchDecks();
+ }, [fetchDecks]);
+
return (
<div>
- <h1>Kioku</h1>
- <p>Spaced repetition learning app</p>
+ <header
+ style={{
+ display: "flex",
+ justifyContent: "space-between",
+ alignItems: "center",
+ marginBottom: "1rem",
+ }}
+ >
+ <h1>Kioku</h1>
+ <button type="button" onClick={logout}>
+ Logout
+ </button>
+ </header>
+
+ <main>
+ <h2>Your Decks</h2>
+
+ {isLoading && <p>Loading decks...</p>}
+
+ {error && (
+ <div role="alert" style={{ color: "red" }}>
+ {error}
+ <button
+ type="button"
+ onClick={fetchDecks}
+ style={{ marginLeft: "0.5rem" }}
+ >
+ Retry
+ </button>
+ </div>
+ )}
+
+ {!isLoading && !error && decks.length === 0 && (
+ <div>
+ <p>You don't have any decks yet.</p>
+ <p>Create your first deck to start learning!</p>
+ </div>
+ )}
+
+ {!isLoading && !error && decks.length > 0 && (
+ <ul style={{ listStyle: "none", padding: 0 }}>
+ {decks.map((deck) => (
+ <li
+ key={deck.id}
+ style={{
+ border: "1px solid #ccc",
+ padding: "1rem",
+ marginBottom: "0.5rem",
+ borderRadius: "4px",
+ }}
+ >
+ <h3 style={{ margin: 0 }}>{deck.name}</h3>
+ {deck.description && (
+ <p style={{ margin: "0.5rem 0 0 0", color: "#666" }}>
+ {deck.description}
+ </p>
+ )}
+ </li>
+ ))}
+ </ul>
+ )}
+ </main>
</div>
);
}