diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-01-01 23:44:50 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-01-01 23:47:21 +0900 |
| commit | 2fb6471a685bec1433be3335f377a1a2313e4820 (patch) | |
| tree | 328ddaeec0c591b06bf005d48b0242345c1336be /src | |
| parent | f30566e1c7126db4c6242ab38d07a9478f79d3db (diff) | |
| download | kioku-2fb6471a685bec1433be3335f377a1a2313e4820.tar.gz kioku-2fb6471a685bec1433be3335f377a1a2313e4820.tar.zst kioku-2fb6471a685bec1433be3335f377a1a2313e4820.zip | |
refactor(client): migrate API calls to typed RPC client
Replace raw fetch() calls with apiClient.rpc typed client across all
modal and page components. This provides better type safety and
eliminates manual auth header handling.
- Make handleResponse public for component usage
- Update all component tests to mock RPC methods instead of fetch
- Change POSTGRES_HOST default to kioku-db for Docker compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
31 files changed, 1096 insertions, 2404 deletions
diff --git a/src/client/api/client.ts b/src/client/api/client.ts index c91160d..fc718a2 100644 --- a/src/client/api/client.ts +++ b/src/client/api/client.ts @@ -117,7 +117,7 @@ export class ApiClient { return response; } - private async handleResponse<T>(response: Response): Promise<T> { + async handleResponse<T>(response: Response): Promise<T> { if (!response.ok) { const errorBody = (await response.json().catch(() => ({}))) as ApiError; throw new ApiClientError( diff --git a/src/client/components/CreateNoteModal.test.tsx b/src/client/components/CreateNoteModal.test.tsx index 5e6932b..dcb3360 100644 --- a/src/client/components/CreateNoteModal.test.tsx +++ b/src/client/components/CreateNoteModal.test.tsx @@ -4,11 +4,32 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockNoteTypesGet = vi.fn(); +const mockNoteTypeGet = vi.fn(); +const mockNotesPost = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + $get: () => mockNoteTypesGet(), + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + }, + }, + decks: { + ":deckId": { + notes: { + $post: (args: unknown) => mockNotesPost(args), + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +43,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { CreateNoteModal } from "./CreateNoteModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("CreateNoteModal", () => { const defaultProps = { isOpen: true, @@ -56,9 +74,9 @@ describe("CreateNoteModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockNoteTypesGet.mockResolvedValue({ ok: true }); + mockNoteTypeGet.mockResolvedValue({ ok: true }); + mockNotesPost.mockResolvedValue({ ok: true }); }); afterEach(() => { @@ -73,15 +91,9 @@ describe("CreateNoteModal", () => { }); it("renders modal when open", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -97,35 +109,21 @@ describe("CreateNoteModal", () => { }); it("loads note types on open", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types", { - headers: { Authorization: "Bearer access-token" }, - }); + expect(mockNoteTypesGet).toHaveBeenCalled(); }); }); it("auto-selects first note type and loads its fields", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -136,21 +134,15 @@ describe("CreateNoteModal", () => { }); // Verify the note type details were fetched - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-1" }, }); }); it("displays note type options in select", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -166,10 +158,7 @@ describe("CreateNoteModal", () => { }); it("shows message when no note types available", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockHandleResponse.mockResolvedValueOnce({ noteTypes: [] }); render(<CreateNoteModal {...defaultProps} />); @@ -188,15 +177,9 @@ describe("CreateNoteModal", () => { fields: [], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: noteTypeWithNoFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: noteTypeWithNoFields }); render(<CreateNoteModal {...defaultProps} />); @@ -210,15 +193,9 @@ describe("CreateNoteModal", () => { }); it("disables create button when fields are empty", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -233,15 +210,9 @@ describe("CreateNoteModal", () => { it("enables create button when all fields have values", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -260,15 +231,9 @@ describe("CreateNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} onClose={onClose} />); @@ -285,15 +250,9 @@ describe("CreateNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} onClose={onClose} />); @@ -313,22 +272,13 @@ describe("CreateNoteModal", () => { const onClose = vi.fn(); const onNoteCreated = vi.fn(); - mockFetch + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - note: { id: "note-1" }, - fieldValues: [], - cards: [{ id: "card-1", isReversed: false }], - }), + note: { id: "note-1" }, + fieldValues: [], + cards: [{ id: "card-1", isReversed: false }], }); render( @@ -349,19 +299,15 @@ describe("CreateNoteModal", () => { await user.click(screen.getByRole("button", { name: "Create Note" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123/notes", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockNotesPost).toHaveBeenCalledWith({ + param: { deckId: "deck-123" }, + json: { noteTypeId: "note-type-1", fields: { "field-1": "What is 2+2?", "field-2": "4", }, - }), + }, }); }); @@ -372,22 +318,13 @@ describe("CreateNoteModal", () => { it("trims whitespace from field values", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - note: { id: "note-1" }, - fieldValues: [], - cards: [], - }), + note: { id: "note-1" }, + fieldValues: [], + cards: [], }); render(<CreateNoteModal {...defaultProps} />); @@ -401,19 +338,15 @@ describe("CreateNoteModal", () => { await user.click(screen.getByRole("button", { name: "Create Note" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123/notes", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockNotesPost).toHaveBeenCalledWith({ + param: { deckId: "deck-123" }, + json: { noteTypeId: "note-type-1", fields: { "field-1": "Question", "field-2": "Answer", }, - }), + }, }); }); }); @@ -421,16 +354,11 @@ describe("CreateNoteModal", () => { it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockImplementationOnce(() => new Promise(() => {})); // Never resolves + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); + + mockNotesPost.mockImplementationOnce(() => new Promise(() => {})); // Never resolves render(<CreateNoteModal {...defaultProps} />); @@ -452,20 +380,10 @@ describe("CreateNoteModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 400, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockRejectedValueOnce(new ApiClientError("Note type not found", 400)); render(<CreateNoteModal {...defaultProps} />); @@ -487,16 +405,11 @@ describe("CreateNoteModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockRejectedValueOnce(new Error("Network error")); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); + + mockNotesPost.mockRejectedValueOnce(new Error("Network error")); render(<CreateNoteModal {...defaultProps} />); @@ -530,19 +443,10 @@ describe("CreateNoteModal", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: reversedNoteType }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ noteType: reversedNoteType }); render(<CreateNoteModal {...defaultProps} />); @@ -561,8 +465,8 @@ describe("CreateNoteModal", () => { }); // Verify the note type details were fetched for the new type - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-2", { - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-2" }, }); }); @@ -572,15 +476,9 @@ describe("CreateNoteModal", () => { isReversible: true, }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: reversedNoteType }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: reversedNoteType }); render(<CreateNoteModal {...defaultProps} />); @@ -591,15 +489,9 @@ describe("CreateNoteModal", () => { }); it("shows card count preview for non-reversible note type", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -609,11 +501,9 @@ describe("CreateNoteModal", () => { }); it("displays error when note types fail to load", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }); + mockHandleResponse.mockRejectedValueOnce( + new ApiClientError("Server error", 500), + ); render(<CreateNoteModal {...defaultProps} />); @@ -626,15 +516,9 @@ describe("CreateNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); const { rerender } = render( <CreateNoteModal @@ -656,16 +540,8 @@ describe("CreateNoteModal", () => { // Click cancel to close await user.click(screen.getByRole("button", { name: "Cancel" })); - // Setup mocks for reopening - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + // Note: The component already has note types loaded (hasLoadedNoteTypes = true) + // so it won't fetch again // Reopen the modal rerender( diff --git a/src/client/components/CreateNoteModal.tsx b/src/client/components/CreateNoteModal.tsx index 86a02a5..912aea8 100644 --- a/src/client/components/CreateNoteModal.tsx +++ b/src/client/components/CreateNoteModal.tsx @@ -53,25 +53,10 @@ export function CreateNoteModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteTypeId}`, { - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: noteTypeId }, }); - - 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<{ noteType: NoteType }>(res); setSelectedNoteType(data.noteType); // Initialize field values for the new note type @@ -96,31 +81,17 @@ export function CreateNoteModal({ 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: NoteTypeSummary[]; + }>(res); setNoteTypes(data.noteTypes); setHasLoadedNoteTypes(true); // Auto-select first note type if available - if (data.noteTypes.length > 0) { - await fetchNoteTypeDetails(data.noteTypes[0].id); + const firstNoteType = data.noteTypes[0]; + if (firstNoteType) { + await fetchNoteTypeDetails(firstNoteType.id); } } catch (err) { if (err instanceof ApiClientError) { @@ -183,37 +154,20 @@ export function CreateNoteModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - // Trim all field values const trimmedFields: Record<string, string> = {}; for (const [fieldId, value] of Object.entries(fieldValues)) { trimmedFields[fieldId] = value.trim(); } - const res = await fetch(`/api/decks/${deckId}/notes`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].notes.$post({ + param: { deckId }, + json: { noteTypeId: selectedNoteType.id, fields: trimmedFields, - }), + }, }); - - 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, - ); - } + await apiClient.handleResponse(res); resetForm(); onNoteCreated(); diff --git a/src/client/components/CreateNoteTypeModal.test.tsx b/src/client/components/CreateNoteTypeModal.test.tsx index 9536f53..59d8312 100644 --- a/src/client/components/CreateNoteTypeModal.test.tsx +++ b/src/client/components/CreateNoteTypeModal.test.tsx @@ -4,11 +4,20 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPost = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + $post: (args: unknown) => mockPost(args), + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +31,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { CreateNoteTypeModal } from "./CreateNoteTypeModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("CreateNoteTypeModal", () => { const defaultProps = { isOpen: true, @@ -38,8 +44,15 @@ describe("CreateNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", + mockPost.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ + noteType: { + id: "note-type-1", + name: "Test Note Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }, }); }); @@ -126,19 +139,6 @@ describe("CreateNoteTypeModal", () => { const onClose = vi.fn(); const onNoteTypeCreated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - noteType: { - id: "note-type-1", - name: "Test Note Type", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: true, - }, - }), - }); - render( <CreateNoteTypeModal isOpen={true} @@ -153,18 +153,13 @@ describe("CreateNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPost).toHaveBeenCalledWith({ + json: { name: "Test Note Type", frontTemplate: "{{Front}}", backTemplate: "{{Back}}", isReversible: true, - }), + }, }); }); @@ -175,30 +170,24 @@ describe("CreateNoteTypeModal", () => { it("trims whitespace from text fields", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: { id: "note-type-1" } }), - }); - render(<CreateNoteTypeModal {...defaultProps} />); await user.type(screen.getByLabelText("Name"), " Test Note Type "); await user.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types", - expect.objectContaining({ - body: expect.stringContaining('"name":"Test Note Type"'), + expect(mockPost).toHaveBeenCalledWith({ + json: expect.objectContaining({ + name: "Test Note Type", }), - ); + }); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPost.mockImplementation(() => new Promise(() => {})); // Never resolves render(<CreateNoteTypeModal {...defaultProps} />); @@ -220,11 +209,9 @@ describe("CreateNoteTypeModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Note type name already exists" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Note type name already exists", 400), + ); render(<CreateNoteTypeModal {...defaultProps} />); @@ -241,7 +228,7 @@ describe("CreateNoteTypeModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPost.mockRejectedValue(new Error("Network error")); render(<CreateNoteTypeModal {...defaultProps} />); @@ -255,23 +242,6 @@ describe("CreateNoteTypeModal", () => { }); }); - it("displays error when not authenticated", async () => { - const user = userEvent.setup(); - - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(<CreateNoteTypeModal {...defaultProps} />); - - await user.type(screen.getByLabelText("Name"), "Test Note Type"); - await user.click(screen.getByRole("button", { name: "Create" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - it("resets form when closed and reopened", async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/src/client/components/CreateNoteTypeModal.tsx b/src/client/components/CreateNoteTypeModal.tsx index 6e43c96..4c3b232 100644 --- a/src/client/components/CreateNoteTypeModal.tsx +++ b/src/client/components/CreateNoteTypeModal.tsx @@ -38,33 +38,15 @@ export function CreateNoteTypeModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch("/api/note-types", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"].$post({ + json: { name: name.trim(), frontTemplate: frontTemplate.trim(), backTemplate: backTemplate.trim(), isReversible, - }), + }, }); - - 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, - ); - } + await apiClient.handleResponse(res); resetForm(); onNoteTypeCreated(); diff --git a/src/client/components/DeleteCardModal.test.tsx b/src/client/components/DeleteCardModal.test.tsx index 4178ee8..6536c6f 100644 --- a/src/client/components/DeleteCardModal.test.tsx +++ b/src/client/components/DeleteCardModal.test.tsx @@ -4,11 +4,26 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + cards: { + ":cardId": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +37,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { DeleteCardModal } from "./DeleteCardModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("DeleteCardModal", () => { const mockCard = { id: "card-123", @@ -45,9 +57,8 @@ describe("DeleteCardModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -142,11 +153,6 @@ describe("DeleteCardModal", () => { const onClose = vi.fn(); const onCardDeleted = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - }); - render( <DeleteCardModal isOpen={true} @@ -160,15 +166,9 @@ describe("DeleteCardModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "DELETE", - headers: { - Authorization: "Bearer access-token", - }, - }, - ); + expect(mockDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + }); }); expect(onCardDeleted).toHaveBeenCalledTimes(1); @@ -178,7 +178,7 @@ describe("DeleteCardModal", () => { it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteCardModal {...defaultProps} />); @@ -198,11 +198,9 @@ describe("DeleteCardModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Card not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Card not found", 404), + ); render(<DeleteCardModal {...defaultProps} />); @@ -216,7 +214,7 @@ describe("DeleteCardModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockDelete.mockRejectedValue(new Error("Network error")); render(<DeleteCardModal {...defaultProps} />); @@ -229,10 +227,12 @@ describe("DeleteCardModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<DeleteCardModal {...defaultProps} />); @@ -249,11 +249,7 @@ describe("DeleteCardModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <DeleteCardModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx index 44a745d..d9cf098 100644 --- a/src/client/components/DeleteCardModal.tsx +++ b/src/client/components/DeleteCardModal.tsx @@ -36,24 +36,12 @@ export function DeleteCardModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/cards/${card.id}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].cards[ + ":cardId" + ].$delete({ + param: { deckId, cardId: card.id }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onCardDeleted(); onClose(); diff --git a/src/client/components/DeleteDeckModal.test.tsx b/src/client/components/DeleteDeckModal.test.tsx index ad1463d..4441064 100644 --- a/src/client/components/DeleteDeckModal.test.tsx +++ b/src/client/components/DeleteDeckModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":id": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { DeleteDeckModal } from "./DeleteDeckModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("DeleteDeckModal", () => { const mockDeck = { id: "deck-123", @@ -44,9 +52,8 @@ describe("DeleteDeckModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -126,11 +133,6 @@ describe("DeleteDeckModal", () => { const onClose = vi.fn(); const onDeckDeleted = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - }); - render( <DeleteDeckModal isOpen={true} @@ -143,11 +145,8 @@ describe("DeleteDeckModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "DELETE", - headers: { - Authorization: "Bearer access-token", - }, + expect(mockDelete).toHaveBeenCalledWith({ + param: { id: "deck-123" }, }); }); @@ -158,7 +157,7 @@ describe("DeleteDeckModal", () => { it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteDeckModal {...defaultProps} />); @@ -178,11 +177,9 @@ describe("DeleteDeckModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Deck not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Deck not found", 404), + ); render(<DeleteDeckModal {...defaultProps} />); @@ -196,7 +193,7 @@ describe("DeleteDeckModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockDelete.mockRejectedValue(new Error("Network error")); render(<DeleteDeckModal {...defaultProps} />); @@ -209,10 +206,12 @@ describe("DeleteDeckModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<DeleteDeckModal {...defaultProps} />); @@ -229,11 +228,7 @@ describe("DeleteDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <DeleteDeckModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx index 5a252e6..edc6093 100644 --- a/src/client/components/DeleteDeckModal.tsx +++ b/src/client/components/DeleteDeckModal.tsx @@ -34,24 +34,10 @@ export function DeleteDeckModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deck.id}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api.decks[":id"].$delete({ + param: { id: deck.id }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onDeckDeleted(); onClose(); diff --git a/src/client/components/DeleteNoteModal.test.tsx b/src/client/components/DeleteNoteModal.test.tsx index a17323a..7130176 100644 --- a/src/client/components/DeleteNoteModal.test.tsx +++ b/src/client/components/DeleteNoteModal.test.tsx @@ -4,12 +4,26 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; -import { DeleteNoteModal } from "./DeleteNoteModal"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + notes: { + ":noteId": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -23,8 +37,8 @@ vi.mock("../api/client", () => ({ }, })); -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError } from "../api/client"; +import { DeleteNoteModal } from "./DeleteNoteModal"; describe("DeleteNoteModal", () => { const defaultProps = { @@ -37,9 +51,8 @@ describe("DeleteNoteModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -125,11 +138,6 @@ describe("DeleteNoteModal", () => { const onClose = vi.fn(); const onNoteDeleted = vi.fn(); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }); - render( <DeleteNoteModal {...defaultProps} @@ -141,9 +149,8 @@ describe("DeleteNoteModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, + expect(mockDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, }); }); @@ -154,11 +161,9 @@ describe("DeleteNoteModal", () => { it("displays error message when delete fails", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to delete note" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Failed to delete note", 500), + ); render(<DeleteNoteModal {...defaultProps} />); @@ -175,12 +180,7 @@ describe("DeleteNoteModal", () => { const user = userEvent.setup(); // Create a promise that we can control - let resolveDelete: ((value: unknown) => void) | undefined; - const deletePromise = new Promise((resolve) => { - resolveDelete = resolve; - }); - - mockFetch.mockReturnValueOnce(deletePromise); + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteNoteModal {...defaultProps} />); @@ -188,24 +188,13 @@ describe("DeleteNoteModal", () => { // Should show "Deleting..." while request is in progress expect(screen.getByText("Deleting...")).toBeDefined(); - - // Resolve the delete request to cleanup - resolveDelete?.({ - ok: true, - json: async () => ({ success: true }), - }); }); it("disables buttons while deleting", async () => { const user = userEvent.setup(); // Create a promise that we can control - let resolveDelete: ((value: unknown) => void) | undefined; - const deletePromise = new Promise((resolve) => { - resolveDelete = resolve; - }); - - mockFetch.mockReturnValueOnce(deletePromise); + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteNoteModal {...defaultProps} />); @@ -220,11 +209,5 @@ describe("DeleteNoteModal", () => { "disabled", true, ); - - // Resolve the delete request to cleanup - resolveDelete?.({ - ok: true, - json: async () => ({ success: true }), - }); }); }); diff --git a/src/client/components/DeleteNoteModal.tsx b/src/client/components/DeleteNoteModal.tsx index 8eec124..5d81fdc 100644 --- a/src/client/components/DeleteNoteModal.tsx +++ b/src/client/components/DeleteNoteModal.tsx @@ -31,24 +31,12 @@ export function DeleteNoteModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/notes/${noteId}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].notes[ + ":noteId" + ].$delete({ + param: { deckId, noteId }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onNoteDeleted(); onClose(); diff --git a/src/client/components/DeleteNoteTypeModal.test.tsx b/src/client/components/DeleteNoteTypeModal.test.tsx index b7159ab..c73fbe0 100644 --- a/src/client/components/DeleteNoteTypeModal.test.tsx +++ b/src/client/components/DeleteNoteTypeModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + ":id": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { DeleteNoteTypeModal } from "./DeleteNoteTypeModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("DeleteNoteTypeModal", () => { const mockNoteType = { id: "note-type-123", @@ -44,9 +52,8 @@ describe("DeleteNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -128,11 +135,6 @@ describe("DeleteNoteTypeModal", () => { const onClose = vi.fn(); const onNoteTypeDeleted = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - }); - render( <DeleteNoteTypeModal isOpen={true} @@ -145,11 +147,8 @@ describe("DeleteNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", { - method: "DELETE", - headers: { - Authorization: "Bearer access-token", - }, + expect(mockDelete).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, }); }); @@ -160,7 +159,7 @@ describe("DeleteNoteTypeModal", () => { it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteNoteTypeModal {...defaultProps} />); @@ -180,11 +179,9 @@ describe("DeleteNoteTypeModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Note type not found", 404), + ); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -200,13 +197,9 @@ describe("DeleteNoteTypeModal", () => { it("displays conflict error when notes exist", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 409, - json: async () => ({ - error: "Cannot delete note type with existing notes", - }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Cannot delete note type with existing notes", 409), + ); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -222,7 +215,7 @@ describe("DeleteNoteTypeModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockDelete.mockRejectedValue(new Error("Network error")); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -235,10 +228,12 @@ describe("DeleteNoteTypeModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -255,11 +250,7 @@ describe("DeleteNoteTypeModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <DeleteNoteTypeModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/DeleteNoteTypeModal.tsx b/src/client/components/DeleteNoteTypeModal.tsx index bd6b4a5..db93482 100644 --- a/src/client/components/DeleteNoteTypeModal.tsx +++ b/src/client/components/DeleteNoteTypeModal.tsx @@ -34,24 +34,10 @@ export function DeleteNoteTypeModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$delete({ + param: { id: noteType.id }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onNoteTypeDeleted(); onClose(); diff --git a/src/client/components/EditCardModal.test.tsx b/src/client/components/EditCardModal.test.tsx index b07dd4b..3486441 100644 --- a/src/client/components/EditCardModal.test.tsx +++ b/src/client/components/EditCardModal.test.tsx @@ -4,11 +4,26 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPut = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + cards: { + ":cardId": { + $put: (args: unknown) => mockPut(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +37,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditCardModal } from "./EditCardModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditCardModal", () => { const mockCard = { id: "card-123", @@ -46,8 +58,13 @@ describe("EditCardModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", + mockPut.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ + card: { + id: "card-123", + front: "Test front", + back: "Test back", + }, }); }); @@ -156,17 +173,6 @@ describe("EditCardModal", () => { const onClose = vi.fn(); const onCardUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - card: { - id: "card-123", - front: "Updated front", - back: "Test back", - }, - }), - }); - render( <EditCardModal isOpen={true} @@ -183,20 +189,13 @@ describe("EditCardModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - front: "Updated front", - back: "Test back", - }), + expect(mockPut).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + json: { + front: "Updated front", + back: "Test back", }, - ); + }); }); expect(onCardUpdated).toHaveBeenCalledTimes(1); @@ -208,17 +207,6 @@ describe("EditCardModal", () => { const onClose = vi.fn(); const onCardUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - card: { - id: "card-123", - front: "Test front", - back: "Updated back", - }, - }), - }); - render( <EditCardModal isOpen={true} @@ -235,20 +223,13 @@ describe("EditCardModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - front: "Test front", - back: "Updated back", - }), + expect(mockPut).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + json: { + front: "Test front", + back: "Updated back", }, - ); + }); }); expect(onCardUpdated).toHaveBeenCalledTimes(1); @@ -258,11 +239,6 @@ describe("EditCardModal", () => { it("trims whitespace from front and back", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ card: { id: "card-123" } }), - }); - const cardWithWhitespace = { ...mockCard, front: " Front ", @@ -273,27 +249,20 @@ describe("EditCardModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - front: "Front", - back: "Back", - }), + expect(mockPut).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + json: { + front: "Front", + back: "Back", }, - ); + }); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves render(<EditCardModal {...defaultProps} />); @@ -315,11 +284,9 @@ describe("EditCardModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Card not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Card not found", 404), + ); render(<EditCardModal {...defaultProps} />); @@ -333,7 +300,7 @@ describe("EditCardModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPut.mockRejectedValue(new Error("Network error")); render(<EditCardModal {...defaultProps} />); @@ -346,10 +313,12 @@ describe("EditCardModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<EditCardModal {...defaultProps} />); @@ -385,11 +354,7 @@ describe("EditCardModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); const { rerender } = render( <EditCardModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx index e38a2b1..726a003 100644 --- a/src/client/components/EditCardModal.tsx +++ b/src/client/components/EditCardModal.tsx @@ -49,31 +49,16 @@ export function EditCardModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/cards/${card.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].cards[ + ":cardId" + ].$put({ + param: { deckId, cardId: card.id }, + json: { front: front.trim(), back: back.trim(), - }), + }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onCardUpdated(); onClose(); diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx index c627dd5..fce17f6 100644 --- a/src/client/components/EditDeckModal.test.tsx +++ b/src/client/components/EditDeckModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPut = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":id": { + $put: (args: unknown) => mockPut(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditDeckModal } from "./EditDeckModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditDeckModal", () => { const mockDeck = { id: "deck-123", @@ -46,8 +54,14 @@ describe("EditDeckModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", + mockPut.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ + deck: { + id: "deck-123", + name: "Test Deck", + description: "Test description", + newCardsPerDay: 20, + }, }); }); @@ -155,18 +169,6 @@ describe("EditDeckModal", () => { const onClose = vi.fn(); const onDeckUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - deck: { - id: "deck-123", - name: "Updated Deck", - description: "Test description", - newCardsPerDay: 20, - }, - }), - }); - render( <EditDeckModal isOpen={true} @@ -182,16 +184,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Updated Deck", description: "Test description", - }), + }, }); }); @@ -204,18 +202,6 @@ describe("EditDeckModal", () => { const onClose = vi.fn(); const onDeckUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - deck: { - id: "deck-123", - name: "Test Deck", - description: "New description", - newCardsPerDay: 20, - }, - }), - }); - render( <EditDeckModal isOpen={true} @@ -231,16 +217,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Test Deck", description: "New description", - }), + }, }); }); @@ -251,18 +233,6 @@ describe("EditDeckModal", () => { it("clears description when input is emptied", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - deck: { - id: "deck-123", - name: "Test Deck", - description: null, - newCardsPerDay: 20, - }, - }), - }); - render(<EditDeckModal {...defaultProps} />); const descInput = screen.getByLabelText("Description (optional)"); @@ -270,16 +240,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Test Deck", description: null, - }), + }, }); }); }); @@ -287,11 +253,6 @@ describe("EditDeckModal", () => { it("trims whitespace from name and description", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ deck: { id: "deck-123" } }), - }); - const deckWithWhitespace = { ...mockDeck, name: " Deck ", @@ -302,16 +263,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Deck", description: "Description", - }), + }, }); }); }); @@ -319,7 +276,7 @@ describe("EditDeckModal", () => { it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves render(<EditDeckModal {...defaultProps} />); @@ -344,11 +301,9 @@ describe("EditDeckModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Deck name already exists" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Deck name already exists", 400), + ); render(<EditDeckModal {...defaultProps} />); @@ -364,7 +319,7 @@ describe("EditDeckModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPut.mockRejectedValue(new Error("Network error")); render(<EditDeckModal {...defaultProps} />); @@ -377,10 +332,12 @@ describe("EditDeckModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<EditDeckModal {...defaultProps} />); @@ -419,11 +376,7 @@ describe("EditDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); const { rerender } = render( <EditDeckModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx index e589900..3babeb5 100644 --- a/src/client/components/EditDeckModal.tsx +++ b/src/client/components/EditDeckModal.tsx @@ -48,31 +48,14 @@ export function EditDeckModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deck.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":id"].$put({ + param: { id: deck.id }, + json: { name: name.trim(), description: description.trim() || null, - }), + }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onDeckUpdated(); onClose(); diff --git a/src/client/components/EditNoteModal.test.tsx b/src/client/components/EditNoteModal.test.tsx index 61f94bd..20c437f 100644 --- a/src/client/components/EditNoteModal.test.tsx +++ b/src/client/components/EditNoteModal.test.tsx @@ -4,11 +4,34 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockNoteGet = vi.fn(); +const mockNotePut = vi.fn(); +const mockNoteTypeGet = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + notes: { + ":noteId": { + $get: (args: unknown) => mockNoteGet(args), + $put: (args: unknown) => mockNotePut(args), + }, + }, + }, + }, + "note-types": { + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +45,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditNoteModal } from "./EditNoteModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditNoteModal", () => { const defaultProps = { isOpen: true, @@ -72,9 +92,9 @@ describe("EditNoteModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockNoteGet.mockResolvedValue({ ok: true }); + mockNotePut.mockResolvedValue({ ok: true }); + mockNoteTypeGet.mockResolvedValue({ ok: true }); }); afterEach(() => { @@ -95,15 +115,9 @@ describe("EditNoteModal", () => { }); it("renders modal when open with noteId", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -117,44 +131,29 @@ describe("EditNoteModal", () => { }); it("fetches note and note type on open", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-123/notes/note-456", - { - headers: { Authorization: "Bearer access-token" }, - }, - ); + expect(mockNoteGet).toHaveBeenCalledWith({ + param: { deckId: "deck-123", noteId: "note-456" }, + }); }); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-1" }, }); }); }); it("populates form with note field values", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -171,15 +170,9 @@ describe("EditNoteModal", () => { }); it("displays note type name (read-only)", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -191,15 +184,9 @@ describe("EditNoteModal", () => { it("disables save button when fields are empty", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -216,15 +203,9 @@ describe("EditNoteModal", () => { }); it("enables save button when all fields have values", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -240,15 +221,9 @@ describe("EditNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} onClose={onClose} />); @@ -265,15 +240,9 @@ describe("EditNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} onClose={onClose} />); @@ -293,19 +262,10 @@ describe("EditNoteModal", () => { const onClose = vi.fn(); const onNoteUpdated = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }); render( <EditNoteModal @@ -327,22 +287,15 @@ describe("EditNoteModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-123/notes/note-456", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", + expect(mockNotePut).toHaveBeenCalledWith({ + param: { deckId: "deck-123", noteId: "note-456" }, + json: { + fields: { + "field-1": "Updated front", + "field-2": "Existing back", }, - body: JSON.stringify({ - fields: { - "field-1": "Updated front", - "field-2": "Existing back", - }, - }), }, - ); + }); }); expect(onNoteUpdated).toHaveBeenCalledTimes(1); @@ -370,19 +323,10 @@ describe("EditNoteModal", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: noteWithWhitespace }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: noteWithWhitespace }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: noteWithWhitespace }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ note: noteWithWhitespace }); render(<EditNoteModal {...defaultProps} />); @@ -393,27 +337,20 @@ describe("EditNoteModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-123/notes/note-456", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", + expect(mockNotePut).toHaveBeenCalledWith({ + param: { deckId: "deck-123", noteId: "note-456" }, + json: { + fields: { + "field-1": "Trimmed", + "field-2": "Value", }, - body: JSON.stringify({ - fields: { - "field-1": "Trimmed", - "field-2": "Value", - }, - }), }, - ); + }); }); }); it("shows loading state during fetch", async () => { - mockFetch.mockImplementationOnce(() => new Promise(() => {})); // Never resolves + mockNoteGet.mockImplementationOnce(() => new Promise(() => {})); // Never resolves render(<EditNoteModal {...defaultProps} />); @@ -423,16 +360,11 @@ describe("EditNoteModal", () => { it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockImplementationOnce(() => new Promise(() => {})); // Never resolves + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); + + mockNotePut.mockImplementationOnce(() => new Promise(() => {})); // Never resolves render(<EditNoteModal {...defaultProps} />); @@ -450,11 +382,9 @@ describe("EditNoteModal", () => { }); it("displays API error message when note fetch fails", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Note not found" }), - }); + mockHandleResponse.mockRejectedValueOnce( + new ApiClientError("Note not found", 404), + ); render(<EditNoteModal {...defaultProps} />); @@ -464,16 +394,9 @@ describe("EditNoteModal", () => { }); it("displays API error message when note type fetch fails", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockRejectedValueOnce(new ApiClientError("Note type not found", 404)); render(<EditNoteModal {...defaultProps} />); @@ -487,20 +410,10 @@ describe("EditNoteModal", () => { it("displays API error message when update fails", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 400, - json: async () => ({ error: "Failed to update note" }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockRejectedValueOnce(new ApiClientError("Failed to update note", 400)); render(<EditNoteModal {...defaultProps} />); @@ -518,7 +431,7 @@ describe("EditNoteModal", () => { }); it("displays generic error on unexpected failure", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + mockNoteGet.mockRejectedValueOnce(new Error("Network error")); render(<EditNoteModal {...defaultProps} />); @@ -529,30 +442,12 @@ describe("EditNoteModal", () => { }); }); - it("displays error when not authenticated", async () => { - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(<EditNoteModal {...defaultProps} />); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - it("resets form when modal is closed and reopened with different noteId", async () => { const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); const { rerender } = render( <EditNoteModal @@ -603,15 +498,9 @@ describe("EditNoteModal", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: newNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: newNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); // Reopen with different noteId rerender( @@ -639,15 +528,9 @@ describe("EditNoteModal", () => { isReversible: true, }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: reversibleNoteType }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: reversibleNoteType }); render(<EditNoteModal {...defaultProps} />); diff --git a/src/client/components/EditNoteModal.tsx b/src/client/components/EditNoteModal.tsx index 5bd864d..ac22332 100644 --- a/src/client/components/EditNoteModal.tsx +++ b/src/client/components/EditNoteModal.tsx @@ -60,25 +60,10 @@ export function EditNoteModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteTypeId}`, { - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: noteTypeId }, }); - - 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<{ noteType: NoteType }>(res); setNoteType(data.noteType); } catch (err) { if (err instanceof ApiClientError) { @@ -98,25 +83,14 @@ export function EditNoteModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/notes/${noteId}`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].notes[ + ":noteId" + ].$get({ + param: { deckId, noteId }, }); - - 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<{ + note: NoteWithFieldValues; + }>(res); setNote(data.note); // Initialize field values from note @@ -176,36 +150,21 @@ export function EditNoteModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - // Trim all field values const trimmedFields: Record<string, string> = {}; for (const [fieldId, value] of Object.entries(fieldValues)) { trimmedFields[fieldId] = value.trim(); } - const res = await fetch(`/api/decks/${deckId}/notes/${note.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].notes[ + ":noteId" + ].$put({ + param: { deckId, noteId: note.id }, + json: { fields: trimmedFields, - }), + }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onNoteUpdated(); handleClose(); diff --git a/src/client/components/EditNoteTypeModal.test.tsx b/src/client/components/EditNoteTypeModal.test.tsx index 61130e2..cc23d8f 100644 --- a/src/client/components/EditNoteTypeModal.test.tsx +++ b/src/client/components/EditNoteTypeModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPut = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + ":id": { + $put: (args: unknown) => mockPut(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditNoteTypeModal } from "./EditNoteTypeModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditNoteTypeModal", () => { const mockNoteType = { id: "note-type-123", @@ -47,9 +55,8 @@ describe("EditNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockPut.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ noteType: mockNoteType }); }); afterEach(() => { @@ -155,17 +162,6 @@ describe("EditNoteTypeModal", () => { const onClose = vi.fn(); const onNoteTypeUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - noteType: { - ...mockNoteType, - name: "Updated Basic", - isReversible: true, - }, - }), - }); - render( <EditNoteTypeModal isOpen={true} @@ -185,18 +181,14 @@ describe("EditNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { name: "Updated Basic", frontTemplate: "{{Front}}", backTemplate: "{{Back}}", isReversible: true, - }), + }, }); }); @@ -207,11 +199,6 @@ describe("EditNoteTypeModal", () => { it("trims whitespace from text fields", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteType }), - }); - render(<EditNoteTypeModal {...defaultProps} />); const nameInput = screen.getByLabelText("Name"); @@ -220,19 +207,19 @@ describe("EditNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123", - expect.objectContaining({ - body: expect.stringContaining('"name":"Updated Basic"'), + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: expect.objectContaining({ + name: "Updated Basic", }), - ); + }); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves render(<EditNoteTypeModal {...defaultProps} />); @@ -253,11 +240,9 @@ describe("EditNoteTypeModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Note type not found", 404), + ); render(<EditNoteTypeModal {...defaultProps} />); @@ -273,7 +258,7 @@ describe("EditNoteTypeModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPut.mockRejectedValue(new Error("Network error")); render(<EditNoteTypeModal {...defaultProps} />); @@ -286,10 +271,12 @@ describe("EditNoteTypeModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<EditNoteTypeModal {...defaultProps} />); @@ -339,11 +326,7 @@ describe("EditNoteTypeModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <EditNoteTypeModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/EditNoteTypeModal.tsx b/src/client/components/EditNoteTypeModal.tsx index 014492e..27ef5d8 100644 --- a/src/client/components/EditNoteTypeModal.tsx +++ b/src/client/components/EditNoteTypeModal.tsx @@ -53,33 +53,16 @@ export function EditNoteTypeModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"][":id"].$put({ + param: { id: noteType.id }, + json: { name: name.trim(), frontTemplate: frontTemplate.trim(), backTemplate: backTemplate.trim(), isReversible, - }), + }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onNoteTypeUpdated(); onClose(); diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx index b49b276..a7a88c5 100644 --- a/src/client/components/ImportNotesModal.tsx +++ b/src/client/components/ImportNotesModal.tsx @@ -73,34 +73,21 @@ export function ImportNotesModal({ const fetchNoteTypes = useCallback(async () => { 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: { id: string; name: string }[]; + }>(res); // Fetch details for each note type to get fields const noteTypesWithFields: NoteType[] = []; for (const nt of data.noteTypes) { - const detailRes = await fetch(`/api/note-types/${nt.id}`, { - headers: authHeader, + const detailRes = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: nt.id }, }); if (detailRes.ok) { - const detailData = await detailRes.json(); + const detailData = await apiClient.handleResponse<{ + noteType: NoteType; + }>(detailRes); noteTypesWithFields.push(detailData.noteType); } } @@ -247,35 +234,16 @@ export function ImportNotesModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/notes/import`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].notes.import.$post({ + param: { deckId }, + json: { notes: validatedRows.map((row) => ({ noteTypeId: row.noteTypeId, fields: row.fields, })), - }), + }, }); - - 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 result = await res.json(); + const result = await apiClient.handleResponse<ImportResult>(res); setImportResult(result); setPhase("complete"); onImportComplete(); diff --git a/src/client/components/NoteTypeEditor.test.tsx b/src/client/components/NoteTypeEditor.test.tsx index 49b35c6..a628859 100644 --- a/src/client/components/NoteTypeEditor.test.tsx +++ b/src/client/components/NoteTypeEditor.test.tsx @@ -4,11 +4,38 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockNoteTypeGet = vi.fn(); +const mockNoteTypePut = vi.fn(); +const mockFieldPost = vi.fn(); +const mockFieldPut = vi.fn(); +const mockFieldDelete = vi.fn(); +const mockFieldsReorder = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + $put: (args: unknown) => mockNoteTypePut(args), + fields: { + $post: (args: unknown) => mockFieldPost(args), + ":fieldId": { + $put: (args: unknown) => mockFieldPut(args), + $delete: (args: unknown) => mockFieldDelete(args), + }, + reorder: { + $put: (args: unknown) => mockFieldsReorder(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +49,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { NoteTypeEditor } from "./NoteTypeEditor"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("NoteTypeEditor", () => { const mockNoteTypeWithFields = { id: "note-type-123", @@ -63,9 +87,12 @@ describe("NoteTypeEditor", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockNoteTypeGet.mockResolvedValue({ ok: true }); + mockNoteTypePut.mockResolvedValue({ ok: true }); + mockFieldPost.mockResolvedValue({ ok: true }); + mockFieldPut.mockResolvedValue({ ok: true }); + mockFieldDelete.mockResolvedValue({ ok: true }); + mockFieldsReorder.mockResolvedValue({ ok: true }); }); afterEach(() => { @@ -80,9 +107,8 @@ describe("NoteTypeEditor", () => { }); it("renders modal and fetches note type when open", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -90,12 +116,9 @@ describe("NoteTypeEditor", () => { expect(screen.getByRole("dialog")).toBeDefined(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123", - expect.objectContaining({ - headers: { Authorization: "Bearer access-token" }, - }), - ); + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + }); }); await waitFor(() => { @@ -104,9 +127,8 @@ describe("NoteTypeEditor", () => { }); it("displays note type data after loading", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -132,30 +154,18 @@ describe("NoteTypeEditor", () => { }); it("displays loading state while fetching", async () => { - let resolvePromise: ((value: Response) => void) | undefined; - const fetchPromise = new Promise<Response>((resolve) => { - resolvePromise = resolve; - }); - mockFetch.mockReturnValue(fetchPromise); + mockNoteTypeGet.mockImplementation(() => new Promise(() => {})); // Never resolves render(<NoteTypeEditor {...defaultProps} />); - // Should show loading spinner + // Should show dialog expect(screen.getByRole("dialog")).toBeDefined(); - - // Resolve the promise to clean up - resolvePromise?.({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - } as Response); }); it("displays error when fetch fails", async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse.mockRejectedValueOnce( + new ApiClientError("Note type not found", 404), + ); render(<NoteTypeEditor {...defaultProps} />); @@ -166,25 +176,12 @@ describe("NoteTypeEditor", () => { }); }); - it("displays error when not authenticated", async () => { - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(<NoteTypeEditor {...defaultProps} />); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - it("calls onClose when Cancel is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} onClose={onClose} />); @@ -202,9 +199,8 @@ describe("NoteTypeEditor", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} onClose={onClose} />); @@ -224,16 +220,10 @@ describe("NoteTypeEditor", () => { const onClose = vi.fn(); const onNoteTypeUpdated = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" }, - }), + noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" }, }); render( @@ -256,18 +246,14 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockNoteTypePut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { name: "Updated Basic", frontTemplate: "{{Front}}", backTemplate: "{{Back}}", isReversible: false, - }), + }, }); }); @@ -278,22 +264,16 @@ describe("NoteTypeEditor", () => { it("adds a new field", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - field: { - id: "field-3", - noteTypeId: "note-type-123", - name: "Hint", - order: 2, - fieldType: "text", - }, - }), + field: { + id: "field-3", + noteTypeId: "note-type-123", + name: "Hint", + order: 2, + fieldType: "text", + }, }); render(<NoteTypeEditor {...defaultProps} />); @@ -307,21 +287,14 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Add" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - name: "Hint", - order: 2, - fieldType: "text", - }), + expect(mockFieldPost).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { + name: "Hint", + order: 2, + fieldType: "text", }, - ); + }); }); await waitFor(() => { @@ -332,15 +305,9 @@ describe("NoteTypeEditor", () => { it("deletes a field", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ success: true }); render(<NoteTypeEditor {...defaultProps} />); @@ -357,31 +324,20 @@ describe("NoteTypeEditor", () => { await user.click(deleteButton); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/field-1", - { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, - }, - ); + expect(mockFieldDelete).toHaveBeenCalledWith({ + param: { id: "note-type-123", fieldId: "field-1" }, + }); }); }); it("displays error when field deletion fails", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 409, - json: async () => ({ - error: "Cannot delete field with existing values", - }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockRejectedValueOnce( + new ApiClientError("Cannot delete field with existing values", 409), + ); render(<NoteTypeEditor {...defaultProps} />); @@ -412,31 +368,25 @@ describe("NoteTypeEditor", () => { it("moves a field up", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - fields: [ - { - id: "field-2", - noteTypeId: "note-type-123", - name: "Back", - order: 0, - fieldType: "text", - }, - { - id: "field-1", - noteTypeId: "note-type-123", - name: "Front", - order: 1, - fieldType: "text", - }, - ], - }), + fields: [ + { + id: "field-2", + noteTypeId: "note-type-123", + name: "Back", + order: 0, + fieldType: "text", + }, + { + id: "field-1", + noteTypeId: "note-type-123", + name: "Front", + order: 1, + fieldType: "text", + }, + ], }); render(<NoteTypeEditor {...defaultProps} />); @@ -454,50 +404,37 @@ describe("NoteTypeEditor", () => { await user.click(secondMoveUpButton); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/reorder", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - fieldIds: ["field-2", "field-1"], - }), + expect(mockFieldsReorder).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { + fieldIds: ["field-2", "field-1"], }, - ); + }); }); }); it("moves a field down", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - fields: [ - { - id: "field-2", - noteTypeId: "note-type-123", - name: "Back", - order: 0, - fieldType: "text", - }, - { - id: "field-1", - noteTypeId: "note-type-123", - name: "Front", - order: 1, - fieldType: "text", - }, - ], - }), + fields: [ + { + id: "field-2", + noteTypeId: "note-type-123", + name: "Back", + order: 0, + fieldType: "text", + }, + { + id: "field-1", + noteTypeId: "note-type-123", + name: "Front", + order: 1, + fieldType: "text", + }, + ], }); render(<NoteTypeEditor {...defaultProps} />); @@ -514,41 +451,28 @@ describe("NoteTypeEditor", () => { await user.click(firstMoveDownButton); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/reorder", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - fieldIds: ["field-2", "field-1"], - }), + expect(mockFieldsReorder).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { + fieldIds: ["field-2", "field-1"], }, - ); + }); }); }); it("edits a field name", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - field: { - id: "field-1", - noteTypeId: "note-type-123", - name: "Question", - order: 0, - fieldType: "text", - }, - }), + field: { + id: "field-1", + noteTypeId: "note-type-123", + name: "Question", + order: 0, + fieldType: "text", + }, }); render(<NoteTypeEditor {...defaultProps} />); @@ -569,26 +493,18 @@ describe("NoteTypeEditor", () => { await user.tab(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/field-1", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - name: "Question", - }), + expect(mockFieldPut).toHaveBeenCalledWith({ + param: { id: "note-type-123", fieldId: "field-1" }, + json: { + name: "Question", }, - ); + }); }); }); it("shows available fields in template help text", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -599,9 +515,8 @@ describe("NoteTypeEditor", () => { }); it("disables move up button for first field", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -616,9 +531,8 @@ describe("NoteTypeEditor", () => { }); it("disables move down button for last field", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -636,9 +550,8 @@ describe("NoteTypeEditor", () => { }); it("disables Add button when new field name is empty", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -654,16 +567,10 @@ describe("NoteTypeEditor", () => { it("toggles reversible option", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - noteType: { ...mockNoteTypeWithFields, isReversible: true }, - }), + noteType: { ...mockNoteTypeWithFields, isReversible: true }, }); render(<NoteTypeEditor {...defaultProps} />); @@ -682,12 +589,12 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenLastCalledWith( - "/api/note-types/note-type-123", - expect.objectContaining({ - body: expect.stringContaining('"isReversible":true'), + expect(mockNoteTypePut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: expect.objectContaining({ + isReversible: true, }), - ); + }); }); }); }); diff --git a/src/client/components/NoteTypeEditor.tsx b/src/client/components/NoteTypeEditor.tsx index 01a4777..2487c62 100644 --- a/src/client/components/NoteTypeEditor.tsx +++ b/src/client/components/NoteTypeEditor.tsx @@ -71,26 +71,13 @@ export function NoteTypeEditor({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteTypeId}`, { - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: noteTypeId }, }); - - 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 fetchedNoteType = data.noteType as NoteTypeWithFields; + const data = await apiClient.handleResponse<{ + noteType: NoteTypeWithFields; + }>(res); + const fetchedNoteType = data.noteType; setNoteType(fetchedNoteType); setName(fetchedNoteType.name); setFrontTemplate(fetchedNoteType.frontTemplate); @@ -138,33 +125,16 @@ export function NoteTypeEditor({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"][":id"].$put({ + param: { id: noteType.id }, + json: { name: name.trim(), frontTemplate: frontTemplate.trim(), backTemplate: backTemplate.trim(), isReversible, - }), + }, }); - - 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, - ); - } + await apiClient.handleResponse(res); onNoteTypeUpdated(); onClose(); @@ -186,37 +156,20 @@ export function NoteTypeEditor({ setIsAddingField(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - const newOrder = fields.length > 0 ? Math.max(...fields.map((f) => f.order)) + 1 : 0; - const res = await fetch(`/api/note-types/${noteType.id}/fields`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"][":id"].fields.$post({ + param: { id: noteType.id }, + json: { name: newFieldName.trim(), order: newOrder, fieldType: "text", - }), + }, }); - - 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<{ field: NoteFieldType }>( + res, + ); setFields([...fields, data.field]); setNewFieldName(""); } catch (err) { @@ -236,35 +189,17 @@ export function NoteTypeEditor({ setFieldError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch( - `/api/note-types/${noteType.id}/fields/${fieldId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ - name: editingFieldName.trim(), - }), + const res = await apiClient.rpc.api["note-types"][":id"].fields[ + ":fieldId" + ].$put({ + param: { id: noteType.id, fieldId }, + json: { + name: editingFieldName.trim(), }, + }); + const data = await apiClient.handleResponse<{ field: NoteFieldType }>( + res, ); - - 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(); setFields(fields.map((f) => (f.id === fieldId ? data.field : f))); setEditingFieldId(null); setEditingFieldName(""); @@ -283,28 +218,12 @@ export function NoteTypeEditor({ setFieldError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch( - `/api/note-types/${noteType.id}/fields/${fieldId}`, - { - method: "DELETE", - 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 res = await apiClient.rpc.api["note-types"][":id"].fields[ + ":fieldId" + ].$delete({ + param: { id: noteType.id, fieldId }, + }); + await apiClient.handleResponse(res); setFields(fields.filter((f) => f.id !== fieldId)); } catch (err) { if (err instanceof ApiClientError) { @@ -334,30 +253,15 @@ export function NoteTypeEditor({ setFieldError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}/fields/reorder`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ fieldIds }), + const res = await apiClient.rpc.api["note-types"][ + ":id" + ].fields.reorder.$put({ + param: { id: noteType.id }, + json: { fieldIds }, }); - - 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<{ fields: NoteFieldType[] }>( + res, + ); setFields( data.fields.sort( (a: NoteFieldType, b: NoteFieldType) => a.order - b.order, 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); |
