import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { apiRequest, apiGet, apiPost, apiPatch, apiDelete, fetchCsrfToken, getCsrfToken, clearCsrfToken, DEFAULT_API_TIMEOUT_MS, } from "./client"; // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; describe("API Client", (): void => { beforeEach((): void => { mockFetch.mockClear(); clearCsrfToken(); }); afterEach((): void => { vi.resetAllMocks(); }); describe("apiRequest", (): void => { it("should make a successful GET request", async (): Promise => { const mockData = { id: "1", name: "Test" }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockData), }); const result = await apiRequest("/test"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test", expect.objectContaining({ headers: expect.objectContaining({ "Content-Type": "application/json", }), credentials: "include", }) ); expect(result).toEqual(mockData); }); it("should include custom headers", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}), }); await apiRequest("/test", { headers: { Authorization: "Bearer token123" }, }); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test", expect.objectContaining({ headers: expect.objectContaining({ "Content-Type": "application/json", Authorization: "Bearer token123", }), }) ); }); it("should throw error on failed request", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found", json: () => Promise.resolve({ code: "NOT_FOUND", message: "Resource not found", }), }); await expect(apiRequest("/test")).rejects.toThrow("Resource not found"); }); it("should handle errors when JSON parsing fails", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Internal Server Error", json: () => Promise.reject(new Error("Invalid JSON")), }); await expect(apiRequest("/test")).rejects.toThrow("Internal Server Error"); }); }); describe("apiGet", (): void => { it("should make a GET request", async (): Promise => { const mockData = { id: "1" }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockData), }); const result = await apiGet("/test"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test", expect.objectContaining({ method: "GET" }) ); expect(result).toEqual(mockData); }); it("should include workspace ID in header when provided", async (): Promise => { const mockData = { id: "1" }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockData), }); await apiGet("/test", "workspace-123"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test", expect.objectContaining({ method: "GET", headers: expect.objectContaining({ "X-Workspace-Id": "workspace-123", }), }) ); }); }); describe("apiPost", (): void => { it("should make a POST request with data", async (): Promise => { const postData = { name: "New Item" }; const mockResponse = { id: "1", ...postData }; // Mock CSRF token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "test-token" }), }); // Mock actual POST request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await apiPost("/test", postData); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test", expect.objectContaining({ method: "POST", body: JSON.stringify(postData), }) ); expect(result).toEqual(mockResponse); }); it("should make a POST request without data", async (): Promise => { // Mock CSRF token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "test-token" }), }); // Mock actual POST request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}), }); await apiPost("/test"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test", expect.objectContaining({ method: "POST", // When no data is provided, body property is not set (not undefined) }) ); // Verify body is not in the call (second call is the actual POST) const callArgs = mockFetch.mock.calls[1]![1] as RequestInit; expect(callArgs.body).toBeUndefined(); }); it("should include workspace ID in header when provided", async (): Promise => { const postData = { name: "New Item" }; // Mock CSRF token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "test-token" }), }); // Mock actual POST request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({}), }); await apiPost("/test", postData, "workspace-456"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test", expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "X-Workspace-Id": "workspace-456", }), }) ); }); }); describe("apiPatch", (): void => { it("should make a PATCH request with data", async (): Promise => { const patchData = { name: "Updated" }; const mockResponse = { id: "1", ...patchData }; // Mock CSRF token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "test-token" }), }); // Mock actual PATCH request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockResponse), }); const result = await apiPatch("/test/1", patchData); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test/1", expect.objectContaining({ method: "PATCH", body: JSON.stringify(patchData), }) ); expect(result).toEqual(mockResponse); }); }); describe("apiDelete", (): void => { it("should make a DELETE request", async (): Promise => { // Mock CSRF token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: "test-token" }), }); // Mock actual DELETE request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }); const result = await apiDelete<{ success: boolean }>("/test/1"); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/test/1", expect.objectContaining({ method: "DELETE" }) ); expect(result).toEqual({ success: true }); }); }); describe("error handling", (): void => { it("should handle network errors", async (): Promise => { mockFetch.mockRejectedValueOnce(new Error("Network request failed")); await expect(apiGet("/test")).rejects.toThrow("Network request failed"); }); it("should handle 401 unauthorized errors", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized", status: 401, json: () => Promise.resolve({ code: "UNAUTHORIZED", message: "Authentication required", }), }); await expect(apiGet("/test")).rejects.toThrow("Authentication required"); }); it("should handle 403 forbidden errors", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Forbidden", status: 403, json: () => Promise.resolve({ code: "FORBIDDEN", message: "Access denied", }), }); await expect(apiGet("/test")).rejects.toThrow("Access denied"); }); it("should handle 404 not found errors", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found", status: 404, json: () => Promise.resolve({ code: "NOT_FOUND", message: "Resource not found", }), }); await expect(apiGet("/test")).rejects.toThrow("Resource not found"); }); it("should handle 500 server errors", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Internal Server Error", status: 500, json: () => Promise.resolve({ code: "INTERNAL_ERROR", message: "Internal server error", }), }); await expect(apiGet("/test")).rejects.toThrow("Internal server error"); }); it("should handle malformed JSON responses", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.reject(new Error("Unexpected token in JSON")), }); await expect(apiGet("/test")).rejects.toThrow("Unexpected token in JSON"); }); it("should handle empty error responses", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Bad Request", status: 400, json: () => Promise.reject(new Error("No JSON body")), }); await expect(apiGet("/test")).rejects.toThrow("Bad Request"); }); it("should handle timeout errors", async (): Promise => { mockFetch.mockImplementationOnce(() => { return new Promise((_, reject) => { setTimeout(() => { reject(new Error("Request timeout")); }, 1); }); }); await expect(apiGet("/test")).rejects.toThrow("Request timeout"); }); it("should handle malformed error responses with details", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Validation Error", status: 422, json: () => Promise.resolve({ code: "VALIDATION_ERROR", message: "Invalid input", details: { fields: { email: "Invalid email format", password: "Password too short", }, }, }), }); await expect(apiGet("/test")).rejects.toThrow("Invalid input"); }); it("should handle CORS errors", async (): Promise => { mockFetch.mockRejectedValueOnce(new TypeError("Failed to fetch")); await expect(apiGet("/test")).rejects.toThrow("Failed to fetch"); }); it("should handle rate limit errors", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Too Many Requests", status: 429, json: () => Promise.resolve({ code: "RATE_LIMIT_EXCEEDED", message: "Too many requests. Please try again later.", }), }); await expect(apiGet("/test")).rejects.toThrow("Too many requests. Please try again later."); }); it("should handle connection refused errors", async (): Promise => { mockFetch.mockRejectedValueOnce({ name: "FetchError", message: "request to http://localhost:3001/test failed, reason: connect ECONNREFUSED", }); await expect(apiGet("/test")).rejects.toMatchObject({ message: expect.stringContaining("ECONNREFUSED"), }); }); }); describe("CSRF Protection", (): void => { describe("fetchCsrfToken", (): void => { it("should fetch CSRF token from API", async (): Promise => { const mockToken = "test-csrf-token-abc123"; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: mockToken }), }); const token = await fetchCsrfToken(); expect(token).toBe(mockToken); expect(mockFetch).toHaveBeenCalledWith( "http://localhost:3001/api/v1/csrf/token", expect.objectContaining({ method: "GET", credentials: "include", }) ); }); it("should throw error when fetch fails", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Internal Server Error", json: () => Promise.resolve({ code: "SERVER_ERROR", message: "Failed to generate token", }), }); await expect(fetchCsrfToken()).rejects.toThrow("Failed to generate token"); }); it("should cache token in memory", async (): Promise => { const mockToken = "cached-token-xyz"; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: mockToken }), }); await fetchCsrfToken(); const cachedToken = getCsrfToken(); expect(cachedToken).toBe(mockToken); }); }); describe("CSRF token inclusion in requests", (): void => { it("should include X-CSRF-Token header in POST requests", async (): Promise => { const mockToken = "post-csrf-token"; // Mock token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: mockToken }), }); // Mock actual POST request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: { id: 1 } }), }); await apiPost("/test", { title: "Test Task" }); // Second call should include CSRF token expect(mockFetch).toHaveBeenCalledTimes(2); const postCall = mockFetch.mock.calls[1]![1] as RequestInit; const headers = postCall.headers as Record; expect(headers["X-CSRF-Token"]).toBe(mockToken); }); it("should include X-CSRF-Token header in PATCH requests", async (): Promise => { const mockToken = "patch-csrf-token"; // Mock token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: mockToken }), }); // Mock actual PATCH request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: { id: 1 } }), }); await apiPatch("/test/1", { title: "Updated Task" }); expect(mockFetch).toHaveBeenCalledTimes(2); const patchCall = mockFetch.mock.calls[1]![1] as RequestInit; const headers = patchCall.headers as Record; expect(headers["X-CSRF-Token"]).toBe(mockToken); }); it("should include X-CSRF-Token header in DELETE requests", async (): Promise => { const mockToken = "delete-csrf-token"; // Mock token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: mockToken }), }); // Mock actual DELETE request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }), }); await apiDelete("/test/1"); expect(mockFetch).toHaveBeenCalledTimes(2); const deleteCall = mockFetch.mock.calls[1]![1] as RequestInit; const headers = deleteCall.headers as Record; expect(headers["X-CSRF-Token"]).toBe(mockToken); }); it("should NOT include X-CSRF-Token header in GET requests", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: [] }), }); await apiGet("/test"); expect(mockFetch).toHaveBeenCalledTimes(1); const getCall = mockFetch.mock.calls[0]![1] as RequestInit; const headers = getCall.headers as Record; expect(headers["X-CSRF-Token"]).toBeUndefined(); }); }); describe("Automatic token refresh on 403 CSRF errors", (): void => { it("should refresh token and retry on 403 CSRF error", async (): Promise => { const oldToken = "old-token"; const newToken = "new-token"; // Initial token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: oldToken }), }); // First POST fails with CSRF error mockFetch.mockResolvedValueOnce({ ok: false, status: 403, json: () => Promise.resolve({ code: "CSRF_ERROR", message: "CSRF token mismatch", }), }); // Token refresh succeeds mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: newToken }), }); // Retry succeeds mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: { id: 1 } }), }); const result = await apiPost("/test", { title: "Test Task" }); expect(result).toEqual({ data: { id: 1 } }); expect(mockFetch).toHaveBeenCalledTimes(4); expect(getCsrfToken()).toBe(newToken); }); it("should throw error if retry also fails", async (): Promise => { const oldToken = "old-token"; const newToken = "new-token"; // Initial token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: oldToken }), }); // First POST fails with CSRF error mockFetch.mockResolvedValueOnce({ ok: false, status: 403, json: () => Promise.resolve({ code: "CSRF_ERROR", message: "CSRF token mismatch", }), }); // Token refresh succeeds mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: newToken }), }); // Retry also fails mockFetch.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ code: "UNAUTHORIZED", message: "Not authenticated", }), }); await expect(apiPost("/test", { title: "Test Task" })).rejects.toThrow("Not authenticated"); }); it("should not retry non-CSRF 403 errors", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: false, status: 403, json: () => Promise.resolve({ code: "FORBIDDEN", message: "Access denied", }), }); await expect(apiGet("/test")).rejects.toThrow("Access denied"); // Should not have retried expect(mockFetch).toHaveBeenCalledTimes(1); }); }); describe("Automatic token fetching", (): void => { it("should fetch token automatically on first state-changing request", async (): Promise => { const mockToken = "auto-fetched-token"; // Token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: mockToken }), }); // Actual request mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: { id: 1 } }), }); await apiPost("/test", { title: "Test Task" }); expect(mockFetch).toHaveBeenCalledTimes(2); expect(getCsrfToken()).toBe(mockToken); }); it("should reuse cached token for subsequent requests", async (): Promise => { const mockToken = "cached-token-reused"; // First request - token fetch mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ token: mockToken }), }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: { id: 1 } }), }); await apiPost("/test", { title: "First Task" }); // Second request - reuses cached token mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: { id: 2 } }), }); await apiPost("/test", { title: "Second Task" }); // Should only fetch token once expect(mockFetch).toHaveBeenCalledTimes(3); const firstPostCall = mockFetch.mock.calls[1]![1] as RequestInit; const secondPostCall = mockFetch.mock.calls[2]![1] as RequestInit; const headers1 = firstPostCall.headers as Record; const headers2 = secondPostCall.headers as Record; expect(headers1["X-CSRF-Token"]).toBe(mockToken); expect(headers2["X-CSRF-Token"]).toBe(mockToken); }); }); }); describe("Request timeout", (): void => { it("should export a default timeout constant of 30000ms", (): void => { expect(DEFAULT_API_TIMEOUT_MS).toBe(30_000); }); it("should pass an AbortController signal to fetch", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: "ok" }), }); await apiRequest("/test"); const callArgs = mockFetch.mock.calls[0]![1] as RequestInit; expect(callArgs.signal).toBeDefined(); expect(callArgs.signal).toBeInstanceOf(AbortSignal); }); it("should abort and throw timeout error when request exceeds timeoutMs", async (): Promise => { // Mock fetch that never resolves, simulating a hanging request mockFetch.mockImplementationOnce( (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { if (init.signal) { init.signal.addEventListener("abort", () => { reject(new DOMException("The operation was aborted.", "AbortError")); }); } }) ); await expect(apiRequest("/slow-endpoint", { timeoutMs: 50 })).rejects.toThrow( "Request to /slow-endpoint timed out after 50ms" ); }); it("should allow disabling timeout with timeoutMs=0", async (): Promise => { mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: "ok" }), }); const result = await apiRequest<{ data: string }>("/test", { timeoutMs: 0 }); expect(result).toEqual({ data: "ok" }); }); it("should clear timeout after successful request", async (): Promise => { const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ data: "ok" }), }); await apiRequest("/test"); expect(clearTimeoutSpy).toHaveBeenCalled(); clearTimeoutSpy.mockRestore(); }); it("should clear timeout after failed request", async (): Promise => { const clearTimeoutSpy = vi.spyOn(global, "clearTimeout"); mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Not Found", status: 404, json: () => Promise.resolve({ code: "NOT_FOUND", message: "Not found", }), }); await expect(apiRequest("/test")).rejects.toThrow("Not found"); expect(clearTimeoutSpy).toHaveBeenCalled(); clearTimeoutSpy.mockRestore(); }); }); });