Skip to content

Commit

Permalink
#153 Add WebSocket Container (#154)
Browse files Browse the repository at this point in the history
  • Loading branch information
obr42 authored Sep 30, 2022
1 parent cd81f8b commit a0923a3
Show file tree
Hide file tree
Showing 9 changed files with 567 additions and 16 deletions.
339 changes: 339 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@babel/eslint-parser": "^7.18.2",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/lodash": "^4.14.180",
Expand All @@ -79,6 +80,7 @@
"eslint-plugin-simple-import-sort": "^7.0.0",
"husky": "^7.0.4",
"jest": "^27.5.1",
"jest-websocket-mock": "^2.4.0",
"lint-prepush": "^2.2.1",
"prettier": "^2.7.1",
"react-app-rewire-webpack-bundle-analyzer": "^1.1.0",
Expand Down
5 changes: 4 additions & 1 deletion src/containers/AuthContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DebugContainer } from 'containers/DebugContainer'
import { SocketContainer } from 'containers/SocketContainer'
import { useMyAxios } from 'hooks/useMyAxios'
import { TokenResponse, useToken } from 'hooks/useToken'
import { useCallback, useEffect, useState } from 'react'
Expand All @@ -22,6 +23,7 @@ export interface User extends UserBase {

const useAuth = () => {
const { DEBUG_LOGIN } = DebugContainer.useContainer()
const { updateSocketToken } = SocketContainer.useContainer()
const { axiosInstance } = useMyAxios()
const navigate = useNavigate()
const [user, _setUser] = useState<string | null>(null)
Expand Down Expand Up @@ -79,10 +81,11 @@ const useAuth = () => {

setUser(username)
setToken({ access, refresh })
updateSocketToken(access)

window.localStorage.setItem(AuthEvents.LOGIN, new Date().toISOString())
},
[axiosInstance, DEBUG_LOGIN, setUser, setToken],
[axiosInstance, DEBUG_LOGIN, setUser, setToken, updateSocketToken],
)

return {
Expand Down
18 changes: 14 additions & 4 deletions src/containers/DebugContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import { DebugSettings } from 'types/config-types'
import { createContainer } from 'unstated-next'

const useDebugContainer = () => {
const DEBUG_LOGIN = false
const DEBUG_AUTH = false
const DEBUG_LOCAL_STORAGE = false
const useDebugContainer = (
initialState: DebugSettings = {
LOGIN: false,
AUTH: false,
LOCAL_STORAGE: false,
SOCKET: false,
},
) => {
const DEBUG_LOGIN = initialState.LOGIN
const DEBUG_AUTH = initialState.AUTH
const DEBUG_LOCAL_STORAGE = initialState.LOCAL_STORAGE
const DEBUG_SOCKET = initialState.SOCKET

return {
DEBUG_LOGIN,
DEBUG_AUTH,
DEBUG_LOCAL_STORAGE,
DEBUG_SOCKET,
}
}

Expand Down
102 changes: 102 additions & 0 deletions src/containers/SocketContainer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { renderHook } from '@testing-library/react-hooks'
import WS from 'jest-websocket-mock'
import { SocketProvider } from 'test/testMocks'

import { SocketContainer } from './SocketContainer'

let consoleSpy: jest.SpyInstance

describe('Socket Container', () => {
beforeEach(() => {
consoleSpy = jest.spyOn(console, 'log')
})

afterEach(() => {
consoleSpy.mockClear()
})

afterAll(() => {
consoleSpy.mockRestore()
})

test('adds function to listener list', () => {
const mockFn = jest.fn()
const { result } = renderHook(() => SocketContainer.useContainer(), {
wrapper: SocketProvider,
})
result.current.addCallback('testCB', mockFn)
expect(consoleSpy).toHaveBeenCalledWith('Adding testCB socket listener')
})

test('does not error adding existing function to listener list', () => {
const mockFn = jest.fn()
const { result } = renderHook(() => SocketContainer.useContainer(), {
wrapper: SocketProvider,
})
result.current.addCallback('testCB', mockFn)
result.current.addCallback('testCB', mockFn)
expect(consoleSpy).toHaveBeenCalledWith('Adding testCB socket listener')
expect(consoleSpy).toHaveBeenCalledTimes(2)
})

test('does not remove non-existent function from listener list if', () => {
const { result } = renderHook(() => SocketContainer.useContainer(), {
wrapper: SocketProvider,
})
result.current.removeCallback('testCB')
expect(consoleSpy).not.toHaveBeenCalledWith(
'Removing testCB socket listener',
)
})

test('removes function from listener list', () => {
const mockFn = jest.fn()
const { result } = renderHook(() => SocketContainer.useContainer(), {
wrapper: SocketProvider,
})
result.current.addCallback('testCB', mockFn)
consoleSpy.mockClear()
result.current.removeCallback('testCB')
expect(consoleSpy).toHaveBeenCalledWith('Removing testCB socket listener')
})

describe('Websocket Tests', () => {
let server: WS

beforeEach(() => {
server = new WS('ws://localhost:2337/api/v1/socket/events')
})

afterEach(() => {
WS.clean()
})

test('calls listeners on message', async () => {
const testMsg = JSON.stringify({ test: 'This is a test' })
const mockFn = jest.fn()
const { result } = renderHook(() => SocketContainer.useContainer(), {
wrapper: SocketProvider,
})
result.current.addCallback('testAdd', mockFn)
await server.connected
server.send(testMsg)
expect(consoleSpy).toHaveBeenCalledWith(
'Socket message',
JSON.parse(testMsg),
)
expect(mockFn).toHaveBeenCalledWith(JSON.parse(testMsg))
}, 8000)

test('sends token to authenticate', async () => {
const msg = { name: 'UPDATE_TOKEN', payload: 'valid_token' }
const msgStr = JSON.stringify(msg)
const { result } = renderHook(() => SocketContainer.useContainer(), {
wrapper: SocketProvider,
})
await server.connected
result.current.updateSocketToken(msg.payload)
await expect(server).toReceiveMessage(msgStr)
expect(server).toHaveReceivedMessages([msgStr])
})
})
})
64 changes: 64 additions & 0 deletions src/containers/SocketContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { DebugContainer } from 'containers/DebugContainer'
import { useEffect, useMemo, useRef } from 'react'
import { createContainer } from 'unstated-next'

type WsCallback = (arg0: MessageEvent['data']) => void
interface CallbackList {
[key: string]: WsCallback
}

const useSocket = () => {
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'
const eventUrl =
protocol + window.location.hostname + ':2337/api/v1/socket/events'
const ws = useMemo(() => new WebSocket(eventUrl), [eventUrl])

const cbList = useRef<CallbackList>({})
const { DEBUG_SOCKET } = DebugContainer.useContainer()

useEffect(() => {
/**
* Emit event to each callback in list upon getting WS message
* @param message WS event
*/
ws.onmessage = (message) => {
const event = JSON.parse(message.data)
if (DEBUG_SOCKET) console.log('Socket message', event)
Object.values(cbList.current).forEach((callback: WsCallback) => {
callback(event)
})
}
}, [DEBUG_SOCKET, ws])

/**
* Adds function to list of callbacks, WILL OVERWRITE EXISTING FUNCTION
* if key already exists in list
* @param key string Name of cb
* @param cb function Callback function
*/
const addCallback = (key: string, cb: WsCallback) => {
if (DEBUG_SOCKET) console.log(`Adding ${key} socket listener`)
cbList.current[key] = cb
}

const removeCallback = (key: string) => {
if (cbList.current[key]) {
if (DEBUG_SOCKET) console.log(`Removing ${key} socket listener`)
delete cbList.current[key]
}
}

const updateSocketToken = (token: string) => {
if (token && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ name: 'UPDATE_TOKEN', payload: token }))
}
}

return {
addCallback,
removeCallback,
updateSocketToken,
}
}

