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:
Jason Woltje
2026-02-05 17:06:23 -06:00
parent 5ae07f7a84
commit 344e5df3bb
7 changed files with 198 additions and 119 deletions

View File

@@ -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(),