UNPKG

@j03fr0st/pubg-ts

Version:

A comprehensive TypeScript wrapper for the PUBG API

417 lines (334 loc) 14.2 kB
import axios from 'axios'; import { HttpClient } from '../../src/api/http-client'; import { PubgApiError, PubgAuthenticationError, PubgNotFoundError, PubgRateLimitError, PubgValidationError, } from '../../src/errors'; import type { PubgClientConfig } from '../../src/types/api'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked<typeof axios>; describe('HttpClient', () => { let httpClient: HttpClient; let config: PubgClientConfig; let mockAxiosInstance: any; beforeEach(() => { mockAxiosInstance = { defaults: { headers: { common: {} } }, interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() }, }, get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn(), request: jest.fn(), }; mockedAxios.create.mockReturnValue(mockAxiosInstance); config = { apiKey: 'test-api-key', shard: 'pc-na', timeout: 5000, retryAttempts: 2, retryDelay: 500, }; httpClient = new HttpClient(config); }); afterEach(() => { jest.clearAllMocks(); }); describe('constructor', () => { it('should create axios instance with correct config', () => { expect(mockedAxios.create).toHaveBeenCalledWith({ baseURL: 'https://api.pubg.com', timeout: 5000, headers: { Authorization: 'Bearer test-api-key', Accept: 'application/vnd.api+json', 'Content-Type': 'application/json', }, }); }); it('should use custom base URL if provided', () => { const customConfig = { ...config, baseUrl: 'https://custom.api.com' }; new HttpClient(customConfig); expect(mockedAxios.create).toHaveBeenLastCalledWith({ baseURL: 'https://custom.api.com', timeout: 5000, headers: { Authorization: 'Bearer test-api-key', Accept: 'application/vnd.api+json', 'Content-Type': 'application/json', }, }); }); }); describe('HTTP methods', () => { it('should make GET request', async () => { const mockResponse = { data: { test: 'data' } }; mockAxiosInstance.get.mockResolvedValue(mockResponse); const result = await httpClient.get('/test'); expect(mockAxiosInstance.get).toHaveBeenCalledWith('/test', undefined); expect(result).toEqual({ test: 'data' }); }); it('should make POST request', async () => { const mockResponse = { data: { test: 'data' } }; const postData = { key: 'value' }; mockAxiosInstance.post.mockResolvedValue(mockResponse); const result = await httpClient.post('/test', postData); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/test', postData, undefined); expect(result).toEqual({ test: 'data' }); }); it('should make PUT request', async () => { const mockResponse = { data: { test: 'data' } }; const putData = { key: 'value' }; mockAxiosInstance.put.mockResolvedValue(mockResponse); const result = await httpClient.put('/test', putData); expect(mockAxiosInstance.put).toHaveBeenCalledWith('/test', putData, undefined); expect(result).toEqual({ test: 'data' }); }); it('should make DELETE request', async () => { const mockResponse = { data: { test: 'data' } }; mockAxiosInstance.delete.mockResolvedValue(mockResponse); const result = await httpClient.delete('/test'); expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/test', undefined); expect(result).toEqual({ test: 'data' }); }); }); describe('caching', () => { beforeEach(() => { // Reset cache for each test httpClient.clearCache(); }); it('should cache successful GET responses', async () => { const mockResponse = { data: { test: 'cached-data' }, status: 200 }; mockAxiosInstance.get.mockResolvedValue(mockResponse); // First request should hit the API const result1 = await httpClient.get('/test'); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); expect(result1).toEqual({ test: 'cached-data' }); // Second request should use cache const result2 = await httpClient.get('/test'); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); // Still only 1 call expect(result2).toEqual({ test: 'cached-data' }); }); it('should not cache when useCache is false', async () => { const mockResponse = { data: { test: 'no-cache' }, status: 200 }; mockAxiosInstance.get.mockResolvedValue(mockResponse); // First request with cache disabled await httpClient.get('/test', { useCache: false }); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); // Second request should also hit API await httpClient.get('/test', { useCache: false }); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2); }); it('should not cache non-200 responses', async () => { const mockResponse = { data: { test: 'error-data' }, status: 400 }; mockAxiosInstance.get.mockResolvedValue(mockResponse); await httpClient.get('/test'); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1); // Second request should hit API again await httpClient.get('/test'); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2); }); it('should generate different cache keys for different URLs and params', async () => { const mockResponse1 = { data: { test: 'data1' }, status: 200 }; const mockResponse2 = { data: { test: 'data2' }, status: 200 }; mockAxiosInstance.get .mockResolvedValueOnce(mockResponse1) .mockResolvedValueOnce(mockResponse2); await httpClient.get('/test1'); await httpClient.get('/test2'); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2); // Requests to same URLs should use cache await httpClient.get('/test1'); await httpClient.get('/test2'); expect(mockAxiosInstance.get).toHaveBeenCalledTimes(2); // Still only 2 calls }); }); describe('error handling', () => { let responseInterceptorError: any; beforeEach(() => { // Get the response error handler from the interceptor setup const responseInterceptorCall = mockAxiosInstance.interceptors.response.use.mock.calls[0]; responseInterceptorError = responseInterceptorCall[1]; // Error handler is the second argument }); it('should throw PubgAuthenticationError for 401 status', async () => { const error = { response: { status: 401, data: { errors: [{ detail: 'Invalid API key' }] }, }, }; await expect(responseInterceptorError(error)).rejects.toThrow(PubgAuthenticationError); await expect(responseInterceptorError(error)).rejects.toThrow('Invalid API key'); }); it('should throw PubgNotFoundError for 404 status', async () => { const error = { response: { status: 404, data: { errors: [{ detail: 'Resource not found' }] }, }, }; await expect(responseInterceptorError(error)).rejects.toThrow(PubgNotFoundError); await expect(responseInterceptorError(error)).rejects.toThrow('Resource not found'); }); it('should throw PubgValidationError for 400 status', async () => { const error = { response: { status: 400, data: { errors: [{ detail: 'Invalid request parameters' }] }, }, }; await expect(responseInterceptorError(error)).rejects.toThrow(PubgValidationError); await expect(responseInterceptorError(error)).rejects.toThrow('Invalid request parameters'); }); it('should throw PubgRateLimitError for 429 status', async () => { const error = { response: { status: 429, headers: { 'retry-after': '120' }, data: { errors: [{ detail: 'Rate limit exceeded' }] }, }, }; await expect(responseInterceptorError(error)).rejects.toThrow(PubgRateLimitError); await expect(responseInterceptorError(error)).rejects.toThrow('Rate limit exceeded'); }); it('should use default retry-after value when header is missing', async () => { const error = { response: { status: 429, headers: {}, data: { errors: [{ detail: 'Rate limit exceeded' }] }, }, }; try { await responseInterceptorError(error); } catch (e) { expect(e).toBeInstanceOf(PubgRateLimitError); expect((e as PubgRateLimitError).retryAfter).toBe(60); } }); it('should use error message when no detail is provided', async () => { const error = { response: { status: 400, data: {}, }, message: 'Network error', }; await expect(responseInterceptorError(error)).rejects.toThrow('Network error'); }); it('should throw PubgApiError for other status codes', async () => { const error = { response: { status: 422, data: { errors: [{ detail: 'Unprocessable entity' }] }, }, }; await expect(responseInterceptorError(error)).rejects.toThrow(PubgApiError); await expect(responseInterceptorError(error)).rejects.toThrow('Unprocessable entity'); }); }); describe('retry logic', () => { let responseInterceptorError: any; beforeEach(() => { // Get the response error handler from the interceptor setup const responseInterceptorCall = mockAxiosInstance.interceptors.response.use.mock.calls[0]; responseInterceptorError = responseInterceptorCall[1]; // Error handler is the second argument }); it('should retry server errors when retryAttempts is configured', async () => { const serverError = { response: { status: 500 }, message: 'Internal server error', config: { url: '/test' }, }; // Mock retry request to succeed mockAxiosInstance.request.mockResolvedValueOnce({ data: { success: true } }); const result = await responseInterceptorError(serverError); expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); expect(result).toEqual({ data: { success: true } }); }); it('should retry for 502, 503, and 504 errors', async () => { const serverErrors = [502, 503, 504]; for (const status of serverErrors) { jest.clearAllMocks(); const error = { response: { status }, message: `Server error ${status}`, config: { url: '/test' }, }; mockAxiosInstance.request.mockResolvedValueOnce({ data: { success: true } }); await responseInterceptorError(error); expect(mockAxiosInstance.request).toHaveBeenCalledTimes(1); } }); it('should not retry when retryAttempts is 0', async () => { // Clear mock calls to get clean state jest.clearAllMocks(); const noRetryConfig = { ...config, retryAttempts: 0 }; const _noRetryClient = new HttpClient(noRetryConfig); // Get the error handler for the no-retry client (should be the first call after clearing) const noRetryErrorHandler = mockAxiosInstance.interceptors.response.use.mock.calls[0][1]; const serverError = { response: { status: 500 }, message: 'Internal server error', }; await expect(noRetryErrorHandler(serverError)).rejects.toThrow(PubgApiError); await expect(noRetryErrorHandler(serverError)).rejects.toThrow( 'Server error: Internal server error' ); }); it('should respect retry configuration', () => { // Test that different retry configurations create different clients const client1 = new HttpClient({ ...config, retryAttempts: 0 }); const client2 = new HttpClient({ ...config, retryAttempts: 3 }); // Both should have created axios instances with interceptors expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled(); expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled(); // This test verifies that the retry configuration is properly set up expect(client1).toBeDefined(); expect(client2).toBeDefined(); }); }); describe('utility methods', () => { it('should return cache stats', () => { const stats = httpClient.getCacheStats(); expect(stats).toHaveProperty('size'); expect(stats).toHaveProperty('maxSize'); expect(stats).toHaveProperty('defaultTtl'); expect(typeof stats.size).toBe('number'); expect(typeof stats.maxSize).toBe('number'); expect(typeof stats.defaultTtl).toBe('number'); }); it('should clear cache', () => { httpClient.clearCache(); const stats = httpClient.getCacheStats(); expect(stats.size).toBe(0); }); it('should return rate limit status', () => { const status = httpClient.getRateLimitStatus(); expect(status).toHaveProperty('remaining'); expect(status).toHaveProperty('resetTime'); expect(typeof status.remaining).toBe('number'); expect(typeof status.resetTime).toBe('number'); }); }); describe('interceptors setup', () => { it('should setup request and response interceptors', () => { // Verify interceptors were registered during construction expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalledTimes(1); expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalledTimes(1); // Verify the interceptor functions are properly configured const requestInterceptor = mockAxiosInstance.interceptors.request.use.mock.calls[0]; const responseInterceptor = mockAxiosInstance.interceptors.response.use.mock.calls[0]; expect(typeof requestInterceptor[0]).toBe('function'); // success handler expect(typeof requestInterceptor[1]).toBe('function'); // error handler expect(typeof responseInterceptor[0]).toBe('function'); // success handler expect(typeof responseInterceptor[1]).toBe('function'); // error handler }); }); });