export const SocketContainer = createContainer(useSocket)
9 changes: 6 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ThemeProvider } from 'components/UI/Theme/ThemeProvider'
import { AuthContainer } from 'containers/AuthContainer'
import { ServerConfigContainer } from 'containers/ConfigContainer'
import { DebugContainer } from 'containers/DebugContainer'
import { SocketContainer } from 'containers/SocketContainer'
import ReactDOM from 'react-dom'
import { HashRouter } from 'react-router-dom'

Expand All @@ -17,9 +18,11 @@ ReactDOM.render(
<CssBaseline />
<ServerConfigContainer.Provider>
<DebugContainer.Provider>
<AuthContainer.Provider>
<App />
</AuthContainer.Provider>
<SocketContainer.Provider>
<AuthContainer.Provider>
<App />
</AuthContainer.Provider>
</SocketContainer.Provider>
</DebugContainer.Provider>
</ServerConfigContainer.Provider>
</ThemeProvider>
Expand Down
37 changes: 29 additions & 8 deletions src/test/testMocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import ErrorBoundary from 'components/ErrorBoundary'
import { AuthContainer } from 'containers/AuthContainer'
import { ServerConfigContainer } from 'containers/ConfigContainer'
import { DebugContainer } from 'containers/DebugContainer'
import { SocketContainer } from 'containers/SocketContainer'
import { Suspense } from 'react'
import { HashRouter } from 'react-router-dom'

Expand All @@ -25,7 +26,9 @@ export const AllProviders = ({ children }: ProviderMocks) => {
<HashRouter>
<ServerConfigContainer.Provider>
<DebugContainer.Provider>
<AuthContainer.Provider>{children}</AuthContainer.Provider>
<SocketContainer.Provider>
<AuthContainer.Provider>{children}</AuthContainer.Provider>
</SocketContainer.Provider>
</DebugContainer.Provider>
</ServerConfigContainer.Provider>
</HashRouter>
Expand All @@ -43,9 +46,11 @@ export const SuspendedProviders = ({ children }: ProviderMocks) => {
<HashRouter>
<ServerConfigContainer.Provider>
<DebugContainer.Provider>
<AuthContainer.Provider>
<Suspense fallback={<>LOADING...</>}>{children}</Suspense>
</AuthContainer.Provider>
<SocketContainer.Provider>
<AuthContainer.Provider>
<Suspense fallback={<>LOADING...</>}>{children}</Suspense>
</AuthContainer.Provider>
</SocketContainer.Provider>
</DebugContainer.Provider>
</ServerConfigContainer.Provider>
</HashRouter>
Expand All @@ -64,17 +69,19 @@ export const LoggedInProviders = ({ children }: ProviderMocks) => {
<ErrorBoundary>
<ServerConfigContainer.Provider>
<DebugContainer.Provider>
<AuthContainer.Provider>
<SubProvider>{children}</SubProvider>
</AuthContainer.Provider>
<SocketContainer.Provider>
<AuthContainer.Provider>
<LoginProvider>{children}</LoginProvider>
</AuthContainer.Provider>
</SocketContainer.Provider>
</DebugContainer.Provider>
</ServerConfigContainer.Provider>
</ErrorBoundary>
</HashRouter>
)
}

const SubProvider = ({ children }: ProviderMocks) => {
const LoginProvider = ({ children }: ProviderMocks) => {
const { login } = AuthContainer.useContainer()
login('admin', 'password')
.then(() => {
Expand All @@ -86,3 +93,17 @@ const SubProvider = ({ children }: ProviderMocks) => {
})
return <>{children}</>
}

/**
* Wrapper that only has socket provider and what it needs to run
* not authenticated, with logs on
* @param param0
* @returns
*/
export const SocketProvider = ({ children }: ProviderMocks) => {
return (
<DebugContainer.Provider initialState={{ SOCKET: true }}>
<SocketContainer.Provider>{children}</SocketContainer.Provider>
</DebugContainer.Provider>
)
}
7 changes: 7 additions & 0 deletions src/types/config-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ export interface VersionConfig {
current_api_version: string
supported_api_versions: [string]
}

export interface DebugSettings {
LOGIN?: boolean
AUTH?: boolean
LOCAL_STORAGE?: boolean
SOCKET?: boolean
}

0 comments on commit a0923a3

Please sign in to comment.