cadfa70e60
* #708 apply strictier linting rules and fix simple eslint bugs * #708 fix eslint errors relate to promise * #708 fix eslint import/no-extraneous-dependencies * #708 fix eslint errors of react-hook * #708 enable eslint check for typescript --------- Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
995 lines
28 KiB
TypeScript
995 lines
28 KiB
TypeScript
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 (
|
|
<I18nContext.Provider
|
|
value={{
|
|
t: (key: string, vars?: Record<string, string>) => {
|
|
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'
|
|
}}
|
|
>
|
|
<Provider store={store}>
|
|
<WebSocketGate />
|
|
</Provider>
|
|
</I18nContext.Provider>
|
|
)
|
|
}
|
|
|
|
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(<TestWrapper store={unauthStore} />)
|
|
|
|
expect(createWebSocketConnection).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should create connection when user is authenticated', async () => {
|
|
;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket)
|
|
|
|
render(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
await waitFor(() => {
|
|
expect(createWebSocketConnection).toHaveBeenCalled()
|
|
})
|
|
|
|
const unauthStore = createMockStore(null, null)
|
|
rerender(<TestWrapper store={unauthStore} />)
|
|
|
|
await waitFor(() => {
|
|
expect(mockSocket.close).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Socket Connection Management', () => {
|
|
it('should create connection with callbacks', async () => {
|
|
;(createWebSocketConnection as jest.Mock).mockResolvedValue(mockSocket)
|
|
|
|
render(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={unauthStore} />)
|
|
|
|
// 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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
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(<TestWrapper store={store} />)
|
|
|
|
await waitFor(() => {
|
|
expect(createWebSocketConnection).toHaveBeenCalled()
|
|
})
|
|
|
|
// Unmount should cleanup
|
|
unmount()
|
|
|
|
expect(mockSocket.close).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|