/** * API client interceptor tests (WEB-M2, WEB-C1). * * Tests that: * - Request interceptor attaches Bearer token from sessionStorage * - Request interceptor handles missing/invalid token data gracefully * - Response interceptor dispatches Redux logout and redirects on 401 * - Response interceptor returns friendly error message on 403 * - Response interceptor returns friendly error message on 404 * - Response interceptor extracts server error detail from response body * - Response interceptor handles network errors (no response) */ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest'; import type { InternalAxiosRequestConfig } from 'axios'; // vi.hoisted returns values accessible in both the hoisted mock scope and test scope. const { interceptors, mockDispatch } = vi.hoisted(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const state: Record = {}; const dispatch = vi.fn(); return { interceptors: state, mockDispatch: dispatch, }; }); // Mock the Redux store vi.mock('../../../app/store', () => ({ store: { dispatch: (...args: unknown[]) => mockDispatch(...args), getState: () => ({ auth: { user: null, isAuthenticated: false, loading: false, error: null } }), subscribe: vi.fn(), replaceReducer: vi.fn(), [Symbol.observable]: vi.fn(), }, })); // Mock the authSlice logout action vi.mock('../../../app/slices/authSlice', () => ({ logout: () => ({ type: 'auth/logout' }), setUser: (user: unknown) => ({ type: 'auth/setUser', payload: user }), default: (state: unknown) => state, })); // Mock axios vi.mock('axios', () => { const mockInstance = { interceptors: { request: { // eslint-disable-next-line @typescript-eslint/no-explicit-any use: (fulfilled: any, rejected: any) => { interceptors.requestFulfilled = fulfilled; interceptors.requestRejected = rejected; return 0; }, }, response: { // eslint-disable-next-line @typescript-eslint/no-explicit-any use: (fulfilled: any, rejected: any) => { interceptors.responseFulfilled = fulfilled; interceptors.responseRejected = rejected; return 0; }, }, }, defaults: { headers: { common: {} } }, }; return { default: { create: () => mockInstance, }, }; }); // Mock sessionStorage const sessionStorageData: Record = {}; vi.stubGlobal('sessionStorage', { getItem: vi.fn((key: string) => sessionStorageData[key] ?? null), setItem: vi.fn((key: string, value: string) => { sessionStorageData[key] = value; }), removeItem: vi.fn((key: string) => { delete sessionStorageData[key]; }), clear: vi.fn(() => { Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); }), get length() { return Object.keys(sessionStorageData).length; }, key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null), }); // Mock window.location const mockLocation = { href: '' }; Object.defineProperty(window, 'location', { value: mockLocation, writable: true, configurable: true, }); describe('API client interceptors', () => { beforeAll(async () => { // Dynamically import the module under test after all mocks are set up await import('../client'); }); beforeEach(() => { vi.clearAllMocks(); Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); mockLocation.href = ''; }); describe('request interceptor', () => { it('WEB-C1: attaches Bearer token from sessionStorage when valid token data exists', () => { const tokenData = JSON.stringify({ token: 'my-jwt-token-123' }); sessionStorageData['proposal_system_token'] = tokenData; const config = { headers: {} as Record, } as unknown as InternalAxiosRequestConfig; const result = interceptors.requestFulfilled(config); expect(result.headers.Authorization).toBe('Bearer my-jwt-token-123'); }); it('WEB-C1: does not attach Authorization header when no token in sessionStorage', () => { const config = { headers: {} as Record, } as unknown as InternalAxiosRequestConfig; const result = interceptors.requestFulfilled(config); expect(result.headers.Authorization).toBeUndefined(); }); it('WEB-C1: handles malformed JSON in sessionStorage gracefully', () => { sessionStorageData['proposal_system_token'] = 'not-valid-json{{{'; const config = { headers: {} as Record, } as unknown as InternalAxiosRequestConfig; const result = interceptors.requestFulfilled(config); expect(result.headers.Authorization).toBeUndefined(); }); it('WEB-C1: does not attach header when token field is missing in parsed data', () => { sessionStorageData['proposal_system_token'] = JSON.stringify({ email: 'user@test.com' }); const config = { headers: {} as Record, } as unknown as InternalAxiosRequestConfig; const result = interceptors.requestFulfilled(config); expect(result.headers.Authorization).toBeUndefined(); }); it('WEB-C1: request error rejection propagates the error', async () => { const error = new Error('request setup failed'); const promise = interceptors.requestRejected(error); await expect(promise).rejects.toEqual(error); }); }); describe('response interceptor', () => { it('WEB-M2: 401 response dispatches Redux logout and redirects to /login', async () => { const error = { response: { status: 401, data: {}, }, request: {}, }; const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('Session expired. Please log in again.'); expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/logout' }); expect(mockLocation.href).toBe('/login'); }); it('WEB-M2: 403 response returns permission error without dispatching logout', async () => { const error = { response: { status: 403, data: {}, }, request: {}, }; const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('You do not have permission to perform this action.'); expect(mockDispatch).not.toHaveBeenCalled(); }); it('404 response returns resource-not-found error', async () => { const error = { response: { status: 404, data: {}, }, request: {}, }; const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('The requested resource was not found.'); }); it('extracts detail message from server error response', async () => { const error = { response: { status: 422, data: { detail: 'Validation failed: scope too short' }, }, request: {}, }; const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('Validation failed: scope too short'); }); it('extracts title message when detail is absent', async () => { const error = { response: { status: 500, data: { title: 'Internal Server Error' }, }, request: {}, }; const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('Internal Server Error'); }); it('falls back to generic message when no detail/title/message in response', async () => { const error = { response: { status: 500, data: {}, }, request: {}, }; const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('An error occurred'); }); it('handles network error (no response from server)', async () => { const error = { request: {}, }; const promise = interceptors.responseRejected(error); await expect(promise).rejects.toThrow('No response from server. Please check your connection.'); }); it('passes through non-axios errors unchanged', async () => { const error = new Error('Something unexpected'); const promise = interceptors.responseRejected(error); await expect(promise).rejects.toEqual(error); }); it('response success passes through unchanged', () => { const response = { data: { id: 1 }, status: 200 }; const result = interceptors.responseFulfilled(response); expect(result).toBe(response); }); }); });