UNPKG

@follow-app/client-sdk

Version:

TypeScript client SDK for Follow RSS Server API

683 lines (563 loc) 20.2 kB
import { beforeEach, describe, expect, it, vi } from "vitest" import { mockFetch } from "../test/setup" import { FollowAPIError, FollowAuthError, FollowTimeoutError, FollowValidationError, } from "../types/errors" import { HttpClient } from "./base" const createMockInterceptors = () => { return { processRequest: vi .fn() .mockImplementation((url, options) => ({ url, options })), processResponse: vi.fn().mockImplementation((response) => response), processError: vi.fn().mockImplementation(() => null), addRequestInterceptor: vi.fn(), addResponseInterceptor: vi.fn(), addErrorInterceptor: vi.fn(), } } // Mock the InterceptorManager at the top level const mockInterceptors = createMockInterceptors() vi.mock("./interceptors", () => ({ InterceptorManager: vi.fn().mockImplementation(() => mockInterceptors), })) describe("HttpClient", () => { let client: HttpClient beforeEach(() => { // Reset all mock functions vi.clearAllMocks() // Reset mockInterceptors to fresh state Object.values(mockInterceptors).forEach((mockFn) => { if (typeof mockFn === "function" && "mockReset" in mockFn) { mockFn.mockReset() } }) // Re-setup the default implementations mockInterceptors.processRequest.mockImplementation((url, options) => ({ url, options, })) mockInterceptors.processResponse.mockImplementation((response) => response) mockInterceptors.processError.mockImplementation(() => null) client = new HttpClient({ baseURL: "https://api.follow.is", timeout: 30000, headers: { "X-Test": "test" }, credentials: "include", }) }) describe("Constructor", () => { it("should initialize with default configuration", () => { const defaultClient = new HttpClient({ baseURL: "https://api.follow.is", }) const config = defaultClient.getConfig() expect(config.baseURL).toBe("https://api.follow.is") expect(config.timeout).toBe(30000) expect(config.credentials).toBe("include") expect(config.headers).toEqual({}) }) it("should initialize with custom configuration", () => { const customClient = new HttpClient({ baseURL: "https://custom.api.com", timeout: 60000, headers: { "Custom-Header": "value" }, credentials: "same-origin", }) const config = customClient.getConfig() expect(config.baseURL).toBe("https://custom.api.com") expect(config.timeout).toBe(60000) expect(config.headers).toEqual({ "Custom-Header": "value" }) expect(config.credentials).toBe("same-origin") }) it("should use custom fetch instance", () => { const customFetch = vi.fn() const customClient = new HttpClient({ baseURL: "https://api.follow.is", fetch: customFetch, }) expect(customClient.getConfig().fetch).toBe(customFetch) }) }) describe("URL Building", () => { it("should build URL without query parameters", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ code: 0, data: {} }), { status: 200, headers: { "content-type": "application/json" }, }), ) await client.get("/test") expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "GET", }), ) }) it("should build URL with query parameters", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ code: 0, data: {} }), { status: 200, headers: { "content-type": "application/json" }, }), ) await client.get("/test", { query: { param1: "value1", param2: 123, param3: true, // @ts-expect-error param4: undefined, // @ts-expect-error param5: null, }, }) expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test?param1=value1&param2=123&param3=true", expect.objectContaining({ method: "GET", }), ) }) }) describe("Request Methods", () => { it("should make GET request", async () => { const mockResponse = { code: 0, data: { id: 1, name: "test" } } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(mockResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) const result = await client.get("/test") expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "GET", headers: expect.objectContaining({ "Content-Type": "application/json", "X-Test": "test", }), }), ) expect(result).toEqual(mockResponse) }) it("should make POST request with body", async () => { const mockResponse = { code: 0, data: { id: 1, created: true } } const requestBody = { name: "test", value: 123 } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(mockResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) const result = await client.post("/test", requestBody) expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "POST", body: JSON.stringify(requestBody), headers: expect.objectContaining({ "Content-Type": "application/json", "X-Test": "test", }), }), ) expect(result).toEqual(mockResponse) }) it("should make PUT request", async () => { const mockResponse = { code: 0, data: { id: 1, updated: true } } const requestBody = { name: "updated", value: 456 } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(mockResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) const result = await client.put("/test", requestBody) expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "PUT", body: JSON.stringify(requestBody), }), ) expect(result).toEqual(mockResponse) }) it("should make PATCH request", async () => { const mockResponse = { code: 0, data: { id: 1, patched: true } } const requestBody = { value: 789 } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(mockResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) const result = await client.patch("/test", requestBody) expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "PATCH", body: JSON.stringify(requestBody), }), ) expect(result).toEqual(mockResponse) }) it("should make DELETE request", async () => { const mockResponse = { code: 0, data: { deleted: true } } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(mockResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) const result = await client.delete("/test") expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "DELETE", }), ) expect(result).toEqual(mockResponse) }) }) describe("Response Handling", () => { it("should handle successful JSON response", async () => { const mockResponse = { code: 0, data: { success: true } } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(mockResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) const result = await client.get("/test") expect(result).toEqual(mockResponse) }) it("should handle non-JSON response", async () => { const textResponse = "Plain text response" mockFetch.mockResolvedValueOnce( new Response(textResponse, { status: 200, headers: { "content-type": "text/plain" }, }), ) const result = await client.get("/test") expect(result).toBe(textResponse) }) it("should handle API response with non-zero code", async () => { const mockResponse = { code: 1001, message: "API Error", data: null } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(mockResponse), { status: 200, headers: { "content-type": "application/json" }, }), ) await expect(client.get("/test")).rejects.toThrow(FollowAPIError) }) }) describe("Error Handling", () => { it("should handle 401 authentication error", async () => { const errorResponse = { code: 401, message: "Unauthorized" } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(errorResponse), { status: 401, headers: { "content-type": "application/json" }, }), ) await expect(client.get("/test")).rejects.toThrow(FollowAuthError) }) it("should handle 400 validation error", async () => { const errorResponse = { code: 400, message: "Validation failed", data: [{ field: "name", message: "Required" }], } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(errorResponse), { status: 400, headers: { "content-type": "application/json" }, }), ) await expect(client.get("/test")).rejects.toThrow(FollowValidationError) }) it("should handle general API error", async () => { const errorResponse = { code: 500, message: "Internal Server Error" } mockFetch.mockResolvedValueOnce( new Response(JSON.stringify(errorResponse), { status: 500, headers: { "content-type": "application/json" }, }), ) await expect(client.get("/test")).rejects.toThrow(FollowAPIError) }) it("should handle non-JSON error response", async () => { mockFetch.mockResolvedValueOnce( new Response("Server Error", { status: 500, statusText: "Internal Server Error", }), ) await expect(client.get("/test")).rejects.toThrow(FollowAPIError) }) it("should handle network timeout", async () => { mockFetch.mockImplementation(() => { return new Promise((_, reject) => { setTimeout(() => { reject(new DOMException("Aborted", "AbortError")) }, 100) }) }) await expect(client.get("/test", { timeout: 50 })).rejects.toThrow( FollowTimeoutError, ) }) it("should handle custom signal abort", async () => { const controller = new AbortController() setTimeout(() => controller.abort(), 100) mockFetch.mockImplementation(() => { return new Promise((_, reject) => { setTimeout(() => { reject(new DOMException("Aborted", "AbortError")) }, 200) }) }) await expect( client.get("/test", { signal: controller.signal }), ).rejects.toThrow(FollowTimeoutError) }) }) describe("Interceptors Integration", () => { it("should process request interceptors", async () => { const modifiedUrl = "https://api.follow.is/modified" const modifiedOptions = { method: "POST", headers: { "X-Modified": "true" }, } mockInterceptors.processRequest.mockResolvedValueOnce({ url: modifiedUrl, options: modifiedOptions, }) mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ code: 0, data: {} }), { status: 200, headers: { "content-type": "application/json" }, }), ) await client.get("/test") expect(mockInterceptors.processRequest).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "GET" }), ) }) it("should process response interceptors", async () => { const originalResponse = new Response( JSON.stringify({ code: 0, data: {} }), { status: 200, headers: { "content-type": "application/json" }, }, ) mockFetch.mockResolvedValueOnce(originalResponse) await client.get("/test") expect(mockInterceptors.processResponse).toHaveBeenCalledWith( originalResponse, "https://api.follow.is/test", expect.objectContaining({ method: "GET" }), ) }) it("should process error interceptors", async () => { const error = new Error("Test error") mockFetch.mockRejectedValueOnce(error) await expect(client.get("/test")).rejects.toThrow(error) expect(mockInterceptors.processError).toHaveBeenCalledWith( error, null, "https://api.follow.is/test", expect.objectContaining({ method: "GET" }), ) }) it("should process error interceptors with response object", async () => { const error = new Error("Test error") mockFetch.mockRejectedValueOnce(error) // Mock processError to verify it receives the response parameter mockInterceptors.processError.mockImplementation((err, res) => { expect(res).toBeNull() // Should be null when error occurs before response return err }) await expect(client.get("/test")).rejects.toThrow(error) expect(mockInterceptors.processError).toHaveBeenCalledWith( error, null, "https://api.follow.is/test", expect.objectContaining({ method: "GET" }), ) }) it("should process error interceptors with response object when API returns error", async () => { const errorResponse = { code: 500, message: "Internal Server Error" } const response = new Response(JSON.stringify(errorResponse), { status: 500, headers: { "content-type": "application/json" }, }) mockFetch.mockResolvedValueOnce(response) // Mock processError to check if it would receive response in real scenario // Note: In current implementation, processError is called in catch block, // so response might not be available. This test documents the current behavior. await expect(client.get("/test")).rejects.toThrow(FollowAPIError) }) it("should handle interceptor-modified errors", async () => { const originalError = new Error("Original error") const modifiedError = new Error("Modified error") mockFetch.mockRejectedValueOnce(originalError) mockInterceptors.processError.mockResolvedValueOnce(modifiedError) await expect(client.get("/test")).rejects.toThrow(modifiedError) }) it("should suppress errors when interceptor returns undefined", async () => { const originalError = new Error("Original error") mockFetch.mockRejectedValueOnce(originalError) // Return undefined to suppress the error mockInterceptors.processError.mockResolvedValueOnce(void 0) // Should not throw since interceptor suppressed the error await expect(client.get("/test")).rejects.toThrow(originalError) }) }) describe("Configuration Management", () => { it("should update configuration", () => { const newConfig = { baseURL: "https://new.api.com", timeout: 60000, headers: { "X-New": "header" }, } client.setConfig(newConfig) const config = client.getConfig() expect(config.baseURL).toBe("https://new.api.com") expect(config.timeout).toBe(60000) // setConfig replaces headers entirely, not merges expect(config.headers).toEqual({ "X-New": "header" }) }) it("should set headers", () => { const newHeaders = { "X-Custom": "value", "Authorization": "Bearer token" } client.setHeaders(newHeaders) const config = client.getConfig() expect(config.headers).toEqual({ "X-Test": "test", "X-Custom": "value", "Authorization": "Bearer token", }) }) it("should set custom fetch instance", () => { const customFetch = vi.fn() client.setFetch(customFetch) const config = client.getConfig() expect(config.fetch).toBe(customFetch) }) it("should get interceptor manager", () => { const interceptors = client.getInterceptors() expect(interceptors).toBe(mockInterceptors) }) }) describe("Request Options", () => { it("should handle custom headers in request", async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ code: 0, data: {} }), { status: 200, headers: { "content-type": "application/json" }, }), ) await client.get("/test", { headers: { "X-Custom": "request-header" }, }) expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ headers: expect.objectContaining({ "X-Test": "test", "X-Custom": "request-header", }), }), ) }) it("should handle custom timeout in request", async () => { mockFetch.mockImplementation(() => { return new Promise((_, reject) => { // Simulate timeout by rejecting with AbortError after delay setTimeout(() => { reject(new DOMException("Aborted", "AbortError")) }, 100) }) }) await expect(client.get("/test", { timeout: 50 })).rejects.toThrow( FollowTimeoutError, ) }) it("should handle form data body", async () => { const formData = new FormData() formData.append("file", "test content") mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ code: 0, data: {} }), { status: 200, headers: { "content-type": "application/json" }, }), ) await client.request("/test", { method: "POST", body: formData, }) expect(mockFetch).toHaveBeenCalledWith( "https://api.follow.is/test", expect.objectContaining({ method: "POST", body: JSON.stringify(formData), }), ) }) }) describe("Edge Cases", () => { it("should handle empty response", async () => { mockFetch.mockResolvedValueOnce( new Response("", { status: 200, }), ) const result = await client.get("/test") expect(result).toBe("") }) it("should handle malformed JSON response", async () => { mockFetch.mockResolvedValueOnce( new Response("{ invalid json", { status: 200, headers: { "content-type": "application/json" }, }), ) await expect(client.get("/test")).rejects.toThrow() }) it("should handle response without content-type", async () => { mockFetch.mockResolvedValueOnce( new Response("Plain response", { status: 200, }), ) const result = await client.get("/test") expect(result).toBe("Plain response") }) it("should handle concurrent requests", async () => { // Create a new Response for each call to avoid "Body already read" error mockFetch.mockImplementation(() => Promise.resolve( new Response(JSON.stringify({ code: 0, data: {} }), { status: 200, headers: { "content-type": "application/json" }, }), ), ) const promises = Array.from({ length: 10 }, (_, i) => client.get(`/test${i}`)) const results = await Promise.all(promises) expect(results).toHaveLength(10) expect(mockFetch).toHaveBeenCalledTimes(10) }) }) })