diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-01-01 23:44:50 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-01-01 23:47:21 +0900 |
| commit | 2fb6471a685bec1433be3335f377a1a2313e4820 (patch) | |
| tree | 328ddaeec0c591b06bf005d48b0242345c1336be /src/client/pages/StudyPage.test.tsx | |
| parent | f30566e1c7126db4c6242ab38d07a9478f79d3db (diff) | |
| download | kioku-2fb6471a685bec1433be3335f377a1a2313e4820.tar.gz kioku-2fb6471a685bec1433be3335f377a1a2313e4820.tar.zst kioku-2fb6471a685bec1433be3335f377a1a2313e4820.zip | |
refactor(client): migrate API calls to typed RPC client
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 <noreply@anthropic.com>
Diffstat (limited to 'src/client/pages/StudyPage.test.tsx')
| -rw-r--r-- | src/client/pages/StudyPage.test.tsx | 368 |
1 files changed, 106 insertions, 262 deletions
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 }), }), ); }); |
