fix(#338): Route all state-changing fetch() calls through API client
- Replace raw fetch() with apiPost/apiPatch/apiDelete in: - ImportExportActions.tsx: POST for file imports - KanbanBoard.tsx: PATCH for task status updates - ActiveProjectsWidget.tsx: POST for widget data fetches - useLayouts.ts: POST/PATCH/DELETE for layout management - Add apiPostFormData() method to API client for FormData uploads - Ensures CSRF token is included in all state-changing requests - Update tests to mock CSRF token fetch for API client usage Refs #338 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,16 +3,24 @@
|
||||
* Following TDD principles
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// We'll implement this hook
|
||||
import { useLayouts, useCreateLayout, useUpdateLayout, useDeleteLayout } from "../useLayouts";
|
||||
import { clearCsrfToken } from "@/lib/api/client";
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Helper to create mock CSRF token response
|
||||
const mockCsrfResponse = (): Response =>
|
||||
({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: "test-csrf-token" }),
|
||||
}) as Response;
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -29,6 +37,11 @@ const createWrapper = () => {
|
||||
describe("useLayouts", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch layouts on mount", async (): Promise<void> => {
|
||||
@@ -82,6 +95,11 @@ describe("useLayouts", (): void => {
|
||||
describe("useCreateLayout", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should create a new layout", async (): Promise<void> => {
|
||||
@@ -92,10 +110,13 @@ describe("useCreateLayout", (): void => {
|
||||
layout: [],
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => mockLayout,
|
||||
});
|
||||
// Mock CSRF token fetch first, then the actual POST request
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockCsrfResponse())
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => mockLayout,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useCreateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -113,7 +134,10 @@ describe("useCreateLayout", (): void => {
|
||||
});
|
||||
|
||||
it("should handle creation errors", async (): Promise<void> => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error"));
|
||||
// Mock CSRF token fetch succeeds but the actual POST fails
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockCsrfResponse())
|
||||
.mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useCreateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -133,6 +157,11 @@ describe("useCreateLayout", (): void => {
|
||||
describe("useUpdateLayout", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should update an existing layout", async (): Promise<void> => {
|
||||
@@ -143,10 +172,13 @@ describe("useUpdateLayout", (): void => {
|
||||
layout: [{ i: "widget-1", x: 0, y: 0, w: 2, h: 2 }],
|
||||
};
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => mockLayout,
|
||||
});
|
||||
// Mock CSRF token fetch first, then the actual PATCH request
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockCsrfResponse())
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => mockLayout,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUpdateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -165,7 +197,10 @@ describe("useUpdateLayout", (): void => {
|
||||
});
|
||||
|
||||
it("should handle update errors", async (): Promise<void> => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error"));
|
||||
// Mock CSRF token fetch succeeds but the actual PATCH fails
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockCsrfResponse())
|
||||
.mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useUpdateLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -185,13 +220,21 @@ describe("useUpdateLayout", (): void => {
|
||||
describe("useDeleteLayout", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should delete a layout", async (): Promise<void> => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => ({ success: true }),
|
||||
});
|
||||
// Mock CSRF token fetch first, then the actual DELETE request
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockCsrfResponse())
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useDeleteLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -205,7 +248,10 @@ describe("useDeleteLayout", (): void => {
|
||||
});
|
||||
|
||||
it("should handle deletion errors", async (): Promise<void> => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("API Error"));
|
||||
// Mock CSRF token fetch succeeds but the actual DELETE fails
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockCsrfResponse())
|
||||
.mockRejectedValueOnce(new Error("API Error"));
|
||||
|
||||
const { result } = renderHook(() => useDeleteLayout(), {
|
||||
wrapper: createWrapper(),
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { UseQueryResult, UseMutationResult } from "@tanstack/react-query";
|
||||
import type { UserLayout, WidgetPlacement } from "@mosaic/shared";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete } from "@/lib/api/client";
|
||||
|
||||
const LAYOUTS_KEY = ["layouts"];
|
||||
|
||||
@@ -30,11 +31,7 @@ export function useLayouts(): UseQueryResult<UserLayout[]> {
|
||||
return useQuery<UserLayout[]>({
|
||||
queryKey: LAYOUTS_KEY,
|
||||
queryFn: async (): Promise<UserLayout[]> => {
|
||||
const response = await fetch("/api/layouts");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch layouts");
|
||||
}
|
||||
return response.json() as Promise<UserLayout[]>;
|
||||
return apiGet<UserLayout[]>("/api/layouts");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -46,11 +43,7 @@ export function useLayout(id: string): UseQueryResult<UserLayout> {
|
||||
return useQuery<UserLayout>({
|
||||
queryKey: [...LAYOUTS_KEY, id],
|
||||
queryFn: async (): Promise<UserLayout> => {
|
||||
const response = await fetch(`/api/layouts/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch layout");
|
||||
}
|
||||
return response.json() as Promise<UserLayout>;
|
||||
return apiGet<UserLayout>(`/api/layouts/${id}`);
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
@@ -63,36 +56,20 @@ export function useDefaultLayout(): UseQueryResult<UserLayout> {
|
||||
return useQuery<UserLayout>({
|
||||
queryKey: [...LAYOUTS_KEY, "default"],
|
||||
queryFn: async (): Promise<UserLayout> => {
|
||||
const response = await fetch("/api/layouts/default");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch default layout");
|
||||
}
|
||||
return response.json() as Promise<UserLayout>;
|
||||
return apiGet<UserLayout>("/api/layouts/default");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new layout
|
||||
* Create a new layout (uses API client for CSRF protection)
|
||||
*/
|
||||
export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLayoutData> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: CreateLayoutData): Promise<UserLayout> => {
|
||||
const response = await fetch("/api/layouts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create layout");
|
||||
}
|
||||
|
||||
return response.json() as Promise<UserLayout>;
|
||||
return apiPost<UserLayout>("/api/layouts", data);
|
||||
},
|
||||
onSuccess: (): void => {
|
||||
// Invalidate layouts cache to refetch
|
||||
@@ -102,26 +79,14 @@ export function useCreateLayout(): UseMutationResult<UserLayout, Error, CreateLa
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing layout
|
||||
* Update an existing layout (uses API client for CSRF protection)
|
||||
*/
|
||||
export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLayoutData> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, ...data }: UpdateLayoutData): Promise<UserLayout> => {
|
||||
const response = await fetch(`/api/layouts/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update layout");
|
||||
}
|
||||
|
||||
return response.json() as Promise<UserLayout>;
|
||||
return apiPatch<UserLayout>(`/api/layouts/${id}`, data);
|
||||
},
|
||||
onSuccess: (_data, variables): void => {
|
||||
// Invalidate affected queries
|
||||
@@ -132,22 +97,14 @@ export function useUpdateLayout(): UseMutationResult<UserLayout, Error, UpdateLa
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a layout
|
||||
* Delete a layout (uses API client for CSRF protection)
|
||||
*/
|
||||
export function useDeleteLayout(): UseMutationResult<void, Error, string> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string): Promise<void> => {
|
||||
const response = await fetch(`/api/layouts/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete layout");
|
||||
}
|
||||
|
||||
await response.json();
|
||||
await apiDelete(`/api/layouts/${id}`);
|
||||
},
|
||||
onSuccess: (): void => {
|
||||
// Invalidate layouts cache to refetch
|
||||
|
||||
Reference in New Issue
Block a user