import axios from 'axios'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createApiClient } from './create-api-client'; import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'; // Type for mocked axios interface MockedAxios { create: ReturnType; } // Mock axios with factory function (required for bun compatibility) vi.mock('axios', () => ({ default: { create: vi.fn(), }, })); // Cast to our mocked type const mockedAxios = axios as unknown as MockedAxios; // Mock environment utils vi.mock('./utils/env', () => ({ getApiUrl: vi.fn(() => 'http://localhost:4000/api'), isDevelopment: vi.fn(() => true), })); describe('createApiClient', () => { let mockAxiosInstance: any; let requestInterceptor: (config: InternalAxiosRequestConfig) => Promise | InternalAxiosRequestConfig; let responseInterceptor: (response: AxiosResponse) => AxiosResponse; let responseErrorInterceptor: (error: any) => Promise; beforeEach(() => { // Reset mocks vi.clearAllMocks(); // Setup localStorage spies (localStorage is provided by test-setup.ts) vi.spyOn(localStorage, 'getItem'); vi.spyOn(localStorage, 'setItem'); vi.spyOn(localStorage, 'removeItem'); vi.spyOn(localStorage, 'clear'); // Setup axios instance mock mockAxiosInstance = { interceptors: { request: { use: vi.fn((success, _error) => { requestInterceptor = success; return 0; }), }, response: { use: vi.fn((success, error) => { responseInterceptor = success; responseErrorInterceptor = error; return 0; }), }, }, get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), }; mockedAxios.create.mockReturnValue(mockAxiosInstance); }); afterEach(() => { vi.restoreAllMocks(); }); describe('Client Creation', () => { it('should create axios instance with default config', () => { createApiClient(); expect(mockedAxios.create).toHaveBeenCalledWith({ baseURL: 'http://localhost:4000/api', timeout: 10000, headers: { 'Content-Type': 'application/json' }, }); }); it('should create axios instance with custom config', () => { createApiClient({ baseURL: 'https://api.example.com', timeout: 5000, headers: { 'Content-Type': 'application/xml' }, }); expect(mockedAxios.create).toHaveBeenCalledWith({ baseURL: 'https://api.example.com', timeout: 5000, headers: { 'Content-Type': 'application/xml' }, }); }); it('should register request and response interceptors', () => { createApiClient(); expect(mockAxiosInstance.interceptors.request.use).toHaveBeenCalled(); expect(mockAxiosInstance.interceptors.response.use).toHaveBeenCalled(); }); }); describe('Request Interceptor - Auth Token Injection', () => { it('should inject auth token from localStorage', async () => { (localStorage.getItem as ReturnType).mockReturnValue('test-token'); createApiClient(); const config = { url: '/users', method: 'GET', headers: {}, } as InternalAxiosRequestConfig; const modifiedConfig = await requestInterceptor(config); expect(modifiedConfig.headers.Authorization).toBe('Bearer test-token'); }); it('should not inject auth token if not in localStorage', async () => { (localStorage.getItem as ReturnType).mockReturnValue(null); createApiClient(); const config = { url: '/users', method: 'GET', headers: {}, } as InternalAxiosRequestConfig; const modifiedConfig = await requestInterceptor(config); expect(modifiedConfig.headers.Authorization).toBeUndefined(); }); it('should use custom tokenStorageKey', async () => { (localStorage.getItem as ReturnType).mockImplementation((key) => { if (key === 'custom_token') {return 'custom-token-value';} return null; }); createApiClient({ tokenStorageKey: 'custom_token' }); const config = { url: '/users', method: 'GET', headers: {}, } as InternalAxiosRequestConfig; const modifiedConfig = await requestInterceptor(config); expect(localStorage.getItem).toHaveBeenCalledWith('custom_token'); expect(modifiedConfig.headers.Authorization).toBe('Bearer custom-token-value'); }); }); describe('Request Interceptor - Logging', () => { it('should log requests when enableLogging is true in development', async () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); createApiClient({ enableLogging: true }); const config = { url: '/users', method: 'GET', headers: {}, params: { page: 1 }, } as InternalAxiosRequestConfig; await requestInterceptor(config); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('[API Request] GET /users'), expect.any(Object) ); consoleLogSpy.mockRestore(); }); it('should not log requests when enableLogging is false', async () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); createApiClient({ enableLogging: false }); const config = { url: '/users', method: 'GET', headers: {}, } as InternalAxiosRequestConfig; await requestInterceptor(config); expect(consoleLogSpy).not.toHaveBeenCalled(); consoleLogSpy.mockRestore(); }); }); describe('Response Interceptor - Logging', () => { it('should log successful responses when enableLogging is true', () => { const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); createApiClient({ enableLogging: true }); const response = { config: { url: '/users', method: 'GET' }, status: 200, data: { users: [] }, } as AxiosResponse; responseInterceptor(response); expect(consoleLogSpy).toHaveBeenCalledWith( expect.stringContaining('[API Response] GET /users - 200'), expect.any(Object) ); consoleLogSpy.mockRestore(); }); it('should log error responses when enableLogging is true', async () => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); createApiClient({ enableLogging: true }); const error = { config: { url: '/users', method: 'POST' }, response: { status: 400, data: { message: 'Bad Request' }, }, message: 'Request failed', }; try { await responseErrorInterceptor(error); } catch (e) { // Expected to throw } expect(consoleErrorSpy).toHaveBeenCalledWith( expect.stringContaining('[API Error] POST /users - 400'), expect.any(Object) ); consoleErrorSpy.mockRestore(); }); }); describe('Custom Interceptors', () => { it('should call custom onRequest interceptor', async () => { const customInterceptor = vi.fn((config) => { config.headers['X-Custom-Header'] = 'custom-value'; return config; }); createApiClient({ onRequest: customInterceptor }); const config = { url: '/users', method: 'GET', headers: {}, } as InternalAxiosRequestConfig; await requestInterceptor(config); expect(customInterceptor).toHaveBeenCalledWith(config); expect(config.headers['X-Custom-Header']).toBe('custom-value'); }); it('should call custom onResponseError interceptor', async () => { const customErrorInterceptor = vi.fn(() => Promise.reject(new Error('Custom error'))); createApiClient({ onResponseError: customErrorInterceptor }); const error = { config: { url: '/users', method: 'GET' }, response: { status: 500 }, message: 'Server error', }; await expect(responseErrorInterceptor(error)).rejects.toThrow('Custom error'); expect(customErrorInterceptor).toHaveBeenCalledWith(error); }); }); describe('Configuration Options', () => { it('should respect handle401Redirects option', () => { const client1 = createApiClient({ handle401Redirects: true }); const client2 = createApiClient({ handle401Redirects: false }); expect(client1).toBeDefined(); expect(client2).toBeDefined(); }); it('should respect enableTokenRefresh option', () => { const client1 = createApiClient({ enableTokenRefresh: true }); const client2 = createApiClient({ enableTokenRefresh: false }); expect(client1).toBeDefined(); expect(client2).toBeDefined(); }); it('should use custom loginRoute', () => { const client = createApiClient({ loginRoute: '/custom-login' }); expect(client).toBeDefined(); }); }); });