proposal-system/web/src/lib/api/__tests__/client.test.ts

278 lines
8.5 KiB
TypeScript
Raw Normal View History

/**
* 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<string, any> = {};
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<string, string> = {};
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<string, string>,
} 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<string, string>,
} 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<string, string>,
} 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<string, string>,
} 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);
});
});
});