From 2fb6471a685bec1433be3335f377a1a2313e4820 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 1 Jan 2026 23:44:50 +0900 Subject: refactor(client): migrate API calls to typed RPC client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw fetch() calls with apiClient.rpc typed client across all modal and page components. This provides better type safety and eliminates manual auth header handling. - Make handleResponse public for component usage - Update all component tests to mock RPC methods instead of fetch - Change POSTGRES_HOST default to kioku-db for Docker compatibility 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/client/components/CreateNoteModal.test.tsx | 348 ++++++----------- src/client/components/CreateNoteModal.tsx | 76 +--- src/client/components/CreateNoteTypeModal.test.tsx | 96 ++--- src/client/components/CreateNoteTypeModal.tsx | 26 +- src/client/components/DeleteCardModal.test.tsx | 70 ++-- src/client/components/DeleteCardModal.tsx | 22 +- src/client/components/DeleteDeckModal.test.tsx | 61 ++- src/client/components/DeleteDeckModal.tsx | 20 +- src/client/components/DeleteNoteModal.test.tsx | 73 ++-- src/client/components/DeleteNoteModal.tsx | 22 +- src/client/components/DeleteNoteTypeModal.test.tsx | 71 ++-- src/client/components/DeleteNoteTypeModal.tsx | 20 +- src/client/components/EditCardModal.test.tsx | 141 +++---- src/client/components/EditCardModal.tsx | 29 +- src/client/components/EditDeckModal.test.tsx | 143 +++---- src/client/components/EditDeckModal.tsx | 27 +- src/client/components/EditNoteModal.test.tsx | 329 ++++++---------- src/client/components/EditNoteModal.tsx | 75 +--- src/client/components/EditNoteTypeModal.test.tsx | 87 ++--- src/client/components/EditNoteTypeModal.tsx | 27 +- src/client/components/ImportNotesModal.tsx | 60 +-- src/client/components/NoteTypeEditor.test.tsx | 417 ++++++++------------- src/client/components/NoteTypeEditor.tsx | 178 ++------- 23 files changed, 793 insertions(+), 1625 deletions(-) (limited to 'src/client/components') 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(); @@ -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(); 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( { // 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 = {}; 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( { 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(); 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(); @@ -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(); @@ -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(); @@ -255,23 +242,6 @@ describe("CreateNoteTypeModal", () => { }); }); - it("displays error when not authenticated", async () => { - const user = userEvent.setup(); - - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(); - - 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( { 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( , 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( { 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( , 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( { 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(); @@ -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(); @@ -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(); @@ -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( { 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( , 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( { 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( { 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( , 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( { 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( { 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(); 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( , 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(); @@ -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(); 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( { 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -529,30 +442,12 @@ describe("EditNoteModal", () => { }); }); - it("displays error when not authenticated", async () => { - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(); - - 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( { ], }; - 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(); 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 = {}; 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( { 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(); 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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( , 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(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(); @@ -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(); @@ -132,30 +154,18 @@ describe("NoteTypeEditor", () => { }); it("displays loading state while fetching", async () => { - let resolvePromise: ((value: Response) => void) | undefined; - const fetchPromise = new Promise((resolve) => { - resolvePromise = resolve; - }); - mockFetch.mockReturnValue(fetchPromise); + mockNoteTypeGet.mockImplementation(() => new Promise(() => {})); // Never resolves render(); - // 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(); @@ -166,25 +176,12 @@ describe("NoteTypeEditor", () => { }); }); - it("displays error when not authenticated", async () => { - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(); - - 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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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(); @@ -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, -- cgit v1.2.3-70-g09d2