aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.env.example2
-rw-r--r--src/client/api/client.ts2
-rw-r--r--src/client/components/CreateNoteModal.test.tsx348
-rw-r--r--src/client/components/CreateNoteModal.tsx76
-rw-r--r--src/client/components/CreateNoteTypeModal.test.tsx96
-rw-r--r--src/client/components/CreateNoteTypeModal.tsx26
-rw-r--r--src/client/components/DeleteCardModal.test.tsx70
-rw-r--r--src/client/components/DeleteCardModal.tsx22
-rw-r--r--src/client/components/DeleteDeckModal.test.tsx61
-rw-r--r--src/client/components/DeleteDeckModal.tsx20
-rw-r--r--src/client/components/DeleteNoteModal.test.tsx73
-rw-r--r--src/client/components/DeleteNoteModal.tsx22
-rw-r--r--src/client/components/DeleteNoteTypeModal.test.tsx71
-rw-r--r--src/client/components/DeleteNoteTypeModal.tsx20
-rw-r--r--src/client/components/EditCardModal.test.tsx141
-rw-r--r--src/client/components/EditCardModal.tsx29
-rw-r--r--src/client/components/EditDeckModal.test.tsx143
-rw-r--r--src/client/components/EditDeckModal.tsx27
-rw-r--r--src/client/components/EditNoteModal.test.tsx329
-rw-r--r--src/client/components/EditNoteModal.tsx75
-rw-r--r--src/client/components/EditNoteTypeModal.test.tsx87
-rw-r--r--src/client/components/EditNoteTypeModal.tsx27
-rw-r--r--src/client/components/ImportNotesModal.tsx60
-rw-r--r--src/client/components/NoteTypeEditor.test.tsx417
-rw-r--r--src/client/components/NoteTypeEditor.tsx178
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx369
-rw-r--r--src/client/pages/DeckDetailPage.tsx42
-rw-r--r--src/client/pages/HomePage.test.tsx22
-rw-r--r--src/client/pages/NoteTypesPage.test.tsx182
-rw-r--r--src/client/pages/NoteTypesPage.tsx23
-rw-r--r--src/client/pages/StudyPage.test.tsx368
-rw-r--r--src/client/pages/StudyPage.tsx74
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);