mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 10:03:14 +00:00
- ProposalNumberGenerator tests (8 tests): format validation (SHI-YYYY-NNNN), sequence incrementing, revision skipping, year boundary isolation, uniqueness, zero-padding, high sequence rollover. Uses SQLite in-memory with Postgres function stubs to support ExecuteSqlRawAsync. - LineItemService state guard tests (18 tests): verifies line items cannot be created/bulk-updated/deleted on Approved or Sent proposals (QA-C2), confirms operations succeed on InReview and Revised statuses, validates KeyNotFoundException on missing proposals, verifies audit logging. - API client interceptor tests (14 tests): request interceptor attaches Bearer token from sessionStorage (WEB-C1), handles missing/malformed token data, response interceptor dispatches Redux logout on 401 (WEB-M2), returns friendly messages for 403/404, extracts server error details, handles network errors. - DbContextFactory updated to suppress InMemoryEventId.TransactionIgnoredWarning so BulkUpdateAsync tests work with in-memory provider. - Added SqliteDbContextFactory for tests requiring relational features. - Added Microsoft.EntityFrameworkCore.Sqlite to test project dependencies. Total: 104 .NET tests (was 77), 26 web tests (was 12). CI already wired.
277 lines
8.5 KiB
TypeScript
277 lines
8.5 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|