@the_cfdude/productboard-mcp
Version:
Model Context Protocol server for Productboard REST API with dynamic tool loading
468 lines (467 loc) • 21.2 kB
JavaScript
/**
* @jest-environment node
*/
import { describe, it, expect, jest, beforeEach, afterEach, } from '@jest/globals';
import { createProductBoardApiClient } from '../utils/api-client.js';
import { AuthenticationError, NetworkError, RateLimitError, } from '../errors/index.js';
// Mock axios before importing it
import axios from 'axios';
jest.mock('axios');
// Mock debug logger
const mockDebugLog = jest.fn();
jest.mock('../utils/debug-logger.js', () => ({
debugLog: mockDebugLog,
}));
// Mock error utilities
const mockSanitizeErrorMessage = jest.fn((message) => message);
jest.mock('../errors/index.js', () => ({
AuthenticationError: class AuthenticationError extends Error {
constructor(message) {
super(message);
this.name = 'AuthenticationError';
}
},
NetworkError: class NetworkError extends Error {
constructor(message, _originalError) {
super(message);
this.name = 'NetworkError';
}
},
RateLimitError: class RateLimitError extends Error {
constructor() {
super('Rate limit exceeded');
this.name = 'RateLimitError';
}
},
sanitizeErrorMessage: mockSanitizeErrorMessage,
}));
describe('API Client', () => {
let mockAxiosInstance;
let mockConfig;
beforeEach(() => {
jest.clearAllMocks();
mockDebugLog.mockClear();
mockSanitizeErrorMessage.mockClear();
// Create mock axios instance
mockAxiosInstance = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
request: jest.fn(),
interceptors: {
request: {
use: jest.fn(),
},
response: {
use: jest.fn(),
},
},
};
const mockedAxios = axios;
mockedAxios.create = jest.fn().mockReturnValue(mockAxiosInstance);
// Default test configuration
mockConfig = {
apiToken: 'test-token-123',
baseUrl: 'https://api.productboard.com',
timeouts: {
request: 30000,
},
};
});
afterEach(() => {
jest.useRealTimers();
});
describe('createProductBoardApiClient', () => {
describe('Client Creation', () => {
it('should create axios instance with correct configuration', () => {
createProductBoardApiClient(mockConfig);
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://api.productboard.com',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: 'Bearer test-token-123',
'X-Version': '1',
},
});
});
it('should create client without auth headers when no token provided', () => {
const configWithoutToken = { ...mockConfig };
delete configWithoutToken.apiToken;
createProductBoardApiClient(configWithoutToken);
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://api.productboard.com',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
});
it('should use default timeout when not specified', () => {
const configWithoutTimeout = { ...mockConfig };
delete configWithoutTimeout.timeouts;
createProductBoardApiClient(configWithoutTimeout);
expect(axios.create).toHaveBeenCalledWith({
baseURL: 'https://api.productboard.com',
timeout: 30000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Authorization: 'Bearer test-token-123',
'X-Version': '1',
},
});
});
});
describe('Interceptors Setup', () => {
it('should register request interceptor', () => {
createProductBoardApiClient(mockConfig);
expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalledWith(expect.any(Function), expect.any(Function));
});
it('should register response interceptor', () => {
createProductBoardApiClient(mockConfig);
expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalledWith(expect.any(Function), expect.any(Function));
});
});
describe('API Interface', () => {
let client;
beforeEach(() => {
client = createProductBoardApiClient(mockConfig);
});
it('should expose all HTTP methods', () => {
expect(typeof client.get).toBe('function');
expect(typeof client.post).toBe('function');
expect(typeof client.put).toBe('function');
expect(typeof client.patch).toBe('function');
expect(typeof client.delete).toBe('function');
expect(typeof client.request).toBe('function');
});
it('should bind methods correctly', async () => {
const mockResponse = { data: { id: 1 } };
mockAxiosInstance.get.mockResolvedValue(mockResponse);
const result = await client.get('/test');
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/test');
expect(result).toBe(mockResponse);
});
});
describe('Request Interceptor', () => {
let requestInterceptor;
beforeEach(() => {
createProductBoardApiClient(mockConfig);
const [successHandler] = mockAxiosInstance.interceptors.request.use.mock.calls[0];
requestInterceptor = successHandler;
});
it('should log request details', async () => {
const requestConfig = {
method: 'get',
url: '/test',
baseURL: 'https://api.productboard.com',
timeout: 30000,
};
const result = requestInterceptor(requestConfig);
// Main behavior: interceptor should return the config unchanged
expect(result).toBe(requestConfig);
// Debug logging is tested separately if needed
});
it('should handle missing method gracefully', () => {
const requestConfig = {
url: '/test',
baseURL: 'https://api.productboard.com',
timeout: 30000,
};
expect(() => requestInterceptor(requestConfig)).not.toThrow();
});
});
describe('Response Interceptor - Success', () => {
let responseInterceptor;
beforeEach(() => {
createProductBoardApiClient(mockConfig);
const [successHandler] = mockAxiosInstance.interceptors.response.use.mock.calls[0];
responseInterceptor = successHandler;
});
it('should log successful responses', async () => {
const response = {
status: 200,
statusText: 'OK',
config: { url: '/test' },
headers: { 'x-response-time': '150ms' },
};
const result = responseInterceptor(response);
// Main behavior: interceptor should return response unchanged
expect(result).toBe(response);
expect(responseInterceptor(response)).toBe(response);
});
it('should handle missing response time header', async () => {
const response = {
status: 200,
statusText: 'OK',
config: { url: '/test' },
headers: {},
};
const result = responseInterceptor(response);
// Main behavior: interceptor should handle missing headers gracefully
expect(result).toBe(response);
});
});
describe('Response Interceptor - Error Handling', () => {
let errorInterceptor;
beforeEach(() => {
createProductBoardApiClient(mockConfig);
const [, errorHandler] = mockAxiosInstance.interceptors.response.use.mock.calls[0];
errorInterceptor = errorHandler;
});
it('should throw AuthenticationError for 401 responses', async () => {
const error = {
response: { status: 401, statusText: 'Unauthorized' },
config: { url: '/test' },
message: 'Unauthorized',
};
expect(() => errorInterceptor(error)).toThrow(AuthenticationError);
});
it('should throw RateLimitError for 429 responses', async () => {
const error = {
response: { status: 429, statusText: 'Too Many Requests' },
config: { url: '/test' },
message: 'Rate limited',
};
expect(() => errorInterceptor(error)).toThrow(RateLimitError);
});
it('should throw NetworkError for 5xx responses', async () => {
const error = {
response: { status: 500, statusText: 'Internal Server Error' },
config: { url: '/test' },
message: 'Server error',
};
expect(() => errorInterceptor(error)).toThrow(NetworkError);
});
it('should throw NetworkError for timeout errors', async () => {
const error = {
code: 'ECONNABORTED',
config: { url: '/test' },
message: 'timeout of 30000ms exceeded',
};
expect(() => errorInterceptor(error)).toThrow(NetworkError);
});
it('should throw NetworkError for connection errors', async () => {
const error = {
code: 'ENOTFOUND',
config: { url: '/test' },
message: 'getaddrinfo ENOTFOUND api.productboard.com',
};
expect(() => errorInterceptor(error)).toThrow(NetworkError);
});
it('should pass through other HTTP errors', async () => {
const error = {
response: { status: 400, statusText: 'Bad Request' },
config: { url: '/test' },
message: 'Bad request',
};
await expect(errorInterceptor(error)).rejects.toBe(error);
});
it('should pass through non-HTTP errors', async () => {
const error = new Error('Unknown error');
await expect(errorInterceptor(error)).rejects.toBe(error);
});
it('should log error details', async () => {
const error = {
response: {
status: 500,
statusText: 'Internal Server Error',
data: { message: 'Server error' },
},
config: { url: '/test' },
message: 'Server error',
};
try {
await errorInterceptor(error);
}
catch {
// Expected to throw - debug logging happens as side effect
}
});
});
});
describe.skip('createResilientApiClient', () => {
let resilientClient;
let baseClient;
beforeEach(async () => {
// Mock the base client creation
baseClient = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
patch: jest.fn(),
delete: jest.fn(),
request: jest.fn(),
};
// Mock createProductBoardApiClient directly in the module mock
jest.doMock('../utils/api-client.js', () => {
const actual = jest.requireActual('../utils/api-client.js');
return {
...actual,
createProductBoardApiClient: jest.fn(() => baseClient),
};
});
// Re-import after mocking
const { createResilientApiClient: createResilientApiClientMocked } = await import('../utils/api-client.js');
resilientClient = createResilientApiClientMocked(mockConfig);
jest.useFakeTimers();
});
afterEach(() => {
jest.dontMock('../utils/api-client.js');
jest.resetModules();
jest.clearAllMocks();
});
describe('Retry Logic', () => {
it('should succeed on first attempt', async () => {
const mockResponse = { data: { id: 1 } };
baseClient.get.mockResolvedValue(mockResponse);
const result = await resilientClient.get('/test');
expect(baseClient.get).toHaveBeenCalledTimes(1);
expect(result).toBe(mockResponse);
});
it('should retry on network errors', async () => {
const networkError = new NetworkError('Connection failed');
baseClient.get
.mockRejectedValueOnce(networkError)
.mockRejectedValueOnce(networkError)
.mockResolvedValue({ data: { id: 1 } });
const promise = resilientClient.get('/test');
// Fast-forward timers for retries
jest.advanceTimersByTime(1000); // First retry delay
await Promise.resolve(); // Allow promises to resolve
jest.advanceTimersByTime(2000); // Second retry delay
await Promise.resolve(); // Allow promises to resolve
const result = await promise;
expect(baseClient.get).toHaveBeenCalledTimes(3);
expect(result).toEqual({ data: { id: 1 } });
});
it('should not retry authentication errors', async () => {
const authError = new AuthenticationError('Invalid token');
baseClient.get.mockRejectedValue(authError);
await expect(resilientClient.get('/test')).rejects.toThrow(AuthenticationError);
expect(baseClient.get).toHaveBeenCalledTimes(1);
});
it('should not retry 4xx errors', async () => {
const clientError = { response: { status: 400 } };
baseClient.get.mockRejectedValue(clientError);
await expect(resilientClient.get('/test')).rejects.toBe(clientError);
expect(baseClient.get).toHaveBeenCalledTimes(1);
});
it('should retry 5xx errors', async () => {
const serverError = { response: { status: 500 } };
baseClient.get
.mockRejectedValueOnce(serverError)
.mockRejectedValueOnce(serverError)
.mockResolvedValue({ data: { id: 1 } });
const promise = resilientClient.get('/test');
jest.advanceTimersByTime(1000);
await Promise.resolve();
jest.advanceTimersByTime(2000);
await Promise.resolve();
await promise;
expect(baseClient.get).toHaveBeenCalledTimes(3);
});
it('should fail after max retries', async () => {
const networkError = new NetworkError('Connection failed');
baseClient.get.mockRejectedValue(networkError);
const promise = resilientClient.get('/test');
// Fast-forward through all retry attempts
jest.advanceTimersByTime(1000);
await Promise.resolve();
jest.advanceTimersByTime(2000);
await Promise.resolve();
jest.advanceTimersByTime(4000);
await Promise.resolve();
await expect(promise).rejects.toThrow(NetworkError);
expect(baseClient.get).toHaveBeenCalledTimes(3);
});
it('should use exponential backoff for retries', async () => {
const networkError = new NetworkError('Connection failed');
baseClient.get.mockRejectedValue(networkError);
const promise = resilientClient.get('/test');
// Check first retry delay (1000ms)
jest.advanceTimersByTime(999);
expect(baseClient.get).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(1);
await Promise.resolve();
expect(baseClient.get).toHaveBeenCalledTimes(2);
// Check second retry delay (2000ms)
jest.advanceTimersByTime(1999);
expect(baseClient.get).toHaveBeenCalledTimes(2);
jest.advanceTimersByTime(1);
await Promise.resolve();
expect(baseClient.get).toHaveBeenCalledTimes(3);
await expect(promise).rejects.toThrow(NetworkError);
});
it('should log retry attempts', async () => {
const networkError = new NetworkError('Connection failed');
baseClient.get.mockRejectedValue(networkError);
const promise = resilientClient.get('/test');
jest.advanceTimersByTime(1000);
await Promise.resolve();
// Debug logging of retry attempts happens as side effect
jest.advanceTimersByTime(2000);
await Promise.resolve();
jest.advanceTimersByTime(4000);
await Promise.resolve();
await expect(promise).rejects.toThrow();
});
});
describe('Method Coverage', () => {
it('should wrap all HTTP methods with retry logic', () => {
expect(typeof resilientClient.get).toBe('function');
expect(typeof resilientClient.post).toBe('function');
expect(typeof resilientClient.put).toBe('function');
expect(typeof resilientClient.patch).toBe('function');
expect(typeof resilientClient.delete).toBe('function');
expect(typeof resilientClient.request).toBe('function');
});
it('should pass through method arguments correctly', async () => {
baseClient.post.mockResolvedValue({ data: { id: 1 } });
const data = { name: 'test' };
const config = { headers: { 'Custom-Header': 'value' } };
await resilientClient.post('/test', data, config);
expect(baseClient.post).toHaveBeenCalledWith('/test', data, config);
});
});
});
describe('Edge Cases', () => {
it('should handle missing error response gracefully', async () => {
createProductBoardApiClient(mockConfig);
const [, errorHandler] = mockAxiosInstance.interceptors.response.use.mock.calls[0];
const error = {
message: 'Network Error',
code: 'NETWORK_ERROR',
};
// Should not throw during error handling
await expect(errorHandler(error)).rejects.toBe(error);
});
it('should handle malformed config objects', () => {
const malformedConfig = {};
expect(() => createProductBoardApiClient(malformedConfig)).not.toThrow();
expect(axios.create).toHaveBeenCalledWith({
baseURL: undefined,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
});
});
it('should handle empty error messages', async () => {
createProductBoardApiClient(mockConfig);
const [, errorHandler] = mockAxiosInstance.interceptors.response.use.mock.calls[0];
const error = {
response: { status: 500, statusText: 'Internal Server Error' },
config: { url: '/test' },
message: '',
};
expect(() => errorHandler(error)).toThrow(NetworkError);
});
});
});