import { setSelectedCalendars } from '@/utils/storage/setSelectedCalendars' import { createWebSocketConnection } from '@/websocket/connection/createConnection' import { registerToCalendars } from '@/websocket/operations/registerToCalendars' import { unregisterToCalendars } from '@/websocket/operations/unregisterToCalendars' import { WebSocketGate } from '@/websocket/WebSocketGate' import { configureStore, Store } from '@reduxjs/toolkit' import { act, cleanup, render, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { I18nContext } from 'twake-i18n' jest.mock('@/websocket/connection/createConnection') jest.mock('@/websocket/operations/registerToCalendars') jest.mock('@/websocket/operations/unregisterToCalendars') jest.mock('@/websocket/connection/lifecycle/pingWebSocket') function TestWrapper({ store }: { store: Store }) { return ( ) => { if (key === 'locale') return 'en' if (vars) { const params = Object.entries(vars) .map(([k, v]) => `${k}=${v}`) .join(',') return `${key}(${params})` } return key }, f: (date: Date) => date.toString(), lang: 'en' }} > ) } describe('WebSocketGate', () => { let store: any let mockSocket: any const createMockStore = ( userData: any = { id: '1' }, tokens: any = { access: 'token' } ) => { return configureStore({ reducer: { user: (state = { userData, tokens }) => state, calendars: (state = { list: {} }) => state } }) } const createMockSocket = (readyState = WebSocket.OPEN) => ({ readyState, close: jest.fn(), send: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), cleanup: jest.fn(), onmessage: null }) beforeEach(() => { store = createMockStore() mockSocket = createMockSocket() localStorage.clear() }) afterEach(() => { cleanup() jest.clearAllMocks() jest.clearAllTimers() localStorage.clear() }) describe('Authentication', () => { beforeEach(() => { jest.useFakeTimers() jest.clearAllMocks() jest.resetModules() }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) it('should not create connection when user is not authenticated', () => { const unauthStore = createMockStore(null, null) render() expect(createWebSocketConnection).not.toHaveBeenCalled() }) it('should create connection when user is authenticated', async () => { ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) }) it('should close existing socket when user logs out', async () => { ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) const { rerender } = render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) const unauthStore = createMockStore(null, null) rerender() await waitFor(() => { expect(mockSocket.close).toHaveBeenCalled() }) }) }) describe('Socket Connection Management', () => { it('should create connection with callbacks', async () => { ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledWith( expect.objectContaining({ onMessage: expect.any(Function), onClose: expect.any(Function), onError: expect.any(Function) }) ) }) }) it('should handle socket close via callback', async () => { let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) // Simulate close event await act(async () => { onCloseCallback?.(new CloseEvent('close')) }) // Verify that subsequent calendar changes don't try to register await act(async () => { setSelectedCalendars(['cal1']) }) await waitFor(() => { expect(registerToCalendars).not.toHaveBeenCalled() }) }) it('should handle connection errors gracefully', async () => { const consoleError = jest.spyOn(console, 'error').mockImplementation() ;(createWebSocketConnection as jest.Mock).mockRejectedValue( new Error('Connection failed') ) render() await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( 'Failed to create WebSocket connection:', expect.any(Error) ) }) consoleError.mockRestore() }) it('should close socket on component unmount', async () => { ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) const { unmount } = render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) unmount() expect(mockSocket.close).toHaveBeenCalled() }) }) describe('Reconnection Logic', () => { beforeEach(() => { jest.useFakeTimers() jest.clearAllMocks() jest.resetModules() }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) it('should trigger reconnection on unexpected close (code 1006)', async () => { jest.useFakeTimers() const consoleWarn = jest.spyOn(console, 'warn').mockImplementation() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Simulate unexpected close await act(async () => { onCloseCallback?.( new CloseEvent('close', { code: 1006, reason: 'Connection lost' }) ) }) expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('WebSocket closed unexpectedly (code: 1006') ) // Advance timer to trigger reconnection await act(async () => { jest.advanceTimersByTime(1000) }) await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(2) }) consoleWarn.mockRestore() jest.useRealTimers() }) it('should NOT reconnect on normal close (code 1000)', async () => { jest.useFakeTimers() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Simulate normal close await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1000 })) }) // Advance timer await act(async () => { jest.advanceTimersByTime(5000) }) // Should NOT reconnect expect(createWebSocketConnection).toHaveBeenCalledTimes(1) jest.useRealTimers() }) it('should NOT reconnect on going away (code 1001)', async () => { jest.useFakeTimers() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Simulate going away await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1001 })) }) await act(async () => { jest.advanceTimersByTime(5000) }) expect(createWebSocketConnection).toHaveBeenCalledTimes(1) jest.useRealTimers() }) it('should reset reconnection attempts counter on successful connection', async () => { jest.useFakeTimers() const consoleLog = jest.spyOn(console, 'info').mockImplementation() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // First failure await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) jest.advanceTimersByTime(1000) }) expect(consoleLog).toHaveBeenCalledWith( expect.stringContaining('(attempt 1/10)') ) // Success - should reset counter // Next failure should start from attempt 1 again await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) }) // Should schedule with initial delay (attempt 1) expect(consoleLog).toHaveBeenCalledWith( expect.stringContaining('(attempt 1/10)') ) consoleLog.mockRestore() jest.useRealTimers() }) it('should not reconnect if authentication is lost during reconnection timeout', async () => { jest.useFakeTimers() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) const { rerender } = render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Trigger reconnection await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) }) // Lose authentication before timeout fires const unauthStore = createMockStore(null, null) rerender() // Advance timer await act(async () => { jest.advanceTimersByTime(1000) }) // Should NOT reconnect (still only 1 connection) expect(createWebSocketConnection).toHaveBeenCalledTimes(1) jest.useRealTimers() }) it('should clear reconnection timeout on component unmount', async () => { jest.useFakeTimers() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) const { unmount } = render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Trigger reconnection await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) }) // Unmount before timeout fires unmount() // Advance timer await act(async () => { jest.advanceTimersByTime(1000) }) // Should NOT reconnect after unmount expect(createWebSocketConnection).toHaveBeenCalledTimes(1) jest.useRealTimers() }) it('should re-sync calendars after reconnection', async () => { jest.useFakeTimers() localStorage.setItem( 'selectedCalendars', JSON.stringify(['cal1', 'cal2']) ) let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal1', '/calendars/cal2' ]) }) jest.clearAllMocks() // Close and reconnect await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) jest.advanceTimersByTime(1000) }) // Should re-register all calendars after reconnection await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal1', '/calendars/cal2' ]) }) jest.useRealTimers() }) }) describe('Browser Online/Offline Events', () => { beforeEach(() => { jest.useFakeTimers() jest.clearAllMocks() jest.resetModules() }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) it('should trigger immediate reconnection when browser goes online', async () => { let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Close connection await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) }) // Trigger online event await act(async () => { window.dispatchEvent(new Event('online')) }) // Should trigger immediate reconnection (no delay) await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(2) }) }) it('should pause reconnection attempts when browser goes offline', async () => { jest.useFakeTimers() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Close connection await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) }) // Go offline before reconnection fires await act(async () => { window.dispatchEvent(new Event('offline')) }) // Advance timer - should NOT reconnect await act(async () => { jest.advanceTimersByTime(10000) }) expect(createWebSocketConnection).toHaveBeenCalledTimes(1) jest.useRealTimers() }) it('should not reconnect when online event fires if already connected', async () => { ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Trigger online event while connected await act(async () => { window.dispatchEvent(new Event('online')) }) // Should NOT trigger new connection expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) it('should reset attempt counter when online event fires', async () => { jest.useFakeTimers() const consoleLog = jest.spyOn(console, 'info').mockImplementation() let onCloseCallback: Function | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) // Multiple failed attempts await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) jest.advanceTimersByTime(1000) }) await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) jest.advanceTimersByTime(2000) }) // Go offline then online await act(async () => { window.dispatchEvent(new Event('offline')) window.dispatchEvent(new Event('online')) }) // Next reconnection should start from attempt 1 await act(async () => { onCloseCallback?.(new CloseEvent('close', { code: 1006 })) }) expect(consoleLog).toHaveBeenCalledWith( expect.stringContaining('(attempt 1/10)') ) consoleLog.mockRestore() jest.useRealTimers() }) }) describe('Calendar Registration', () => { it('should not register if socket is not open', async () => { localStorage.setItem( 'selectedCalendars', JSON.stringify(['cal1', 'cal2']) ) const connectingSocket = createMockSocket(WebSocket.CONNECTING) ;(createWebSocketConnection as jest.Mock).mockResolvedValue( connectingSocket ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) expect(registerToCalendars).not.toHaveBeenCalled() }) it('should register to calendars when socket opens and calendars are selected', async () => { localStorage.setItem( 'selectedCalendars', JSON.stringify(['cal1', 'cal2']) ) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal1', '/calendars/cal2' ]) }) }) it('should register only new calendars when calendar list changes', async () => { localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal1' ]) }) jest.clearAllMocks() await act(async () => { setSelectedCalendars(['cal1', 'cal2', 'cal3']) }) await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal2', '/calendars/cal3' ]) }) }) it('should unregister removed calendars when calendar list changes', async () => { localStorage.setItem( 'selectedCalendars', JSON.stringify(['cal1', 'cal2', 'cal3']) ) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalled() }) jest.clearAllMocks() await act(async () => { setSelectedCalendars(['cal1']) }) await waitFor(() => { expect(unregisterToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal2', '/calendars/cal3' ]) }) }) it('should both register and unregister when calendar list partially changes', async () => { localStorage.setItem( 'selectedCalendars', JSON.stringify(['cal1', 'cal2']) ) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalled() }) jest.clearAllMocks() await act(async () => { setSelectedCalendars(['cal1', 'cal3']) }) await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal3' ]) expect(unregisterToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal2' ]) }) }) it('should handle registration errors and not update previous calendar list', async () => { const consoleError = jest.spyOn(console, 'error').mockImplementation() localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) ;(registerToCalendars as jest.Mock).mockImplementation(() => { throw new Error('Registration failed') }) render() await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( 'Failed to register calendar:', expect.any(Error) ) }) await waitFor(() => { expect(mockSocket.close).not.toHaveBeenCalled() }) jest.clearAllMocks() ;(registerToCalendars as jest.Mock).mockImplementation(() => {}) await act(async () => { setSelectedCalendars(['cal1', 'cal2']) }) await waitFor(() => { // Should register both calendars since previous update failed and didn't update the ref expect(registerToCalendars).toHaveBeenCalledWith(mockSocket, [ '/calendars/cal1', '/calendars/cal2' ]) }) consoleError.mockRestore() }) it('should not attempt registration when no calendars are selected', async () => { localStorage.setItem('selectedCalendars', JSON.stringify([])) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) expect(registerToCalendars).not.toHaveBeenCalled() expect(unregisterToCalendars).not.toHaveBeenCalled() }) }) describe('Edge Cases', () => { it('should handle rapid calendar changes correctly', async () => { localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalledTimes(1) }) // Rapid changes await act(async () => { setSelectedCalendars(['cal1', 'cal2']) setSelectedCalendars(['cal1', 'cal2', 'cal3']) }) await waitFor(() => { expect(registerToCalendars).toHaveBeenCalled() }) }) it('should handle socket closed during calendar update', async () => { localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalled() }) jest.clearAllMocks() await act(async () => { mockSocket.readyState = WebSocket.CLOSED setSelectedCalendars(['cal1', 'cal2']) }) // Wait a bit to ensure the effect would have run if it was going to jest.useFakeTimers() jest.advanceTimersByTime(100) jest.useRealTimers() expect(registerToCalendars).not.toHaveBeenCalled() }) it('should handle unregistration errors gracefully', async () => { const consoleError = jest.spyOn(console, 'error').mockImplementation() localStorage.setItem( 'selectedCalendars', JSON.stringify(['cal1', 'cal2']) ) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) render() await waitFor(() => { expect(registerToCalendars).toHaveBeenCalled() }) jest.clearAllMocks() ;(unregisterToCalendars as jest.Mock).mockImplementation(() => { throw new Error('Unregistration failed') }) await act(async () => { setSelectedCalendars(['cal1']) }) await waitFor(() => { expect(consoleError).toHaveBeenCalledWith( 'Failed to unregister calendar:', expect.any(Error) ) }) consoleError.mockRestore() }) it('should handle socket that becomes open after initial connection', async () => { const closedSocket = createMockSocket(WebSocket.CONNECTING) localStorage.setItem('selectedCalendars', JSON.stringify(['cal1'])) ;(createWebSocketConnection as jest.Mock).mockResolvedValue(closedSocket) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) // Socket is not open yet, should not register expect(registerToCalendars).not.toHaveBeenCalled() // Simulate socket becoming open await act(async () => { closedSocket.readyState = WebSocket.OPEN // Trigger a calendar change to re-run the effect setSelectedCalendars(['cal1', 'cal2']) }) // Still should not register because isSocketOpen state is false // The component only sets isSocketOpen to true when socket.readyState is OPEN during connection expect(registerToCalendars).not.toHaveBeenCalled() }) }) describe('Ping/Pong Integration', () => { beforeEach(() => { jest.useFakeTimers() }) afterEach(() => { jest.runOnlyPendingTimers() jest.useRealTimers() }) it('should trigger reconnection when ping detects dead connection (via socket close)', async () => { const consoleWarn = jest.spyOn(console, 'warn').mockImplementation() let onCloseCallback: ((event: CloseEvent) => void) | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) ;(createWebSocketConnection as jest.Mock).mockClear() // In the real implementation, when ping detects dead connection, // it calls socket.close() which triggers the onClose callback // This simulates that flow await act(async () => { if (onCloseCallback) { onCloseCallback(new CloseEvent('close', { code: 1006 })) } }) // Should schedule reconnection expect(consoleWarn).toHaveBeenCalledWith( expect.stringContaining('WebSocket closed unexpectedly') ) // Advance to trigger reconnection await act(async () => { jest.advanceTimersByTime(1500) }) // Should reconnect await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) consoleWarn.mockRestore() }) it('should stop ping monitoring when socket closes normally', async () => { let onCloseCallback: ((event: CloseEvent) => void) | undefined ;(createWebSocketConnection as jest.Mock).mockImplementation( callbacks => { onCloseCallback = callbacks.onClose return Promise.resolve(mockSocket) } ) render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) // Normal close (code 1000) - like logout or page navigation await act(async () => { if (onCloseCallback) { onCloseCallback(new CloseEvent('close', { code: 1000 })) } }) // Should not attempt reconnection await act(async () => { jest.advanceTimersByTime(5000) }) expect(createWebSocketConnection).toHaveBeenCalledTimes(1) }) it('should cleanup ping monitoring on component unmount', async () => { ;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket) const { unmount } = render() await waitFor(() => { expect(createWebSocketConnection).toHaveBeenCalled() }) // Unmount should cleanup unmount() expect(mockSocket.close).toHaveBeenCalled() }) }) })