From 2fb6471a685bec1433be3335f377a1a2313e4820 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 1 Jan 2026 23:44:50 +0900 Subject: refactor(client): migrate API calls to typed RPC client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw fetch() calls with apiClient.rpc typed client across all modal and page components. This provides better type safety and eliminates manual auth header handling. - Make handleResponse public for component usage - Update all component tests to mock RPC methods instead of fetch - Change POSTGRES_HOST default to kioku-db for Docker compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/pages/DeckDetailPage.test.tsx | 369 ++++++++----------------------- src/client/pages/DeckDetailPage.tsx | 42 +--- src/client/pages/HomePage.test.tsx | 22 +- src/client/pages/NoteTypesPage.test.tsx | 182 +++++---------- src/client/pages/NoteTypesPage.tsx | 23 +- src/client/pages/StudyPage.test.tsx | 368 +++++++++--------------------- src/client/pages/StudyPage.tsx | 74 ++----- 7 files changed, 302 insertions(+), 778 deletions(-) (limited to 'src/client/pages') diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index 1ef6ae7..d88a7a3 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -6,10 +6,14 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route, Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; -import { apiClient } from "../api/client"; import { AuthProvider } from "../stores"; import { DeckDetailPage } from "./DeckDetailPage"; +const mockDeckGet = vi.fn(); +const mockCardsGet = vi.fn(); +const mockNoteDelete = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -21,11 +25,23 @@ vi.mock("../api/client", () => ({ rpc: { api: { decks: { - $get: vi.fn(), - $post: vi.fn(), + ":id": { + $get: (args: unknown) => mockDeckGet(args), + }, + ":deckId": { + cards: { + $get: (args: unknown) => mockCardsGet(args), + }, + notes: { + ":noteId": { + $delete: (args: unknown) => mockNoteDelete(args), + }, + }, + }, }, }, }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -39,9 +55,7 @@ vi.mock("../api/client", () => ({ }, })); -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError, apiClient } from "../api/client"; const mockDeck = { id: "deck-1", @@ -171,6 +185,9 @@ describe("DeckDetailPage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -179,15 +196,8 @@ describe("DeckDetailPage", () => { }); it("renders back link and deck name", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -202,7 +212,8 @@ describe("DeckDetailPage", () => { }); it("shows loading state while fetching data", async () => { - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDeckGet.mockImplementation(() => new Promise(() => {})); // Never resolves + mockCardsGet.mockImplementation(() => new Promise(() => {})); // Never resolves renderWithProviders(); @@ -211,15 +222,8 @@ describe("DeckDetailPage", () => { }); it("displays empty state when no cards exist", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -230,15 +234,8 @@ describe("DeckDetailPage", () => { }); it("displays list of cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -251,15 +248,8 @@ describe("DeckDetailPage", () => { }); it("displays card count", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -269,15 +259,8 @@ describe("DeckDetailPage", () => { }); it("displays card state labels", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -288,15 +271,8 @@ describe("DeckDetailPage", () => { }); it("displays card stats (reps and lapses)", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -308,11 +284,8 @@ describe("DeckDetailPage", () => { }); it("displays error on API failure for deck", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Deck not found" }), - }); + mockDeckGet.mockRejectedValue(new ApiClientError("Deck not found", 404)); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -322,16 +295,10 @@ describe("DeckDetailPage", () => { }); it("displays error on API failure for cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to load cards" }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockRejectedValue( + new ApiClientError("Failed to load cards", 500), + ); renderWithProviders(); @@ -344,27 +311,12 @@ describe("DeckDetailPage", () => { it("allows retry after error", async () => { const user = userEvent.setup(); - // First call fails for both deck and cards (they run in parallel) - mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - // Second call (retry) succeeds - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + // First call fails + mockDeckGet + .mockRejectedValueOnce(new ApiClientError("Server error", 500)) + // Retry succeeds + .mockResolvedValueOnce({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -381,40 +333,26 @@ describe("DeckDetailPage", () => { }); }); - it("passes auth header when fetching data", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + it("calls correct RPC endpoints when fetching data", async () => { + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockDeckGet).toHaveBeenCalledWith({ + param: { id: "deck-1" }, }); }); - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/cards", { - headers: { Authorization: "Bearer access-token" }, + expect(mockCardsGet).toHaveBeenCalledWith({ + param: { deckId: "deck-1" }, }); }); it("does not show description if deck has none", async () => { const deckWithoutDescription = { ...mockDeck, description: null }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: deckWithoutDescription }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + mockDeckGet.mockResolvedValue({ deck: deckWithoutDescription }); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -430,15 +368,8 @@ describe("DeckDetailPage", () => { describe("Delete Note", () => { it("shows Delete button for each note", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -455,15 +386,8 @@ describe("DeckDetailPage", () => { it("opens delete confirmation modal when Delete button is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -488,15 +412,8 @@ describe("DeckDetailPage", () => { it("closes delete modal when Cancel is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -522,26 +439,12 @@ describe("DeckDetailPage", () => { it("deletes note and refreshes list on confirmation", async () => { const user = userEvent.setup(); - mockFetch - // Initial load - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }) - // Delete request - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }) - // Refresh cards after deletion - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [mockCards[1]] }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet + .mockResolvedValueOnce({ cards: mockCards }) + // Refresh after deletion + .mockResolvedValueOnce({ cards: [mockCards[1]] }); + mockNoteDelete.mockResolvedValue({ success: true }); renderWithProviders(); @@ -574,9 +477,8 @@ describe("DeckDetailPage", () => { }); // Verify DELETE request was made to notes endpoint - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, }); // Verify card count updated @@ -588,22 +490,11 @@ describe("DeckDetailPage", () => { it("displays error when delete fails", async () => { const user = userEvent.setup(); - mockFetch - // Initial load - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }) - // Delete request fails - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to delete note" }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); + mockNoteDelete.mockRejectedValue( + new ApiClientError("Failed to delete note", 500), + ); renderWithProviders(); @@ -641,15 +532,8 @@ describe("DeckDetailPage", () => { describe("Card Grouping by Note", () => { it("groups cards by noteId and displays as note groups", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -664,15 +548,8 @@ describe("DeckDetailPage", () => { }); it("shows Normal and Reversed badges for note-based cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -684,15 +561,8 @@ describe("DeckDetailPage", () => { }); it("shows note card count in note group header", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -703,15 +573,8 @@ describe("DeckDetailPage", () => { }); it("shows edit note button for note groups", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -724,15 +587,8 @@ describe("DeckDetailPage", () => { }); it("shows delete note button for note groups", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -749,15 +605,8 @@ describe("DeckDetailPage", () => { it("opens delete note modal when delete button is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -779,26 +628,12 @@ describe("DeckDetailPage", () => { it("deletes note and refreshes list when confirmed", async () => { const user = userEvent.setup(); - mockFetch - // Initial load - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }) - // Delete request - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }) + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet + .mockResolvedValueOnce({ cards: mockNoteBasedCards }) // Refresh cards after deletion - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + .mockResolvedValueOnce({ cards: [] }); + mockNoteDelete.mockResolvedValue({ success: true }); renderWithProviders(); @@ -827,9 +662,8 @@ describe("DeckDetailPage", () => { }); // Verify DELETE request was made to notes endpoint - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, }); // Should show empty state after deletion @@ -839,15 +673,8 @@ describe("DeckDetailPage", () => { }); it("displays note preview from normal card content", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index 3741111..f9b50f2 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -233,50 +233,20 @@ export function DeckDetailPage() { const fetchDeck = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":id"].$get({ + param: { id: deckId }, }); - - 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(); + const data = await apiClient.handleResponse<{ deck: Deck }>(res); setDeck(data.deck); }, [deckId]); const fetchCards = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/cards`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({ + param: { deckId }, }); - - 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(); + const data = await apiClient.handleResponse<{ cards: Card[] }>(res); setCards(data.cards); }, [deckId]); diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx index 944dd31..cb96aa3 100644 --- a/src/client/pages/HomePage.test.tsx +++ b/src/client/pages/HomePage.test.tsx @@ -11,6 +11,10 @@ import { apiClient } from "../api/client"; import { AuthProvider, SyncProvider } from "../stores"; import { HomePage } from "./HomePage"; +const mockDeckPut = vi.fn(); +const mockDeckDelete = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -24,9 +28,14 @@ vi.mock("../api/client", () => ({ decks: { $get: vi.fn(), $post: vi.fn(), + ":id": { + $put: (args: unknown) => mockDeckPut(args), + $delete: (args: unknown) => mockDeckDelete(args), + }, }, }, }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -110,6 +119,9 @@ describe("HomePage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -544,10 +556,7 @@ describe("HomePage", () => { }), ); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ deck: updatedDeck }), - }); + mockDeckPut.mockResolvedValue({ deck: updatedDeck }); renderWithProviders(); @@ -686,10 +695,7 @@ describe("HomePage", () => { }), ); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - }); + mockDeckDelete.mockResolvedValue({ success: true }); renderWithProviders(); diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx index 8364d17..c0559f6 100644 --- a/src/client/pages/NoteTypesPage.test.tsx +++ b/src/client/pages/NoteTypesPage.test.tsx @@ -7,10 +7,16 @@ 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, SyncProvider } from "../stores"; import { NoteTypesPage } from "./NoteTypesPage"; +const mockNoteTypesGet = vi.fn(); +const mockNoteTypesPost = vi.fn(); +const mockNoteTypeGet = vi.fn(); +const mockNoteTypePut = vi.fn(); +const mockNoteTypeDelete = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -19,6 +25,20 @@ vi.mock("../api/client", () => ({ getTokens: vi.fn(), getAuthHeader: vi.fn(), onSessionExpired: vi.fn(() => vi.fn()), + rpc: { + api: { + "note-types": { + $get: () => mockNoteTypesGet(), + $post: (args: unknown) => mockNoteTypesPost(args), + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + $put: (args: unknown) => mockNoteTypePut(args), + $delete: (args: unknown) => mockNoteTypeDelete(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -32,9 +52,7 @@ vi.mock("../api/client", () => ({ }, })); -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError, apiClient } from "../api/client"; const mockNoteTypes = [ { @@ -81,6 +99,9 @@ describe("NoteTypesPage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -89,10 +110,7 @@ describe("NoteTypesPage", () => { }); it("renders page title and back button", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -101,7 +119,7 @@ describe("NoteTypesPage", () => { }); it("shows loading state while fetching note types", async () => { - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockNoteTypesGet.mockImplementation(() => new Promise(() => {})); // Never resolves renderWithProviders(); @@ -110,10 +128,7 @@ describe("NoteTypesPage", () => { }); it("displays empty state when no note types exist", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -128,10 +143,7 @@ describe("NoteTypesPage", () => { }); it("displays list of note types", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -144,10 +156,7 @@ describe("NoteTypesPage", () => { }); it("displays reversible badge for reversible note types", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -161,10 +170,7 @@ describe("NoteTypesPage", () => { }); it("displays template info for each note type", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -177,11 +183,9 @@ describe("NoteTypesPage", () => { }); it("displays error on API failure", async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: "Internal server error" }), - }); + mockNoteTypesGet.mockRejectedValue( + new ApiClientError("Internal server error", 500), + ); renderWithProviders(); @@ -193,7 +197,7 @@ describe("NoteTypesPage", () => { }); it("displays generic error on unexpected failure", async () => { - mockFetch.mockRejectedValue(new Error("Network error")); + mockNoteTypesGet.mockRejectedValue(new Error("Network error")); renderWithProviders(); @@ -206,16 +210,9 @@ describe("NoteTypesPage", () => { it("allows retry after error", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet + .mockRejectedValueOnce(new ApiClientError("Server error", 500)) + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -230,27 +227,19 @@ describe("NoteTypesPage", () => { }); }); - it("passes auth header when fetching note types", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + it("calls correct RPC endpoint when fetching note types", async () => { + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types", { - headers: { Authorization: "Bearer access-token" }, - }); + expect(mockNoteTypesGet).toHaveBeenCalled(); }); }); describe("Create Note Type", () => { it("shows New Note Type button", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -265,10 +254,7 @@ describe("NoteTypesPage", () => { it("opens modal when New Note Type button is clicked", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -296,19 +282,10 @@ describe("NoteTypesPage", () => { updatedAt: "2024-01-03T00:00:00Z", }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: newNoteType }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [newNoteType] }), - }); + mockNoteTypesGet + .mockResolvedValueOnce({ noteTypes: [] }) + .mockResolvedValueOnce({ noteTypes: [newNoteType] }); + mockNoteTypesPost.mockResolvedValue({ noteType: newNoteType }); renderWithProviders(); @@ -341,10 +318,7 @@ describe("NoteTypesPage", () => { describe("Edit Note Type", () => { it("shows Edit button for each note type", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -380,15 +354,8 @@ describe("NoteTypesPage", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); + mockNoteTypeGet.mockResolvedValue({ noteType: mockNoteTypeWithFields }); renderWithProviders(); @@ -437,25 +404,13 @@ describe("NoteTypesPage", () => { name: "Updated Basic", }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockNoteTypesGet + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: updatedNoteType }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - noteTypes: [updatedNoteType, mockNoteTypes[1]], - }), + noteTypes: [updatedNoteType, mockNoteTypes[1]], }); + mockNoteTypeGet.mockResolvedValue({ noteType: mockNoteTypeWithFields }); + mockNoteTypePut.mockResolvedValue({ noteType: updatedNoteType }); renderWithProviders(); @@ -498,10 +453,7 @@ describe("NoteTypesPage", () => { describe("Delete Note Type", () => { it("shows Delete button for each note type", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -517,10 +469,7 @@ describe("NoteTypesPage", () => { it("opens delete modal when Delete button is clicked", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -544,19 +493,10 @@ describe("NoteTypesPage", () => { it("deletes note type and refreshes list", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [mockNoteTypes[1]] }), - }); + mockNoteTypesGet + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteTypes: [mockNoteTypes[1]] }); + mockNoteTypeDelete.mockResolvedValue({ success: true }); renderWithProviders(); diff --git a/src/client/pages/NoteTypesPage.tsx b/src/client/pages/NoteTypesPage.tsx index d819ece..5b50c61 100644 --- a/src/client/pages/NoteTypesPage.tsx +++ b/src/client/pages/NoteTypesPage.tsx @@ -42,25 +42,10 @@ export function NoteTypesPage() { setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch("/api/note-types", { - headers: authHeader, - }); - - 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(); + const res = await apiClient.rpc.api["note-types"].$get(); + const data = await apiClient.handleResponse<{ noteTypes: NoteType[] }>( + res, + ); setNoteTypes(data.noteTypes); } catch (err) { if (err instanceof ApiClientError) { diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx index bc87b9d..edf683a 100644 --- a/src/client/pages/StudyPage.test.tsx +++ b/src/client/pages/StudyPage.test.tsx @@ -6,10 +6,14 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route, Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; -import { apiClient } from "../api/client"; import { AuthProvider } from "../stores"; import { StudyPage } from "./StudyPage"; +const mockDeckGet = vi.fn(); +const mockStudyGet = vi.fn(); +const mockStudyPost = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -21,11 +25,21 @@ vi.mock("../api/client", () => ({ rpc: { api: { decks: { - $get: vi.fn(), - $post: vi.fn(), + ":id": { + $get: (args: unknown) => mockDeckGet(args), + }, + ":deckId": { + study: { + $get: (args: unknown) => mockStudyGet(args), + ":cardId": { + $post: (args: unknown) => mockStudyPost(args), + }, + }, + }, }, }, }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -39,9 +53,7 @@ vi.mock("../api/client", () => ({ }, })); -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError, apiClient } from "../api/client"; const mockDeck = { id: "deck-1", @@ -117,6 +129,9 @@ describe("StudyPage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -126,7 +141,8 @@ describe("StudyPage", () => { describe("Loading and Initial State", () => { it("shows loading state while fetching data", async () => { - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDeckGet.mockImplementation(() => new Promise(() => {})); // Never resolves + mockStudyGet.mockImplementation(() => new Promise(() => {})); // Never resolves renderWithProviders(); @@ -135,15 +151,8 @@ describe("StudyPage", () => { }); it("renders deck name and back link", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -156,37 +165,27 @@ describe("StudyPage", () => { expect(screen.getByText(/Back to Deck/)).toBeDefined(); }); - it("passes auth header when fetching data", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + it("calls correct RPC endpoints when fetching data", async () => { + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [] }); renderWithProviders(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockDeckGet).toHaveBeenCalledWith({ + param: { id: "deck-1" }, }); }); - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/study", { - headers: { Authorization: "Bearer access-token" }, + expect(mockStudyGet).toHaveBeenCalledWith({ + param: { deckId: "deck-1" }, }); }); }); describe("Error Handling", () => { it("displays error on API failure", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Deck not found" }), - }); + mockDeckGet.mockRejectedValue(new ApiClientError("Deck not found", 404)); + mockStudyGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -200,26 +199,11 @@ describe("StudyPage", () => { it("allows retry after error", async () => { const user = userEvent.setup(); // First call fails - mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) + mockDeckGet + .mockRejectedValueOnce(new ApiClientError("Server error", 500)) // Retry succeeds - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + .mockResolvedValueOnce({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -239,15 +223,8 @@ describe("StudyPage", () => { describe("No Cards State", () => { it("shows no cards message when deck has no due cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -263,15 +240,8 @@ describe("StudyPage", () => { describe("Card Display and Progress", () => { it("shows remaining cards count", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -283,15 +253,8 @@ describe("StudyPage", () => { }); it("displays the front of the first card", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -301,15 +264,8 @@ describe("StudyPage", () => { }); it("does not show rating buttons before card is flipped", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -325,15 +281,8 @@ describe("StudyPage", () => { it("reveals answer when card is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -349,15 +298,8 @@ describe("StudyPage", () => { it("shows rating buttons after card is flipped", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -377,15 +319,8 @@ describe("StudyPage", () => { it("displays rating labels on buttons", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -406,20 +341,11 @@ describe("StudyPage", () => { it("submits review and moves to next card", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - // Submit review - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -438,16 +364,11 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // Verify API was called - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-1/study/card-1", + // Verify API was called with correct params + expect(mockStudyPost).toHaveBeenCalledWith( expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Bearer access-token", - "Content-Type": "application/json", - }), - body: expect.stringContaining('"rating":3'), + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 3 }), }), ); }); @@ -455,19 +376,11 @@ describe("StudyPage", () => { it("updates remaining count after review", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -490,20 +403,11 @@ describe("StudyPage", () => { it("shows error when rating submission fails", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to submit review" }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockRejectedValue( + new ApiClientError("Failed to submit review", 500), + ); renderWithProviders(); @@ -526,19 +430,11 @@ describe("StudyPage", () => { it("shows session complete screen after all cards reviewed", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [mockDueCards[0]] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [mockDueCards[0]] }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -561,25 +457,11 @@ describe("StudyPage", () => { it("shows correct count for multiple cards reviewed", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - // First review - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }) - // Second review - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[1], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -608,19 +490,11 @@ describe("StudyPage", () => { it("provides navigation links after session complete", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [mockDueCards[0]] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [mockDueCards[0]] }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -644,15 +518,8 @@ describe("StudyPage", () => { it("flips card with Space key", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -668,15 +535,8 @@ describe("StudyPage", () => { it("flips card with Enter key", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -692,19 +552,11 @@ describe("StudyPage", () => { it("rates card with number keys", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -719,10 +571,10 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-1/study/card-1", + expect(mockStudyPost).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.stringContaining('"rating":3'), + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 3 }), }), ); }); @@ -730,19 +582,11 @@ describe("StudyPage", () => { it("supports all rating keys (1, 2, 3, 4)", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -753,10 +597,10 @@ describe("StudyPage", () => { await user.keyboard(" "); // Flip await user.keyboard("1"); // Rate as Again - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-1/study/card-1", + expect(mockStudyPost).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.stringContaining('"rating":1'), + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 1 }), }), ); }); diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 0eb5118..43fd195 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -68,50 +68,20 @@ export function StudyPage() { const fetchDeck = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":id"].$get({ + param: { id: deckId }, }); - - 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(); + const data = await apiClient.handleResponse<{ deck: Deck }>(res); setDeck(data.deck); }, [deckId]); const fetchDueCards = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/study`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].study.$get({ + param: { deckId }, }); - - 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(); + const data = await apiClient.handleResponse<{ cards: Card[] }>(res); setCards(data.cards); }, [deckId]); @@ -158,31 +128,13 @@ export function StudyPage() { const durationMs = Date.now() - cardStartTimeRef.current; try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch( - `/api/decks/${deckId}/study/${currentCard.id}`, - { - method: "POST", - headers: { - ...authHeader, - "Content-Type": "application/json", - }, - body: JSON.stringify({ rating, durationMs }), - }, - ); - - 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 res = await apiClient.rpc.api.decks[":deckId"].study[ + ":cardId" + ].$post({ + param: { deckId, cardId: currentCard.id }, + json: { rating, durationMs }, + }); + await apiClient.handleResponse(res); setCompletedCount((prev) => prev + 1); setIsFlipped(false); -- cgit v1.2.3-70-g09d2