UNPKG

@follow-app/client-sdk

Version:

TypeScript client SDK for Follow RSS Server API

879 lines (731 loc) 26.6 kB
import { beforeEach, describe, expect, it, vi } from "vitest" import type { RequestOptions } from "../types" import type { ErrorInterceptor, RequestInterceptor, ResponseInterceptor, } from "./interceptors" import { commonInterceptors, InterceptorManager } from "./interceptors" describe("InterceptorManager", () => { let manager: InterceptorManager beforeEach(() => { manager = new InterceptorManager() }) describe("Request Interceptors", () => { it("should add and execute request interceptor", async () => { const interceptor: RequestInterceptor = vi.fn().mockReturnValue({ url: "https://modified.com", options: { method: "POST" }, }) manager.addRequestInterceptor(interceptor) const result = await manager.processRequest("https://original.com", { method: "GET", }) expect(interceptor).toHaveBeenCalledWith({ url: "https://original.com", options: { method: "GET" }, }) expect(result).toEqual({ url: "https://modified.com", options: { method: "POST" }, }) }) it("should execute multiple request interceptors in order", async () => { const interceptor1: RequestInterceptor = vi.fn().mockReturnValue({ url: "https://step1.com", options: { method: "POST" }, }) const interceptor2: RequestInterceptor = vi.fn().mockReturnValue({ url: "https://step2.com", options: { method: "PUT" }, }) manager.addRequestInterceptor(interceptor1) manager.addRequestInterceptor(interceptor2) const result = await manager.processRequest("https://original.com", { method: "GET", }) expect(interceptor1).toHaveBeenCalledWith({ url: "https://original.com", options: { method: "GET" }, }) expect(interceptor2).toHaveBeenCalledWith({ url: "https://step1.com", options: { method: "POST" }, }) expect(result).toEqual({ url: "https://step2.com", options: { method: "PUT" }, }) }) it("should remove request interceptor", async () => { const interceptor: RequestInterceptor = vi.fn().mockReturnValue({ url: "https://modified.com", options: { method: "POST" }, }) const remove = manager.addRequestInterceptor(interceptor) remove() const result = await manager.processRequest("https://original.com", { method: "GET", }) expect(interceptor).not.toHaveBeenCalled() expect(result).toEqual({ url: "https://original.com", options: { method: "GET" }, }) }) it("should handle async request interceptors", async () => { const interceptor: RequestInterceptor = vi.fn().mockResolvedValue({ url: "https://async.com", options: { method: "PATCH" }, }) manager.addRequestInterceptor(interceptor) const result = await manager.processRequest("https://original.com", { method: "GET", }) expect(result).toEqual({ url: "https://async.com", options: { method: "PATCH" }, }) }) it("should handle interceptor that returns undefined", async () => { // @ts-expect-error const interceptor: RequestInterceptor = vi.fn().mockReturnValue() manager.addRequestInterceptor(interceptor) const result = await manager.processRequest("https://original.com", { method: "GET", }) expect(result).toEqual({ url: "https://original.com", options: { method: "GET" }, }) }) }) describe("Response Interceptors", () => { it("should add and execute response interceptor", async () => { const originalResponse = new Response("original") const modifiedResponse = new Response("modified") const interceptor: ResponseInterceptor = vi .fn() .mockReturnValue(modifiedResponse) manager.addResponseInterceptor(interceptor) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(interceptor).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: originalResponse, }) expect(result).toBe(modifiedResponse) }) it("should execute multiple response interceptors in order", async () => { const originalResponse = new Response("original") const response1 = new Response("step1") const response2 = new Response("step2") const interceptor1: ResponseInterceptor = vi .fn() .mockReturnValue(response1) const interceptor2: ResponseInterceptor = vi .fn() .mockReturnValue(response2) manager.addResponseInterceptor(interceptor1) manager.addResponseInterceptor(interceptor2) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(interceptor1).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: originalResponse, }) expect(interceptor2).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: response1, }) expect(result).toBe(response2) }) it("should remove response interceptor", async () => { const originalResponse = new Response("original") const interceptor: ResponseInterceptor = vi .fn() .mockReturnValue(new Response("modified")) const remove = manager.addResponseInterceptor(interceptor) remove() const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(interceptor).not.toHaveBeenCalled() expect(result).toBe(originalResponse) }) it("should handle async response interceptors", async () => { const originalResponse = new Response("original") const asyncResponse = new Response("async") const interceptor: ResponseInterceptor = vi .fn() .mockResolvedValue(asyncResponse) manager.addResponseInterceptor(interceptor) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(result).toBe(asyncResponse) }) it("should not modify response when interceptor returns undefined", async () => { const originalResponse = new Response("original") const interceptor: ResponseInterceptor = vi.fn().mockReturnValue(void 0) manager.addResponseInterceptor(interceptor) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(interceptor).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: originalResponse, }) expect(result).toBe(originalResponse) }) it("should not modify response when interceptor returns non-Response object", async () => { const originalResponse = new Response("original") const interceptor = vi.fn().mockReturnValue({ not: "a response" }) as ResponseInterceptor manager.addResponseInterceptor(interceptor) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(result).toBe(originalResponse) }) it("should not modify response when interceptor returns null", async () => { const originalResponse = new Response("original") const interceptor = vi.fn().mockReturnValue(null) as ResponseInterceptor manager.addResponseInterceptor(interceptor) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(result).toBe(originalResponse) }) it("should handle mixed interceptors with some returning undefined", async () => { const originalResponse = new Response("original") const modifiedResponse = new Response("modified") const interceptor1: ResponseInterceptor = vi.fn().mockReturnValue(void 0) const interceptor2: ResponseInterceptor = vi.fn().mockReturnValue(modifiedResponse) const interceptor3: ResponseInterceptor = vi.fn().mockReturnValue(void 0) manager.addResponseInterceptor(interceptor1) manager.addResponseInterceptor(interceptor2) manager.addResponseInterceptor(interceptor3) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(interceptor1).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: originalResponse, }) expect(interceptor2).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: originalResponse, // Should still get original since interceptor1 returned undefined }) expect(interceptor3).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: modifiedResponse, // Should get modified response from interceptor2 }) expect(result).toBe(modifiedResponse) }) it("should properly handle instanceof Response check with async interceptors", async () => { const originalResponse = new Response("original") const modifiedResponse = new Response("modified") const interceptor1: ResponseInterceptor = vi.fn().mockResolvedValue(void 0) const interceptor2: ResponseInterceptor = vi.fn().mockResolvedValue(modifiedResponse) manager.addResponseInterceptor(interceptor1) manager.addResponseInterceptor(interceptor2) const result = await manager.processResponse( originalResponse, "https://test.com", { method: "GET" }, ) expect(result).toBe(modifiedResponse) }) }) describe("Error Interceptors", () => { it("should add and execute error interceptor", async () => { const originalError = new Error("original") const modifiedError = new Error("modified") const interceptor: ErrorInterceptor = vi .fn() .mockReturnValue(modifiedError) manager.addErrorInterceptor(interceptor) const result = await manager.processError( originalError, null, "https://test.com", { method: "GET" }, ) expect(interceptor).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: null, error: originalError, }) expect(result).toBe(modifiedError) }) it("should add and execute error interceptor with response parameter", async () => { const originalError = new Error("original") const modifiedError = new Error("modified") const mockResponse = new Response("error response", { status: 500 }) const interceptor: ErrorInterceptor = vi .fn() .mockReturnValue(modifiedError) manager.addErrorInterceptor(interceptor) const result = await manager.processError( originalError, mockResponse, "https://test.com", { method: "GET" }, ) expect(interceptor).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: mockResponse, error: originalError, }) expect(result).toBe(modifiedError) }) it("should handle null response parameter", async () => { const originalError = new Error("original") const interceptor: ErrorInterceptor = vi.fn().mockReturnValue(originalError) manager.addErrorInterceptor(interceptor) const result = await manager.processError( originalError, null, "https://test.com", { method: "GET" }, ) expect(interceptor).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: null, error: originalError, }) expect(result).toBe(originalError) }) it("should execute multiple error interceptors in order", async () => { const originalError = new Error("original") const error1 = new Error("step1") const error2 = new Error("step2") const interceptor1: ErrorInterceptor = vi.fn().mockReturnValue(error1) const interceptor2: ErrorInterceptor = vi.fn().mockReturnValue(error2) manager.addErrorInterceptor(interceptor1) manager.addErrorInterceptor(interceptor2) const result = await manager.processError( originalError, null, "https://test.com", { method: "GET" }, ) expect(interceptor1).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: null, error: originalError, }) expect(interceptor2).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: null, error: error1, }) expect(result).toBe(error2) }) it("should remove error interceptor", async () => { const originalError = new Error("original") const interceptor: ErrorInterceptor = vi .fn() .mockReturnValue(new Error("modified")) const remove = manager.addErrorInterceptor(interceptor) remove() const result = await manager.processError( originalError, null, "https://test.com", { method: "GET" }, ) expect(interceptor).not.toHaveBeenCalled() expect(result).toBe(originalError) }) it("should handle error interceptor that returns undefined", async () => { const originalError = new Error("original") const mockResponse = new Response("error response", { status: 500 }) // @ts-expect-error const interceptor: ErrorInterceptor = vi.fn().mockReturnValue() manager.addErrorInterceptor(interceptor) const result = await manager.processError( originalError, mockResponse, "https://test.com", { method: "GET" }, ) expect(interceptor).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: mockResponse, error: originalError, }) expect(result).toBeUndefined() }) it("should handle async error interceptors with response", async () => { const originalError = new Error("original") const asyncError = new Error("async") const mockResponse = new Response("error response", { status: 503 }) const interceptor: ErrorInterceptor = vi .fn() .mockResolvedValue(asyncError) manager.addErrorInterceptor(interceptor) const result = await manager.processError( originalError, mockResponse, "https://test.com", { method: "GET" }, ) expect(interceptor).toHaveBeenCalledWith({ url: "https://test.com", options: { method: "GET" }, response: mockResponse, error: originalError, }) expect(result).toBe(asyncError) }) }) describe("Clear Interceptors", () => { it("should clear all interceptors", async () => { const requestInterceptor: RequestInterceptor = vi.fn().mockReturnValue({ url: "https://modified.com", options: { method: "POST" }, }) const responseInterceptor: ResponseInterceptor = vi .fn() .mockReturnValue(new Response("modified")) const errorInterceptor: ErrorInterceptor = vi .fn() .mockReturnValue(new Error("modified")) manager.addRequestInterceptor(requestInterceptor) manager.addResponseInterceptor(responseInterceptor) manager.addErrorInterceptor(errorInterceptor) manager.clear() // Test that interceptors are no longer called const requestResult = await manager.processRequest("https://test.com", { method: "GET", }) const responseResult = await manager.processResponse( new Response("original"), "https://test.com", { method: "GET" }, ) const errorResult = await manager.processError( new Error("original"), null, "https://test.com", { method: "GET" }, ) expect(requestInterceptor).not.toHaveBeenCalled() expect(responseInterceptor).not.toHaveBeenCalled() expect(errorInterceptor).not.toHaveBeenCalled() expect(requestResult).toEqual({ url: "https://test.com", options: { method: "GET" }, }) expect(responseResult).toBeInstanceOf(Response) expect(errorResult).toBeInstanceOf(Error) }) }) }) describe("Common Interceptors", () => { describe("addAuthToken", () => { it("should add authorization header to request", () => { const interceptor = commonInterceptors.addAuthToken("test-token") const options: RequestOptions = { method: "GET", headers: { "X-Custom": "value" }, } const result = interceptor({ url: "https://test.com", options }) expect(result).toEqual({ url: "https://test.com", options: { method: "GET", headers: { "X-Custom": "value", "Authorization": "Bearer test-token", }, }, }) }) it("should handle request without existing headers", () => { const interceptor = commonInterceptors.addAuthToken("test-token") const options: RequestOptions = { method: "GET" } const result = interceptor({ url: "https://test.com", options }) expect(result).toEqual({ url: "https://test.com", options: { method: "GET", headers: { Authorization: "Bearer test-token", }, }, }) }) }) describe("logRequests", () => { it("should log request details", () => { const mockLogger = { log: vi.fn() } const interceptor = commonInterceptors.logRequests(mockLogger) const result = interceptor({ url: "https://test.com", options: { method: "POST" } }) expect(mockLogger.log).toHaveBeenCalledWith( "Request: POST https://test.com", ) expect(result).toEqual({ url: "https://test.com", options: { method: "POST" }, }) }) it("should default to GET method in logs", () => { const mockLogger = { log: vi.fn() } const interceptor = commonInterceptors.logRequests(mockLogger) interceptor({ url: "https://test.com", options: {} }) expect(mockLogger.log).toHaveBeenCalledWith( "Request: GET https://test.com", ) }) }) describe("logResponses", () => { it("should log response details", () => { const mockLogger = { log: vi.fn() } const interceptor = commonInterceptors.logResponses(mockLogger) const response = new Response("test", { status: 200 }) const result = interceptor({ url: "https://test.com", options: { method: "POST" }, response, }) expect(mockLogger.log).toHaveBeenCalledWith( "Response: 200 POST https://test.com", ) expect(result).toBe(response) }) it("should default to GET method in logs", () => { const mockLogger = { log: vi.fn() } const interceptor = commonInterceptors.logResponses(mockLogger) const response = new Response("test", { status: 404 }) interceptor({ url: "https://test.com", options: {}, response, }) expect(mockLogger.log).toHaveBeenCalledWith( "Response: 404 GET https://test.com", ) }) }) describe("retryOnError", () => { it("should retry on error up to max retries", async () => { const interceptor = commonInterceptors.retryOnError(2, 10) const error = new Error("test error") // First call - should retry (return undefined) const result1 = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error, }) expect(result1).toBeUndefined() // Second call - should retry (return undefined) const result2 = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error, }) expect(result2).toBeUndefined() // Third call - should return error (max retries exceeded) const result3 = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error, }) expect(result3).toBe(error) }) it("should use default retry settings", async () => { // Use shorter delay to avoid test timeout const interceptor = commonInterceptors.retryOnError(3, 10) const error = new Error("test error") // Should retry 3 times by default for (let i = 0; i < 3; i++) { const result = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error, }) expect(result).toBeUndefined() } // Fourth call should return error const result = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error, }) expect(result).toBe(error) }) it("should handle different errors independently", async () => { const interceptor = commonInterceptors.retryOnError(1, 10) const error1 = new Error("error 1") const error2 = new Error("error 2") // First error - should retry const result1 = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error: error1, }) expect(result1).toBeUndefined() // Second error - should retry (independent counter) const result2 = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error: error2, }) expect(result2).toBeUndefined() // First error again - should return error (max retries exceeded) const result3 = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error: error1, }) expect(result3).toBe(error1) // Second error again - should return error (max retries exceeded) const result4 = await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error: error2, }) expect(result4).toBe(error2) }) it("should wait before retry with exponential backoff", async () => { const interceptor = commonInterceptors.retryOnError(2, 100) const error = new Error("test error") const start = Date.now() // First retry await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error, }) const firstRetryTime = Date.now() - start // Second retry await interceptor({ url: "https://test.com", options: { method: "GET" }, response: null, error, }) const secondRetryTime = Date.now() - start // Should have waited at least 100ms for first retry and 200ms for second expect(firstRetryTime).toBeGreaterThanOrEqual(90) expect(secondRetryTime).toBeGreaterThanOrEqual(290) // 100ms + 200ms }) }) }) describe("Interceptor Integration", () => { it("should work with all interceptor types together", async () => { const manager = new InterceptorManager() const mockLogger = { log: vi.fn() } // Add interceptors manager.addRequestInterceptor(commonInterceptors.addAuthToken("token")) manager.addRequestInterceptor(commonInterceptors.logRequests(mockLogger)) manager.addResponseInterceptor(commonInterceptors.logResponses(mockLogger)) manager.addErrorInterceptor(commonInterceptors.retryOnError(1, 10)) // Test request processing const requestResult = await manager.processRequest("https://test.com", { method: "POST", }) expect(requestResult.options.headers).toEqual({ Authorization: "Bearer token", }) expect(mockLogger.log).toHaveBeenCalledWith( "Request: POST https://test.com", ) // Test response processing const response = new Response("test", { status: 200 }) const responseResult = await manager.processResponse( response, "https://test.com", { method: "POST" }, ) expect(responseResult).toBe(response) expect(mockLogger.log).toHaveBeenCalledWith( "Response: 200 POST https://test.com", ) // Test error processing const error = new Error("test error") const errorResult = await manager.processError(error, null, "https://test.com", { method: "POST", }) expect(errorResult).toBeUndefined() // Should retry }) it("should handle interceptor removal in complex scenarios", async () => { const manager = new InterceptorManager() const mockLogger = { log: vi.fn() } const removeAuth = manager.addRequestInterceptor( commonInterceptors.addAuthToken("token"), ) const removeLog = manager.addRequestInterceptor( commonInterceptors.logRequests(mockLogger), ) // Test with all interceptors let result = await manager.processRequest("https://test.com", { method: "GET", }) expect(result.options.headers).toEqual({ Authorization: "Bearer token" }) expect(mockLogger.log).toHaveBeenCalledWith( "Request: GET https://test.com", ) // Remove auth interceptor removeAuth() mockLogger.log.mockClear() result = await manager.processRequest("https://test.com", { method: "GET", }) expect(result.options.headers).toBeUndefined() expect(mockLogger.log).toHaveBeenCalledWith( "Request: GET https://test.com", ) // Remove log interceptor removeLog() mockLogger.log.mockClear() result = await manager.processRequest("https://test.com", { method: "GET", }) expect(result.options.headers).toBeUndefined() expect(mockLogger.log).not.toHaveBeenCalled() }) })