@follow-app/client-sdk
Version:
TypeScript client SDK for Follow RSS Server API
879 lines (731 loc) • 26.6 kB
text/typescript
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()
})
})