@@ -189,7 +201,7 @@ jest.mock('../LogsViewer.jsx', () => ({nodename, height}) => (
Logs Viewer Mock
@@ -3212,4 +3224,137 @@ type = flag
// Component should have unmounted cleanly without errors
expect(true).toBe(true);
});
+
+ test('handles console URL copy error', async () => {
+ require('react-router-dom').useParams.mockReturnValue({
+ objectName: 'root/svc/svc1',
+ });
+
+ const mockStateWithContainer = {
+ objectStatus: {
+ 'root/svc/svc1': {avail: 'up', frozen: null},
+ },
+ objectInstanceStatus: {
+ 'root/svc/svc1': {
+ node1: {
+ avail: 'up',
+ frozen_at: null,
+ resources: {
+ containerRes: {
+ status: 'up',
+ label: 'Container Resource',
+ type: 'container.docker',
+ provisioned: {state: 'true', mtime: '2023-01-01T12:00:00Z'},
+ running: true,
+ },
+ },
+ },
+ },
+ },
+ instanceMonitor: {
+ 'node1:root/svc/svc1': {
+ state: 'running',
+ global_expect: 'placed@node1',
+ resources: {containerRes: {restart: {remaining: 0}}},
+ },
+ },
+ instanceConfig: {
+ 'root/svc/svc1': {
+ resources: {
+ containerRes: {is_monitored: true, is_disabled: false, is_standby: false, restart: 0},
+ },
+ },
+ },
+ configUpdates: [],
+ clearConfigUpdate: jest.fn(),
+ };
+
+ useEventStore.mockImplementation((selector) => selector(mockStateWithContainer));
+
+ global.fetch.mockImplementation((url) => {
+ if (url.includes('/console')) {
+ return Promise.resolve({
+ ok: true,
+ headers: {
+ get: (header) => header === 'Location' ? 'https://console.example.com/session123' : null
+ }
+ });
+ }
+ if (url.includes('/config/file') || url.includes('/data/keys')) {
+ return Promise.resolve({
+ ok: true,
+ text: () => Promise.resolve('config data'),
+ json: () => Promise.resolve({items: []})
+ });
+ }
+ return Promise.resolve({ok: true, text: () => Promise.resolve('success')});
+ });
+
+ // Mock clipboard to reject
+ const mockClipboard = {
+ writeText: jest.fn().mockRejectedValue(new Error('Clipboard error')),
+ };
+ Object.defineProperty(global.navigator, 'clipboard', {
+ value: mockClipboard,
+ writable: true,
+ configurable: true,
+ });
+
+ render(
+
+
+ }/>
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('node1')).toBeInTheDocument();
+ }, {timeout: 10000});
+
+ const resourcesAccordion = screen.getByRole('button', {
+ name: /expand resources for node node1/i,
+ });
+ await userEvent.click(resourcesAccordion);
+
+ await waitFor(() => {
+ expect(screen.getByText('containerRes')).toBeInTheDocument();
+ });
+
+ const resourceActionButtons = screen.getAllByRole('button').filter(button =>
+ button.getAttribute('aria-label')?.includes('Resource containerRes actions')
+ );
+ await userEvent.click(resourceActionButtons[0]);
+
+ const menus = await screen.findAllByRole('menu');
+ const consoleItem = within(menus[0]).getByRole('menuitem', {name: /console/i});
+ await userEvent.click(consoleItem);
+
+ await waitFor(() => {
+ const dialogs = screen.getAllByRole('dialog');
+ const consoleDialog = dialogs.find(d => d.textContent?.includes('Open Console') && d.textContent?.includes('containerRes'));
+ expect(consoleDialog).toBeInTheDocument();
+ });
+
+ const dialogs = screen.getAllByRole('dialog');
+ const consoleDialog = dialogs.find(d => d.textContent?.includes('Open Console') && d.textContent?.includes('containerRes'));
+ const openConsoleButton = within(consoleDialog).getByRole('button', {name: /Open Console/i});
+ await userEvent.click(openConsoleButton);
+
+ await waitFor(() => {
+ const urlDialogs = screen.getAllByRole('dialog');
+ const urlDialog = urlDialogs.find(d => d.textContent?.includes('Console URL') && !d.textContent?.includes('containerRes'));
+ expect(urlDialog).toBeInTheDocument();
+ });
+
+ const urlDialogs = screen.getAllByRole('dialog');
+ const urlDialog = urlDialogs.find(d => d.textContent?.includes('Console URL') && !d.textContent?.includes('containerRes'));
+ const copyButton = within(urlDialog).getByRole('button', {name: /Copy URL/i});
+ await userEvent.click(copyButton);
+
+ // Clipboard error is silently handled (catch block)
+ expect(mockClipboard.writeText).toHaveBeenCalled();
+
+ delete global.navigator.clipboard;
+ });
});
diff --git a/src/components/tests/OidcCallback.test.jsx b/src/components/tests/OidcCallback.test.jsx
index 9197a33e..9775c1d5 100644
--- a/src/components/tests/OidcCallback.test.jsx
+++ b/src/components/tests/OidcCallback.test.jsx
@@ -28,6 +28,13 @@ jest.mock('../../context/OidcAuthContext.tsx', () => ({
jest.mock('../../config/oidcConfiguration.js', () => jest.fn());
+jest.mock('../../utils/logger.js', () => ({
+ info: jest.fn(),
+ debug: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+}));
+
describe('OidcCallback Component', () => {
const mockNavigate = jest.fn();
const mockAuthDispatch = jest.fn();
@@ -39,6 +46,10 @@ describe('OidcCallback Component', () => {
addAccessTokenExpiring: jest.fn(),
addAccessTokenExpired: jest.fn(),
addSilentRenewError: jest.fn(),
+ removeUserLoaded: jest.fn(),
+ removeAccessTokenExpiring: jest.fn(),
+ removeAccessTokenExpired: jest.fn(),
+ removeSilentRenewError: jest.fn(),
},
};
const mockRecreateUserManager = jest.fn();
@@ -53,6 +64,7 @@ describe('OidcCallback Component', () => {
};
let broadcastChannelMock;
+ let logger;
beforeEach(() => {
jest.clearAllMocks();
@@ -65,9 +77,9 @@ describe('OidcCallback Component', () => {
recreateUserManager: mockRecreateUserManager,
});
oidcConfiguration.mockResolvedValue({some: 'config'});
- console.error = jest.fn();
- console.log = jest.fn();
- console.warn = jest.fn();
+
+ // Import logger after mocking
+ logger = require('../../utils/logger.js');
// Set up BroadcastChannel mock
broadcastChannelMock = {
@@ -86,6 +98,7 @@ describe('OidcCallback Component', () => {
afterEach(() => {
delete global.BroadcastChannel;
+ jest.restoreAllMocks();
});
test('renders loading text', () => {
@@ -103,7 +116,7 @@ describe('OidcCallback Component', () => {
expect(mockRecreateUserManager).toHaveBeenCalledWith({some: 'config'});
expect(oidcConfiguration).toHaveBeenCalledWith(mockAuthInfo);
- expect(console.log).toHaveBeenCalledWith('Initializing UserManager with authInfo');
+ expect(logger.info).toHaveBeenCalledWith('Initializing UserManager with authInfo');
});
test('does not call recreateUserManager when authInfo is null', async () => {
@@ -127,7 +140,7 @@ describe('OidcCallback Component', () => {
});
expect(oidcConfiguration).toHaveBeenCalledWith(mockAuthInfo);
- expect(console.error).toHaveBeenCalledWith('Failed to initialize OIDC config:', error);
+ expect(logger.error).toHaveBeenCalledWith('Failed to initialize OIDC config:', error);
expect(mockNavigate).toHaveBeenCalledWith('/auth-choice');
});
@@ -143,7 +156,7 @@ describe('OidcCallback Component', () => {
expect(mockUserManager.signinRedirectCallback).toHaveBeenCalled();
});
- expect(console.log).toHaveBeenCalledWith('Handling OIDC callback or session check');
+ expect(logger.debug).toHaveBeenCalledWith('Handling OIDC callback or session check');
});
test('handles existing user session with expired token', async () => {
@@ -163,7 +176,7 @@ describe('OidcCallback Component', () => {
expect(mockUserManager.signinRedirectCallback).toHaveBeenCalled();
});
- expect(console.log).toHaveBeenCalledWith('Handling OIDC callback or session check');
+ expect(logger.debug).toHaveBeenCalledWith('Handling OIDC callback or session check');
});
test('handles getUser returning null user', async () => {
@@ -183,7 +196,7 @@ describe('OidcCallback Component', () => {
expect(mockUserManager.signinRedirectCallback).toHaveBeenCalled();
});
- expect(console.log).toHaveBeenCalledWith('Handling OIDC callback or session check');
+ expect(logger.debug).toHaveBeenCalledWith('Handling OIDC callback or session check');
});
test('handles getUser error', async () => {
@@ -204,7 +217,7 @@ describe('OidcCallback Component', () => {
expect(mockUserManager.signinRedirectCallback).toHaveBeenCalled();
});
- expect(console.error).toHaveBeenCalledWith('Failed to get user:', error);
+ expect(logger.error).toHaveBeenCalledWith('Failed to get user:', error);
});
test('handles successful signinRedirectCallback', async () => {
@@ -240,7 +253,7 @@ describe('OidcCallback Component', () => {
expect(mockUserManager.events.addUserLoaded).toHaveBeenCalled();
expect(mockUserManager.events.addAccessTokenExpiring).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/');
- expect(console.log).toHaveBeenCalledWith('User refreshed:', 'testuser', 'expires_at:', 1234567890);
+ expect(logger.info).toHaveBeenCalledWith('User refreshed:', 'testuser', 'expires_at:', 1234567890);
expect(broadcastChannelMock.postMessage).toHaveBeenCalledWith({
type: 'tokenUpdated',
data: 'mock-access-token',
@@ -265,7 +278,7 @@ describe('OidcCallback Component', () => {
expect(localStorage.getItem('tokenExpiration')).toBeNull();
expect(mockAuthDispatch).not.toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/auth-choice');
- expect(console.error).toHaveBeenCalledWith('signinRedirectCallback failed:', error);
+ expect(logger.error).toHaveBeenCalledWith('signinRedirectCallback failed:', error);
});
test('adds event listeners for user loaded and token expiring', async () => {
@@ -291,7 +304,7 @@ describe('OidcCallback Component', () => {
});
expect(localStorage.getItem('authToken')).toBe('mock-access-token');
expect(localStorage.getItem('tokenExpiration')).toBe('1234567890');
- expect(console.log).toHaveBeenCalledWith('User refreshed:', 'testuser', 'expires_at:', 1234567890);
+ expect(logger.info).toHaveBeenCalledWith('User refreshed:', 'testuser', 'expires_at:', 1234567890);
expect(broadcastChannelMock.postMessage).toHaveBeenCalledWith({
type: 'tokenUpdated',
data: 'mock-access-token',
@@ -301,7 +314,7 @@ describe('OidcCallback Component', () => {
const addAccessTokenExpiringCallback = mockUserManager.events.addAccessTokenExpiring.mock.calls[0][0];
addAccessTokenExpiringCallback();
- expect(console.log).toHaveBeenCalledWith('Access token is about to expire, attempting silent renew...');
+ expect(logger.debug).toHaveBeenCalledWith('Access token is about to expire, attempting silent renew...');
});
test('re-runs effect when authInfo changes', async () => {
@@ -339,6 +352,7 @@ describe('OidcCallback Component', () => {
const newUserManager = {
...mockUserManager,
signinRedirectCallback: jest.fn().mockResolvedValue(mockUser),
+ getUser: jest.fn().mockResolvedValue(null),
};
useOidc.mockReturnValue({
userManager: newUserManager,
@@ -366,7 +380,7 @@ describe('OidcCallback Component', () => {
const addAccessTokenExpiredCallback = mockUserManager.events.addAccessTokenExpired.mock.calls[0][0];
addAccessTokenExpiredCallback();
- expect(console.warn).toHaveBeenCalledWith('Access token expired, redirecting to /auth-choice');
+ expect(logger.warn).toHaveBeenCalledWith('Access token expired, redirecting to /auth-choice');
expect(localStorage.getItem('authToken')).toBeNull();
expect(localStorage.getItem('tokenExpiration')).toBeNull();
expect(mockNavigate).toHaveBeenCalledWith('/auth-choice');
@@ -389,7 +403,7 @@ describe('OidcCallback Component', () => {
const error = new Error('Silent renew error');
addSilentRenewErrorCallback(error);
- expect(console.error).toHaveBeenCalledWith('Silent renew failed:', error);
+ expect(logger.error).toHaveBeenCalledWith('Silent renew failed:', error);
expect(localStorage.getItem('authToken')).toBeNull();
expect(localStorage.getItem('tokenExpiration')).toBeNull();
expect(mockNavigate).toHaveBeenCalledWith('/auth-choice');
@@ -423,7 +437,7 @@ describe('OidcCallback Component', () => {
});
expect(localStorage.getItem('authToken')).toBe('new-token');
expect(localStorage.getItem('tokenExpiration')).toBe('9876543210');
- expect(console.log).toHaveBeenCalledWith('Token updated from another tab');
+ expect(logger.info).toHaveBeenCalledWith('Token updated from another tab');
expect(broadcastChannelMock.close).toHaveBeenCalled();
});
@@ -453,7 +467,7 @@ describe('OidcCallback Component', () => {
expect(localStorage.getItem('authToken')).toBeNull();
expect(localStorage.getItem('tokenExpiration')).toBeNull();
expect(localStorage.getItem('authChoice')).toBeNull();
- expect(console.log).toHaveBeenCalledWith('Logout triggered from another tab');
+ expect(logger.info).toHaveBeenCalledWith('Logout triggered from another tab');
expect(mockNavigate).toHaveBeenCalledWith('/auth-choice');
expect(broadcastChannelMock.close).toHaveBeenCalled();
});
@@ -556,4 +570,100 @@ describe('OidcCallback Component', () => {
screen.getByText('Logging ...');
}).not.toThrow();
});
+
+ test('sets up event handlers only once', async () => {
+ useOidc.mockReturnValue({
+ userManager: mockUserManager,
+ recreateUserManager: mockRecreateUserManager,
+ });
+ mockUserManager.getUser.mockResolvedValue(mockUser);
+
+ const {rerender} = render(
);
+
+ await waitFor(() => {
+ expect(mockUserManager.events.addUserLoaded).toHaveBeenCalledTimes(1);
+ });
+
+ // Re-render and verify event handlers are not added again
+ rerender(
);
+
+ expect(mockUserManager.events.addUserLoaded).toHaveBeenCalledTimes(1);
+ });
+
+ test('handles null authDispatch in onUserRefreshed', async () => {
+ useAuthDispatch.mockReturnValue(null);
+ useOidc.mockReturnValue({
+ userManager: mockUserManager,
+ recreateUserManager: mockRecreateUserManager,
+ });
+ mockUserManager.getUser.mockResolvedValue(mockUser);
+ render(
);
+
+ await waitFor(() => {
+ expect(mockUserManager.getUser).toHaveBeenCalled();
+ });
+
+ // Should not crash even though authDispatch is null
+ expect(localStorage.getItem('authToken')).toBe('mock-access-token');
+ expect(broadcastChannelMock.postMessage).toHaveBeenCalled();
+ });
+
+ test('handles user with null profile in onUserRefreshed', async () => {
+ useOidc.mockReturnValue({
+ userManager: mockUserManager,
+ recreateUserManager: mockRecreateUserManager,
+ });
+ const userWithNullProfile = {
+ ...mockUser,
+ profile: null,
+ };
+ mockUserManager.getUser.mockResolvedValue(userWithNullProfile);
+ render(
);
+
+ await waitFor(() => {
+ expect(mockUserManager.getUser).toHaveBeenCalled();
+ });
+
+ // Should not crash when profile is null
+ expect(logger.info).toHaveBeenCalledWith('User refreshed:', undefined, 'expires_at:', 1234567890);
+ });
+
+ test('handles user with null expires_at', async () => {
+ useOidc.mockReturnValue({
+ userManager: mockUserManager,
+ recreateUserManager: mockRecreateUserManager,
+ });
+ const userWithoutExpiresAt = {
+ ...mockUser,
+ expires_at: null,
+ };
+ mockUserManager.getUser.mockResolvedValue(userWithoutExpiresAt);
+ render(
);
+
+ await waitFor(() => {
+ expect(mockUserManager.getUser).toHaveBeenCalled();
+ });
+
+ expect(localStorage.getItem('tokenExpiration')).toBe('');
+ });
+
+ test('setupEventHandlers uses correct dependencies', async () => {
+ useOidc.mockReturnValue({
+ userManager: mockUserManager,
+ recreateUserManager: mockRecreateUserManager,
+ });
+ mockUserManager.getUser.mockResolvedValue(mockUser);
+
+ render(
);
+
+ await waitFor(() => {
+ expect(mockUserManager.events.addUserLoaded).toHaveBeenCalled();
+ });
+
+ // Verify all event handlers were added
+ expect(mockUserManager.events.addUserLoaded).toHaveBeenCalled();
+ expect(mockUserManager.events.addAccessTokenExpiring).toHaveBeenCalled();
+ expect(mockUserManager.events.addAccessTokenExpired).toHaveBeenCalled();
+ expect(mockUserManager.events.addSilentRenewError).toHaveBeenCalled();
+ });
});
diff --git a/src/components/tests/WhoAmI.test.jsx b/src/components/tests/WhoAmI.test.jsx
index 54113933..7c86f5bc 100644
--- a/src/components/tests/WhoAmI.test.jsx
+++ b/src/components/tests/WhoAmI.test.jsx
@@ -13,6 +13,7 @@ const mockLocalStorage = {
getItem: jest.fn(),
removeItem: jest.fn(),
setItem: jest.fn(),
+ clear: jest.fn(),
};
Object.defineProperty(window, 'localStorage', {value: mockLocalStorage});
@@ -32,6 +33,13 @@ jest.mock('../../context/AuthProvider.jsx', () => ({
Logout: 'LOGOUT',
}));
+jest.mock('../../hooks/useFetchDaemonStatus', () => jest.fn());
+
+jest.mock('../../utils/logger.js', () => ({
+ error: jest.fn(),
+ info: jest.fn(),
+}));
+
describe('WhoAmI Component', () => {
const mockToken = 'mock-auth-token';
const mockUserInfo = {
@@ -44,6 +52,8 @@ describe('WhoAmI Component', () => {
const mockNavigate = jest.fn();
const mockAuthDispatch = jest.fn();
+ const mockFetchNodes = jest.fn();
+ const mockUseFetchDaemonStatus = require('../../hooks/useFetchDaemonStatus');
beforeEach(() => {
jest.clearAllMocks();
@@ -88,6 +98,11 @@ describe('WhoAmI Component', () => {
},
});
+ mockUseFetchDaemonStatus.mockReturnValue({
+ daemon: {nodename: 'test-node'},
+ fetchNodes: mockFetchNodes,
+ });
+
// Mock GitHub API call for version
global.fetch.mockImplementation((url) => {
if (url === 'https://api.github.com/repos/opensvc/om3-webapp/releases') {
@@ -398,11 +413,134 @@ describe('WhoAmI Component', () => {
expect(titles.length).toBeGreaterThan(0);
expect(titles[0]).toBeInTheDocument();
});
+
+ expect(mockFetchNodes).not.toHaveBeenCalled();
});
- test('sets appVersion to cached value or Unknown when GitHub fetch fails', async () => {
+ test('calls fetchNodes when authToken exists in localStorage', async () => {
+ mockLocalStorage.getItem.mockImplementation((key) => {
+ if (key === 'authToken') return mockToken;
+ if (key === 'darkMode') return 'false';
+ return null;
+ });
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockFetchNodes).toHaveBeenCalledWith(mockToken);
+ });
+ });
+
+ test('handles error in fetchNodes call', async () => {
+ const mockLogger = require('../../utils/logger.js');
+ mockFetchNodes.mockRejectedValue(new Error('Daemon fetch failed'));
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockLogger.error).toHaveBeenCalledWith('Error fetching daemon status:', expect.any(Error));
+ });
+ });
+
+ test('sets appVersion to cached value when cache is valid', async () => {
+ const cachedVersion = '1.0.0';
+ const cacheTime = Date.now().toString();
+
+ mockLocalStorage.getItem.mockImplementation((key) => {
+ if (key === 'appVersion') return cachedVersion;
+ if (key === 'appVersionTime') return cacheTime;
+ if (key === 'authToken') return mockToken;
+ if (key === 'darkMode') return 'false';
+ return null;
+ });
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(`v${cachedVersion}`)).toBeInTheDocument();
+ });
+
+ // Should not call GitHub API
+ expect(global.fetch).not.toHaveBeenCalledWith('https://api.github.com/repos/opensvc/om3-webapp/releases');
+ });
+
+ test('fetches appVersion from GitHub when cache is expired', async () => {
+ const oldCacheTime = (Date.now() - 4000000).toString(); // More than 1 hour ago
+ const cachedVersion = '1.0.0';
+
+ mockLocalStorage.getItem.mockImplementation((key) => {
+ if (key === 'appVersion') return cachedVersion;
+ if (key === 'appVersionTime') return oldCacheTime;
+ if (key === 'authToken') return mockToken;
+ if (key === 'darkMode') return 'false';
+ return null;
+ });
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('v1.2.3')).toBeInTheDocument();
+ });
+
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith('appVersion', '1.2.3');
+ expect(mockLocalStorage.setItem).toHaveBeenCalledWith('appVersionTime', expect.any(String));
+ });
+
+ test('sets appVersion to cached value when GitHub fetch fails but cache exists', async () => {
+ const cachedVersion = '1.0.0';
+ const cacheTime = (Date.now() - 4000000).toString(); // Expired cache
+
+ mockLocalStorage.getItem.mockImplementation((key) => {
+ if (key === 'appVersion') return cachedVersion;
+ if (key === 'appVersionTime') return cacheTime;
+ if (key === 'authToken') return mockToken;
+ if (key === 'darkMode') return 'false';
+ return null;
+ });
+
+ global.fetch.mockImplementation((url) => {
+ if (url === URL_AUTH_WHOAMI) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(mockUserInfo),
+ });
+ }
+ if (url.includes('github')) {
+ return Promise.reject(new Error('Network error'));
+ }
+ });
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(`v${cachedVersion}`)).toBeInTheDocument();
+ });
+ });
+
+ test('sets appVersion to Unknown when GitHub fetch fails and no cache exists', async () => {
mockLocalStorage.getItem.mockImplementation((key) => {
if (key === 'appVersion') return null;
+ if (key === 'appVersionTime') return null;
if (key === 'authToken') return mockToken;
if (key === 'darkMode') return 'false';
return null;
@@ -427,7 +565,77 @@ describe('WhoAmI Component', () => {
);
await waitFor(() => {
- expect(screen.getByText(/vUnknown|vloading/i)).toBeInTheDocument();
+ expect(screen.getByText('vUnknown')).toBeInTheDocument();
+ });
+ });
+
+ test('handles daemon status with missing nodename', async () => {
+ mockUseFetchDaemonStatus.mockReturnValue({
+ daemon: {},
+ fetchNodes: mockFetchNodes,
+ });
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ });
+ });
+
+ test('handles WhoAmI fetch with missing auth token', async () => {
+ mockLocalStorage.getItem.mockImplementation((key) => {
+ if (key === 'authToken') return null;
+ if (key === 'darkMode') return 'false';
+ return null;
+ });
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(fetch).toHaveBeenCalledWith(URL_AUTH_WHOAMI, {
+ credentials: 'include',
+ headers: {
+ Authorization: 'Bearer null',
+ },
+ });
+ });
+ });
+
+ test('handles WhoAmI response with missing fields', async () => {
+ const incompleteUserInfo = {
+ auth: null,
+ name: null,
+ raw_grant: null
+ };
+
+ global.fetch.mockImplementation((url) => {
+ if (url === URL_AUTH_WHOAMI) {
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(incompleteUserInfo),
+ });
+ }
+ return Promise.resolve({
+ json: () => Promise.resolve([{tag_name: 'v1.2.3'}]),
+ });
+ });
+
+ renderWithDarkModeProvider(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getAllByText('N/A').length).toBeGreaterThan(0);
});
});
});
diff --git a/src/eventSourceManager.jsx b/src/eventSourceManager.jsx
index 9985e463..6a98139c 100644
--- a/src/eventSourceManager.jsx
+++ b/src/eventSourceManager.jsx
@@ -73,10 +73,23 @@ const createBufferManager = () => {
configUpdated: new Set(),
};
let flushTimeout = null;
+ let eventCount = 0;
+ const FLUSH_DELAY = 500;
+ const BATCH_SIZE = 50;
const scheduleFlush = () => {
+ eventCount++;
+ if (eventCount >= BATCH_SIZE) {
+ if (flushTimeout) {
+ clearTimeout(flushTimeout);
+ flushTimeout = null;
+ }
+ flushBuffers();
+ return;
+ }
+
if (!flushTimeout) {
- flushTimeout = setTimeout(flushBuffers, 250);
+ flushTimeout = setTimeout(flushBuffers, FLUSH_DELAY);
}
};
@@ -94,10 +107,20 @@ const createBufferManager = () => {
setConfigUpdated,
} = store;
+ let updateCount = 0;
+
+ if (Object.keys(buffers.nodeStatus).length) {
+ setNodeStatuses({...store.nodeStatus, ...buffers.nodeStatus});
+ buffers.nodeStatus = {};
+ updateCount++;
+ }
+
if (Object.keys(buffers.objectStatus).length) {
setObjectStatuses({...store.objectStatus, ...buffers.objectStatus});
buffers.objectStatus = {};
+ updateCount++;
}
+
if (Object.keys(buffers.instanceStatus).length) {
const mergedInst = {...store.objectInstanceStatus};
for (const obj of Object.keys(buffers.instanceStatus)) {
@@ -105,28 +128,33 @@ const createBufferManager = () => {
}
setInstanceStatuses(mergedInst);
buffers.instanceStatus = {};
+ updateCount++;
}
- if (Object.keys(buffers.nodeStatus).length) {
- setNodeStatuses({...store.nodeStatus, ...buffers.nodeStatus});
- buffers.nodeStatus = {};
- }
+
if (Object.keys(buffers.nodeMonitor).length) {
setNodeMonitors({...store.nodeMonitor, ...buffers.nodeMonitor});
buffers.nodeMonitor = {};
+ updateCount++;
}
+
if (Object.keys(buffers.nodeStats).length) {
setNodeStats({...store.nodeStats, ...buffers.nodeStats});
buffers.nodeStats = {};
+ updateCount++;
}
+
if (Object.keys(buffers.heartbeatStatus).length) {
logger.debug('buffer:', buffers.heartbeatStatus);
setHeartbeatStatuses({...store.heartbeatStatus, ...buffers.heartbeatStatus});
buffers.heartbeatStatus = {};
}
+
if (Object.keys(buffers.instanceMonitor).length) {
setInstanceMonitors({...store.instanceMonitor, ...buffers.instanceMonitor});
buffers.instanceMonitor = {};
+ updateCount++;
}
+
if (Object.keys(buffers.instanceConfig).length) {
for (const path of Object.keys(buffers.instanceConfig)) {
for (const node of Object.keys(buffers.instanceConfig[path])) {
@@ -134,13 +162,23 @@ const createBufferManager = () => {
}
}
buffers.instanceConfig = {};
+ updateCount++;
}
+
if (buffers.configUpdated.size) {
setConfigUpdated([...buffers.configUpdated]);
buffers.configUpdated.clear();
+ updateCount++;
+ }
+
+ if (updateCount > 0) {
+ logger.debug(`Flushed ${updateCount} buffer types with ${eventCount} events`);
}
+
flushTimeout = null;
+ eventCount = 0;
};
+
return {buffers, scheduleFlush};
};
@@ -188,11 +226,13 @@ export const createEventSource = (url, token) => {
if (error.status === 401) {
logger.warn('🔐 Authentication error detected');
const newToken = localStorage.getItem('authToken');
+
if (newToken && newToken !== token) {
logger.info('🔄 New token available, updating EventSource');
updateEventSourceToken(newToken);
return;
}
+
if (window.oidcUserManager) {
logger.info('🔄 Attempting silent token renewal...');
window.oidcUserManager.signinSilent()
@@ -214,7 +254,6 @@ export const createEventSource = (url, token) => {
reconnectAttempts++;
const delay = Math.min(BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts) + Math.random() * 100, MAX_RECONNECT_DELAY);
logger.info(`🔄 Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
-
setTimeout(() => {
const currentToken = getCurrentToken();
if (currentToken) {
@@ -373,11 +412,13 @@ export const createLoggerEventSource = (url, token, filters) => {
if (error.status === 401) {
logger.warn('🔐 Authentication error detected in logger');
const newToken = localStorage.getItem('authToken');
+
if (newToken && newToken !== token) {
logger.info('🔄 New token available, updating Logger EventSource');
updateLoggerEventSourceToken(newToken);
return;
}
+
if (window.oidcUserManager) {
window.oidcUserManager.signinSilent()
.then(user => {
@@ -398,7 +439,6 @@ export const createLoggerEventSource = (url, token, filters) => {
reconnectAttempts++;
const delay = Math.min(BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts) + Math.random() * 100, MAX_RECONNECT_DELAY);
logger.info(`🔄 Logger reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
-
setTimeout(() => {
const currentToken = getCurrentToken();
if (currentToken) {
diff --git a/src/hooks/AuthInfo.jsx b/src/hooks/AuthInfo.jsx
index a7a70710..c469d541 100644
--- a/src/hooks/AuthInfo.jsx
+++ b/src/hooks/AuthInfo.jsx
@@ -25,7 +25,7 @@ function useAuthInfo() {
fetchData()
.catch(error => {
if (isMounted) {
- logger.error("Erreur non gérée dans fetchData:", error);
+ logger.error("Unhandled error in fetchData:", error);
}
});
diff --git a/src/hooks/tests/AuthInfo.test.jsx b/src/hooks/tests/AuthInfo.test.jsx
index 1d08ad9f..a08f6f38 100644
--- a/src/hooks/tests/AuthInfo.test.jsx
+++ b/src/hooks/tests/AuthInfo.test.jsx
@@ -1,6 +1,7 @@
import {renderHook, act} from '@testing-library/react';
import useAuthInfo from '../AuthInfo';
import {URL_AUTH_INFO} from '../../config/apiPath';
+import logger from '../../utils/logger.js';
jest.mock('../../config/apiPath', () => ({
URL_AUTH_INFO: 'http://mock-api/auth-info',
@@ -8,157 +9,127 @@ jest.mock('../../config/apiPath', () => ({
describe('useAuthInfo hook', () => {
let originalFetch;
- const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {
- });
-
+ let loggerErrorSpy;
beforeEach(() => {
originalFetch = global.fetch;
- consoleLogSpy.mockClear();
+ loggerErrorSpy = jest.spyOn(logger, 'error').mockImplementation(() => {});
+ loggerErrorSpy.mockClear();
});
-
afterEach(() => {
global.fetch = originalFetch;
+ loggerErrorSpy.mockRestore();
});
-
test('returns undefined initially', () => {
global.fetch = jest.fn();
const {result} = renderHook(() => useAuthInfo());
expect(result.current).toBeUndefined();
});
-
test('fetches and sets authInfo on successful response', async () => {
const mockData = {user: 'testuser', role: 'admin'};
-
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue(mockData),
});
-
const {result} = renderHook(() => useAuthInfo());
-
await act(async () => {
await Promise.resolve();
});
-
expect(result.current).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledWith(URL_AUTH_INFO);
});
-
test('keeps authInfo undefined and logs error on fetch failure', async () => {
const error = new Error('Network error');
-
global.fetch = jest.fn().mockRejectedValue(error);
-
const {result} = renderHook(() => useAuthInfo());
-
await act(async () => {
await Promise.resolve();
});
-
expect(result.current).toBeUndefined();
- expect(consoleLogSpy).toHaveBeenCalledWith(error);
+ expect(loggerErrorSpy).toHaveBeenCalledWith(error);
});
-
test('keeps authInfo undefined and logs error on JSON parsing failure', async () => {
const error = new Error('Invalid JSON');
-
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn().mockRejectedValue(error),
});
-
const {result} = renderHook(() => useAuthInfo());
-
await act(async () => {
await Promise.resolve();
});
-
expect(result.current).toBeUndefined();
- expect(consoleLogSpy).toHaveBeenCalledWith(error);
+ expect(loggerErrorSpy).toHaveBeenCalledWith(error);
});
-
test('fetch is called only once on mount', async () => {
const mockData = {user: 'testuser'};
-
const fetchMock = jest.fn().mockResolvedValue({
json: jest.fn().mockResolvedValue(mockData),
});
-
global.fetch = fetchMock;
-
const {result, rerender} = renderHook(() => useAuthInfo());
-
await act(async () => {
await Promise.resolve();
});
-
expect(result.current).toEqual(mockData);
-
rerender();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
-
test('does not update state after unmount', async () => {
const mockData = {user: 'testuser'};
-
let resolveJson;
const jsonPromise = new Promise((res) => (resolveJson = res));
-
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn(() => jsonPromise),
});
-
const {result, unmount} = renderHook(() => useAuthInfo());
-
unmount();
-
await act(async () => {
resolveJson(mockData);
await jsonPromise;
});
-
expect(result.current).toBeUndefined();
});
-
test('does not log error after unmount on fetch failure', async () => {
const error = new Error('Network error');
-
let rejectFetch;
const fetchPromise = new Promise((_, rej) => (rejectFetch = rej));
-
global.fetch = jest.fn(() => fetchPromise);
-
const {result, unmount} = renderHook(() => useAuthInfo());
-
unmount();
-
await act(async () => {
rejectFetch(error);
await Promise.resolve();
});
-
expect(result.current).toBeUndefined();
- expect(consoleLogSpy).not.toHaveBeenCalled();
+ expect(loggerErrorSpy).not.toHaveBeenCalled();
});
-
test('does not log error after unmount on JSON parsing failure', async () => {
const error = new Error('Invalid JSON');
-
let rejectJson;
const jsonPromise = new Promise((_, rej) => (rejectJson = rej));
-
global.fetch = jest.fn().mockResolvedValue({
json: jest.fn(() => jsonPromise),
});
-
const {result, unmount} = renderHook(() => useAuthInfo());
-
unmount();
-
await act(async () => {
rejectJson(error);
await Promise.resolve();
});
-
expect(result.current).toBeUndefined();
- expect(consoleLogSpy).not.toHaveBeenCalled();
+ expect(loggerErrorSpy).not.toHaveBeenCalled();
+ });
+ test('logs unhandled error if error occurs during error handling', async () => {
+ const networkError = new Error('Network error');
+ const handlingError = new Error('Error in error handler');
+ loggerErrorSpy.mockImplementationOnce(() => { throw handlingError; });
+ global.fetch = jest.fn().mockRejectedValue(networkError);
+ const {result} = renderHook(() => useAuthInfo());
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(result.current).toBeUndefined();
+ expect(loggerErrorSpy).toHaveBeenCalledTimes(2);
+ expect(loggerErrorSpy.mock.calls[0][0]).toEqual(networkError);
+ expect(loggerErrorSpy.mock.calls[1][0]).toBe("Unhandled error in fetchData:");
+ expect(loggerErrorSpy.mock.calls[1][1]).toEqual(handlingError);
});
});
diff --git a/src/hooks/tests/useFetchDaemonStatus.test.jsx b/src/hooks/tests/useFetchDaemonStatus.test.jsx
index 78b794d2..a36f6434 100644
--- a/src/hooks/tests/useFetchDaemonStatus.test.jsx
+++ b/src/hooks/tests/useFetchDaemonStatus.test.jsx
@@ -72,7 +72,10 @@ describe('useFetchDaemonStatus Hook', () => {
beforeEach(() => {
jest.clearAllMocks();
fetchDaemonStatus.mockReset();
- console.error = jest.fn(); // Mock console.error for error logging
+ console.error = jest.fn();
+
+ // Clear localStorage before each test
+ localStorage.clear();
});
test('initializes with correct default states', () => {
@@ -165,8 +168,6 @@ describe('useFetchDaemonStatus Hook', () => {
expect(fetchDaemonStatus).toHaveBeenCalledWith(mockToken);
});
- // NOUVEAUX TESTS POUR AMÉLIORER LE COVERAGE DES BRANCHES
-
test('handles cluster config without name', async () => {
const mockDaemonStatusWithoutClusterName = {
daemon: {status: 'running'},
@@ -317,6 +318,7 @@ describe('useFetchDaemonStatus Hook', () => {
expect(fetchDaemonStatus).not.toHaveBeenCalled();
expect(screen.getByTestId('nodes').textContent).toBe('[]');
+ expect(screen.getByTestId('error').textContent).toBe('');
});
test('handles complex node structures', async () => {
@@ -382,4 +384,111 @@ describe('useFetchDaemonStatus Hook', () => {
expect(screen.getByTestId('error').textContent).toBe('');
expect(screen.getByTestId('nodes').textContent).not.toBe('[]');
});
+
+ test('should load cached data from localStorage on mount', () => {
+ const cachedData = {
+ nodes: [{nodename: 'cached-node', status: 'cached'}],
+ daemon: {status: 'cached-running'},
+ clusterStats: {nodeCount: 1},
+ clusterName: 'cached-cluster'
+ };
+
+ localStorage.setItem('cachedNodes', JSON.stringify(cachedData.nodes));
+ localStorage.setItem('cachedDaemon', JSON.stringify(cachedData.daemon));
+ localStorage.setItem('cachedClusterStats', JSON.stringify(cachedData.clusterStats));
+ localStorage.setItem('cachedClusterName', JSON.stringify(cachedData.clusterName));
+
+ render(
);
+
+ expect(screen.getByTestId('nodes').textContent).toBe(JSON.stringify(cachedData.nodes));
+ expect(screen.getByTestId('daemon').textContent).toBe(JSON.stringify(cachedData.daemon));
+ expect(screen.getByTestId('clusterStats').textContent).toBe(JSON.stringify(cachedData.clusterStats));
+ expect(screen.getByTestId('clusterName').textContent).toBe(cachedData.clusterName);
+ });
+
+ test('should handle localStorage errors when loading cache', () => {
+ const localStorageMock = {
+ getItem: jest.fn().mockImplementation(() => {
+ throw new Error('Storage error');
+ }),
+ setItem: jest.fn(),
+ removeItem: jest.fn(),
+ clear: jest.fn()
+ };
+ Object.defineProperty(window, 'localStorage', {value: localStorageMock});
+
+ render();
+
+ expect(console.warn).toHaveBeenCalledWith('Failed to load cached data:', expect.any(Error));
+
+ // Restore original localStorage
+ Object.defineProperty(window, 'localStorage', {value: localStorage});
+ });
+
+ test('should handle localStorage errors when caching data', async () => {
+ fetchDaemonStatus.mockResolvedValue(mockDaemonStatus);
+
+ const localStorageMock = {
+ getItem: jest.fn(),
+ setItem: jest.fn().mockImplementation(() => {
+ throw new Error('Storage write error');
+ }),
+ removeItem: jest.fn(),
+ clear: jest.fn()
+ };
+ Object.defineProperty(window, 'localStorage', {value: localStorageMock});
+
+ render();
+
+ fireEvent.click(screen.getByTestId('fetchNodes'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('loading').textContent).toBe('false');
+ });
+
+ expect(console.warn).toHaveBeenCalledWith('Failed to cache data:', expect.any(Error));
+
+ // Restore original localStorage
+ Object.defineProperty(window, 'localStorage', {value: localStorage});
+ });
+
+ test('should handle localStorage errors when clearing cache on API error', async () => {
+ fetchDaemonStatus.mockRejectedValue(new Error('API error'));
+
+ const localStorageMock = {
+ getItem: jest.fn(),
+ setItem: jest.fn(),
+ removeItem: jest.fn().mockImplementation(() => {
+ throw new Error('Storage remove error');
+ }),
+ clear: jest.fn()
+ };
+ Object.defineProperty(window, 'localStorage', {value: localStorageMock});
+
+ render();
+
+ fireEvent.click(screen.getByTestId('fetchNodes'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('loading').textContent).toBe('false');
+ });
+
+ expect(console.warn).toHaveBeenCalledWith('Failed to clear cache on error:', expect.any(Error));
+
+ // Restore original localStorage
+ Object.defineProperty(window, 'localStorage', {value: localStorage});
+ });
+
+ test('should not call API when token is empty', async () => {
+ render();
+
+ fireEvent.click(screen.getByTestId('fetchNodes'));
+
+ await waitFor(() => {
+ expect(screen.getByTestId('error').textContent).toBe('Token is required to fetch daemon status');
+ });
+
+ expect(fetchDaemonStatus).not.toHaveBeenCalled();
+ expect(screen.getByTestId('loading').textContent).toBe('false');
+ });
});
diff --git a/src/hooks/useFetchDaemonStatus.jsx b/src/hooks/useFetchDaemonStatus.jsx
index 54ff6073..ae62f1dc 100644
--- a/src/hooks/useFetchDaemonStatus.jsx
+++ b/src/hooks/useFetchDaemonStatus.jsx
@@ -1,4 +1,4 @@
-import {useState, useRef, useCallback} from "react";
+import {useState, useRef, useCallback, useEffect} from "react";
import {fetchDaemonStatus} from "../services/api";
import logger from '../utils/logger.js';
@@ -10,11 +10,45 @@ const useFetchDaemonStatus = () => {
const cacheRef = useRef([]);
const [clusterStats, setClusterStats] = useState({});
const [clusterName, setClusterName] = useState("");
+ const isInitialMount = useRef(true);
+
+ // Load cached data on mount
+ useEffect(() => {
+ try {
+ const cachedNodes = localStorage.getItem('cachedNodes');
+ const cachedDaemon = localStorage.getItem('cachedDaemon');
+ const cachedClusterStats = localStorage.getItem('cachedClusterStats');
+ const cachedClusterName = localStorage.getItem('cachedClusterName');
+ if (cachedNodes) {
+ const nodesArray = JSON.parse(cachedNodes);
+ setNodes(nodesArray);
+ cacheRef.current = nodesArray;
+ }
+ if (cachedDaemon) {
+ setDaemon(JSON.parse(cachedDaemon));
+ }
+ if (cachedClusterStats) {
+ setClusterStats(JSON.parse(cachedClusterStats));
+ }
+ if (cachedClusterName) {
+ setClusterName(JSON.parse(cachedClusterName));
+ }
+ } catch (err) {
+ logger.warn("Failed to load cached data:", err);
+ }
+ }, []);
// Memoize refreshDaemonStatus with useCallback
const refreshDaemonStatus = useCallback(async (token) => {
- setLoading(true);
+ if (!token) {
+ setError("Token is required to fetch daemon status");
+ return;
+ }
+
+ const hasCache = cacheRef.current.length > 0;
+ if (!hasCache) setLoading(true);
setError("");
+
try {
const result = await fetchDaemonStatus(token);
const nodesArray = Object.keys(result.cluster.node).map((key) => ({
@@ -28,9 +62,34 @@ const useFetchDaemonStatus = () => {
});
setClusterName(result.cluster.config.name || "Cluster");
cacheRef.current = nodesArray;
+ // Cache in localStorage
+ try {
+ localStorage.setItem('cachedNodes', JSON.stringify(nodesArray));
+ localStorage.setItem('cachedDaemon', JSON.stringify(result.daemon));
+ localStorage.setItem('cachedClusterStats', JSON.stringify({nodeCount: nodesArray.length}));
+ localStorage.setItem('cachedClusterName', JSON.stringify(result.cluster.config.name || "Cluster"));
+ } catch (err) {
+ logger.warn("Failed to cache data:", err);
+ }
} catch (err) {
logger.error("Error while fetching daemon statuses:", err);
setError("Failed to retrieve daemon statuses.");
+ // Clear cached data on error
+ setNodes([]);
+ setDaemon({});
+ setClusterStats({});
+ setClusterName("");
+ cacheRef.current = [];
+
+ // Clear localStorage on error
+ try {
+ localStorage.removeItem('cachedNodes');
+ localStorage.removeItem('cachedDaemon');
+ localStorage.removeItem('cachedClusterStats');
+ localStorage.removeItem('cachedClusterName');
+ } catch (storageErr) {
+ logger.warn("Failed to clear cache on error:", storageErr);
+ }
} finally {
setLoading(false);
}
diff --git a/src/services/api.jsx b/src/services/api.jsx
index 36ac790c..e333c301 100644
--- a/src/services/api.jsx
+++ b/src/services/api.jsx
@@ -1,70 +1,104 @@
import {URL_CLUSTER_STATUS} from "../config/apiPath.js";
-// ApiError encapsulates HTTP errors with status and optional server body
export class ApiError extends Error {
constructor(message, {status = null, statusText = null, body = null} = {}) {
super(message);
this.name = 'ApiError';
this.status = status;
this.statusText = statusText;
- this.body = body; // parsed JSON or text from server when available
+ this.body = body;
}
}
-// Centralized fetch wrapper that returns parsed JSON on success and throws ApiError on failure
export async function apiFetch(url, options = {}) {
+ const controller = new AbortController();
+ const timeoutMs = options.timeout || 10000;
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
+
let response;
try {
- response = await fetch(url, options);
+ response = await fetch(url, {
+ ...options,
+ signal: controller.signal
+ });
+ clearTimeout(timeoutId);
} catch (networkErr) {
- // Network-level error (DNS, CORS, connection, aborted, etc.)
- throw new ApiError(networkErr.message || 'Network error', { body: null });
+ clearTimeout(timeoutId);
+ if (networkErr.name === 'AbortError') {
+ throw new ApiError(`Request timed out after ${timeoutMs}ms`, {body: null});
+ }
+ throw new ApiError(networkErr.message || 'Network error', {body: null});
}
- const headers = response.headers || {};
- const contentType = (headers.get && headers.get('content-type')) || '';
- let parsedBody = null;
+ let parsedBody = null;
- // Try to parse JSON when content-type indicates JSON
- if (contentType.includes('application/json')) {
- try {
+ let contentType = null;
+ if (response.headers && typeof response.headers.get === 'function') {
+ contentType = response.headers.get('content-type');
+ }
+
+ try {
+ if (contentType && contentType.includes('application/json')) {
+ if (typeof response.json === 'function') {
parsedBody = await response.json();
- } catch (e) {
- parsedBody = null;
}
}
- // Fallbacks: if no parsedBody yet, try response.json() if available, then response.text()
if (parsedBody === null) {
if (typeof response.json === 'function') {
try {
parsedBody = await response.json();
- } catch (e) {
- parsedBody = null;
+ } catch (jsonError) {
+ if (typeof response.text === 'function') {
+ try {
+ parsedBody = await response.text();
+ } catch (textError) {
+ parsedBody = null;
+ }
+ }
}
- }
- }
-
- if (parsedBody === null) {
- if (typeof response.text === 'function') {
+ } else if (typeof response.text === 'function') {
try {
parsedBody = await response.text();
- } catch (e) {
+ } catch (textError) {
parsedBody = null;
}
}
}
+ } catch (parseError) {
+ parsedBody = null;
+ }
if (!response.ok) {
- const serverMessage = parsedBody && typeof parsedBody === 'object' ? parsedBody.message || JSON.stringify(parsedBody) : parsedBody;
- const message = serverMessage || response.statusText || `Request failed with status ${response.status}`;
- throw new ApiError(message, { status: response.status, statusText: response.statusText, body: parsedBody });
+ let serverMessage = parsedBody;
+
+ if (parsedBody && typeof parsedBody === 'object') {
+ serverMessage = parsedBody.message || JSON.stringify(parsedBody);
+ }
+
+ const message = serverMessage ||
+ response.statusText ||
+ `Request failed with status ${response.status}`;
+
+ throw new ApiError(message, {
+ status: response.status,
+ statusText: response.statusText,
+ body: parsedBody
+ });
}
return parsedBody;
}
-export const fetchDaemonStatus = async (token) => {
- const headers = token ? { Authorization: `Bearer ${token}` } : {};
- return await apiFetch(URL_CLUSTER_STATUS, {method: 'GET', headers});
-};
\ No newline at end of file
+export const fetchDaemonStatus = async (token, options = {}) => {
+ const headers = {
+ ...options.headers,
+ ...(token && {Authorization: `Bearer ${token}`})
+ };
+
+ return await apiFetch(URL_CLUSTER_STATUS, {
+ method: 'GET',
+ headers,
+ ...options
+ });
+};
diff --git a/src/services/tests/api.test.jsx b/src/services/tests/api.test.jsx
index 5e7871a4..555608f8 100644
--- a/src/services/tests/api.test.jsx
+++ b/src/services/tests/api.test.jsx
@@ -15,8 +15,27 @@ const createHeadersWithoutGet = () => ({});
describe("fetchDaemonStatus", () => {
const token = "fake-token";
- afterEach(() => {
+ beforeEach(() => {
+ // Clear all mocks before each test
jest.clearAllMocks();
+
+ // Mock AbortController for consistent testing
+ global.AbortController = jest.fn(() => ({
+ abort: jest.fn(),
+ signal: {
+ aborted: false,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ }
+ }));
+
+ // Mock clearTimeout to avoid timer issues
+ global.clearTimeout = jest.fn();
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
});
test("calls fetch with correct URL and headers", async () => {
@@ -31,12 +50,20 @@ describe("fetchDaemonStatus", () => {
const result = await fetchDaemonStatus(token);
- expect(fetch).toHaveBeenCalledWith(URL_CLUSTER_STATUS, {
- method: "GET",
- headers: {
- Authorization: `Bearer ${token}`,
- },
- });
+ expect(fetch).toHaveBeenCalledWith(
+ URL_CLUSTER_STATUS,
+ expect.objectContaining({
+ method: "GET",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ );
+
+ // Verify signal is present
+ const fetchCall = fetch.mock.calls[0];
+ expect(fetchCall[1]).toHaveProperty('signal');
+ expect(fetchCall[1].signal).toBeDefined();
expect(result).toEqual(mockData);
});
@@ -126,10 +153,18 @@ describe("fetchDaemonStatus", () => {
const result = await fetchDaemonStatus(null);
- expect(fetch).toHaveBeenCalledWith(URL_CLUSTER_STATUS, {
- method: "GET",
- headers: {},
- });
+ expect(fetch).toHaveBeenCalledWith(
+ URL_CLUSTER_STATUS,
+ expect.objectContaining({
+ method: "GET",
+ headers: {},
+ })
+ );
+
+ // Verify signal is present
+ const fetchCall = fetch.mock.calls[0];
+ expect(fetchCall[1]).toHaveProperty('signal');
+ expect(fetchCall[1].signal).toBeDefined();
expect(result).toEqual(mockData);
});
@@ -157,8 +192,6 @@ describe("fetchDaemonStatus", () => {
});
});
- // New tests to improve branch coverage
-
test("handles response without headers.get method", async () => {
const mockData = {status: "ok"};
@@ -303,4 +336,57 @@ describe("fetchDaemonStatus", () => {
body: mockErrorBody
});
});
+ test("throws ApiError on request timeout", async () => {
+ jest.useFakeTimers();
+ // Override AbortController mock to handle listeners properly
+ const listeners = [];
+ global.AbortController = jest.fn(() => ({
+ abort: () => {
+ listeners.forEach(listener => listener());
+ },
+ signal: {
+ aborted: false,
+ addEventListener: (type, listener) => {
+ if (type === 'abort') {
+ listeners.push(listener);
+ }
+ },
+ removeEventListener: (type, listener) => {
+ if (type === 'abort') {
+ const index = listeners.indexOf(listener);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ }
+ },
+ dispatchEvent: jest.fn(),
+ }
+ }));
+ // Mock fetch to reject on abort
+ fetch.mockImplementationOnce((url, options) => {
+ return new Promise((resolve, reject) => {
+ const abortError = new Error('Operation aborted');
+ abortError.name = 'AbortError';
+ options.signal.addEventListener('abort', () => reject(abortError));
+ });
+ });
+ const timeoutMs = 5000;
+ const promise = fetchDaemonStatus(token, { timeout: timeoutMs });
+ jest.advanceTimersByTime(timeoutMs);
+ await expect(promise).rejects.toMatchObject({
+ name: 'ApiError',
+ message: `Request timed out after ${timeoutMs}ms`,
+ body: null
+ });
+ jest.useRealTimers();
+ });
+ test("handles response without json and text methods", async () => {
+ fetch.mockResolvedValueOnce({
+ ok: true,
+ headers: createHeaders("application/json"),
+ // No json or text methods
+ });
+ const result = await fetchDaemonStatus(token);
+ expect(result).toBeNull();
+ });
});