socket.spec.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import { act, fireEvent, render, RenderResult, waitFor } from '@testing-library/react';
  2. import WS from 'jest-websocket-mock';
  3. import * as nanoid from 'nanoid';
  4. import React, { Dispatch } from 'react';
  5. import * as storageHooks from 'react-storage-hooks';
  6. import { AnyAction, LocalAction, RemoteAction } from '../actions';
  7. import * as effects from '../effects/effects';
  8. import { GlobalState } from '../reducer';
  9. import { useOnMessage, useDispatchWithEffects, useSocket } from './socket';
  10. jest.mock('nanoid');
  11. describe(useOnMessage.name, () => {
  12. const dispatch: Dispatch<AnyAction> = jest.fn();
  13. const testMessage = {
  14. data: JSON.stringify({
  15. type: 'SOME_ACTION_FROM_SOCKET',
  16. payload: {
  17. some: 'thing',
  18. },
  19. }),
  20. } as MessageEvent<unknown>;
  21. const TestComponent: React.FC = () => {
  22. const onMessage = useOnMessage(dispatch);
  23. return <button onClick={(): void => onMessage(testMessage)}>Simulate message!</button>;
  24. };
  25. it('should return a function which dispatches actions', () => {
  26. expect.assertions(2);
  27. const { getByText } = render(<TestComponent />);
  28. act(() => {
  29. fireEvent.click(getByText('Simulate message!'));
  30. });
  31. expect(dispatch).toHaveBeenCalledTimes(1);
  32. expect(dispatch).toHaveBeenCalledWith({
  33. type: 'SOME_ACTION_FROM_SOCKET',
  34. payload: {
  35. some: 'thing',
  36. },
  37. });
  38. });
  39. });
  40. describe(useDispatchWithEffects.name, () => {
  41. const someAction = ({
  42. type: 'SOME_ACTION',
  43. payload: 'yes',
  44. } as unknown) as LocalAction;
  45. const state = ({ my: 'state' } as unknown) as GlobalState;
  46. const dispatch = jest.fn();
  47. const socket = ({
  48. send: jest.fn(),
  49. OPEN: WebSocket.OPEN,
  50. readyState: WebSocket.OPEN,
  51. } as unknown) as WebSocket;
  52. const someEffect = ({
  53. type: 'SOME_EFFECT',
  54. payload: {
  55. fromClient: 'us',
  56. data: 'yes',
  57. },
  58. } as unknown) as RemoteAction;
  59. const TestComponent: React.FC = () => {
  60. const dispatchWithEffects = useDispatchWithEffects(state, dispatch, socket);
  61. return (
  62. <>
  63. <button onClick={(): void => dispatchWithEffects(someAction)}>Dispatch!</button>
  64. </>
  65. );
  66. };
  67. describe('when an action is dispatched', () => {
  68. let globalEffectsSpy: jest.SpyInstance;
  69. describe('and no effect is associated', () => {
  70. beforeEach(() => {
  71. globalEffectsSpy = jest.spyOn(effects, 'globalEffects').mockReturnValueOnce(null);
  72. });
  73. it('should dispatch the action to the local store', async () => {
  74. expect.hasAssertions();
  75. const { getByText } = render(<TestComponent />);
  76. expect(dispatch).not.toHaveBeenCalled();
  77. act(() => {
  78. fireEvent.click(getByText('Dispatch!'));
  79. });
  80. await waitFor(() => {
  81. expect(dispatch).toHaveBeenCalledWith(someAction);
  82. });
  83. });
  84. it('should not send a message to the socket', () => {
  85. expect.assertions(1);
  86. const { getByText } = render(<TestComponent />);
  87. act(() => {
  88. fireEvent.click(getByText('Dispatch!'));
  89. });
  90. expect(socket.send).not.toHaveBeenCalled();
  91. });
  92. });
  93. describe('and an effect is associated', () => {
  94. beforeEach(() => {
  95. globalEffectsSpy = jest.spyOn(effects, 'globalEffects').mockReturnValueOnce(someEffect);
  96. });
  97. it('should dispatch the action to the local store', async () => {
  98. expect.hasAssertions();
  99. const { getByText } = render(<TestComponent />);
  100. expect(dispatch).not.toHaveBeenCalled();
  101. act(() => {
  102. fireEvent.click(getByText('Dispatch!'));
  103. });
  104. await waitFor(() => {
  105. expect(dispatch).toHaveBeenCalledWith(someAction);
  106. });
  107. });
  108. it('should send a message to the socket', () => {
  109. expect.assertions(4);
  110. const { getByText } = render(<TestComponent />);
  111. act(() => {
  112. fireEvent.click(getByText('Dispatch!'));
  113. });
  114. expect(globalEffectsSpy).toHaveBeenCalledTimes(1);
  115. expect(globalEffectsSpy).toHaveBeenCalledWith(state, someAction);
  116. expect(socket.send).toHaveBeenCalledTimes(1);
  117. expect(socket.send).toHaveBeenCalledWith(JSON.stringify(someEffect));
  118. });
  119. });
  120. });
  121. });
  122. describe(useSocket.name, () => {
  123. let nanoidMock: jest.SpyInstance;
  124. beforeEach(() => {
  125. nanoidMock = jest.spyOn(nanoid, 'nanoid').mockReturnValue('A5v3D');
  126. });
  127. afterEach(WS.clean);
  128. const onMessage = jest.fn();
  129. const onLogin = jest.fn();
  130. const TestComponent: React.FC = () => {
  131. const { onIdentify, socket, ...hookResult } = useSocket(onMessage, onLogin);
  132. return (
  133. <>
  134. <button onClick={(): void => onIdentify('my-client-name')}>Identify!</button>
  135. <button onClick={(): void => socket?.send('Hello world!')}>Say hello!</button>
  136. <div data-testid="hook-result">{JSON.stringify(hookResult)}</div>
  137. </>
  138. );
  139. };
  140. it.each`
  141. testCase | key | expectedValue
  142. ${'the name'} | ${'name'} | ${''}
  143. ${'the connecting status'} | ${'connecting'} | ${false}
  144. ${'the error status'} | ${'error'} | ${false}
  145. ${'the ready status'} | ${'ready'} | ${false}
  146. ${'the identified status'} | ${'identified'} | ${false}
  147. `('should return $testCase', ({ key, expectedValue }) => {
  148. expect.assertions(1);
  149. const { getByTestId } = render(<TestComponent />);
  150. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  151. expect.objectContaining({
  152. [key]: expectedValue,
  153. }),
  154. );
  155. });
  156. describe('when identifying', () => {
  157. let server: WS;
  158. const saveName: React.Dispatch<unknown> = jest.fn();
  159. beforeEach(() => {
  160. server = new WS('ws://my-api.url:1234/pubsub');
  161. jest
  162. .spyOn(storageHooks, 'useStorageState')
  163. .mockReturnValue(['' as unknown, saveName, undefined]);
  164. });
  165. const setupIdentify = (): RenderResult => {
  166. const renderResult = render(<TestComponent />);
  167. act(() => {
  168. fireEvent.click(renderResult.getByText('Identify!'));
  169. });
  170. return renderResult;
  171. };
  172. it('should create a new connection to the socket, using a unique client name in the query', async () => {
  173. expect.assertions(1);
  174. setupIdentify();
  175. const res = await server.connected;
  176. expect(res.url).toBe('ws://my-api.url:1234/pubsub?client-name=my-client-name-A5v3D');
  177. });
  178. it('should open a new socket', async () => {
  179. expect.assertions(2);
  180. const { getByText } = setupIdentify();
  181. await act(async () => {
  182. await server.connected;
  183. });
  184. act(() => {
  185. fireEvent.click(getByText('Say hello!'));
  186. });
  187. await expect(server).toReceiveMessage('Hello world!');
  188. expect(server).toHaveReceivedMessages(['Hello world!']);
  189. });
  190. it('should set the connecting, ready and identified state', async () => {
  191. expect.assertions(2);
  192. const { getByTestId } = setupIdentify();
  193. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  194. expect.objectContaining({
  195. connecting: true,
  196. error: false,
  197. ready: false,
  198. identified: true,
  199. }),
  200. );
  201. await act(async () => {
  202. await server.connected;
  203. });
  204. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  205. expect.objectContaining({
  206. connecting: false,
  207. error: false,
  208. ready: true,
  209. identified: true,
  210. }),
  211. );
  212. });
  213. it('should return the unique name', async () => {
  214. expect.assertions(1);
  215. const { getByTestId } = setupIdentify();
  216. await act(async () => {
  217. await server.connected;
  218. });
  219. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  220. expect.objectContaining({
  221. name: 'my-client-name-A5v3D',
  222. }),
  223. );
  224. });
  225. it('should save the client name', async () => {
  226. expect.assertions(2);
  227. setupIdentify();
  228. await act(async () => {
  229. await server.connected;
  230. });
  231. expect(saveName).toHaveBeenCalledTimes(1);
  232. expect(saveName).toHaveBeenCalledWith('my-client-name');
  233. });
  234. it('should call onLogin', async () => {
  235. expect.assertions(2);
  236. setupIdentify();
  237. await act(async () => {
  238. await server.connected;
  239. });
  240. expect(onLogin).toHaveBeenCalledTimes(1);
  241. expect(onLogin).toHaveBeenCalledWith('my-client-name-A5v3D');
  242. });
  243. });
  244. describe('when the name is stored in localStorage', () => {
  245. let server: WS;
  246. const saveName: React.Dispatch<unknown> = jest.fn();
  247. beforeEach(() => {
  248. server = new WS('ws://my-api.url:1234/pubsub');
  249. jest
  250. .spyOn(storageHooks, 'useStorageState')
  251. .mockReturnValue(['my-stored-name' as unknown, saveName, undefined]);
  252. });
  253. it('should set the status', () => {
  254. expect.assertions(1);
  255. const { getByTestId } = render(<TestComponent />);
  256. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  257. expect.objectContaining({
  258. connecting: true,
  259. error: false,
  260. identified: true,
  261. ready: false,
  262. }),
  263. );
  264. });
  265. it('should open a socket immediately, using a unique version of the stored name', async () => {
  266. expect.assertions(3);
  267. const { getByText } = render(<TestComponent />);
  268. const res = await server.connected;
  269. expect(res.url).toBe('ws://my-api.url:1234/pubsub?client-name=my-stored-name-A5v3D');
  270. act(() => {
  271. fireEvent.click(getByText('Say hello!'));
  272. });
  273. await expect(server).toReceiveMessage('Hello world!');
  274. expect(server).toHaveReceivedMessages(['Hello world!']);
  275. });
  276. it('should set connecting to false after the socket is connected', async () => {
  277. expect.assertions(1);
  278. const { getByTestId } = render(<TestComponent />);
  279. await server.connected;
  280. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  281. expect.objectContaining({
  282. connecting: false,
  283. error: false,
  284. identified: true,
  285. ready: true,
  286. }),
  287. );
  288. });
  289. });
  290. describe('when a message is received from the server', () => {
  291. let server: WS;
  292. beforeEach(() => {
  293. server = new WS('ws://my-api.url:1234/pubsub');
  294. });
  295. it('should call onMessage', async () => {
  296. expect.assertions(2);
  297. const { getByText } = render(<TestComponent />);
  298. act(() => {
  299. fireEvent.click(getByText('Identify!'));
  300. });
  301. await server.connected;
  302. server.send('foo');
  303. expect(onMessage).toHaveBeenCalledTimes(1);
  304. expect(onMessage).toHaveBeenCalledWith(expect.objectContaining({ data: 'foo' }));
  305. });
  306. });
  307. describe('when an error occurs', () => {
  308. let server: WS;
  309. beforeEach(() => {
  310. nanoidMock.mockRestore();
  311. jest
  312. .spyOn(nanoid, 'nanoid')
  313. .mockReturnValueOnce('called-once-from-storedname')
  314. .mockReturnValueOnce('a1234')
  315. .mockReturnValue('notthis');
  316. server = new WS('ws://my-api.url:1234/pubsub');
  317. });
  318. const setupError = async (): Promise<RenderResult> => {
  319. const utils = render(<TestComponent />);
  320. act(() => {
  321. fireEvent.click(utils.getByText('Identify!'));
  322. });
  323. await server.connected;
  324. act(() => {
  325. server.error();
  326. });
  327. server = new WS('ws://my-api.url:1234/pubsub');
  328. return utils;
  329. };
  330. it('should reconnect automatically', async () => {
  331. expect.assertions(1);
  332. const { unmount } = await setupError();
  333. await server.connected;
  334. server.send('foo');
  335. expect(onMessage).toHaveBeenCalledTimes(1);
  336. act(() => {
  337. unmount();
  338. });
  339. });
  340. it('should use the same name when reconnecting', async () => {
  341. expect.assertions(1);
  342. await setupError();
  343. const res = await server.connected;
  344. expect(res.url).toBe('ws://my-api.url:1234/pubsub?client-name=my-client-name-a1234');
  345. });
  346. it('should set error to true but keep the identified state', async () => {
  347. expect.hasAssertions();
  348. const { getByTestId, unmount } = await setupError();
  349. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  350. expect.objectContaining({
  351. ready: false,
  352. error: true,
  353. connecting: false,
  354. identified: true,
  355. }),
  356. );
  357. await waitFor(() => {
  358. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  359. expect.objectContaining({
  360. ready: false,
  361. error: true,
  362. connecting: true,
  363. identified: true,
  364. }),
  365. );
  366. });
  367. await server.connected;
  368. await waitFor(() => {
  369. expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
  370. expect.objectContaining({
  371. ready: true,
  372. error: false,
  373. connecting: false,
  374. identified: true,
  375. }),
  376. );
  377. });
  378. act(() => {
  379. unmount();
  380. });
  381. });
  382. });
  383. });