so i was working on a jest test file that is testing a hook that deals with session management and the project has moved forward to react 18 which made the testing library, react-hooks, deprecated. I went ahead and checked the deprecated library and it said that in transition to react 18 that some features were moved to the official testing library of react 18 but the one function that i actively used in my test file and is not available right now, is the waitfornextupdate function. Due to this missing function, my test file has failed many tests and i am wondering if there is alternative option to make my test file work.
This is the test file that was working before the react 18 upgrade:
import type { ReactNode } from 'react';
import React from 'react';
import { renderHook, act } from '@testing-library/react-hooks';
import { MockedProvider } from '@apollo/client/testing';
import { toast } from 'react-toastify';
import useSession from './useSession';
import { GET_COMMUNITY_SESSION_TIMEOUT_DATA } from 'GraphQl/Queries/Queries';
import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations';
import { errorHandler } from 'utils/errorHandler';
import { BrowserRouter } from 'react-router-dom';
jest.mock('react-toastify', () => ({
toast: {
info: jest.fn(),
warning: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('utils/errorHandler', () => ({
errorHandler: jest.fn(),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const MOCKS = [
{
request: {
query: GET_COMMUNITY_SESSION_TIMEOUT_DATA,
},
result: {
data: {
getCommunityData: {
timeout: 30,
},
},
},
delay: 100,
},
{
request: {
query: REVOKE_REFRESH_TOKEN,
},
result: {
data: {
revokeRefreshTokenForUser: true,
},
},
// delay: 100,
},
];
const wait = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
describe('useSession Hook', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(window, 'addEventListener').mockImplementation(jest.fn());
jest.spyOn(window, 'removeEventListener').mockImplementation(jest.fn());
Object.defineProperty(global, 'localStorage', {
value: {
clear: jest.fn(),
},
writable: true,
});
});
afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers(); // Reset timers after each test
jest.restoreAllMocks();
});
test('should handle visibility change to visible', async () => {
jest.useFakeTimers();
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
// await waitForNextUpdate();
act(() => {
result.current.startSession();
});
// Simulate visibility change
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
act(() => {
jest.advanceTimersByTime(15 * 60 * 1000);
});
await waitForNextUpdate();
expect(window.addEventListener).toHaveBeenCalledWith(
'mousemove',
expect.any(Function),
);
expect(window.addEventListener).toHaveBeenCalledWith(
'keydown',
expect.any(Function),
);
expect(toast.warning).toHaveBeenCalledWith('sessionWarning'); // Toast warning should be shown
jest.useRealTimers();
});
test('should handle visibility change to hidden and ensure no warning appears in 15 minutes', async () => {
// Use Jest's fake timers to control time
jest.useFakeTimers();
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
// Mock the visibility state to 'hidden'
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
act(() => {
result.current.startSession();
});
// Simulate visibility change
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
// Fast-forward time by 15 minutes (900,000 milliseconds)
act(() => {
jest.advanceTimersByTime(15 * 60 * 1000);
});
await waitForNextUpdate();
// Assertions to check listeners and warning toast
expect(window.removeEventListener).toHaveBeenCalledWith(
'mousemove',
expect.any(Function),
);
expect(window.removeEventListener).toHaveBeenCalledWith(
'keydown',
expect.any(Function),
);
expect(toast.warning).not.toHaveBeenCalled(); // Ensure no toast warnings in 15 minutes
// Restore the real timers after the test
jest.useRealTimers();
});
test('should register event listeners on startSession', async () => {
// Mock the removeEventListener functions for both window and document
const addEventListenerMock = jest.fn();
// Temporarily replace the real methods with the mock
const originalWindowAddEventListener = window.addEventListener;
const originalDocumentAddEventListener = document.addEventListener;
window.addEventListener = addEventListenerMock;
document.addEventListener = addEventListenerMock;
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
await waitForNextUpdate();
act(() => {
result.current.startSession();
});
// Test that event listeners were removed
expect(addEventListenerMock).toHaveBeenCalledWith(
'mousemove',
expect.any(Function),
);
expect(addEventListenerMock).toHaveBeenCalledWith(
'keydown',
expect.any(Function),
);
expect(addEventListenerMock).toHaveBeenCalledWith(
'visibilitychange',
expect.any(Function),
);
window.addEventListener = originalWindowAddEventListener;
document.addEventListener = originalDocumentAddEventListener;
});
test('should call handleLogout after session timeout', async (): Promise<void> => {
jest.useFakeTimers(); // Control the passage of time
const wrapper = ({
children,
}: {
children?: React.ReactNode;
}): JSX.Element => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
);
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper,
});
const { startSession } = result.current;
act(() => {
startSession();
});
act(() => {
jest.advanceTimersByTime(31 * 60 * 1000); // Adjust this time based on the session timeout
});
await waitForNextUpdate();
expect(global.localStorage.clear).toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledTimes(2);
expect(toast.warning).toHaveBeenNthCalledWith(1, 'sessionWarning'); // First call for session warning
expect(toast.warning).toHaveBeenNthCalledWith(2, 'sessionLogout', {
autoClose: false,
}); // Second call for session logout
});
test('should show a warning toast before session expiration', async (): Promise<void> => {
jest.useFakeTimers();
const { result, waitFor } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
act(() => {
result.current.startSession();
});
act(() => {
jest.advanceTimersByTime(15 * 60 * 1000); // 15 minutes in milliseconds
});
await waitFor(() =>
expect(toast.warning).toHaveBeenCalledWith('sessionWarning'),
);
jest.useRealTimers();
});
test('should handle error when revoking token fails', async (): Promise<void> => {
const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation();
const errorMocks = [
{
request: {
query: GET_COMMUNITY_SESSION_TIMEOUT_DATA,
},
result: {
data: {
getCommunityData: {
timeout: 30,
},
},
},
delay: 1000,
},
{
request: {
query: REVOKE_REFRESH_TOKEN,
},
error: new Error('Failed to revoke refresh token'),
},
];
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={errorMocks} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
act(() => {
result.current.startSession();
result.current.handleLogout();
});
await waitForNextUpdate();
expect(consoleErrorMock).toHaveBeenCalledWith(
'Error revoking refresh token:',
expect.any(Error),
);
consoleErrorMock.mockRestore();
});
test('should set session timeout based on fetched data', async () => {
jest.spyOn(global, 'setTimeout');
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
await waitForNextUpdate();
act(() => {
result.current.startSession();
});
// await wait(600);
expect(global.setTimeout).toHaveBeenCalled();
});
test('should call errorHandler on query error', async () => {
const errorMocks = [
{
request: {
query: GET_COMMUNITY_SESSION_TIMEOUT_DATA,
},
error: new Error('An error occurred'),
},
];
const { waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={errorMocks} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
await waitForNextUpdate(); // Wait for the hook to process the update
expect(errorHandler).toHaveBeenCalled();
});
test('should handle logout and revoke token', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
act(() => {
result.current.startSession();
result.current.handleLogout();
});
await waitForNextUpdate();
expect(global.localStorage.clear).toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledWith('sessionLogout', {
autoClose: false,
});
});
test('should remove event listeners on endSession', async () => {
const { result, waitForNextUpdate } = renderHook(() => useSession(), {
wrapper: ({ children }: { children?: ReactNode }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
// Mock the removeEventListener functions for both window and document
const removeEventListenerMock = jest.fn();
// Temporarily replace the real methods with the mock
const originalWindowRemoveEventListener = window.removeEventListener;
const originalDocumentRemoveEventListener = document.removeEventListener;
window.removeEventListener = removeEventListenerMock;
document.removeEventListener = removeEventListenerMock;
await waitForNextUpdate();
act(() => {
result.current.startSession();
});
act(() => {
result.current.endSession();
});
// Test that event listeners were removed
expect(removeEventListenerMock).toHaveBeenCalledWith(
'mousemove',
expect.any(Function),
);
expect(removeEventListenerMock).toHaveBeenCalledWith(
'keydown',
expect.any(Function),
);
expect(removeEventListenerMock).toHaveBeenCalledWith(
'visibilitychange',
expect.any(Function),
);
// Restore the original removeEventListener functions
window.removeEventListener = originalWindowRemoveEventListener;
document.removeEventListener = originalDocumentRemoveEventListener;
});
test('should call initialize timers when session is still active when the user returns to the tab', async () => {
jest.useFakeTimers();
jest.spyOn(global, 'setTimeout').mockImplementation(jest.fn());
const { result } = renderHook(() => useSession(), {
wrapper: ({ children }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
jest.advanceTimersByTime(1000);
// Set initial visibility state to visible
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
// Start the session
act(() => {
result.current.startSession();
jest.advanceTimersByTime(10 * 60 * 1000); // Fast-forward
});
// Simulate the user leaving the tab (set visibility to hidden)
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
// Fast-forward time by more than the session timeout
act(() => {
jest.advanceTimersByTime(5 * 60 * 1000); // Fast-forward
});
// Simulate the user returning to the tab
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
jest.advanceTimersByTime(1000);
expect(global.setTimeout).toHaveBeenCalled();
// Restore real timers
jest.useRealTimers();
});
test('should call handleLogout when session expires due to inactivity away from tab', async () => {
jest.useFakeTimers(); // Use fake timers to control time
const { result, waitFor } = renderHook(() => useSession(), {
wrapper: ({ children }) => (
<MockedProvider mocks={MOCKS} addTypename={false}>
<BrowserRouter>{children}</BrowserRouter>
</MockedProvider>
),
});
jest.advanceTimersByTime(1000);
// Set initial visibility state to visible
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
// Start the session
act(() => {
result.current.startSession();
jest.advanceTimersByTime(10 * 60 * 1000); // Fast-forward
});
// Simulate the user leaving the tab (set visibility to hidden)
Object.defineProperty(document, 'visibilityState', {
value: 'hidden',
writable: true,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
// Fast-forward time by more than the session timeout
act(() => {
jest.advanceTimersByTime(32 * 60 * 1000); // Fast-forward by 32 minutes
});
// Simulate the user returning to the tab
Object.defineProperty(document, 'visibilityState', {
value: 'visible',
writable: true,
});
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
jest.advanceTimersByTime(250);
await waitFor(() => {
expect(global.localStorage.clear).toHaveBeenCalled();
expect(toast.warning).toHaveBeenCalledWith('sessionLogout', {
autoClose: false,
});
});
// Restore real timers
jest.useRealTimers();
});
});
this is the hook that is being tested :
import { useMutation, useQuery } from '@apollo/client';
import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations';
import { GET_COMMUNITY_SESSION_TIMEOUT_DATA } from 'GraphQl/Queries/Queries';
import { t } from 'i18next';
import { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import { errorHandler } from 'utils/errorHandler';
type UseSessionReturnType = {
startSession: () => void;
endSession: () => void;
handleLogout: () => void;
extendSession: () => void; //for when logged in already, simply extend session
};
/**
* Custom hook for managing user session timeouts in a React application.
*
* This hook handles:
* - Starting and ending the user session.
* - Displaying a warning toast at half of the session timeout duration.
* - Logging the user out and displaying a session expiration toast when the session times out.
* - Automatically resetting the timers when user activity is detected.
* - Pausing session timers when the tab is inactive and resuming them when it becomes active again.
*
* @returns UseSessionReturnType - An object with methods to start and end the session, and to handle logout.
*/
const useSession = (): UseSessionReturnType => {
const { t: tCommon } = useTranslation('common');
let startTime: number;
let timeoutDuration: number;
const [sessionTimeout, setSessionTimeout] = useState<number>(30);
// const sessionTimeout = 30;
const sessionTimerRef = useRef<NodeJS.Timeout | null>(null);
const warningTimerRef = useRef<NodeJS.Timeout | null>(null);
const navigate = useNavigate();
const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN);
const { data, error: queryError } = useQuery(
GET_COMMUNITY_SESSION_TIMEOUT_DATA,
);
useEffect(() => {
if (queryError) {
errorHandler(t, queryError as Error);
} else {
const sessionTimeoutData = data?.getCommunityData;
if (sessionTimeoutData) {
setSessionTimeout(sessionTimeoutData.timeout);
}
}
}, [data, queryError]);
const resetTimers = (): void => {
if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current);
if (warningTimerRef.current) clearTimeout(warningTimerRef.current);
};
const endSession = (): void => {
resetTimers();
window.removeEventListener('mousemove', extendSession);
window.removeEventListener('keydown', extendSession);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
const handleLogout = async (): Promise<void> => {
try {
await revokeRefreshToken();
} catch (error) {
console.error('Error revoking refresh token:', error);
// toast.error('Failed to revoke session. Please try again.');
}
localStorage.clear();
endSession();
navigate('/');
toast.warning(tCommon('sessionLogout'), { autoClose: false });
};
const initializeTimers = (
timeLeft?: number,
warningTimeLeft?: number,
): void => {
const warningTime = warningTimeLeft ?? sessionTimeout / 2;
const sessionTimeoutInMilliseconds =
(timeLeft || sessionTimeout) * 60 * 1000;
const warningTimeInMilliseconds = warningTime * 60 * 1000;
timeoutDuration = sessionTimeoutInMilliseconds;
startTime = Date.now();
warningTimerRef.current = setTimeout(() => {
toast.warning(tCommon('sessionWarning'));
}, warningTimeInMilliseconds);
sessionTimerRef.current = setTimeout(async () => {
await handleLogout();
}, sessionTimeoutInMilliseconds);
};
const extendSession = (): void => {
resetTimers();
initializeTimers();
};
const startSession = (): void => {
resetTimers();
initializeTimers();
window.removeEventListener('mousemove', extendSession);
window.removeEventListener('keydown', extendSession);
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('mousemove', extendSession);
window.addEventListener('keydown', extendSession);
document.addEventListener('visibilitychange', handleVisibilityChange);
};
const handleVisibilityChange = async (): Promise<void> => {
if (document.visibilityState === 'hidden') {
window.removeEventListener('mousemove', extendSession);
window.removeEventListener('keydown', extendSession);
resetTimers(); // Optionally reset timers to prevent them from running in the background
} else if (document.visibilityState === 'visible') {
window.removeEventListener('mousemove', extendSession);
window.removeEventListener('keydown', extendSession); // Ensure no duplicates
window.addEventListener('mousemove', extendSession);
window.addEventListener('keydown', extendSession);
// Calculate remaining time now that the tab is active again
const elapsedTime = Date.now() - startTime;
const remainingTime = timeoutDuration - elapsedTime;
const remainingSessionTime = Math.max(remainingTime, 0); // Ensures the remaining time is non-negative and measured in ms;
if (remainingSessionTime > 0) {
// Calculate remaining warning time only if session time is positive
const remainingWarningTime = Math.max(remainingSessionTime / 2, 0);
initializeTimers(
remainingSessionTime / 60 / 1000,
remainingWarningTime / 60 / 1000,
);
} else {
// Handle session expiration immediately if time has run out
await handleLogout();
}
}
};
useEffect(() => {
return () => {
endSession();
};
}, []);
return {
startSession,
endSession,
handleLogout,
extendSession,
};
};
export default useSession;
i first tried to force install the deprecated library but as expected it caused conflict with the upgraded react 18 framework so it would appear that i need to find an alternative function or do something entirely differently