aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/pages
diff options
context:
space:
mode:
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);