import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'; import { apiClient, api as configuredApiInstance, API_ENDPOINTS } from '../api'; // Adjust path import { API_BASE_URL as MOCK_API_BASE_URL } from '@/config/api-config'; import { useAuthStore } from '@/stores/auth'; import router from '@/router'; // --- Mocks --- vi.mock('axios', async (importOriginal) => { const actualAxios = await importOriginal(); return { ...actualAxios, // Spread actual axios to get CancelToken, isCancel, etc. create: vi.fn(), // Mock axios.create // Mocking static methods if needed by the module under test, though not directly by api.ts // get: vi.fn(), // post: vi.fn(), }; }); vi.mock('@/stores/auth', () => ({ useAuthStore: vi.fn(() => ({ refreshToken: null, setTokens: vi.fn(), clearTokens: vi.fn(), })), })); vi.mock('@/router', () => ({ default: { push: vi.fn(), }, })); // Mock localStorage const localStorageMock = (() => { let store: Record = {}; return { getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { store[key] = value.toString(); }, removeItem: (key: string) => { delete store[key]; }, clear: () => { store = {}; }, }; })(); Object.defineProperty(window, 'localStorage', { value: localStorageMock }); // --- Test Suite --- describe('API Service (api.ts)', () => { let mockAxiosInstance: Partial; let requestInterceptor: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig | Promise; let responseInterceptorSuccess: (response: any) => any; let responseInterceptorError: (error: any) => Promise; let mockAuthStore: ReturnType; beforeEach(() => { // Reset all mocks vi.clearAllMocks(); localStorageMock.clear(); // Setup mock axios instance that axios.create will return mockAxiosInstance = { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn(), interceptors: { request: { use: vi.fn((successCallback) => { requestInterceptor = successCallback; }) }, response: { use: vi.fn((successCallback, errorCallback) => { responseInterceptorSuccess = successCallback; responseInterceptorError = errorCallback; })}, }, defaults: { headers: { common: {} } } as any, // Mock defaults if accessed }; (axios.create as vi.Mock).mockReturnValue(mockAxiosInstance); // Re-evaluate api.ts by importing it or re-triggering its setup // This is tricky as modules are cached. For simplicity, we assume the interceptors // are captured correctly when api.ts was first imported by the test runner. // The configuredApiInstance is the one created in api.ts // We need to ensure our mockAxiosInstance is what it uses. // The `api` export from `../api` is the axios instance. // We can't easily swap it post-import if api.ts doesn't export a factory. // However, by mocking axios.create before api.ts is first processed by Jest/Vitest, // configuredApiInstance *should* be our mockAxiosInstance. mockAuthStore = useAuthStore(); (useAuthStore as vi.Mock).mockReturnValue(mockAuthStore); // Ensure this instance is used // Manually call the interceptor setup functions from api.ts if they were exported // Or, rely on the fact that they were called when api.ts was imported. // The interceptors are set up on `configuredApiInstance` which should be `mockAxiosInstance`. // So, `requestInterceptor` and `responseInterceptorError` should be populated. // This part is a bit of a dance with module imports. // To be absolutely sure, `api.ts` could export its setup functions or be more DI-friendly. // For now, we assume the interceptors on `mockAxiosInstance` got registered. }); describe('Axios Instance Configuration', () => { it('should create an axios instance with correct baseURL and default headers', () => { // This test relies on axios.create being called when api.ts is imported. // The call to axios.create() happens when api.ts is loaded. expect(axios.create).toHaveBeenCalledWith({ baseURL: MOCK_API_BASE_URL, headers: { 'Content-Type': 'application/json' }, withCredentials: true, }); }); }); describe('Request Interceptor', () => { it('should add Authorization header if token exists in localStorage', () => { localStorageMock.setItem('token', 'test-token'); const config: InternalAxiosRequestConfig = { headers: {} } as InternalAxiosRequestConfig; // configuredApiInstance is the instance from api.ts, which should have the interceptor // We need to ensure our mockAxiosInstance.interceptors.request.use captured the callback // Then we call it manually. const processedConfig = requestInterceptor(config); expect(processedConfig.headers.Authorization).toBe('Bearer test-token'); }); it('should not add Authorization header if token does not exist', () => { const config: InternalAxiosRequestConfig = { headers: {} } as InternalAxiosRequestConfig; const processedConfig = requestInterceptor(config); expect(processedConfig.headers.Authorization).toBeUndefined(); }); }); describe('Response Interceptor (401 Refresh Logic)', () => { const originalRequestConfig: InternalAxiosRequestConfig = { headers: {}, _retry: false } as InternalAxiosRequestConfig; it('successful token refresh and retry', async () => { localStorageMock.setItem('token', 'old-token'); // For the initial failed request mockAuthStore.refreshToken = 'valid-refresh-token'; const error = { config: originalRequestConfig, response: { status: 401 } }; (mockAxiosInstance.post as vi.Mock).mockResolvedValueOnce({ // for refresh call data: { access_token: 'new-access-token', refresh_token: 'new-refresh-token' }, }); (mockAxiosInstance as any).mockResolvedValueOnce({ data: 'retried request data' }); // for retried original request const result = await responseInterceptorError(error); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/jwt/refresh', { refresh_token: 'valid-refresh-token' }); expect(mockAuthStore.setTokens).toHaveBeenCalledWith({ access_token: 'new-access-token', refresh_token: 'new-refresh-token' }); expect(originalRequestConfig.headers.Authorization).toBe('Bearer new-access-token'); expect(result.data).toBe('retried request data'); }); it('failed token refresh redirects to login', async () => { mockAuthStore.refreshToken = 'valid-refresh-token'; const error = { config: originalRequestConfig, response: { status: 401 } }; const refreshError = new Error('Refresh failed'); (mockAxiosInstance.post as vi.Mock).mockRejectedValueOnce(refreshError); // refresh call fails await expect(responseInterceptorError(error)).rejects.toThrow('Refresh failed'); expect(mockAxiosInstance.post).toHaveBeenCalledWith('/auth/jwt/refresh', { refresh_token: 'valid-refresh-token' }); expect(mockAuthStore.clearTokens).toHaveBeenCalled(); expect(router.push).toHaveBeenCalledWith('/auth/login'); }); it('no refresh token, redirects to login', async () => { mockAuthStore.refreshToken = null; const error = { config: originalRequestConfig, response: { status: 401 } }; await expect(responseInterceptorError(error)).rejects.toEqual(error); // Original error rejected expect(mockAuthStore.clearTokens).toHaveBeenCalled(); expect(router.push).toHaveBeenCalledWith('/auth/login'); expect(mockAxiosInstance.post).not.toHaveBeenCalledWith('/auth/jwt/refresh', expect.anything()); }); it('should not retry if _retry is already true', async () => { const error = { config: { ...originalRequestConfig, _retry: true }, response: { status: 401 } }; await expect(responseInterceptorError(error)).rejects.toEqual(error); expect(mockAxiosInstance.post).not.toHaveBeenCalledWith('/auth/jwt/refresh', expect.anything()); }); it('passes through non-401 errors', async () => { const error = { config: originalRequestConfig, response: { status: 500, data: 'Server Error' } }; await expect(responseInterceptorError(error)).rejects.toEqual(error); expect(mockAxiosInstance.post).not.toHaveBeenCalledWith('/auth/jwt/refresh', expect.anything()); }); }); describe('apiClient methods', () => { // Note: API_BASE_URL is part of getApiUrl, which prepends it. // The mockAxiosInstance is already configured with baseURL. // So, api.get(URL) will have URL = MOCK_API_BASE_URL + endpoint. // However, the apiClient methods call getApiUrl(endpoint) which results in MOCK_API_BASE_URL + endpoint. // This means the final URL passed to the mockAxiosInstance.get (etc.) will be effectively MOCK_API_BASE_URL + MOCK_API_BASE_URL + endpoint // This is a slight duplication issue in the original api.ts's apiClient if not careful. // For testing, we'll assume the passed endpoint to apiClient methods is relative (e.g., "/users") // and getApiUrl correctly forms the full path once. const testEndpoint = '/test'; // Example, will be combined with API_ENDPOINTS const fullTestEndpoint = MOCK_API_BASE_URL + API_ENDPOINTS.AUTH.LOGIN; // Using a concrete endpoint const responseData = { message: 'success' }; const requestData = { foo: 'bar' }; beforeEach(() => { // Reset mockAxiosInstance calls for each apiClient method test (mockAxiosInstance.get as vi.Mock).mockClear(); (mockAxiosInstance.post as vi.Mock).mockClear(); (mockAxiosInstance.put as vi.Mock).mockClear(); (mockAxiosInstance.patch as vi.Mock).mockClear(); (mockAxiosInstance.delete as vi.Mock).mockClear(); }); it('apiClient.get calls configuredApiInstance.get with full URL', async () => { (mockAxiosInstance.get as vi.Mock).mockResolvedValue({ data: responseData }); const result = await apiClient.get(API_ENDPOINTS.AUTH.LOGIN); expect(mockAxiosInstance.get).toHaveBeenCalledWith(fullTestEndpoint, undefined); expect(result.data).toEqual(responseData); }); it('apiClient.post calls configuredApiInstance.post with full URL and data', async () => { (mockAxiosInstance.post as vi.Mock).mockResolvedValue({ data: responseData }); const result = await apiClient.post(API_ENDPOINTS.AUTH.LOGIN, requestData); expect(mockAxiosInstance.post).toHaveBeenCalledWith(fullTestEndpoint, requestData, undefined); expect(result.data).toEqual(responseData); }); it('apiClient.put calls configuredApiInstance.put with full URL and data', async () => { (mockAxiosInstance.put as vi.Mock).mockResolvedValue({ data: responseData }); const result = await apiClient.put(API_ENDPOINTS.AUTH.LOGIN, requestData); expect(mockAxiosInstance.put).toHaveBeenCalledWith(fullTestEndpoint, requestData, undefined); expect(result.data).toEqual(responseData); }); it('apiClient.patch calls configuredApiInstance.patch with full URL and data', async () => { (mockAxiosInstance.patch as vi.Mock).mockResolvedValue({ data: responseData }); const result = await apiClient.patch(API_ENDPOINTS.AUTH.LOGIN, requestData); expect(mockAxiosInstance.patch).toHaveBeenCalledWith(fullTestEndpoint, requestData, undefined); expect(result.data).toEqual(responseData); }); it('apiClient.delete calls configuredApiInstance.delete with full URL', async () => { (mockAxiosInstance.delete as vi.Mock).mockResolvedValue({ data: responseData }); const result = await apiClient.delete(API_ENDPOINTS.AUTH.LOGIN); expect(mockAxiosInstance.delete).toHaveBeenCalledWith(fullTestEndpoint, undefined); expect(result.data).toEqual(responseData); }); it('apiClient.get propagates errors', async () => { const error = new Error('Network Error'); (mockAxiosInstance.get as vi.Mock).mockRejectedValue(error); await expect(apiClient.get(API_ENDPOINTS.AUTH.LOGIN)).rejects.toThrow('Network Error'); }); }); describe('Interceptor Registration on Actual Instance', () => { // This is more of an integration test for the interceptor setup itself. it('should have registered interceptors on the true configuredApiInstance', () => { // configuredApiInstance is the actual instance from api.ts // We check if its interceptors.request.use was called (which our mock does) // This relies on the mockAxiosInstance being the one that was used. expect(mockAxiosInstance.interceptors?.request.use).toHaveBeenCalled(); expect(mockAxiosInstance.interceptors?.response.use).toHaveBeenCalled(); }); }); }); // Mock actual config values vi.mock('@/config/api-config', () => ({ API_BASE_URL: 'http://mockapi.com/api/v1', API_VERSION: 'v1', API_ENDPOINTS: { AUTH: { LOGIN: '/auth/login/', SIGNUP: '/auth/signup/', REFRESH: '/auth/jwt/refresh/', LOGOUT: '/auth/logout/', }, USERS: { PROFILE: '/users/me/', // Add other user-related endpoints here }, LISTS: { BASE: '/lists/', BY_ID: (id: string | number) => `/lists/${id}/`, ITEMS: (listId: string | number) => `/lists/${listId}/items/`, ITEM: (listId: string | number, itemId: string | number) => `/lists/${listId}/items/${itemId}/`, } // Add other resource endpoints here }, }));