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:
@@ -6,6 +6,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
|
||||
interface ActiveProject {
|
||||
id: string;
|
||||
@@ -43,14 +44,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
useEffect(() => {
|
||||
const fetchProjects = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch("/api/widgets/data/active-projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as ActiveProject[];
|
||||
setProjects(data);
|
||||
}
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<ActiveProject[]>("/api/widgets/data/active-projects");
|
||||
setProjects(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active projects:", error);
|
||||
} finally {
|
||||
@@ -71,14 +67,9 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
useEffect(() => {
|
||||
const fetchAgentSessions = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch("/api/widgets/data/agent-chains", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as AgentSession[];
|
||||
setAgentSessions(data);
|
||||
}
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<AgentSession[]>("/api/widgets/data/agent-chains");
|
||||
setAgentSessions(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agent sessions:", error);
|
||||
} finally {
|
||||
|
||||
@@ -3,26 +3,48 @@
|
||||
* Following TDD principles
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { ActiveProjectsWidget } from "../ActiveProjectsWidget";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { clearCsrfToken } from "@/lib/api/client";
|
||||
|
||||
// Mock fetch for API calls
|
||||
global.fetch = vi.fn() as typeof global.fetch;
|
||||
|
||||
// Helper to create mock CSRF token response
|
||||
const mockCsrfResponse = (): Response =>
|
||||
({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ token: "test-csrf-token" }),
|
||||
}) as Response;
|
||||
|
||||
describe("ActiveProjectsWidget", (): void => {
|
||||
beforeEach((): void => {
|
||||
vi.clearAllMocks();
|
||||
clearCsrfToken(); // Clear cached CSRF token between tests
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render loading state initially", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Intentionally empty - creates a never-resolving promise for loading state
|
||||
})
|
||||
);
|
||||
// First call returns CSRF token, subsequent calls never resolve (loading state)
|
||||
let csrfReturned = false;
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
// Return CSRF token on first request
|
||||
if (urlString.includes("csrf") && !csrfReturned) {
|
||||
csrfReturned = true;
|
||||
return Promise.resolve(mockCsrfResponse());
|
||||
}
|
||||
// All other requests never resolve
|
||||
return new Promise(() => {
|
||||
// Intentionally empty - creates a never-resolving promise for loading state
|
||||
});
|
||||
});
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
@@ -57,6 +79,10 @@ describe("ActiveProjectsWidget", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
// Return CSRF token
|
||||
if (urlString.includes("csrf")) {
|
||||
return Promise.resolve(mockCsrfResponse());
|
||||
}
|
||||
if (urlString.includes("active-projects")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -103,6 +129,10 @@ describe("ActiveProjectsWidget", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
// Return CSRF token
|
||||
if (urlString.includes("csrf")) {
|
||||
return Promise.resolve(mockCsrfResponse());
|
||||
}
|
||||
if (urlString.includes("active-projects")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -127,12 +157,18 @@ describe("ActiveProjectsWidget", (): void => {
|
||||
});
|
||||
|
||||
it("should handle empty states", async (): Promise<void> => {
|
||||
vi.mocked(global.fetch).mockImplementation(() =>
|
||||
Promise.resolve({
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
// Return CSRF token
|
||||
if (urlString.includes("csrf")) {
|
||||
return Promise.resolve(mockCsrfResponse());
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response)
|
||||
);
|
||||
} as Response);
|
||||
});
|
||||
|
||||
render(<ActiveProjectsWidget id="active-projects-1" />);
|
||||
|
||||
@@ -161,6 +197,10 @@ describe("ActiveProjectsWidget", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
// Return CSRF token
|
||||
if (urlString.includes("csrf")) {
|
||||
return Promise.resolve(mockCsrfResponse());
|
||||
}
|
||||
if (urlString.includes("agent-chains")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -207,6 +247,10 @@ describe("ActiveProjectsWidget", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
// Return CSRF token
|
||||
if (urlString.includes("csrf")) {
|
||||
return Promise.resolve(mockCsrfResponse());
|
||||
}
|
||||
if (urlString.includes("agent-chains")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -251,6 +295,10 @@ describe("ActiveProjectsWidget", (): void => {
|
||||
vi.mocked(global.fetch).mockImplementation((url: RequestInfo | URL) => {
|
||||
const urlString = typeof url === "string" ? url : url instanceof URL ? url.toString() : "";
|
||||
|
||||
// Return CSRF token
|
||||
if (urlString.includes("csrf")) {
|
||||
return Promise.resolve(mockCsrfResponse());
|
||||
}
|
||||
if (urlString.includes("active-projects")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
|
||||
Reference in New Issue
Block a user