diff --git a/src/components/App.jsx b/src/components/App.jsx index 955aaab4..bc3dc8d6 100644 --- a/src/components/App.jsx +++ b/src/components/App.jsx @@ -1,21 +1,11 @@ -import React, {useEffect, useCallback} from "react"; +import React, {useEffect, useCallback, lazy, Suspense} from "react"; import {Routes, Route, Navigate, useNavigate} from "react-router-dom"; import OidcCallback from "./OidcCallback"; import SilentRenew from "./SilentRenew.jsx"; import AuthChoice from "./AuthChoice.jsx"; import Login from "./Login.jsx"; import '../styles/main.css'; -import NodesTable from "./NodesTable"; -import Objects from "./Objects"; -import ObjectDetails from "./ObjectDetails"; -import ClusterOverview from "./Cluster"; import NavBar from './NavBar'; -import Namespaces from "./Namespaces"; -import Heartbeats from "./Heartbeats"; -import Pools from "./Pools"; -import Network from "./Network"; -import NetworkDetails from "./NetworkDetails"; -import WhoAmI from "./WhoAmI"; import {OidcProvider, useOidc} from "../context/OidcAuthContext.tsx"; import { AuthProvider, @@ -31,6 +21,25 @@ import logger from "../utils/logger.js"; import {useDarkMode} from "../context/DarkModeContext"; import {ThemeProvider, createTheme} from '@mui/material/styles'; +// Lazy load components for code splitting +const NodesTable = lazy(() => import("./NodesTable")); +const Objects = lazy(() => import("./Objects")); +const ObjectDetails = lazy(() => import("./ObjectDetails")); +const ClusterOverview = lazy(() => import("./Cluster")); +const Namespaces = lazy(() => import("./Namespaces")); +const Heartbeats = lazy(() => import("./Heartbeats")); +const Pools = lazy(() => import("./Pools")); +const Network = lazy(() => import("./Network")); +const NetworkDetails = lazy(() => import("./NetworkDetails")); +const WhoAmI = lazy(() => import("./WhoAmI")); + +// Loading component for Suspense fallback +const Loading = () => ( +
+
Loading...
+
+); + const DynamicThemeProvider = ({children}) => { const {isDarkMode} = useDarkMode(); @@ -309,26 +318,28 @@ const App = () => {
- - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - }/> - + }> + + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + }/> + +
diff --git a/src/components/Cluster.jsx b/src/components/Cluster.jsx index a4486bc7..61574a1b 100644 --- a/src/components/Cluster.jsx +++ b/src/components/Cluster.jsx @@ -1,9 +1,8 @@ import logger from '../utils/logger.js'; -import React, {useEffect, useState, useRef} from "react"; +import React, {useEffect, useState, useRef, useMemo} from "react"; import {useNavigate} from "react-router-dom"; import {Box, Typography} from "@mui/material"; import axios from "axios"; - import useEventStore from "../hooks/useEventStore.js"; import { GridNodes, @@ -19,6 +18,7 @@ import EventLogger from "../components/EventLogger"; const ClusterOverview = () => { const navigate = useNavigate(); + const nodeStatus = useEventStore((state) => state.nodeStatus); const objectStatus = useEventStore((state) => state.objectStatus); const heartbeatStatus = useEventStore((state) => state.heartbeatStatus); @@ -49,6 +49,7 @@ const ClusterOverview = () => { if (token) { startEventReception(token); + // Fetch pools axios.get(URL_POOL, { headers: {Authorization: `Bearer ${token}`} @@ -79,89 +80,113 @@ const ClusterOverview = () => { setNetworks([]); }); } + return () => { isMounted.current = false; }; }, []); - const nodeCount = Object.keys(nodeStatus).length; + const nodeStats = useMemo(() => { + const count = Object.keys(nodeStatus).length; + let frozen = 0; + let unfrozen = 0; - let frozenCount = 0; - let unfrozenCount = 0; + Object.values(nodeStatus).forEach((node) => { + const isFrozen = node?.frozen_at && node?.frozen_at !== "0001-01-01T00:00:00Z"; + if (isFrozen) frozen++; + else unfrozen++; + }); - Object.values(nodeStatus).forEach((node) => { - const isFrozen = node?.frozen_at && node?.frozen_at !== "0001-01-01T00:00:00Z"; - if (isFrozen) frozenCount++; - else unfrozenCount++; - }); + return {count, frozen, unfrozen}; + }, [nodeStatus]); - const namespaces = new Set(); - const statusCount = {up: 0, down: 0, warn: 0, "n/a": 0, unprovisioned: 0}; - const objectsPerNamespace = {}; - const statusPerNamespace = {}; + const objectStats = useMemo(() => { + const namespaces = new Set(); + const statusCount = {up: 0, down: 0, warn: 0, "n/a": 0, unprovisioned: 0}; + const objectsPerNamespace = {}; + const statusPerNamespace = {}; - const extractNamespace = (objectPath) => { - const parts = objectPath.split("/"); - return parts.length === 3 ? parts[0] : "root"; - }; + const extractNamespace = (objectPath) => { + const parts = objectPath.split("/"); + return parts.length === 3 ? parts[0] : "root"; + }; - Object.entries(objectStatus).forEach(([objectPath, status]) => { - const ns = extractNamespace(objectPath); - namespaces.add(ns); - objectsPerNamespace[ns] = (objectsPerNamespace[ns] || 0) + 1; + Object.entries(objectStatus).forEach(([objectPath, status]) => { + const ns = extractNamespace(objectPath); + namespaces.add(ns); + objectsPerNamespace[ns] = (objectsPerNamespace[ns] || 0) + 1; - const s = status?.avail?.toLowerCase() || "n/a"; - if (!statusPerNamespace[ns]) { - statusPerNamespace[ns] = {up: 0, down: 0, warn: 0, "n/a": 0, unprovisioned: 0}; - } - if (s === "up" || s === "down" || s === "warn" || s === "n/a") { - statusPerNamespace[ns][s]++; - statusCount[s]++; - } else { - statusPerNamespace[ns]["n/a"]++; - statusCount["n/a"]++; - } + const s = status?.avail?.toLowerCase() || "n/a"; + if (!statusPerNamespace[ns]) { + statusPerNamespace[ns] = {up: 0, down: 0, warn: 0, "n/a": 0, unprovisioned: 0}; + } - // Count unprovisioned objects - const provisioned = status?.provisioned; - const isUnprovisioned = provisioned === "false" || provisioned === false; - if (isUnprovisioned) { - statusPerNamespace[ns].unprovisioned++; - statusCount.unprovisioned++; - } - }); + if (s === "up" || s === "down" || s === "warn" || s === "n/a") { + statusPerNamespace[ns][s]++; + statusCount[s]++; + } else { + statusPerNamespace[ns]["n/a"]++; + statusCount["n/a"]++; + } - const namespaceCount = namespaces.size; + // Count unprovisioned objects + const provisioned = status?.provisioned; + const isUnprovisioned = provisioned === "false" || provisioned === false; + if (isUnprovisioned) { + statusPerNamespace[ns].unprovisioned++; + statusCount.unprovisioned++; + } + }); - const namespaceSubtitle = Object.entries(objectsPerNamespace) - .map(([ns, count]) => ({namespace: ns, count, status: statusPerNamespace[ns]})); + const namespaceSubtitle = Object.entries(objectsPerNamespace) + .map(([ns, count]) => ({ + namespace: ns, + count, + status: statusPerNamespace[ns] + })); - const heartbeatIds = new Set(); - let beatingCount = 0; - let staleCount = 0; - const stateCount = {running: 0, stopped: 0, failed: 0, warning: 0, unknown: 0}; + return { + objectCount: Object.keys(objectStatus).length, + namespaceCount: namespaces.size, + statusCount, + namespaceSubtitle + }; + }, [objectStatus]); - Object.values(heartbeatStatus).forEach(node => { - (node.streams || []).forEach(stream => { - const peer = Object.values(stream.peers || {})[0]; - const baseId = stream.id.split('.')[0]; - heartbeatIds.add(baseId); + const heartbeatStats = useMemo(() => { + const heartbeatIds = new Set(); + let beating = 0; + let stale = 0; + const stateCount = {running: 0, stopped: 0, failed: 0, warning: 0, unknown: 0}; - if (peer?.is_beating) { - beatingCount++; - } else { - staleCount++; - } + Object.values(heartbeatStatus).forEach(node => { + (node.streams || []).forEach(stream => { + const peer = Object.values(stream.peers || {})[0]; + const baseId = stream.id?.split('.')[0]; + if (baseId) heartbeatIds.add(baseId); - const state = stream.state || 'unknown'; - if (stateCount.hasOwnProperty(state)) { - stateCount[state]++; - } else { - stateCount.unknown++; - } + if (peer?.is_beating) { + beating++; + } else { + stale++; + } + + const state = stream.state || 'unknown'; + if (stateCount.hasOwnProperty(state)) { + stateCount[state]++; + } else { + stateCount.unknown++; + } + }); }); - }); - const heartbeatCount = heartbeatIds.size; + + return { + count: heartbeatIds.size, + beating, + stale, + stateCount + }; + }, [heartbeatStatus]); return ( { }}> navigate("/nodes")} /> navigate(globalState ? `/objects?globalState=${globalState}` : '/objects')} /> { const params = new URLSearchParams(); if (status) params.append('status', status); @@ -249,8 +274,8 @@ const ClusterOverview = () => { {/* Right side - Namespaces */} navigate(url || "/namespaces")} /> diff --git a/src/components/Network.jsx b/src/components/Network.jsx index 2d562bf7..29951536 100644 --- a/src/components/Network.jsx +++ b/src/components/Network.jsx @@ -1,8 +1,7 @@ -import React, {useEffect, useState, useRef} from "react"; +import React, {useEffect, useState, useRef, useMemo} from "react"; import {useNavigate} from "react-router-dom"; import { Box, - Paper, Typography, Table, TableBody, @@ -49,10 +48,10 @@ const Network = () => { } }; - fetchNetworks(); + void fetchNetworks(); }, []); - const sortedNetworks = React.useMemo(() => { + const sortedNetworks = useMemo(() => { return [...networks].sort((a, b) => { let diff = 0; if (sortColumn === "name") { @@ -109,7 +108,6 @@ const Network = () => { }} > { ) : ( { useEffect(() => { const token = localStorage.getItem("authToken"); if (token) { - // Handle the promise properly + // Start event reception immediately + startEventReception(token, nodeEventTypes); + // Fetch daemon status in parallel fetchNodes(token).catch(error => { logger.error("Failed to fetch nodes:", error); }); - startEventReception(token, nodeEventTypes); } return () => { @@ -571,9 +572,9 @@ const NodesTable = () => { - {sortedNodes.map((nodename, index) => ( + {sortedNodes.map((nodename) => ( () =>
Heartbeats< jest.mock('../Pools', () => () =>
Pools
); jest.mock('../Objects', () => () =>
Objects
); jest.mock('../ObjectDetails', () => () =>
ObjectDetails
); +jest.mock('../Network', () => () =>
Network
); +jest.mock('../NetworkDetails', () => () =>
NetworkDetails
); +jest.mock('../WhoAmI', () => () =>
WhoAmI
); +jest.mock('../SilentRenew.jsx', () => () =>
SilentRenew
); jest.mock('../AuthChoice.jsx', () => () =>
AuthChoice
); jest.mock('../Login', () => () =>
Login
); jest.mock('../OidcCallback', () => () =>
OidcCallback
); @@ -834,4 +838,492 @@ describe('App Component', () => { expect(await screen.findByTestId('auth-choice')).toBeInTheDocument(); }); -}); + + test('handles silent renew error callback with error parameter', async () => { + mockAuthState.authChoice = 'openid'; + + mockLocalStorage.getItem.mockImplementation((k) => + k === 'authToken' ? 'dummy' : (k === 'authChoice' ? 'openid' : null) + ); + + renderAppWithProviders(['/cluster']); + + await waitFor(() => + expect(mockUserManager.events.addSilentRenewError).toHaveBeenCalled() + ); + + const errorCb = mockUserManager.events.addSilentRenewError.mock.calls[0][0]; + + act(() => errorCb(new Error('renew failed'))); + + await waitFor(() => + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('authToken') + ); + + await waitFor(() => + expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('tokenExpiration') + ); + + expect(mockNavigate).toHaveBeenCalledWith('/auth-choice', {replace: true}); + }); + + test('onUserRefreshed callback handles user without profile', async () => { + mockAuthState.authChoice = 'openid'; + + const userWithoutProfile = { + access_token: 'new-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + + // Simulate that getUser returns this user + mockUserManager.getUser.mockResolvedValue(userWithoutProfile); + + mockLocalStorage.getItem.mockImplementation((k) => + k === 'authToken' ? 'dummy' : (k === 'authChoice' ? 'openid' : null) + ); + + renderAppWithProviders(['/cluster']); + + // Wait for getUser to be called + await waitFor(() => expect(mockUserManager.getUser).toHaveBeenCalled()); + + // The onUserRefreshed callback should still be called + await waitFor(() => + expect(mockAuthDispatch).toHaveBeenCalledWith({ + type: 'SetAccessToken', + data: 'new-token' + }) + ); + + // But Login action should not be dispatched since there's no profile + expect(mockAuthDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({type: 'Login', data: expect.anything()}) + ); + }); + + test('visibilitychange triggers auth check on resume', async () => { + const validToken = makeTokenWithExp(3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return validToken; + if (k === 'authChoice') return 'basic'; + return null; + }); + + renderAppWithProviders(['/cluster']); + + // Mock document.visibilityState + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + configurable: true, + }); + + // Trigger visibilitychange event + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Should not redirect since token is valid + expect(await screen.findByTestId('cluster')).toBeInTheDocument(); + }); + + test('handleCheckAuthOnResume handles OIDC with valid token', async () => { + mockAuthState.authChoice = 'openid'; + + // Mock valid token (not expired) + const validToken = makeTokenWithExp(3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return validToken; + if (k === 'authChoice') return 'openid'; + return null; + }); + + renderAppWithProviders(['/cluster']); + + // Simulate focus + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + // Should not redirect + expect(await screen.findByTestId('cluster')).toBeInTheDocument(); + expect(mockNavigate).not.toHaveBeenCalledWith('/auth-choice', {replace: true}); + }); + + test('handleCheckAuthOnResume handles basic auth with expired token', async () => { + mockAuthState.authChoice = 'basic'; + + // Mock expired token + const expiredToken = makeTokenWithExp(-3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return expiredToken; + if (k === 'authChoice') return 'basic'; + return null; + }); + + renderAppWithProviders(['/cluster']); + + // Simulate focus + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + // Should redirect + expect(await screen.findByTestId('auth-choice')).toBeInTheDocument(); + expect(mockNavigate).toHaveBeenCalledWith('/auth-choice', {replace: true}); + }); + + test('event listeners are cleaned up on unmount', async () => { + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + const documentAddEventListenerSpy = jest.spyOn(document, 'addEventListener'); + const documentRemoveEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + + const validToken = makeTokenWithExp(3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return validToken; + if (k === 'authChoice') return 'basic'; + return null; + }); + + const {unmount} = renderAppWithProviders(['/']); + + // Wait for initial render + await screen.findByTestId('navbar'); + + // Unmount the component + unmount(); + + // Check that event listeners were removed + expect(removeEventListenerSpy).toHaveBeenCalledWith('storage', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('focus', expect.any(Function)); + expect(removeEventListenerSpy).toHaveBeenCalledWith('om3:auth-redirect', expect.any(Function)); + expect(documentRemoveEventListenerSpy).toHaveBeenCalledWith('visibilitychange', expect.any(Function)); + }); + + test('handles all route paths correctly', async () => { + const validToken = makeTokenWithExp(3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return validToken; + if (k === 'authChoice') return 'basic'; + return null; + }); + + // Test each protected route + const routes = [ + {path: '/namespaces', testId: 'namespaces'}, + {path: '/nodes', testId: 'nodes'}, + {path: '/storage-pools', testId: 'pools'}, + {path: '/network', testId: 'network'}, + {path: '/objects', testId: 'objects'}, + {path: '/whoami', testId: 'whoami'}, + ]; + + for (const route of routes) { + mockNavigate.mockClear(); + const {unmount} = renderAppWithProviders([route.path]); + + await waitFor(() => { + expect(screen.getByTestId(route.testId)).toBeInTheDocument(); + }); + + unmount(); + } + + // Test non-protected routes + const nonProtectedRoutes = [ + {path: '/heartbeats', testId: 'heartbeats'}, + {path: '/silent-renew', testId: 'silent-renew'}, + {path: '/auth-callback', testId: 'auth-callback'}, + {path: '/auth-choice', testId: 'auth-choice'}, + {path: '/auth/login', testId: 'login'}, + ]; + + for (const route of nonProtectedRoutes) { + mockNavigate.mockClear(); + const {unmount} = renderAppWithProviders([route.path]); + + await waitFor(() => { + expect(screen.getByTestId(route.testId)).toBeInTheDocument(); + }); + + unmount(); + } + }); + + test('ProtectedRoute with null token and null authChoice', async () => { + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return null; + if (k === 'authChoice') return null; + return null; + }); + + renderAppWithProviders(['/cluster']); + + expect(await screen.findByTestId('auth-choice')).toBeInTheDocument(); + }); + + test('onUserRefreshed callback is called when user loaded event fires', async () => { + mockAuthState.authChoice = 'openid'; + + renderAppWithProviders(['/cluster']); + + await waitFor(() => + expect(mockUserManager.events.addUserLoaded).toHaveBeenCalled() + ); + + const userLoadedCallback = mockUserManager.events.addUserLoaded.mock.calls[0][0]; + expect(userLoadedCallback).toBeDefined(); + + const testUser = { + profile: {preferred_username: 'event-user'}, + access_token: 'event-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + + // Call the callback directly + act(() => { + userLoadedCallback(testUser); + }); + + await waitFor(() => + expect(mockAuthDispatch).toHaveBeenCalledWith({ + type: 'SetAccessToken', + data: 'event-token' + }) + ); + + await waitFor(() => + expect(mockLocalStorage.setItem).toHaveBeenCalledWith('authToken', 'event-token') + ); + }); + + // NOUVEAUX TESTS POUR AMÉLIORER LA COUVERTURE - SIMPLIFIÉS + + test('isTokenValid returns false for null token', async () => { + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return null; // null token + if (k === 'authChoice') return 'basic'; + return null; + }); + + renderAppWithProviders(['/cluster']); + + expect(await screen.findByTestId('auth-choice')).toBeInTheDocument(); + }); + + test('isTokenValid returns false for empty token string', async () => { + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return ''; // empty string token + if (k === 'authChoice') return 'basic'; + return null; + }); + + renderAppWithProviders(['/cluster']); + + expect(await screen.findByTestId('auth-choice')).toBeInTheDocument(); + }); + + test('onUserRefreshed handles user with profile but no preferred_username', async () => { + mockAuthState.authChoice = 'openid'; + + const userWithProfileNoUsername = { + profile: {}, // Profile exists but no preferred_username + access_token: 'token-no-username', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }; + + renderAppWithProviders(['/cluster']); + + await waitFor(() => + expect(mockUserManager.events.addUserLoaded).toHaveBeenCalled() + ); + + const userLoadedCallback = mockUserManager.events.addUserLoaded.mock.calls[0][0]; + + // Call the callback with user without preferred_username + act(() => { + userLoadedCallback(userWithProfileNoUsername); + }); + + await waitFor(() => + expect(mockAuthDispatch).toHaveBeenCalledWith({ + type: 'SetAccessToken', + data: 'token-no-username' + }) + ); + + // Login action should not be dispatched since there's no preferred_username + expect(mockAuthDispatch).not.toHaveBeenCalledWith( + expect.objectContaining({type: 'Login', data: expect.anything()}) + ); + }); + + test('handleTokenExpired callback navigates to auth-choice', async () => { + mockAuthState.authChoice = 'openid'; + + renderAppWithProviders(['/cluster']); + + await waitFor(() => + expect(mockUserManager.events.addAccessTokenExpired).toHaveBeenCalled() + ); + + const expiredCb = mockUserManager.events.addAccessTokenExpired.mock.calls[0][0]; + + act(() => expiredCb()); + + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith('/auth-choice', {replace: true}) + ); + }); + + test('silent renew on expired user with successful renew', async () => { + const expiredUser = { + profile: {preferred_username: 'expired-user'}, + expired: true, + }; + + const refreshedUser = { + profile: {preferred_username: 'refreshed-user'}, + access_token: 'refreshed-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + expired: false, + }; + + mockUserManager.getUser.mockResolvedValue(expiredUser); + mockUserManager.signinSilent.mockResolvedValue(refreshedUser); + + mockAuthState.authChoice = 'openid'; + mockLocalStorage.getItem.mockImplementation((k) => + k === 'authToken' ? 'dummy' : (k === 'authChoice' ? 'openid' : null) + ); + + renderAppWithProviders(['/cluster']); + + await waitFor(() => expect(mockUserManager.getUser).toHaveBeenCalled()); + await waitFor(() => expect(mockUserManager.signinSilent).toHaveBeenCalled()); + + // Should dispatch actions for refreshed user + await waitFor(() => + expect(mockAuthDispatch).toHaveBeenCalledWith({ + type: 'SetAccessToken', + data: 'refreshed-token' + }) + ); + + await waitFor(() => + expect(mockAuthDispatch).toHaveBeenCalledWith({ + type: 'Login', + data: 'refreshed-user' + }) + ); + }); + + test('network details route with parameter', async () => { + const validToken = makeTokenWithExp(3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return validToken; + if (k === 'authChoice') return 'basic'; + return null; + }); + + renderAppWithProviders(['/network/test-network']); + + expect(await screen.findByTestId('network-details')).toBeInTheDocument(); + }); + + test('object details route with parameter', async () => { + const validToken = makeTokenWithExp(3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return validToken; + if (k === 'authChoice') return 'basic'; + return null; + }); + + renderAppWithProviders(['/objects/test-object']); + + expect(await screen.findByTestId('object-details')).toBeInTheDocument(); + }); + + test('auth choice saved to localStorage when authChoice changes', async () => { + // Simulate authChoice changing from null to 'basic' + let currentAuthChoice = null; + mockAuthState.authChoice = 'basic'; + + mockLocalStorage.setItem.mockImplementation((key, value) => { + if (key === 'authChoice') { + currentAuthChoice = value; + } + }); + + mockLocalStorage.getItem.mockImplementation((key) => { + if (key === 'authChoice') return currentAuthChoice; + return null; + }); + + renderAppWithProviders(['/']); + + await waitFor(() => { + expect(mockLocalStorage.setItem).toHaveBeenCalledWith('authChoice', 'basic'); + }); + }); + + test('handleCheckAuthOnResume for OIDC with no token', async () => { + mockAuthState.authChoice = 'openid'; + + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return null; // No token + if (k === 'authChoice') return 'openid'; + return null; + }); + + renderAppWithProviders(['/cluster']); + + // Simulate focus + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + // Should redirect + expect(await screen.findByTestId('auth-choice')).toBeInTheDocument(); + expect(mockNavigate).toHaveBeenCalledWith('/auth-choice', {replace: true}); + }); + + test('handleCheckAuthOnResume for OIDC with expired token and userManager', async () => { + mockAuthState.authChoice = 'openid'; + + // Mock expired token + const expiredToken = makeTokenWithExp(-3600); + mockLocalStorage.getItem.mockImplementation((k) => { + if (k === 'authToken') return expiredToken; + if (k === 'authChoice') return 'openid'; + return null; + }); + + // Mock successful silent renew + const refreshedUser = { + profile: {preferred_username: 'refreshed-user'}, + access_token: 'refreshed-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + expired: false, + }; + + mockUserManager.signinSilent.mockResolvedValue(refreshedUser); + + renderAppWithProviders(['/cluster']); + + // Simulate focus event + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + // Wait for silent renew to complete + await waitFor(() => expect(mockUserManager.signinSilent).toHaveBeenCalled()); + + // Should update storage + await waitFor(() => + expect(mockLocalStorage.setItem).toHaveBeenCalledWith('authToken', 'refreshed-token') + ); + + // Should not redirect + expect(await screen.findByTestId('cluster')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/tests/KeysSection.test.jsx b/src/components/tests/KeysSection.test.jsx index a4bf4356..e7f1b593 100644 --- a/src/components/tests/KeysSection.test.jsx +++ b/src/components/tests/KeysSection.test.jsx @@ -51,8 +51,8 @@ jest.mock('@mui/material', () => { ), CircularProgress: () =>
Loading...
, Typography: ({children, ...props}) => {children}, - Dialog: ({children, open, maxWidth, fullWidth, ...props}) => - open ?
{children}
: null, + Dialog: ({children, open, maxWidth, fullWidth, onClose, ...props}) => + open ?
{ if (e.key === 'Escape') onClose(); }} {...props}>{children}
: null, DialogTitle: ({children, ...props}) =>
{children}
, DialogContent: ({children, ...props}) =>
{children}
, DialogActions: ({children, ...props}) =>
{children}
, @@ -302,7 +302,6 @@ describe('KeysSection Component', () => { render( ); - await waitFor(() => { expect(screen.getByText((content) => /Object Keys \(0\)/i.test(content))).toBeInTheDocument(); }, {timeout: 15000}); @@ -471,7 +470,9 @@ describe('KeysSection Component', () => { const dialog = await screen.findByRole('dialog'); expect(dialog).toHaveTextContent(/Update Key/i); - + await waitFor(() => { + expect(screen.getByText('No file chosen')).toBeInTheDocument(); + }); const nameInput = within(dialog).getByPlaceholderText('Key Name'); // Upload file and update name @@ -958,6 +959,9 @@ describe('KeysSection Component', () => { await user.click(addButton); }); const dialog = await screen.findByRole('dialog'); + await waitFor(() => { + expect(screen.getByText('No file selected')).toBeInTheDocument(); + }); const cancelButton = within(dialog).getByRole('button', {name: /Cancel/i}); await act(async () => { await user.click(cancelButton); @@ -1040,4 +1044,538 @@ describe('KeysSection Component', () => { // Verify fetch was not called for keys expect(global.fetch).not.toHaveBeenCalledWith(expect.stringContaining('/data/keys')); }); + + test('displays error when fetching keys returns not ok', async () => { + global.fetch.mockImplementationOnce((url, options) => Promise.resolve({ok: false, status: 500})); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(0\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const keysAccordion = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(keysAccordion); + }); + await waitFor(() => { + expect(screen.getByText(/Failed to fetch keys: 500/i)).toBeInTheDocument(); + }); + }); + + test('handles failed key deletion due to not ok response', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/key') && options.method === 'DELETE') { + return Promise.resolve({ok: false, status: 400}); + } + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + items: [ + {name: 'key1', node: 'node1', size: 2626}, + ], + }), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(1\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const deleteButton = await screen.findAllByRole('button', {name: /Delete key key1/i}); + await act(async () => { + await user.click(deleteButton[0]); + }); + const dialog = await screen.findByRole('dialog'); + const confirmButton = within(dialog).getByRole('button', {name: /Delete/i}); + await act(async () => { + await user.click(confirmButton); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Deleting key key1…', 'info'); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Failed to delete key: 400', 'error'); + }); + }); + + test('handles error in key deletion due to network error', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/key') && options.method === 'DELETE') { + return Promise.reject(new Error('Network error')); + } + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + items: [ + {name: 'key1', node: 'node1', size: 2626}, + ], + }), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(1\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const deleteButton = await screen.findAllByRole('button', {name: /Delete key key1/i}); + await act(async () => { + await user.click(deleteButton[0]); + }); + const dialog = await screen.findByRole('dialog'); + const confirmButton = within(dialog).getByRole('button', {name: /Delete/i}); + await act(async () => { + await user.click(confirmButton); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Deleting key key1…', 'info'); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Error: Network error', 'error'); + }); + }); + + test('handles failed key creation due to not ok response', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/key') && options.method === 'POST') { + return Promise.resolve({ok: false, status: 400}); + } + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({items: []}), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(0\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const addButton = screen.getByRole('button', {name: /add new key/i}); + await act(async () => { + await user.click(addButton); + }); + const dialog = await screen.findByRole('dialog'); + const nameInput = within(dialog).getByPlaceholderText('Key Name'); + await act(async () => { + await user.type(nameInput, 'newKey'); + await uploadFile(dialog, 'create'); + }); + const createButton = within(dialog).getByRole('button', {name: /Create/i}); + await act(async () => { + await user.click(createButton); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Creating key newKey…', 'info'); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Failed to create key: 400', 'error'); + }); + }); + + test('handles error in key creation due to network error', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/key') && options.method === 'POST') { + return Promise.reject(new Error('Network error')); + } + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({items: []}), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(0\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const addButton = screen.getByRole('button', {name: /add new key/i}); + await act(async () => { + await user.click(addButton); + }); + const dialog = await screen.findByRole('dialog'); + const nameInput = within(dialog).getByPlaceholderText('Key Name'); + await act(async () => { + await user.type(nameInput, 'newKey'); + await uploadFile(dialog, 'create'); + }); + const createButton = within(dialog).getByRole('button', {name: /Create/i}); + await act(async () => { + await user.click(createButton); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Creating key newKey…', 'info'); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Error: Network error', 'error'); + }); + }); + + test('handles failed key update due to not ok response', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/key') && options.method === 'PUT') { + return Promise.resolve({ok: false, status: 400}); + } + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + items: [ + {name: 'key1', node: 'node1', size: 2626}, + ], + }), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(1\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const editButton = await screen.findAllByRole('button', {name: /Edit key key1/i}); + await act(async () => { + await user.click(editButton[0]); + }); + const dialog = await screen.findByRole('dialog'); + const nameInput = within(dialog).getByPlaceholderText('Key Name'); + await act(async () => { + await user.clear(nameInput); + await user.type(nameInput, 'updatedKey'); + await uploadFile(dialog, 'update'); + }); + const updateButton = within(dialog).getByRole('button', {name: /Update/i}); + await act(async () => { + await user.click(updateButton); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Updating key updatedKey…', 'info'); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Failed to update key: 400', 'error'); + }); + }); + + test('handles error in key update due to network error', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/key') && options.method === 'PUT') { + return Promise.reject(new Error('Network error')); + } + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + items: [ + {name: 'key1', node: 'node1', size: 2626}, + ], + }), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(1\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const editButton = await screen.findAllByRole('button', {name: /Edit key key1/i}); + await act(async () => { + await user.click(editButton[0]); + }); + const dialog = await screen.findByRole('dialog'); + const nameInput = within(dialog).getByPlaceholderText('Key Name'); + await act(async () => { + await user.clear(nameInput); + await user.type(nameInput, 'updatedKey'); + await uploadFile(dialog, 'update'); + }); + const updateButton = within(dialog).getByRole('button', {name: /Update/i}); + await act(async () => { + await user.click(updateButton); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Updating key updatedKey…', 'info'); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Error: Network error', 'error'); + }); + }); + + test('handles non-array keys data', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({items: 'not an array'}), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(0\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + await waitFor(() => { + expect(screen.getByText(/No keys available/i)).toBeInTheDocument(); + }); + }); + test('handles invalid kind in fetchKeys', async () => { + global.fetch.mockImplementation((url, options) => { + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.queryByText(/Object Keys/i)).not.toBeInTheDocument(); + }); + }); + + test('handles key creation', async () => { + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(2\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const addButton = screen.getByRole('button', {name: /add new key/i}); + await act(async () => { + await user.click(addButton); + }); + const dialog = await screen.findByRole('dialog'); + expect(dialog).toHaveTextContent(/Create New Key/i); + const nameInput = within(dialog).getByPlaceholderText('Key Name'); + let fileInput; + await waitFor(() => { + fileInput = findFileInput(dialog, 'create'); + expect(fileInput).toBeInTheDocument(); + }); + await act(async () => { + await user.type(nameInput, 'newKey'); + await user.upload(fileInput, new File(['content'], 'test.txt')); + }); + await waitFor(() => { + expect(screen.getByText('test.txt')).toBeInTheDocument(); + }); + const createButton = within(dialog).getByRole('button', {name: /Create/i}); + await act(async () => { + await user.click(createButton); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith('Creating key newKey…', 'info'); + }); + await waitFor(() => { + expect(openSnackbar).toHaveBeenCalledWith("Key 'newKey' created successfully"); + }); + }); + + test('disables buttons during key update', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/key') && options.method === 'PUT') { + return new Promise(() => {}); + } + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + items: [ + {name: 'key1', node: 'node1', size: 2626}, + ], + }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({}), + }); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(1\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const editButton = await screen.findAllByRole('button', {name: /Edit key key1/i}); + await act(async () => { + await user.click(editButton[0]); + }); + const dialog = await screen.findByRole('dialog'); + const nameInput = within(dialog).getByPlaceholderText('Key Name'); + let fileInput; + await waitFor(() => { + fileInput = findFileInput(dialog, 'update'); + expect(fileInput).toBeInTheDocument(); + }); + await act(async () => { + await user.type(nameInput, 'updatedKey'); + await user.upload(fileInput, new File(['content'], 'key.txt')); + }); + const updateButton = within(dialog).getByRole('button', {name: /Update/i}); + await act(async () => { + await user.click(updateButton); + }); + await waitFor(() => { + expect(updateButton).toBeDisabled(); + }); + const cancelButton = within(dialog).getByRole('button', {name: /Cancel/i}); + await waitFor(() => { + expect(cancelButton).toBeDisabled(); + }); + await waitFor(() => { + expect(fileInput).toBeDisabled(); + }); + // Check main add button disabled + expect(screen.getByRole('button', {name: /add new key/i})).toBeDisabled(); + // Check table edit and delete disabled + expect(screen.getByRole('button', {name: /Edit key key1/i})).toBeDisabled(); + expect(screen.getByRole('button', {name: /Delete key key1/i})).toBeDisabled(); + }, 20000); + + test('handles key creation with file selected display', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({items: []}), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(0\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const addButton = screen.getByRole('button', {name: /add new key/i}); + await act(async () => { + await user.click(addButton); + }); + const dialog = await screen.findByRole('dialog'); + await waitFor(() => { + expect(screen.getByText('No file selected')).toBeInTheDocument(); + }); + const nameInput = within(dialog).getByPlaceholderText('Key Name'); + await act(async () => { + await user.type(nameInput, 'newKey'); + await uploadFile(dialog, 'create'); + }); + await waitFor(() => { + expect(screen.getByText('test.txt')).toBeInTheDocument(); + }); + }); + + test('closes create dialog with escape key', async () => { + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(0\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const addButton = screen.getByRole('button', {name: /add new key/i}); + await act(async () => { + await user.click(addButton); + }); + await screen.findByRole('dialog'); + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + test('closes update dialog with escape key', async () => { + global.fetch.mockImplementation((url, options) => { + if (url.includes('/data/keys')) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ + items: [ + {name: 'key1', node: 'node1', size: 2626}, + ], + }), + }); + } + return Promise.resolve({ok: true, json: () => Promise.resolve({})}); + }); + render( + + ); + await waitFor(() => { + expect(screen.getByText((content) => /Object Keys \(1\)/i.test(content))).toBeInTheDocument(); + }, {timeout: 15000}); + const accordionSummary = screen.getByRole('button', {name: /Object Keys/i}); + await act(async () => { + await user.click(accordionSummary); + }); + const editButton = await screen.findAllByRole('button', {name: /Edit key key1/i}); + await act(async () => { + await user.click(editButton[0]); + }); + await screen.findByRole('dialog'); + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/components/tests/ObjectDetails.test.jsx b/src/components/tests/ObjectDetails.test.jsx index fe74f3bd..6c3befc7 100644 --- a/src/components/tests/ObjectDetails.test.jsx +++ b/src/components/tests/ObjectDetails.test.jsx @@ -88,7 +88,19 @@ jest.mock('@mui/material', () => { {children} ), - TextField: ({label, value, onChange, disabled, multiline, rows, id, fullWidth, helperText, slotProps, ...props}) => { + TextField: ({ + label, + value, + onChange, + disabled, + multiline, + rows, + id, + fullWidth, + helperText, + slotProps, + ...props + }) => { const inputId = id || `textfield-${label}`; return (
@@ -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(); + }); });