diff options
32 files changed, 1097 insertions, 2405 deletions
diff --git a/.env.example b/.env.example index 6ba9628..65f6851 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ POSTGRES_USER=kioku POSTGRES_PASSWORD=kioku POSTGRES_DB=kioku -POSTGRES_HOST=localhost +POSTGRES_HOST=kioku-db POSTGRES_PORT=5432 JWT_SECRET=your-secret-key-here diff --git a/src/client/api/client.ts b/src/client/api/client.ts index c91160d..fc718a2 100644 --- a/src/client/api/client.ts +++ b/src/client/api/client.ts @@ -117,7 +117,7 @@ export class ApiClient { return response; } - private async handleResponse<T>(response: Response): Promise<T> { + async handleResponse<T>(response: Response): Promise<T> { if (!response.ok) { const errorBody = (await response.json().catch(() => ({}))) as ApiError; throw new ApiClientError( diff --git a/src/client/components/CreateNoteModal.test.tsx b/src/client/components/CreateNoteModal.test.tsx index 5e6932b..dcb3360 100644 --- a/src/client/components/CreateNoteModal.test.tsx +++ b/src/client/components/CreateNoteModal.test.tsx @@ -4,11 +4,32 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockNoteTypesGet = vi.fn(); +const mockNoteTypeGet = vi.fn(); +const mockNotesPost = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + $get: () => mockNoteTypesGet(), + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + }, + }, + decks: { + ":deckId": { + notes: { + $post: (args: unknown) => mockNotesPost(args), + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +43,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { CreateNoteModal } from "./CreateNoteModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("CreateNoteModal", () => { const defaultProps = { isOpen: true, @@ -56,9 +74,9 @@ describe("CreateNoteModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockNoteTypesGet.mockResolvedValue({ ok: true }); + mockNoteTypeGet.mockResolvedValue({ ok: true }); + mockNotesPost.mockResolvedValue({ ok: true }); }); afterEach(() => { @@ -73,15 +91,9 @@ describe("CreateNoteModal", () => { }); it("renders modal when open", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -97,35 +109,21 @@ describe("CreateNoteModal", () => { }); it("loads note types on open", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types", { - headers: { Authorization: "Bearer access-token" }, - }); + expect(mockNoteTypesGet).toHaveBeenCalled(); }); }); it("auto-selects first note type and loads its fields", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -136,21 +134,15 @@ describe("CreateNoteModal", () => { }); // Verify the note type details were fetched - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-1" }, }); }); it("displays note type options in select", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -166,10 +158,7 @@ describe("CreateNoteModal", () => { }); it("shows message when no note types available", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockHandleResponse.mockResolvedValueOnce({ noteTypes: [] }); render(<CreateNoteModal {...defaultProps} />); @@ -188,15 +177,9 @@ describe("CreateNoteModal", () => { fields: [], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: noteTypeWithNoFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: noteTypeWithNoFields }); render(<CreateNoteModal {...defaultProps} />); @@ -210,15 +193,9 @@ describe("CreateNoteModal", () => { }); it("disables create button when fields are empty", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -233,15 +210,9 @@ describe("CreateNoteModal", () => { it("enables create button when all fields have values", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -260,15 +231,9 @@ describe("CreateNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} onClose={onClose} />); @@ -285,15 +250,9 @@ describe("CreateNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} onClose={onClose} />); @@ -313,22 +272,13 @@ describe("CreateNoteModal", () => { const onClose = vi.fn(); const onNoteCreated = vi.fn(); - mockFetch + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - note: { id: "note-1" }, - fieldValues: [], - cards: [{ id: "card-1", isReversed: false }], - }), + note: { id: "note-1" }, + fieldValues: [], + cards: [{ id: "card-1", isReversed: false }], }); render( @@ -349,19 +299,15 @@ describe("CreateNoteModal", () => { await user.click(screen.getByRole("button", { name: "Create Note" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123/notes", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockNotesPost).toHaveBeenCalledWith({ + param: { deckId: "deck-123" }, + json: { noteTypeId: "note-type-1", fields: { "field-1": "What is 2+2?", "field-2": "4", }, - }), + }, }); }); @@ -372,22 +318,13 @@ describe("CreateNoteModal", () => { it("trims whitespace from field values", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - note: { id: "note-1" }, - fieldValues: [], - cards: [], - }), + note: { id: "note-1" }, + fieldValues: [], + cards: [], }); render(<CreateNoteModal {...defaultProps} />); @@ -401,19 +338,15 @@ describe("CreateNoteModal", () => { await user.click(screen.getByRole("button", { name: "Create Note" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123/notes", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockNotesPost).toHaveBeenCalledWith({ + param: { deckId: "deck-123" }, + json: { noteTypeId: "note-type-1", fields: { "field-1": "Question", "field-2": "Answer", }, - }), + }, }); }); }); @@ -421,16 +354,11 @@ describe("CreateNoteModal", () => { it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockImplementationOnce(() => new Promise(() => {})); // Never resolves + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); + + mockNotesPost.mockImplementationOnce(() => new Promise(() => {})); // Never resolves render(<CreateNoteModal {...defaultProps} />); @@ -452,20 +380,10 @@ describe("CreateNoteModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 400, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockRejectedValueOnce(new ApiClientError("Note type not found", 400)); render(<CreateNoteModal {...defaultProps} />); @@ -487,16 +405,11 @@ describe("CreateNoteModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockRejectedValueOnce(new Error("Network error")); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); + + mockNotesPost.mockRejectedValueOnce(new Error("Network error")); render(<CreateNoteModal {...defaultProps} />); @@ -530,19 +443,10 @@ describe("CreateNoteModal", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: reversedNoteType }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ noteType: reversedNoteType }); render(<CreateNoteModal {...defaultProps} />); @@ -561,8 +465,8 @@ describe("CreateNoteModal", () => { }); // Verify the note type details were fetched for the new type - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-2", { - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-2" }, }); }); @@ -572,15 +476,9 @@ describe("CreateNoteModal", () => { isReversible: true, }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: reversedNoteType }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: reversedNoteType }); render(<CreateNoteModal {...defaultProps} />); @@ -591,15 +489,9 @@ describe("CreateNoteModal", () => { }); it("shows card count preview for non-reversible note type", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<CreateNoteModal {...defaultProps} />); @@ -609,11 +501,9 @@ describe("CreateNoteModal", () => { }); it("displays error when note types fail to load", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }); + mockHandleResponse.mockRejectedValueOnce( + new ApiClientError("Server error", 500), + ); render(<CreateNoteModal {...defaultProps} />); @@ -626,15 +516,9 @@ describe("CreateNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); const { rerender } = render( <CreateNoteModal @@ -656,16 +540,8 @@ describe("CreateNoteModal", () => { // Click cancel to close await user.click(screen.getByRole("button", { name: "Cancel" })); - // Setup mocks for reopening - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + // Note: The component already has note types loaded (hasLoadedNoteTypes = true) + // so it won't fetch again // Reopen the modal rerender( diff --git a/src/client/components/CreateNoteModal.tsx b/src/client/components/CreateNoteModal.tsx index 86a02a5..912aea8 100644 --- a/src/client/components/CreateNoteModal.tsx +++ b/src/client/components/CreateNoteModal.tsx @@ -53,25 +53,10 @@ export function CreateNoteModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteTypeId}`, { - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: noteTypeId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ noteType: NoteType }>(res); setSelectedNoteType(data.noteType); // Initialize field values for the new note type @@ -96,31 +81,17 @@ export function CreateNoteModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch("/api/note-types", { - headers: authHeader, - }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const res = await apiClient.rpc.api["note-types"].$get(); + const data = await apiClient.handleResponse<{ + noteTypes: NoteTypeSummary[]; + }>(res); setNoteTypes(data.noteTypes); setHasLoadedNoteTypes(true); // Auto-select first note type if available - if (data.noteTypes.length > 0) { - await fetchNoteTypeDetails(data.noteTypes[0].id); + const firstNoteType = data.noteTypes[0]; + if (firstNoteType) { + await fetchNoteTypeDetails(firstNoteType.id); } } catch (err) { if (err instanceof ApiClientError) { @@ -183,37 +154,20 @@ export function CreateNoteModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - // Trim all field values const trimmedFields: Record<string, string> = {}; for (const [fieldId, value] of Object.entries(fieldValues)) { trimmedFields[fieldId] = value.trim(); } - const res = await fetch(`/api/decks/${deckId}/notes`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].notes.$post({ + param: { deckId }, + json: { noteTypeId: selectedNoteType.id, fields: trimmedFields, - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); resetForm(); onNoteCreated(); diff --git a/src/client/components/CreateNoteTypeModal.test.tsx b/src/client/components/CreateNoteTypeModal.test.tsx index 9536f53..59d8312 100644 --- a/src/client/components/CreateNoteTypeModal.test.tsx +++ b/src/client/components/CreateNoteTypeModal.test.tsx @@ -4,11 +4,20 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPost = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + $post: (args: unknown) => mockPost(args), + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +31,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { CreateNoteTypeModal } from "./CreateNoteTypeModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("CreateNoteTypeModal", () => { const defaultProps = { isOpen: true, @@ -38,8 +44,15 @@ describe("CreateNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", + mockPost.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ + noteType: { + id: "note-type-1", + name: "Test Note Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }, }); }); @@ -126,19 +139,6 @@ describe("CreateNoteTypeModal", () => { const onClose = vi.fn(); const onNoteTypeCreated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - noteType: { - id: "note-type-1", - name: "Test Note Type", - frontTemplate: "{{Front}}", - backTemplate: "{{Back}}", - isReversible: true, - }, - }), - }); - render( <CreateNoteTypeModal isOpen={true} @@ -153,18 +153,13 @@ describe("CreateNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPost).toHaveBeenCalledWith({ + json: { name: "Test Note Type", frontTemplate: "{{Front}}", backTemplate: "{{Back}}", isReversible: true, - }), + }, }); }); @@ -175,30 +170,24 @@ describe("CreateNoteTypeModal", () => { it("trims whitespace from text fields", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: { id: "note-type-1" } }), - }); - render(<CreateNoteTypeModal {...defaultProps} />); await user.type(screen.getByLabelText("Name"), " Test Note Type "); await user.click(screen.getByRole("button", { name: "Create" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types", - expect.objectContaining({ - body: expect.stringContaining('"name":"Test Note Type"'), + expect(mockPost).toHaveBeenCalledWith({ + json: expect.objectContaining({ + name: "Test Note Type", }), - ); + }); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPost.mockImplementation(() => new Promise(() => {})); // Never resolves render(<CreateNoteTypeModal {...defaultProps} />); @@ -220,11 +209,9 @@ describe("CreateNoteTypeModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Note type name already exists" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Note type name already exists", 400), + ); render(<CreateNoteTypeModal {...defaultProps} />); @@ -241,7 +228,7 @@ describe("CreateNoteTypeModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPost.mockRejectedValue(new Error("Network error")); render(<CreateNoteTypeModal {...defaultProps} />); @@ -255,23 +242,6 @@ describe("CreateNoteTypeModal", () => { }); }); - it("displays error when not authenticated", async () => { - const user = userEvent.setup(); - - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(<CreateNoteTypeModal {...defaultProps} />); - - await user.type(screen.getByLabelText("Name"), "Test Note Type"); - await user.click(screen.getByRole("button", { name: "Create" })); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - it("resets form when closed and reopened", async () => { const user = userEvent.setup(); const onClose = vi.fn(); diff --git a/src/client/components/CreateNoteTypeModal.tsx b/src/client/components/CreateNoteTypeModal.tsx index 6e43c96..4c3b232 100644 --- a/src/client/components/CreateNoteTypeModal.tsx +++ b/src/client/components/CreateNoteTypeModal.tsx @@ -38,33 +38,15 @@ export function CreateNoteTypeModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch("/api/note-types", { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"].$post({ + json: { name: name.trim(), frontTemplate: frontTemplate.trim(), backTemplate: backTemplate.trim(), isReversible, - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); resetForm(); onNoteTypeCreated(); diff --git a/src/client/components/DeleteCardModal.test.tsx b/src/client/components/DeleteCardModal.test.tsx index 4178ee8..6536c6f 100644 --- a/src/client/components/DeleteCardModal.test.tsx +++ b/src/client/components/DeleteCardModal.test.tsx @@ -4,11 +4,26 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + cards: { + ":cardId": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +37,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { DeleteCardModal } from "./DeleteCardModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("DeleteCardModal", () => { const mockCard = { id: "card-123", @@ -45,9 +57,8 @@ describe("DeleteCardModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -142,11 +153,6 @@ describe("DeleteCardModal", () => { const onClose = vi.fn(); const onCardDeleted = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - }); - render( <DeleteCardModal isOpen={true} @@ -160,15 +166,9 @@ describe("DeleteCardModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "DELETE", - headers: { - Authorization: "Bearer access-token", - }, - }, - ); + expect(mockDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + }); }); expect(onCardDeleted).toHaveBeenCalledTimes(1); @@ -178,7 +178,7 @@ describe("DeleteCardModal", () => { it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteCardModal {...defaultProps} />); @@ -198,11 +198,9 @@ describe("DeleteCardModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Card not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Card not found", 404), + ); render(<DeleteCardModal {...defaultProps} />); @@ -216,7 +214,7 @@ describe("DeleteCardModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockDelete.mockRejectedValue(new Error("Network error")); render(<DeleteCardModal {...defaultProps} />); @@ -229,10 +227,12 @@ describe("DeleteCardModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<DeleteCardModal {...defaultProps} />); @@ -249,11 +249,7 @@ describe("DeleteCardModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <DeleteCardModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/DeleteCardModal.tsx b/src/client/components/DeleteCardModal.tsx index 44a745d..d9cf098 100644 --- a/src/client/components/DeleteCardModal.tsx +++ b/src/client/components/DeleteCardModal.tsx @@ -36,24 +36,12 @@ export function DeleteCardModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/cards/${card.id}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].cards[ + ":cardId" + ].$delete({ + param: { deckId, cardId: card.id }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onCardDeleted(); onClose(); diff --git a/src/client/components/DeleteDeckModal.test.tsx b/src/client/components/DeleteDeckModal.test.tsx index ad1463d..4441064 100644 --- a/src/client/components/DeleteDeckModal.test.tsx +++ b/src/client/components/DeleteDeckModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":id": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { DeleteDeckModal } from "./DeleteDeckModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("DeleteDeckModal", () => { const mockDeck = { id: "deck-123", @@ -44,9 +52,8 @@ describe("DeleteDeckModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -126,11 +133,6 @@ describe("DeleteDeckModal", () => { const onClose = vi.fn(); const onDeckDeleted = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - }); - render( <DeleteDeckModal isOpen={true} @@ -143,11 +145,8 @@ describe("DeleteDeckModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "DELETE", - headers: { - Authorization: "Bearer access-token", - }, + expect(mockDelete).toHaveBeenCalledWith({ + param: { id: "deck-123" }, }); }); @@ -158,7 +157,7 @@ describe("DeleteDeckModal", () => { it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteDeckModal {...defaultProps} />); @@ -178,11 +177,9 @@ describe("DeleteDeckModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Deck not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Deck not found", 404), + ); render(<DeleteDeckModal {...defaultProps} />); @@ -196,7 +193,7 @@ describe("DeleteDeckModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockDelete.mockRejectedValue(new Error("Network error")); render(<DeleteDeckModal {...defaultProps} />); @@ -209,10 +206,12 @@ describe("DeleteDeckModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<DeleteDeckModal {...defaultProps} />); @@ -229,11 +228,7 @@ describe("DeleteDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <DeleteDeckModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/DeleteDeckModal.tsx b/src/client/components/DeleteDeckModal.tsx index 5a252e6..edc6093 100644 --- a/src/client/components/DeleteDeckModal.tsx +++ b/src/client/components/DeleteDeckModal.tsx @@ -34,24 +34,10 @@ export function DeleteDeckModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deck.id}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api.decks[":id"].$delete({ + param: { id: deck.id }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onDeckDeleted(); onClose(); diff --git a/src/client/components/DeleteNoteModal.test.tsx b/src/client/components/DeleteNoteModal.test.tsx index a17323a..7130176 100644 --- a/src/client/components/DeleteNoteModal.test.tsx +++ b/src/client/components/DeleteNoteModal.test.tsx @@ -4,12 +4,26 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; -import { DeleteNoteModal } from "./DeleteNoteModal"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + notes: { + ":noteId": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -23,8 +37,8 @@ vi.mock("../api/client", () => ({ }, })); -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError } from "../api/client"; +import { DeleteNoteModal } from "./DeleteNoteModal"; describe("DeleteNoteModal", () => { const defaultProps = { @@ -37,9 +51,8 @@ describe("DeleteNoteModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -125,11 +138,6 @@ describe("DeleteNoteModal", () => { const onClose = vi.fn(); const onNoteDeleted = vi.fn(); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }); - render( <DeleteNoteModal {...defaultProps} @@ -141,9 +149,8 @@ describe("DeleteNoteModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, + expect(mockDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, }); }); @@ -154,11 +161,9 @@ describe("DeleteNoteModal", () => { it("displays error message when delete fails", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to delete note" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Failed to delete note", 500), + ); render(<DeleteNoteModal {...defaultProps} />); @@ -175,12 +180,7 @@ describe("DeleteNoteModal", () => { const user = userEvent.setup(); // Create a promise that we can control - let resolveDelete: ((value: unknown) => void) | undefined; - const deletePromise = new Promise((resolve) => { - resolveDelete = resolve; - }); - - mockFetch.mockReturnValueOnce(deletePromise); + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteNoteModal {...defaultProps} />); @@ -188,24 +188,13 @@ describe("DeleteNoteModal", () => { // Should show "Deleting..." while request is in progress expect(screen.getByText("Deleting...")).toBeDefined(); - - // Resolve the delete request to cleanup - resolveDelete?.({ - ok: true, - json: async () => ({ success: true }), - }); }); it("disables buttons while deleting", async () => { const user = userEvent.setup(); // Create a promise that we can control - let resolveDelete: ((value: unknown) => void) | undefined; - const deletePromise = new Promise((resolve) => { - resolveDelete = resolve; - }); - - mockFetch.mockReturnValueOnce(deletePromise); + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteNoteModal {...defaultProps} />); @@ -220,11 +209,5 @@ describe("DeleteNoteModal", () => { "disabled", true, ); - - // Resolve the delete request to cleanup - resolveDelete?.({ - ok: true, - json: async () => ({ success: true }), - }); }); }); diff --git a/src/client/components/DeleteNoteModal.tsx b/src/client/components/DeleteNoteModal.tsx index 8eec124..5d81fdc 100644 --- a/src/client/components/DeleteNoteModal.tsx +++ b/src/client/components/DeleteNoteModal.tsx @@ -31,24 +31,12 @@ export function DeleteNoteModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/notes/${noteId}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].notes[ + ":noteId" + ].$delete({ + param: { deckId, noteId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onNoteDeleted(); onClose(); diff --git a/src/client/components/DeleteNoteTypeModal.test.tsx b/src/client/components/DeleteNoteTypeModal.test.tsx index b7159ab..c73fbe0 100644 --- a/src/client/components/DeleteNoteTypeModal.test.tsx +++ b/src/client/components/DeleteNoteTypeModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockDelete = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + ":id": { + $delete: (args: unknown) => mockDelete(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { DeleteNoteTypeModal } from "./DeleteNoteTypeModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("DeleteNoteTypeModal", () => { const mockNoteType = { id: "note-type-123", @@ -44,9 +52,8 @@ describe("DeleteNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockDelete.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({}); }); afterEach(() => { @@ -128,11 +135,6 @@ describe("DeleteNoteTypeModal", () => { const onClose = vi.fn(); const onNoteTypeDeleted = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }), - }); - render( <DeleteNoteTypeModal isOpen={true} @@ -145,11 +147,8 @@ describe("DeleteNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Delete" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", { - method: "DELETE", - headers: { - Authorization: "Bearer access-token", - }, + expect(mockDelete).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, }); }); @@ -160,7 +159,7 @@ describe("DeleteNoteTypeModal", () => { it("shows loading state during deletion", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDelete.mockImplementation(() => new Promise(() => {})); // Never resolves render(<DeleteNoteTypeModal {...defaultProps} />); @@ -180,11 +179,9 @@ describe("DeleteNoteTypeModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Note type not found", 404), + ); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -200,13 +197,9 @@ describe("DeleteNoteTypeModal", () => { it("displays conflict error when notes exist", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 409, - json: async () => ({ - error: "Cannot delete note type with existing notes", - }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Cannot delete note type with existing notes", 409), + ); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -222,7 +215,7 @@ describe("DeleteNoteTypeModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockDelete.mockRejectedValue(new Error("Network error")); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -235,10 +228,12 @@ describe("DeleteNoteTypeModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<DeleteNoteTypeModal {...defaultProps} />); @@ -255,11 +250,7 @@ describe("DeleteNoteTypeModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <DeleteNoteTypeModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/DeleteNoteTypeModal.tsx b/src/client/components/DeleteNoteTypeModal.tsx index bd6b4a5..db93482 100644 --- a/src/client/components/DeleteNoteTypeModal.tsx +++ b/src/client/components/DeleteNoteTypeModal.tsx @@ -34,24 +34,10 @@ export function DeleteNoteTypeModal({ setIsDeleting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}`, { - method: "DELETE", - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$delete({ + param: { id: noteType.id }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onNoteTypeDeleted(); onClose(); diff --git a/src/client/components/EditCardModal.test.tsx b/src/client/components/EditCardModal.test.tsx index b07dd4b..3486441 100644 --- a/src/client/components/EditCardModal.test.tsx +++ b/src/client/components/EditCardModal.test.tsx @@ -4,11 +4,26 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPut = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + cards: { + ":cardId": { + $put: (args: unknown) => mockPut(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +37,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditCardModal } from "./EditCardModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditCardModal", () => { const mockCard = { id: "card-123", @@ -46,8 +58,13 @@ describe("EditCardModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", + mockPut.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ + card: { + id: "card-123", + front: "Test front", + back: "Test back", + }, }); }); @@ -156,17 +173,6 @@ describe("EditCardModal", () => { const onClose = vi.fn(); const onCardUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - card: { - id: "card-123", - front: "Updated front", - back: "Test back", - }, - }), - }); - render( <EditCardModal isOpen={true} @@ -183,20 +189,13 @@ describe("EditCardModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - front: "Updated front", - back: "Test back", - }), + expect(mockPut).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + json: { + front: "Updated front", + back: "Test back", }, - ); + }); }); expect(onCardUpdated).toHaveBeenCalledTimes(1); @@ -208,17 +207,6 @@ describe("EditCardModal", () => { const onClose = vi.fn(); const onCardUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - card: { - id: "card-123", - front: "Test front", - back: "Updated back", - }, - }), - }); - render( <EditCardModal isOpen={true} @@ -235,20 +223,13 @@ describe("EditCardModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - front: "Test front", - back: "Updated back", - }), + expect(mockPut).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + json: { + front: "Test front", + back: "Updated back", }, - ); + }); }); expect(onCardUpdated).toHaveBeenCalledTimes(1); @@ -258,11 +239,6 @@ describe("EditCardModal", () => { it("trims whitespace from front and back", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ card: { id: "card-123" } }), - }); - const cardWithWhitespace = { ...mockCard, front: " Front ", @@ -273,27 +249,20 @@ describe("EditCardModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-456/cards/card-123", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - front: "Front", - back: "Back", - }), + expect(mockPut).toHaveBeenCalledWith({ + param: { deckId: "deck-456", cardId: "card-123" }, + json: { + front: "Front", + back: "Back", }, - ); + }); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves render(<EditCardModal {...defaultProps} />); @@ -315,11 +284,9 @@ describe("EditCardModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Card not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Card not found", 404), + ); render(<EditCardModal {...defaultProps} />); @@ -333,7 +300,7 @@ describe("EditCardModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPut.mockRejectedValue(new Error("Network error")); render(<EditCardModal {...defaultProps} />); @@ -346,10 +313,12 @@ describe("EditCardModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<EditCardModal {...defaultProps} />); @@ -385,11 +354,7 @@ describe("EditCardModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); const { rerender } = render( <EditCardModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/EditCardModal.tsx b/src/client/components/EditCardModal.tsx index e38a2b1..726a003 100644 --- a/src/client/components/EditCardModal.tsx +++ b/src/client/components/EditCardModal.tsx @@ -49,31 +49,16 @@ export function EditCardModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/cards/${card.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].cards[ + ":cardId" + ].$put({ + param: { deckId, cardId: card.id }, + json: { front: front.trim(), back: back.trim(), - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onCardUpdated(); onClose(); diff --git a/src/client/components/EditDeckModal.test.tsx b/src/client/components/EditDeckModal.test.tsx index c627dd5..fce17f6 100644 --- a/src/client/components/EditDeckModal.test.tsx +++ b/src/client/components/EditDeckModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPut = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":id": { + $put: (args: unknown) => mockPut(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditDeckModal } from "./EditDeckModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditDeckModal", () => { const mockDeck = { id: "deck-123", @@ -46,8 +54,14 @@ describe("EditDeckModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", + mockPut.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ + deck: { + id: "deck-123", + name: "Test Deck", + description: "Test description", + newCardsPerDay: 20, + }, }); }); @@ -155,18 +169,6 @@ describe("EditDeckModal", () => { const onClose = vi.fn(); const onDeckUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - deck: { - id: "deck-123", - name: "Updated Deck", - description: "Test description", - newCardsPerDay: 20, - }, - }), - }); - render( <EditDeckModal isOpen={true} @@ -182,16 +184,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Updated Deck", description: "Test description", - }), + }, }); }); @@ -204,18 +202,6 @@ describe("EditDeckModal", () => { const onClose = vi.fn(); const onDeckUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - deck: { - id: "deck-123", - name: "Test Deck", - description: "New description", - newCardsPerDay: 20, - }, - }), - }); - render( <EditDeckModal isOpen={true} @@ -231,16 +217,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Test Deck", description: "New description", - }), + }, }); }); @@ -251,18 +233,6 @@ describe("EditDeckModal", () => { it("clears description when input is emptied", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - deck: { - id: "deck-123", - name: "Test Deck", - description: null, - newCardsPerDay: 20, - }, - }), - }); - render(<EditDeckModal {...defaultProps} />); const descInput = screen.getByLabelText("Description (optional)"); @@ -270,16 +240,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Test Deck", description: null, - }), + }, }); }); }); @@ -287,11 +253,6 @@ describe("EditDeckModal", () => { it("trims whitespace from name and description", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ deck: { id: "deck-123" } }), - }); - const deckWithWhitespace = { ...mockDeck, name: " Deck ", @@ -302,16 +263,12 @@ describe("EditDeckModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "deck-123" }, + json: { name: "Deck", description: "Description", - }), + }, }); }); }); @@ -319,7 +276,7 @@ describe("EditDeckModal", () => { it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves render(<EditDeckModal {...defaultProps} />); @@ -344,11 +301,9 @@ describe("EditDeckModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Deck name already exists" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Deck name already exists", 400), + ); render(<EditDeckModal {...defaultProps} />); @@ -364,7 +319,7 @@ describe("EditDeckModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPut.mockRejectedValue(new Error("Network error")); render(<EditDeckModal {...defaultProps} />); @@ -377,10 +332,12 @@ describe("EditDeckModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<EditDeckModal {...defaultProps} />); @@ -419,11 +376,7 @@ describe("EditDeckModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 400)); const { rerender } = render( <EditDeckModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/EditDeckModal.tsx b/src/client/components/EditDeckModal.tsx index e589900..3babeb5 100644 --- a/src/client/components/EditDeckModal.tsx +++ b/src/client/components/EditDeckModal.tsx @@ -48,31 +48,14 @@ export function EditDeckModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deck.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":id"].$put({ + param: { id: deck.id }, + json: { name: name.trim(), description: description.trim() || null, - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onDeckUpdated(); onClose(); diff --git a/src/client/components/EditNoteModal.test.tsx b/src/client/components/EditNoteModal.test.tsx index 61f94bd..20c437f 100644 --- a/src/client/components/EditNoteModal.test.tsx +++ b/src/client/components/EditNoteModal.test.tsx @@ -4,11 +4,34 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockNoteGet = vi.fn(); +const mockNotePut = vi.fn(); +const mockNoteTypeGet = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + decks: { + ":deckId": { + notes: { + ":noteId": { + $get: (args: unknown) => mockNoteGet(args), + $put: (args: unknown) => mockNotePut(args), + }, + }, + }, + }, + "note-types": { + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +45,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditNoteModal } from "./EditNoteModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditNoteModal", () => { const defaultProps = { isOpen: true, @@ -72,9 +92,9 @@ describe("EditNoteModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockNoteGet.mockResolvedValue({ ok: true }); + mockNotePut.mockResolvedValue({ ok: true }); + mockNoteTypeGet.mockResolvedValue({ ok: true }); }); afterEach(() => { @@ -95,15 +115,9 @@ describe("EditNoteModal", () => { }); it("renders modal when open with noteId", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -117,44 +131,29 @@ describe("EditNoteModal", () => { }); it("fetches note and note type on open", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-123/notes/note-456", - { - headers: { Authorization: "Bearer access-token" }, - }, - ); + expect(mockNoteGet).toHaveBeenCalledWith({ + param: { deckId: "deck-123", noteId: "note-456" }, + }); }); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-1" }, }); }); }); it("populates form with note field values", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -171,15 +170,9 @@ describe("EditNoteModal", () => { }); it("displays note type name (read-only)", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -191,15 +184,9 @@ describe("EditNoteModal", () => { it("disables save button when fields are empty", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -216,15 +203,9 @@ describe("EditNoteModal", () => { }); it("enables save button when all fields have values", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} />); @@ -240,15 +221,9 @@ describe("EditNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} onClose={onClose} />); @@ -265,15 +240,9 @@ describe("EditNoteModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); render(<EditNoteModal {...defaultProps} onClose={onClose} />); @@ -293,19 +262,10 @@ describe("EditNoteModal", () => { const onClose = vi.fn(); const onNoteUpdated = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }); render( <EditNoteModal @@ -327,22 +287,15 @@ describe("EditNoteModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-123/notes/note-456", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", + expect(mockNotePut).toHaveBeenCalledWith({ + param: { deckId: "deck-123", noteId: "note-456" }, + json: { + fields: { + "field-1": "Updated front", + "field-2": "Existing back", }, - body: JSON.stringify({ - fields: { - "field-1": "Updated front", - "field-2": "Existing back", - }, - }), }, - ); + }); }); expect(onNoteUpdated).toHaveBeenCalledTimes(1); @@ -370,19 +323,10 @@ describe("EditNoteModal", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: noteWithWhitespace }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: noteWithWhitespace }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: noteWithWhitespace }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ note: noteWithWhitespace }); render(<EditNoteModal {...defaultProps} />); @@ -393,27 +337,20 @@ describe("EditNoteModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-123/notes/note-456", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", + expect(mockNotePut).toHaveBeenCalledWith({ + param: { deckId: "deck-123", noteId: "note-456" }, + json: { + fields: { + "field-1": "Trimmed", + "field-2": "Value", }, - body: JSON.stringify({ - fields: { - "field-1": "Trimmed", - "field-2": "Value", - }, - }), }, - ); + }); }); }); it("shows loading state during fetch", async () => { - mockFetch.mockImplementationOnce(() => new Promise(() => {})); // Never resolves + mockNoteGet.mockImplementationOnce(() => new Promise(() => {})); // Never resolves render(<EditNoteModal {...defaultProps} />); @@ -423,16 +360,11 @@ describe("EditNoteModal", () => { it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockImplementationOnce(() => new Promise(() => {})); // Never resolves + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); + + mockNotePut.mockImplementationOnce(() => new Promise(() => {})); // Never resolves render(<EditNoteModal {...defaultProps} />); @@ -450,11 +382,9 @@ describe("EditNoteModal", () => { }); it("displays API error message when note fetch fails", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Note not found" }), - }); + mockHandleResponse.mockRejectedValueOnce( + new ApiClientError("Note not found", 404), + ); render(<EditNoteModal {...defaultProps} />); @@ -464,16 +394,9 @@ describe("EditNoteModal", () => { }); it("displays API error message when note type fetch fails", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockRejectedValueOnce(new ApiClientError("Note type not found", 404)); render(<EditNoteModal {...defaultProps} />); @@ -487,20 +410,10 @@ describe("EditNoteModal", () => { it("displays API error message when update fails", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 400, - json: async () => ({ error: "Failed to update note" }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockRejectedValueOnce(new ApiClientError("Failed to update note", 400)); render(<EditNoteModal {...defaultProps} />); @@ -518,7 +431,7 @@ describe("EditNoteModal", () => { }); it("displays generic error on unexpected failure", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + mockNoteGet.mockRejectedValueOnce(new Error("Network error")); render(<EditNoteModal {...defaultProps} />); @@ -529,30 +442,12 @@ describe("EditNoteModal", () => { }); }); - it("displays error when not authenticated", async () => { - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(<EditNoteModal {...defaultProps} />); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - it("resets form when modal is closed and reopened with different noteId", async () => { const onClose = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); const { rerender } = render( <EditNoteModal @@ -603,15 +498,9 @@ describe("EditNoteModal", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: newNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: newNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }); // Reopen with different noteId rerender( @@ -639,15 +528,9 @@ describe("EditNoteModal", () => { isReversible: true, }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ note: mockNoteWithFieldValues }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: reversibleNoteType }), - }); + mockHandleResponse + .mockResolvedValueOnce({ note: mockNoteWithFieldValues }) + .mockResolvedValueOnce({ noteType: reversibleNoteType }); render(<EditNoteModal {...defaultProps} />); diff --git a/src/client/components/EditNoteModal.tsx b/src/client/components/EditNoteModal.tsx index 5bd864d..ac22332 100644 --- a/src/client/components/EditNoteModal.tsx +++ b/src/client/components/EditNoteModal.tsx @@ -60,25 +60,10 @@ export function EditNoteModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteTypeId}`, { - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: noteTypeId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ noteType: NoteType }>(res); setNoteType(data.noteType); } catch (err) { if (err instanceof ApiClientError) { @@ -98,25 +83,14 @@ export function EditNoteModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/notes/${noteId}`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].notes[ + ":noteId" + ].$get({ + param: { deckId, noteId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ + note: NoteWithFieldValues; + }>(res); setNote(data.note); // Initialize field values from note @@ -176,36 +150,21 @@ export function EditNoteModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - // Trim all field values const trimmedFields: Record<string, string> = {}; for (const [fieldId, value] of Object.entries(fieldValues)) { trimmedFields[fieldId] = value.trim(); } - const res = await fetch(`/api/decks/${deckId}/notes/${note.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].notes[ + ":noteId" + ].$put({ + param: { deckId, noteId: note.id }, + json: { fields: trimmedFields, - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onNoteUpdated(); handleClose(); diff --git a/src/client/components/EditNoteTypeModal.test.tsx b/src/client/components/EditNoteTypeModal.test.tsx index 61130e2..cc23d8f 100644 --- a/src/client/components/EditNoteTypeModal.test.tsx +++ b/src/client/components/EditNoteTypeModal.test.tsx @@ -4,11 +4,22 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockPut = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + ":id": { + $put: (args: unknown) => mockPut(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +33,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { EditNoteTypeModal } from "./EditNoteTypeModal"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("EditNoteTypeModal", () => { const mockNoteType = { id: "note-type-123", @@ -47,9 +55,8 @@ describe("EditNoteTypeModal", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockPut.mockResolvedValue({ ok: true }); + mockHandleResponse.mockResolvedValue({ noteType: mockNoteType }); }); afterEach(() => { @@ -155,17 +162,6 @@ describe("EditNoteTypeModal", () => { const onClose = vi.fn(); const onNoteTypeUpdated = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ - noteType: { - ...mockNoteType, - name: "Updated Basic", - isReversible: true, - }, - }), - }); - render( <EditNoteTypeModal isOpen={true} @@ -185,18 +181,14 @@ describe("EditNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { name: "Updated Basic", frontTemplate: "{{Front}}", backTemplate: "{{Back}}", isReversible: true, - }), + }, }); }); @@ -207,11 +199,6 @@ describe("EditNoteTypeModal", () => { it("trims whitespace from text fields", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteType }), - }); - render(<EditNoteTypeModal {...defaultProps} />); const nameInput = screen.getByLabelText("Name"); @@ -220,19 +207,19 @@ describe("EditNoteTypeModal", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123", - expect.objectContaining({ - body: expect.stringContaining('"name":"Updated Basic"'), + expect(mockPut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: expect.objectContaining({ + name: "Updated Basic", }), - ); + }); }); }); it("shows loading state during submission", async () => { const user = userEvent.setup(); - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockPut.mockImplementation(() => new Promise(() => {})); // Never resolves render(<EditNoteTypeModal {...defaultProps} />); @@ -253,11 +240,9 @@ describe("EditNoteTypeModal", () => { it("displays API error message", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Note type not found", 404), + ); render(<EditNoteTypeModal {...defaultProps} />); @@ -273,7 +258,7 @@ describe("EditNoteTypeModal", () => { it("displays generic error on unexpected failure", async () => { const user = userEvent.setup(); - mockFetch.mockRejectedValue(new Error("Network error")); + mockPut.mockRejectedValue(new Error("Network error")); render(<EditNoteTypeModal {...defaultProps} />); @@ -286,10 +271,12 @@ describe("EditNoteTypeModal", () => { }); }); - it("displays error when not authenticated", async () => { + it("displays error when handleResponse throws", async () => { const user = userEvent.setup(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); + mockHandleResponse.mockRejectedValue( + new ApiClientError("Not authenticated", 401), + ); render(<EditNoteTypeModal {...defaultProps} />); @@ -339,11 +326,7 @@ describe("EditNoteTypeModal", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Some error" }), - }); + mockHandleResponse.mockRejectedValue(new ApiClientError("Some error", 404)); const { rerender } = render( <EditNoteTypeModal {...defaultProps} onClose={onClose} />, diff --git a/src/client/components/EditNoteTypeModal.tsx b/src/client/components/EditNoteTypeModal.tsx index 014492e..27ef5d8 100644 --- a/src/client/components/EditNoteTypeModal.tsx +++ b/src/client/components/EditNoteTypeModal.tsx @@ -53,33 +53,16 @@ export function EditNoteTypeModal({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"][":id"].$put({ + param: { id: noteType.id }, + json: { name: name.trim(), frontTemplate: frontTemplate.trim(), backTemplate: backTemplate.trim(), isReversible, - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onNoteTypeUpdated(); onClose(); diff --git a/src/client/components/ImportNotesModal.tsx b/src/client/components/ImportNotesModal.tsx index b49b276..a7a88c5 100644 --- a/src/client/components/ImportNotesModal.tsx +++ b/src/client/components/ImportNotesModal.tsx @@ -73,34 +73,21 @@ export function ImportNotesModal({ const fetchNoteTypes = useCallback(async () => { try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch("/api/note-types", { - headers: authHeader, - }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const res = await apiClient.rpc.api["note-types"].$get(); + const data = await apiClient.handleResponse<{ + noteTypes: { id: string; name: string }[]; + }>(res); // Fetch details for each note type to get fields const noteTypesWithFields: NoteType[] = []; for (const nt of data.noteTypes) { - const detailRes = await fetch(`/api/note-types/${nt.id}`, { - headers: authHeader, + const detailRes = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: nt.id }, }); if (detailRes.ok) { - const detailData = await detailRes.json(); + const detailData = await apiClient.handleResponse<{ + noteType: NoteType; + }>(detailRes); noteTypesWithFields.push(detailData.noteType); } } @@ -247,35 +234,16 @@ export function ImportNotesModal({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/notes/import`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api.decks[":deckId"].notes.import.$post({ + param: { deckId }, + json: { notes: validatedRows.map((row) => ({ noteTypeId: row.noteTypeId, fields: row.fields, })), - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const result = await res.json(); + const result = await apiClient.handleResponse<ImportResult>(res); setImportResult(result); setPhase("complete"); onImportComplete(); diff --git a/src/client/components/NoteTypeEditor.test.tsx b/src/client/components/NoteTypeEditor.test.tsx index 49b35c6..a628859 100644 --- a/src/client/components/NoteTypeEditor.test.tsx +++ b/src/client/components/NoteTypeEditor.test.tsx @@ -4,11 +4,38 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { apiClient } from "../api/client"; + +const mockNoteTypeGet = vi.fn(); +const mockNoteTypePut = vi.fn(); +const mockFieldPost = vi.fn(); +const mockFieldPut = vi.fn(); +const mockFieldDelete = vi.fn(); +const mockFieldsReorder = vi.fn(); +const mockHandleResponse = vi.fn(); vi.mock("../api/client", () => ({ apiClient: { - getAuthHeader: vi.fn(), + rpc: { + api: { + "note-types": { + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + $put: (args: unknown) => mockNoteTypePut(args), + fields: { + $post: (args: unknown) => mockFieldPost(args), + ":fieldId": { + $put: (args: unknown) => mockFieldPut(args), + $delete: (args: unknown) => mockFieldDelete(args), + }, + reorder: { + $put: (args: unknown) => mockFieldsReorder(args), + }, + }, + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -22,13 +49,10 @@ vi.mock("../api/client", () => ({ }, })); +import { ApiClientError } from "../api/client"; // Import after mock is set up import { NoteTypeEditor } from "./NoteTypeEditor"; -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; - describe("NoteTypeEditor", () => { const mockNoteTypeWithFields = { id: "note-type-123", @@ -63,9 +87,12 @@ describe("NoteTypeEditor", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(apiClient.getAuthHeader).mockReturnValue({ - Authorization: "Bearer access-token", - }); + mockNoteTypeGet.mockResolvedValue({ ok: true }); + mockNoteTypePut.mockResolvedValue({ ok: true }); + mockFieldPost.mockResolvedValue({ ok: true }); + mockFieldPut.mockResolvedValue({ ok: true }); + mockFieldDelete.mockResolvedValue({ ok: true }); + mockFieldsReorder.mockResolvedValue({ ok: true }); }); afterEach(() => { @@ -80,9 +107,8 @@ describe("NoteTypeEditor", () => { }); it("renders modal and fetches note type when open", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -90,12 +116,9 @@ describe("NoteTypeEditor", () => { expect(screen.getByRole("dialog")).toBeDefined(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123", - expect.objectContaining({ - headers: { Authorization: "Bearer access-token" }, - }), - ); + expect(mockNoteTypeGet).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + }); }); await waitFor(() => { @@ -104,9 +127,8 @@ describe("NoteTypeEditor", () => { }); it("displays note type data after loading", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -132,30 +154,18 @@ describe("NoteTypeEditor", () => { }); it("displays loading state while fetching", async () => { - let resolvePromise: ((value: Response) => void) | undefined; - const fetchPromise = new Promise<Response>((resolve) => { - resolvePromise = resolve; - }); - mockFetch.mockReturnValue(fetchPromise); + mockNoteTypeGet.mockImplementation(() => new Promise(() => {})); // Never resolves render(<NoteTypeEditor {...defaultProps} />); - // Should show loading spinner + // Should show dialog expect(screen.getByRole("dialog")).toBeDefined(); - - // Resolve the promise to clean up - resolvePromise?.({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - } as Response); }); it("displays error when fetch fails", async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 404, - json: async () => ({ error: "Note type not found" }), - }); + mockHandleResponse.mockRejectedValueOnce( + new ApiClientError("Note type not found", 404), + ); render(<NoteTypeEditor {...defaultProps} />); @@ -166,25 +176,12 @@ describe("NoteTypeEditor", () => { }); }); - it("displays error when not authenticated", async () => { - vi.mocked(apiClient.getAuthHeader).mockReturnValue(undefined); - - render(<NoteTypeEditor {...defaultProps} />); - - await waitFor(() => { - expect(screen.getByRole("alert").textContent).toContain( - "Not authenticated", - ); - }); - }); - it("calls onClose when Cancel is clicked", async () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} onClose={onClose} />); @@ -202,9 +199,8 @@ describe("NoteTypeEditor", () => { const user = userEvent.setup(); const onClose = vi.fn(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} onClose={onClose} />); @@ -224,16 +220,10 @@ describe("NoteTypeEditor", () => { const onClose = vi.fn(); const onNoteTypeUpdated = vi.fn(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" }, - }), + noteType: { ...mockNoteTypeWithFields, name: "Updated Basic" }, }); render( @@ -256,18 +246,14 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types/note-type-123", { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ + expect(mockNoteTypePut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { name: "Updated Basic", frontTemplate: "{{Front}}", backTemplate: "{{Back}}", isReversible: false, - }), + }, }); }); @@ -278,22 +264,16 @@ describe("NoteTypeEditor", () => { it("adds a new field", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - field: { - id: "field-3", - noteTypeId: "note-type-123", - name: "Hint", - order: 2, - fieldType: "text", - }, - }), + field: { + id: "field-3", + noteTypeId: "note-type-123", + name: "Hint", + order: 2, + fieldType: "text", + }, }); render(<NoteTypeEditor {...defaultProps} />); @@ -307,21 +287,14 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Add" })); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - name: "Hint", - order: 2, - fieldType: "text", - }), + expect(mockFieldPost).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { + name: "Hint", + order: 2, + fieldType: "text", }, - ); + }); }); await waitFor(() => { @@ -332,15 +305,9 @@ describe("NoteTypeEditor", () => { it("deletes a field", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockResolvedValueOnce({ success: true }); render(<NoteTypeEditor {...defaultProps} />); @@ -357,31 +324,20 @@ describe("NoteTypeEditor", () => { await user.click(deleteButton); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/field-1", - { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, - }, - ); + expect(mockFieldDelete).toHaveBeenCalledWith({ + param: { id: "note-type-123", fieldId: "field-1" }, + }); }); }); it("displays error when field deletion fails", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 409, - json: async () => ({ - error: "Cannot delete field with existing values", - }), - }); + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) + .mockRejectedValueOnce( + new ApiClientError("Cannot delete field with existing values", 409), + ); render(<NoteTypeEditor {...defaultProps} />); @@ -412,31 +368,25 @@ describe("NoteTypeEditor", () => { it("moves a field up", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - fields: [ - { - id: "field-2", - noteTypeId: "note-type-123", - name: "Back", - order: 0, - fieldType: "text", - }, - { - id: "field-1", - noteTypeId: "note-type-123", - name: "Front", - order: 1, - fieldType: "text", - }, - ], - }), + fields: [ + { + id: "field-2", + noteTypeId: "note-type-123", + name: "Back", + order: 0, + fieldType: "text", + }, + { + id: "field-1", + noteTypeId: "note-type-123", + name: "Front", + order: 1, + fieldType: "text", + }, + ], }); render(<NoteTypeEditor {...defaultProps} />); @@ -454,50 +404,37 @@ describe("NoteTypeEditor", () => { await user.click(secondMoveUpButton); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/reorder", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - fieldIds: ["field-2", "field-1"], - }), + expect(mockFieldsReorder).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { + fieldIds: ["field-2", "field-1"], }, - ); + }); }); }); it("moves a field down", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - fields: [ - { - id: "field-2", - noteTypeId: "note-type-123", - name: "Back", - order: 0, - fieldType: "text", - }, - { - id: "field-1", - noteTypeId: "note-type-123", - name: "Front", - order: 1, - fieldType: "text", - }, - ], - }), + fields: [ + { + id: "field-2", + noteTypeId: "note-type-123", + name: "Back", + order: 0, + fieldType: "text", + }, + { + id: "field-1", + noteTypeId: "note-type-123", + name: "Front", + order: 1, + fieldType: "text", + }, + ], }); render(<NoteTypeEditor {...defaultProps} />); @@ -514,41 +451,28 @@ describe("NoteTypeEditor", () => { await user.click(firstMoveDownButton); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/reorder", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - fieldIds: ["field-2", "field-1"], - }), + expect(mockFieldsReorder).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: { + fieldIds: ["field-2", "field-1"], }, - ); + }); }); }); it("edits a field name", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - field: { - id: "field-1", - noteTypeId: "note-type-123", - name: "Question", - order: 0, - fieldType: "text", - }, - }), + field: { + id: "field-1", + noteTypeId: "note-type-123", + name: "Question", + order: 0, + fieldType: "text", + }, }); render(<NoteTypeEditor {...defaultProps} />); @@ -569,26 +493,18 @@ describe("NoteTypeEditor", () => { await user.tab(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith( - "/api/note-types/note-type-123/fields/field-1", - { - method: "PUT", - headers: { - "Content-Type": "application/json", - Authorization: "Bearer access-token", - }, - body: JSON.stringify({ - name: "Question", - }), + expect(mockFieldPut).toHaveBeenCalledWith({ + param: { id: "note-type-123", fieldId: "field-1" }, + json: { + name: "Question", }, - ); + }); }); }); it("shows available fields in template help text", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -599,9 +515,8 @@ describe("NoteTypeEditor", () => { }); it("disables move up button for first field", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -616,9 +531,8 @@ describe("NoteTypeEditor", () => { }); it("disables move down button for last field", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -636,9 +550,8 @@ describe("NoteTypeEditor", () => { }); it("disables Add button when new field name is empty", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), + mockHandleResponse.mockResolvedValueOnce({ + noteType: mockNoteTypeWithFields, }); render(<NoteTypeEditor {...defaultProps} />); @@ -654,16 +567,10 @@ describe("NoteTypeEditor", () => { it("toggles reversible option", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockHandleResponse + .mockResolvedValueOnce({ noteType: mockNoteTypeWithFields }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - noteType: { ...mockNoteTypeWithFields, isReversible: true }, - }), + noteType: { ...mockNoteTypeWithFields, isReversible: true }, }); render(<NoteTypeEditor {...defaultProps} />); @@ -682,12 +589,12 @@ describe("NoteTypeEditor", () => { await user.click(screen.getByRole("button", { name: "Save Changes" })); await waitFor(() => { - expect(mockFetch).toHaveBeenLastCalledWith( - "/api/note-types/note-type-123", - expect.objectContaining({ - body: expect.stringContaining('"isReversible":true'), + expect(mockNoteTypePut).toHaveBeenCalledWith({ + param: { id: "note-type-123" }, + json: expect.objectContaining({ + isReversible: true, }), - ); + }); }); }); }); diff --git a/src/client/components/NoteTypeEditor.tsx b/src/client/components/NoteTypeEditor.tsx index 01a4777..2487c62 100644 --- a/src/client/components/NoteTypeEditor.tsx +++ b/src/client/components/NoteTypeEditor.tsx @@ -71,26 +71,13 @@ export function NoteTypeEditor({ setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteTypeId}`, { - headers: authHeader, + const res = await apiClient.rpc.api["note-types"][":id"].$get({ + param: { id: noteTypeId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); - const fetchedNoteType = data.noteType as NoteTypeWithFields; + const data = await apiClient.handleResponse<{ + noteType: NoteTypeWithFields; + }>(res); + const fetchedNoteType = data.noteType; setNoteType(fetchedNoteType); setName(fetchedNoteType.name); setFrontTemplate(fetchedNoteType.frontTemplate); @@ -138,33 +125,16 @@ export function NoteTypeEditor({ setIsSubmitting(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"][":id"].$put({ + param: { id: noteType.id }, + json: { name: name.trim(), frontTemplate: frontTemplate.trim(), backTemplate: backTemplate.trim(), isReversible, - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + await apiClient.handleResponse(res); onNoteTypeUpdated(); onClose(); @@ -186,37 +156,20 @@ export function NoteTypeEditor({ setIsAddingField(true); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - const newOrder = fields.length > 0 ? Math.max(...fields.map((f) => f.order)) + 1 : 0; - const res = await fetch(`/api/note-types/${noteType.id}/fields`, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ + const res = await apiClient.rpc.api["note-types"][":id"].fields.$post({ + param: { id: noteType.id }, + json: { name: newFieldName.trim(), order: newOrder, fieldType: "text", - }), + }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ field: NoteFieldType }>( + res, + ); setFields([...fields, data.field]); setNewFieldName(""); } catch (err) { @@ -236,35 +189,17 @@ export function NoteTypeEditor({ setFieldError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch( - `/api/note-types/${noteType.id}/fields/${fieldId}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ - name: editingFieldName.trim(), - }), + const res = await apiClient.rpc.api["note-types"][":id"].fields[ + ":fieldId" + ].$put({ + param: { id: noteType.id, fieldId }, + json: { + name: editingFieldName.trim(), }, + }); + const data = await apiClient.handleResponse<{ field: NoteFieldType }>( + res, ); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); setFields(fields.map((f) => (f.id === fieldId ? data.field : f))); setEditingFieldId(null); setEditingFieldName(""); @@ -283,28 +218,12 @@ export function NoteTypeEditor({ setFieldError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch( - `/api/note-types/${noteType.id}/fields/${fieldId}`, - { - method: "DELETE", - headers: authHeader, - }, - ); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - + const res = await apiClient.rpc.api["note-types"][":id"].fields[ + ":fieldId" + ].$delete({ + param: { id: noteType.id, fieldId }, + }); + await apiClient.handleResponse(res); setFields(fields.filter((f) => f.id !== fieldId)); } catch (err) { if (err instanceof ApiClientError) { @@ -334,30 +253,15 @@ export function NoteTypeEditor({ setFieldError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/note-types/${noteType.id}/fields/reorder`, { - method: "PUT", - headers: { - "Content-Type": "application/json", - ...authHeader, - }, - body: JSON.stringify({ fieldIds }), + const res = await apiClient.rpc.api["note-types"][ + ":id" + ].fields.reorder.$put({ + param: { id: noteType.id }, + json: { fieldIds }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ fields: NoteFieldType[] }>( + res, + ); setFields( data.fields.sort( (a: NoteFieldType, b: NoteFieldType) => a.order - b.order, diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx index 1ef6ae7..d88a7a3 100644 --- a/src/client/pages/DeckDetailPage.test.tsx +++ b/src/client/pages/DeckDetailPage.test.tsx @@ -6,10 +6,14 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route, Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; -import { apiClient } from "../api/client"; import { AuthProvider } from "../stores"; import { DeckDetailPage } from "./DeckDetailPage"; +const mockDeckGet = vi.fn(); +const mockCardsGet = vi.fn(); +const mockNoteDelete = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -21,11 +25,23 @@ vi.mock("../api/client", () => ({ rpc: { api: { decks: { - $get: vi.fn(), - $post: vi.fn(), + ":id": { + $get: (args: unknown) => mockDeckGet(args), + }, + ":deckId": { + cards: { + $get: (args: unknown) => mockCardsGet(args), + }, + notes: { + ":noteId": { + $delete: (args: unknown) => mockNoteDelete(args), + }, + }, + }, }, }, }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -39,9 +55,7 @@ vi.mock("../api/client", () => ({ }, })); -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError, apiClient } from "../api/client"; const mockDeck = { id: "deck-1", @@ -171,6 +185,9 @@ describe("DeckDetailPage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -179,15 +196,8 @@ describe("DeckDetailPage", () => { }); it("renders back link and deck name", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -202,7 +212,8 @@ describe("DeckDetailPage", () => { }); it("shows loading state while fetching data", async () => { - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDeckGet.mockImplementation(() => new Promise(() => {})); // Never resolves + mockCardsGet.mockImplementation(() => new Promise(() => {})); // Never resolves renderWithProviders(); @@ -211,15 +222,8 @@ describe("DeckDetailPage", () => { }); it("displays empty state when no cards exist", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -230,15 +234,8 @@ describe("DeckDetailPage", () => { }); it("displays list of cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -251,15 +248,8 @@ describe("DeckDetailPage", () => { }); it("displays card count", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -269,15 +259,8 @@ describe("DeckDetailPage", () => { }); it("displays card state labels", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -288,15 +271,8 @@ describe("DeckDetailPage", () => { }); it("displays card stats (reps and lapses)", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -308,11 +284,8 @@ describe("DeckDetailPage", () => { }); it("displays error on API failure for deck", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Deck not found" }), - }); + mockDeckGet.mockRejectedValue(new ApiClientError("Deck not found", 404)); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -322,16 +295,10 @@ describe("DeckDetailPage", () => { }); it("displays error on API failure for cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to load cards" }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockRejectedValue( + new ApiClientError("Failed to load cards", 500), + ); renderWithProviders(); @@ -344,27 +311,12 @@ describe("DeckDetailPage", () => { it("allows retry after error", async () => { const user = userEvent.setup(); - // First call fails for both deck and cards (they run in parallel) - mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - // Second call (retry) succeeds - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + // First call fails + mockDeckGet + .mockRejectedValueOnce(new ApiClientError("Server error", 500)) + // Retry succeeds + .mockResolvedValueOnce({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -381,40 +333,26 @@ describe("DeckDetailPage", () => { }); }); - it("passes auth header when fetching data", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + it("calls correct RPC endpoints when fetching data", async () => { + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockDeckGet).toHaveBeenCalledWith({ + param: { id: "deck-1" }, }); }); - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/cards", { - headers: { Authorization: "Bearer access-token" }, + expect(mockCardsGet).toHaveBeenCalledWith({ + param: { deckId: "deck-1" }, }); }); it("does not show description if deck has none", async () => { const deckWithoutDescription = { ...mockDeck, description: null }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: deckWithoutDescription }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + mockDeckGet.mockResolvedValue({ deck: deckWithoutDescription }); + mockCardsGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -430,15 +368,8 @@ describe("DeckDetailPage", () => { describe("Delete Note", () => { it("shows Delete button for each note", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -455,15 +386,8 @@ describe("DeckDetailPage", () => { it("opens delete confirmation modal when Delete button is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -488,15 +412,8 @@ describe("DeckDetailPage", () => { it("closes delete modal when Cancel is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); renderWithProviders(); @@ -522,26 +439,12 @@ describe("DeckDetailPage", () => { it("deletes note and refreshes list on confirmation", async () => { const user = userEvent.setup(); - mockFetch - // Initial load - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }) - // Delete request - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }) - // Refresh cards after deletion - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [mockCards[1]] }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet + .mockResolvedValueOnce({ cards: mockCards }) + // Refresh after deletion + .mockResolvedValueOnce({ cards: [mockCards[1]] }); + mockNoteDelete.mockResolvedValue({ success: true }); renderWithProviders(); @@ -574,9 +477,8 @@ describe("DeckDetailPage", () => { }); // Verify DELETE request was made to notes endpoint - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, }); // Verify card count updated @@ -588,22 +490,11 @@ describe("DeckDetailPage", () => { it("displays error when delete fails", async () => { const user = userEvent.setup(); - mockFetch - // Initial load - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockCards }), - }) - // Delete request fails - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to delete note" }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockCards }); + mockNoteDelete.mockRejectedValue( + new ApiClientError("Failed to delete note", 500), + ); renderWithProviders(); @@ -641,15 +532,8 @@ describe("DeckDetailPage", () => { describe("Card Grouping by Note", () => { it("groups cards by noteId and displays as note groups", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -664,15 +548,8 @@ describe("DeckDetailPage", () => { }); it("shows Normal and Reversed badges for note-based cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -684,15 +561,8 @@ describe("DeckDetailPage", () => { }); it("shows note card count in note group header", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -703,15 +573,8 @@ describe("DeckDetailPage", () => { }); it("shows edit note button for note groups", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -724,15 +587,8 @@ describe("DeckDetailPage", () => { }); it("shows delete note button for note groups", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -749,15 +605,8 @@ describe("DeckDetailPage", () => { it("opens delete note modal when delete button is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); @@ -779,26 +628,12 @@ describe("DeckDetailPage", () => { it("deletes note and refreshes list when confirmed", async () => { const user = userEvent.setup(); - mockFetch - // Initial load - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }) - // Delete request - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }) + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet + .mockResolvedValueOnce({ cards: mockNoteBasedCards }) // Refresh cards after deletion - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + .mockResolvedValueOnce({ cards: [] }); + mockNoteDelete.mockResolvedValue({ success: true }); renderWithProviders(); @@ -827,9 +662,8 @@ describe("DeckDetailPage", () => { }); // Verify DELETE request was made to notes endpoint - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/notes/note-1", { - method: "DELETE", - headers: { Authorization: "Bearer access-token" }, + expect(mockNoteDelete).toHaveBeenCalledWith({ + param: { deckId: "deck-1", noteId: "note-1" }, }); // Should show empty state after deletion @@ -839,15 +673,8 @@ describe("DeckDetailPage", () => { }); it("displays note preview from normal card content", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockNoteBasedCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockCardsGet.mockResolvedValue({ cards: mockNoteBasedCards }); renderWithProviders(); diff --git a/src/client/pages/DeckDetailPage.tsx b/src/client/pages/DeckDetailPage.tsx index 3741111..f9b50f2 100644 --- a/src/client/pages/DeckDetailPage.tsx +++ b/src/client/pages/DeckDetailPage.tsx @@ -233,50 +233,20 @@ export function DeckDetailPage() { const fetchDeck = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":id"].$get({ + param: { id: deckId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ deck: Deck }>(res); setDeck(data.deck); }, [deckId]); const fetchCards = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/cards`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].cards.$get({ + param: { deckId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ cards: Card[] }>(res); setCards(data.cards); }, [deckId]); diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx index 944dd31..cb96aa3 100644 --- a/src/client/pages/HomePage.test.tsx +++ b/src/client/pages/HomePage.test.tsx @@ -11,6 +11,10 @@ import { apiClient } from "../api/client"; import { AuthProvider, SyncProvider } from "../stores"; import { HomePage } from "./HomePage"; +const mockDeckPut = vi.fn(); +const mockDeckDelete = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -24,9 +28,14 @@ vi.mock("../api/client", () => ({ decks: { $get: vi.fn(), $post: vi.fn(), + ":id": { + $put: (args: unknown) => mockDeckPut(args), + $delete: (args: unknown) => mockDeckDelete(args), + }, }, }, }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -110,6 +119,9 @@ describe("HomePage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -544,10 +556,7 @@ describe("HomePage", () => { }), ); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ deck: updatedDeck }), - }); + mockDeckPut.mockResolvedValue({ deck: updatedDeck }); renderWithProviders(); @@ -686,10 +695,7 @@ describe("HomePage", () => { }), ); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({}), - }); + mockDeckDelete.mockResolvedValue({ success: true }); renderWithProviders(); diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx index 8364d17..c0559f6 100644 --- a/src/client/pages/NoteTypesPage.test.tsx +++ b/src/client/pages/NoteTypesPage.test.tsx @@ -7,10 +7,16 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; -import { apiClient } from "../api/client"; import { AuthProvider, SyncProvider } from "../stores"; import { NoteTypesPage } from "./NoteTypesPage"; +const mockNoteTypesGet = vi.fn(); +const mockNoteTypesPost = vi.fn(); +const mockNoteTypeGet = vi.fn(); +const mockNoteTypePut = vi.fn(); +const mockNoteTypeDelete = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -19,6 +25,20 @@ vi.mock("../api/client", () => ({ getTokens: vi.fn(), getAuthHeader: vi.fn(), onSessionExpired: vi.fn(() => vi.fn()), + rpc: { + api: { + "note-types": { + $get: () => mockNoteTypesGet(), + $post: (args: unknown) => mockNoteTypesPost(args), + ":id": { + $get: (args: unknown) => mockNoteTypeGet(args), + $put: (args: unknown) => mockNoteTypePut(args), + $delete: (args: unknown) => mockNoteTypeDelete(args), + }, + }, + }, + }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -32,9 +52,7 @@ vi.mock("../api/client", () => ({ }, })); -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError, apiClient } from "../api/client"; const mockNoteTypes = [ { @@ -81,6 +99,9 @@ describe("NoteTypesPage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -89,10 +110,7 @@ describe("NoteTypesPage", () => { }); it("renders page title and back button", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -101,7 +119,7 @@ describe("NoteTypesPage", () => { }); it("shows loading state while fetching note types", async () => { - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockNoteTypesGet.mockImplementation(() => new Promise(() => {})); // Never resolves renderWithProviders(); @@ -110,10 +128,7 @@ describe("NoteTypesPage", () => { }); it("displays empty state when no note types exist", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -128,10 +143,7 @@ describe("NoteTypesPage", () => { }); it("displays list of note types", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -144,10 +156,7 @@ describe("NoteTypesPage", () => { }); it("displays reversible badge for reversible note types", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -161,10 +170,7 @@ describe("NoteTypesPage", () => { }); it("displays template info for each note type", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -177,11 +183,9 @@ describe("NoteTypesPage", () => { }); it("displays error on API failure", async () => { - mockFetch.mockResolvedValue({ - ok: false, - status: 500, - json: async () => ({ error: "Internal server error" }), - }); + mockNoteTypesGet.mockRejectedValue( + new ApiClientError("Internal server error", 500), + ); renderWithProviders(); @@ -193,7 +197,7 @@ describe("NoteTypesPage", () => { }); it("displays generic error on unexpected failure", async () => { - mockFetch.mockRejectedValue(new Error("Network error")); + mockNoteTypesGet.mockRejectedValue(new Error("Network error")); renderWithProviders(); @@ -206,16 +210,9 @@ describe("NoteTypesPage", () => { it("allows retry after error", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet + .mockRejectedValueOnce(new ApiClientError("Server error", 500)) + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -230,27 +227,19 @@ describe("NoteTypesPage", () => { }); }); - it("passes auth header when fetching note types", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + it("calls correct RPC endpoint when fetching note types", async () => { + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/note-types", { - headers: { Authorization: "Bearer access-token" }, - }); + expect(mockNoteTypesGet).toHaveBeenCalled(); }); }); describe("Create Note Type", () => { it("shows New Note Type button", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -265,10 +254,7 @@ describe("NoteTypesPage", () => { it("opens modal when New Note Type button is clicked", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: [] }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: [] }); renderWithProviders(); @@ -296,19 +282,10 @@ describe("NoteTypesPage", () => { updatedAt: "2024-01-03T00:00:00Z", }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: newNoteType }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [newNoteType] }), - }); + mockNoteTypesGet + .mockResolvedValueOnce({ noteTypes: [] }) + .mockResolvedValueOnce({ noteTypes: [newNoteType] }); + mockNoteTypesPost.mockResolvedValue({ noteType: newNoteType }); renderWithProviders(); @@ -341,10 +318,7 @@ describe("NoteTypesPage", () => { describe("Edit Note Type", () => { it("shows Edit button for each note type", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -380,15 +354,8 @@ describe("NoteTypesPage", () => { ], }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); + mockNoteTypeGet.mockResolvedValue({ noteType: mockNoteTypeWithFields }); renderWithProviders(); @@ -437,25 +404,13 @@ describe("NoteTypesPage", () => { name: "Updated Basic", }; - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: mockNoteTypeWithFields }), - }) + mockNoteTypesGet + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteType: updatedNoteType }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - noteTypes: [updatedNoteType, mockNoteTypes[1]], - }), + noteTypes: [updatedNoteType, mockNoteTypes[1]], }); + mockNoteTypeGet.mockResolvedValue({ noteType: mockNoteTypeWithFields }); + mockNoteTypePut.mockResolvedValue({ noteType: updatedNoteType }); renderWithProviders(); @@ -498,10 +453,7 @@ describe("NoteTypesPage", () => { describe("Delete Note Type", () => { it("shows Delete button for each note type", async () => { - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -517,10 +469,7 @@ describe("NoteTypesPage", () => { it("opens delete modal when Delete button is clicked", async () => { const user = userEvent.setup(); - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }); + mockNoteTypesGet.mockResolvedValue({ noteTypes: mockNoteTypes }); renderWithProviders(); @@ -544,19 +493,10 @@ describe("NoteTypesPage", () => { it("deletes note type and refreshes list", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: mockNoteTypes }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ noteTypes: [mockNoteTypes[1]] }), - }); + mockNoteTypesGet + .mockResolvedValueOnce({ noteTypes: mockNoteTypes }) + .mockResolvedValueOnce({ noteTypes: [mockNoteTypes[1]] }); + mockNoteTypeDelete.mockResolvedValue({ success: true }); renderWithProviders(); diff --git a/src/client/pages/NoteTypesPage.tsx b/src/client/pages/NoteTypesPage.tsx index d819ece..5b50c61 100644 --- a/src/client/pages/NoteTypesPage.tsx +++ b/src/client/pages/NoteTypesPage.tsx @@ -42,25 +42,10 @@ export function NoteTypesPage() { setError(null); try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch("/api/note-types", { - headers: authHeader, - }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const res = await apiClient.rpc.api["note-types"].$get(); + const data = await apiClient.handleResponse<{ noteTypes: NoteType[] }>( + res, + ); setNoteTypes(data.noteTypes); } catch (err) { if (err instanceof ApiClientError) { diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx index bc87b9d..edf683a 100644 --- a/src/client/pages/StudyPage.test.tsx +++ b/src/client/pages/StudyPage.test.tsx @@ -6,10 +6,14 @@ import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Route, Router } from "wouter"; import { memoryLocation } from "wouter/memory-location"; -import { apiClient } from "../api/client"; import { AuthProvider } from "../stores"; import { StudyPage } from "./StudyPage"; +const mockDeckGet = vi.fn(); +const mockStudyGet = vi.fn(); +const mockStudyPost = vi.fn(); +const mockHandleResponse = vi.fn(); + vi.mock("../api/client", () => ({ apiClient: { login: vi.fn(), @@ -21,11 +25,21 @@ vi.mock("../api/client", () => ({ rpc: { api: { decks: { - $get: vi.fn(), - $post: vi.fn(), + ":id": { + $get: (args: unknown) => mockDeckGet(args), + }, + ":deckId": { + study: { + $get: (args: unknown) => mockStudyGet(args), + ":cardId": { + $post: (args: unknown) => mockStudyPost(args), + }, + }, + }, }, }, }, + handleResponse: (res: unknown) => mockHandleResponse(res), }, ApiClientError: class ApiClientError extends Error { constructor( @@ -39,9 +53,7 @@ vi.mock("../api/client", () => ({ }, })); -// Mock fetch globally -const mockFetch = vi.fn(); -global.fetch = mockFetch; +import { ApiClientError, apiClient } from "../api/client"; const mockDeck = { id: "deck-1", @@ -117,6 +129,9 @@ describe("StudyPage", () => { vi.mocked(apiClient.getAuthHeader).mockReturnValue({ Authorization: "Bearer access-token", }); + + // handleResponse passes through whatever it receives + mockHandleResponse.mockImplementation((res) => Promise.resolve(res)); }); afterEach(() => { @@ -126,7 +141,8 @@ describe("StudyPage", () => { describe("Loading and Initial State", () => { it("shows loading state while fetching data", async () => { - mockFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + mockDeckGet.mockImplementation(() => new Promise(() => {})); // Never resolves + mockStudyGet.mockImplementation(() => new Promise(() => {})); // Never resolves renderWithProviders(); @@ -135,15 +151,8 @@ describe("StudyPage", () => { }); it("renders deck name and back link", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -156,37 +165,27 @@ describe("StudyPage", () => { expect(screen.getByText(/Back to Deck/)).toBeDefined(); }); - it("passes auth header when fetching data", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + it("calls correct RPC endpoints when fetching data", async () => { + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [] }); renderWithProviders(); await waitFor(() => { - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1", { - headers: { Authorization: "Bearer access-token" }, + expect(mockDeckGet).toHaveBeenCalledWith({ + param: { id: "deck-1" }, }); }); - expect(mockFetch).toHaveBeenCalledWith("/api/decks/deck-1/study", { - headers: { Authorization: "Bearer access-token" }, + expect(mockStudyGet).toHaveBeenCalledWith({ + param: { deckId: "deck-1" }, }); }); }); describe("Error Handling", () => { it("displays error on API failure", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - json: async () => ({ error: "Deck not found" }), - }); + mockDeckGet.mockRejectedValue(new ApiClientError("Deck not found", 404)); + mockStudyGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -200,26 +199,11 @@ describe("StudyPage", () => { it("allows retry after error", async () => { const user = userEvent.setup(); // First call fails - mockFetch - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Server error" }), - }) + mockDeckGet + .mockRejectedValueOnce(new ApiClientError("Server error", 500)) // Retry succeeds - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + .mockResolvedValueOnce({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -239,15 +223,8 @@ describe("StudyPage", () => { describe("No Cards State", () => { it("shows no cards message when deck has no due cards", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [] }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [] }); renderWithProviders(); @@ -263,15 +240,8 @@ describe("StudyPage", () => { describe("Card Display and Progress", () => { it("shows remaining cards count", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -283,15 +253,8 @@ describe("StudyPage", () => { }); it("displays the front of the first card", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -301,15 +264,8 @@ describe("StudyPage", () => { }); it("does not show rating buttons before card is flipped", async () => { - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -325,15 +281,8 @@ describe("StudyPage", () => { it("reveals answer when card is clicked", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -349,15 +298,8 @@ describe("StudyPage", () => { it("shows rating buttons after card is flipped", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -377,15 +319,8 @@ describe("StudyPage", () => { it("displays rating labels on buttons", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -406,20 +341,11 @@ describe("StudyPage", () => { it("submits review and moves to next card", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - // Submit review - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -438,16 +364,11 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - // Verify API was called - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-1/study/card-1", + // Verify API was called with correct params + expect(mockStudyPost).toHaveBeenCalledWith( expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Bearer access-token", - "Content-Type": "application/json", - }), - body: expect.stringContaining('"rating":3'), + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 3 }), }), ); }); @@ -455,19 +376,11 @@ describe("StudyPage", () => { it("updates remaining count after review", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -490,20 +403,11 @@ describe("StudyPage", () => { it("shows error when rating submission fails", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: false, - status: 500, - json: async () => ({ error: "Failed to submit review" }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockRejectedValue( + new ApiClientError("Failed to submit review", 500), + ); renderWithProviders(); @@ -526,19 +430,11 @@ describe("StudyPage", () => { it("shows session complete screen after all cards reviewed", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [mockDueCards[0]] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [mockDueCards[0]] }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -561,25 +457,11 @@ describe("StudyPage", () => { it("shows correct count for multiple cards reviewed", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - // First review - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }) - // Second review - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[1], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -608,19 +490,11 @@ describe("StudyPage", () => { it("provides navigation links after session complete", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: [mockDueCards[0]] }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: [mockDueCards[0]] }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -644,15 +518,8 @@ describe("StudyPage", () => { it("flips card with Space key", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -668,15 +535,8 @@ describe("StudyPage", () => { it("flips card with Enter key", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); renderWithProviders(); @@ -692,19 +552,11 @@ describe("StudyPage", () => { it("rates card with number keys", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -719,10 +571,10 @@ describe("StudyPage", () => { expect(screen.getByTestId("card-front").textContent).toBe("Goodbye"); }); - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-1/study/card-1", + expect(mockStudyPost).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.stringContaining('"rating":3'), + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 3 }), }), ); }); @@ -730,19 +582,11 @@ describe("StudyPage", () => { it("supports all rating keys (1, 2, 3, 4)", async () => { const user = userEvent.setup(); - mockFetch - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ deck: mockDeck }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ cards: mockDueCards }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ card: { ...mockDueCards[0], reps: 1 } }), - }); + mockDeckGet.mockResolvedValue({ deck: mockDeck }); + mockStudyGet.mockResolvedValue({ cards: mockDueCards }); + mockStudyPost.mockResolvedValue({ + card: { ...mockDueCards[0], reps: 1 }, + }); renderWithProviders(); @@ -753,10 +597,10 @@ describe("StudyPage", () => { await user.keyboard(" "); // Flip await user.keyboard("1"); // Rate as Again - expect(mockFetch).toHaveBeenCalledWith( - "/api/decks/deck-1/study/card-1", + expect(mockStudyPost).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.stringContaining('"rating":1'), + param: { deckId: "deck-1", cardId: "card-1" }, + json: expect.objectContaining({ rating: 1 }), }), ); }); diff --git a/src/client/pages/StudyPage.tsx b/src/client/pages/StudyPage.tsx index 0eb5118..43fd195 100644 --- a/src/client/pages/StudyPage.tsx +++ b/src/client/pages/StudyPage.tsx @@ -68,50 +68,20 @@ export function StudyPage() { const fetchDeck = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":id"].$get({ + param: { id: deckId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ deck: Deck }>(res); setDeck(data.deck); }, [deckId]); const fetchDueCards = useCallback(async () => { if (!deckId) return; - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch(`/api/decks/${deckId}/study`, { - headers: authHeader, + const res = await apiClient.rpc.api.decks[":deckId"].study.$get({ + param: { deckId }, }); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } - - const data = await res.json(); + const data = await apiClient.handleResponse<{ cards: Card[] }>(res); setCards(data.cards); }, [deckId]); @@ -158,31 +128,13 @@ export function StudyPage() { const durationMs = Date.now() - cardStartTimeRef.current; try { - const authHeader = apiClient.getAuthHeader(); - if (!authHeader) { - throw new ApiClientError("Not authenticated", 401); - } - - const res = await fetch( - `/api/decks/${deckId}/study/${currentCard.id}`, - { - method: "POST", - headers: { - ...authHeader, - "Content-Type": "application/json", - }, - body: JSON.stringify({ rating, durationMs }), - }, - ); - - if (!res.ok) { - const errorBody = await res.json().catch(() => ({})); - throw new ApiClientError( - (errorBody as { error?: string }).error || - `Request failed with status ${res.status}`, - res.status, - ); - } + const res = await apiClient.rpc.api.decks[":deckId"].study[ + ":cardId" + ].$post({ + param: { deckId, cardId: currentCard.id }, + json: { rating, durationMs }, + }); + await apiClient.handleResponse(res); setCompletedCount((prev) => prev + 1); setIsFlipped(false); |
