import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useSessionTimeout } from '../src/hooks/useSessionTimeout'; // Mock localStorage const localStorageMock = (() => { let store: Record = {}; return { getItem: (key: string) => store[key] || null, setItem: (key: string, value: string) => { store[key] = value; }, removeItem: (key: string) => { delete store[key]; }, clear: () => { store = {}; }, }; })(); Object.defineProperty(global, 'localStorage', { value: localStorageMock, }); // Mock fetch const fetchMock = vi.fn(); global.fetch = fetchMock; describe('useSessionTimeout', () => { const mockLogout = vi.fn().mockResolvedValue(undefined); beforeEach(() => { vi.useFakeTimers(); localStorage.clear(); fetchMock.mockReset(); mockLogout.mockClear(); }); afterEach(() => { vi.useRealTimers(); }); it('should not show warning when user is active', () => { const { result } = renderHook(() => useSessionTimeout(true, mockLogout) ); expect(result.current.isWarningOpen).toBe(false); }); it('should show warning after idle period approaches timeout', () => { const { result } = renderHook(() => useSessionTimeout(true, mockLogout) ); // Fast-forward 12 minutes (warning threshold is 3 min before 15 min timeout) act(() => { vi.advanceTimersByTime(12 * 60 * 1000); }); // Fast-forward past the activity check interval (5 seconds) act(() => { vi.advanceTimersByTime(10 * 1000); }); expect(result.current.isWarningOpen).toBe(true); expect(result.current.countdownSeconds).toBeGreaterThan(0); }); it('should call logout when session expires', async () => { const { result } = renderHook(() => useSessionTimeout(true, mockLogout) ); // Fast-forward past the 15-minute timeout act(() => { vi.advanceTimersByTime(16 * 60 * 1000); }); // Wait for async logout await act(async () => { vi.advanceTimersByTime(10 * 1000); }); expect(mockLogout).toHaveBeenCalled(); }); it('should extend session when extendSession is called', async () => { fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve({ success: true, code: 200, message: 'Session refreshed', data: { expires_at: '2026-04-26T20:00:00Z' }, }), }); const { result } = renderHook(() => useSessionTimeout(true, mockLogout) ); // Trigger warning act(() => { vi.advanceTimersByTime(12 * 60 * 1000); }); act(() => { vi.advanceTimersByTime(10 * 1000); }); expect(result.current.isWarningOpen).toBe(true); // Extend session await act(async () => { await result.current.extendSession(); }); expect(result.current.isWarningOpen).toBe(false); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining('/auth/sessions/refresh'), expect.objectContaining({ method: 'POST' }) ); }); it('should send heartbeat periodically', () => { fetchMock.mockResolvedValue({ json: () => Promise.resolve({ success: true, code: 200, message: 'OK', data: { user: { id: '1', email: 'test@example.com' } }, }), }); renderHook(() => useSessionTimeout(true, mockLogout)); // Fast-forward past heartbeat interval (5 minutes) act(() => { vi.advanceTimersByTime(5 * 60 * 1000 + 1000); }); expect(fetchMock).toHaveBeenCalledWith( expect.stringContaining('/users/me'), expect.any(Object) ); }); it('should logout on 401 heartbeat response', async () => { fetchMock.mockRejectedValueOnce({ code: 401, message: 'Unauthorized' }); renderHook(() => useSessionTimeout(true, mockLogout)); // Fast-forward past heartbeat interval await act(async () => { vi.advanceTimersByTime(5 * 60 * 1000 + 1000); }); // Wait for the async catch block await act(async () => { await Promise.resolve(); }); expect(mockLogout).toHaveBeenCalled(); }); });