aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-01-01 23:44:50 +0900
committernsfisis <nsfisis@gmail.com>2026-01-01 23:47:21 +0900
commit2fb6471a685bec1433be3335f377a1a2313e4820 (patch)
tree328ddaeec0c591b06bf005d48b0242345c1336be /src/client/pages
parentf30566e1c7126db4c6242ab38d07a9478f79d3db (diff)
downloadkioku-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')
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx369
-rw-r--r--src/client/pages/DeckDetailPage.tsx42
-rw-r--r--src/client/pages/HomePage.test.tsx22
-rw-r--r--src/client/pages/NoteTypesPage.test.tsx182
-rw-r--r--src/client/pages/NoteTypesPage.tsx23
-rw-r--r--src/client/pages/StudyPage.test.tsx368
-rw-r--r--src/client/pages/StudyPage.tsx74
7 files changed, 302 insertions, 778 deletions
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);