Преглед на файлове

feat: connect to pubsub URL with autoconnect from stored client name

Fela Maslen преди 5 години
родител
ревизия
6d12c67d1f
променени са 10 файла, в които са добавени 241 реда и са изтрити 16 реда
  1. 1 0
      gmus/.env.example
  2. 1 0
      gmus/.env.test
  3. 2 0
      gmus/.eslintrc.js
  4. 1 0
      gmus/.gitignore
  5. 3 2
      gmus/package.json
  6. 161 0
      gmus/src/hooks/socket.spec.tsx
  7. 26 13
      gmus/src/hooks/socket.ts
  8. 27 0
      gmus/src/utils/url.spec.ts
  9. 6 0
      gmus/src/utils/url.ts
  10. 13 1
      gmus/yarn.lock

+ 1 - 0
gmus/.env.example

@@ -0,0 +1 @@
+REACT_APP_API_URL=http://localhost:3000

+ 1 - 0
gmus/.env.test

@@ -0,0 +1 @@
+REACT_APP_API_URL=http://my-api.url:1234

+ 2 - 0
gmus/.eslintrc.js

@@ -46,6 +46,7 @@ module.exports = {
       },
     ],
     '@typescript-eslint/no-shadow': 'error',
+    '@typescript-eslint/no-unused-expressions': 'error',
     '@typescript-eslint/no-use-before-define': 'error',
     '@typescript-eslint/no-var-requires': 'off',
     camelcase: 'off',
@@ -92,6 +93,7 @@ module.exports = {
     'no-bitwise': 'off',
     'no-underscore-dangle': 'off',
     'no-shadow': 'off',
+    'no-unused-expressions': 'off',
     'no-use-before-define': 'off',
     'no-warning-comments': 'warn',
     'prettier/prettier': ['error'],

+ 1 - 0
gmus/.gitignore

@@ -17,6 +17,7 @@
 .env.development.local
 .env.test.local
 .env.production.local
+!.env.test
 
 npm-debug.log*
 yarn-debug.log*

+ 3 - 2
gmus/package.json

@@ -38,6 +38,8 @@
     "eslint-config-airbnb-base": "^14.2.1",
     "eslint-config-prettier": "^7.0.0",
     "eslint-plugin-prettier": "^3.3.0",
+    "jest-websocket-mock": "^2.2.0",
+    "mock-socket": "^9.0.3",
     "prettier": "^2.2.1",
     "react": "^17.0.1",
     "react-dom": "^17.0.1",
@@ -46,6 +48,5 @@
     "react-storage-hooks": "^4.0.1",
     "typescript": "^4.0.3",
     "web-vitals": "^0.2.4"
-  },
-  "devDependencies": {}
+  }
 }

+ 161 - 0
gmus/src/hooks/socket.spec.tsx

@@ -0,0 +1,161 @@
+import { act, fireEvent, render, RenderResult } from '@testing-library/react';
+import WS from 'jest-websocket-mock';
+import React from 'react';
+import * as storageHooks from 'react-storage-hooks';
+
+import { useSocket } from './socket';
+
+describe(useSocket.name, () => {
+  afterEach(WS.clean);
+
+  const TestComponent: React.FC = () => {
+    const { onIdentify, socket, ...hookResult } = useSocket();
+
+    return (
+      <>
+        <button onClick={(): void => onIdentify('my-client-name')}>Identify!</button>
+        <button onClick={(): void => socket?.send('Hello world!')}>Say hello!</button>
+        <div data-testid="hook-result">{JSON.stringify(hookResult)}</div>
+      </>
+    );
+  };
+
+  it.each`
+    testCase                   | key             | expectedValue
+    ${'the name'}              | ${'name'}       | ${''}
+    ${'the error status'}      | ${'error'}      | ${false}
+    ${'the connecting status'} | ${'connecting'} | ${false}
+    ${'the connected status'}  | ${'connected'}  | ${false}
+  `('should return $testCase', ({ key, expectedValue }) => {
+    expect.assertions(1);
+    const { getByTestId } = render(<TestComponent />);
+
+    expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+      expect.objectContaining({
+        [key]: expectedValue,
+      }),
+    );
+  });
+
+  describe('when identifying', () => {
+    let server: WS;
+    const saveName: React.Dispatch<unknown> = jest.fn();
+    beforeEach(() => {
+      server = new WS('ws://my-api.url:1234/pubsub');
+      jest
+        .spyOn(storageHooks, 'useStorageState')
+        .mockReturnValue(['' as unknown, saveName, undefined]);
+    });
+
+    const setupIdentify = (): RenderResult => {
+      const renderResult = render(<TestComponent />);
+      act(() => {
+        fireEvent.click(renderResult.getByText('Identify!'));
+      });
+
+      return renderResult;
+    };
+
+    it('should create a new connection to the socket, using the client name in the query', async () => {
+      expect.assertions(1);
+      setupIdentify();
+
+      const res = await server.connected;
+      expect(res.url).toBe('ws://my-api.url:1234/pubsub?client-name=my-client-name');
+    });
+
+    it('should open a new socket', async () => {
+      expect.assertions(2);
+      const { getByText } = setupIdentify();
+      await act(async () => {
+        await server.connected;
+      });
+
+      act(() => {
+        fireEvent.click(getByText('Say hello!'));
+      });
+
+      await expect(server).toReceiveMessage('Hello world!');
+      expect(server).toHaveReceivedMessages(['Hello world!']);
+    });
+
+    it('should set the connected state to true', async () => {
+      expect.assertions(1);
+      const { getByTestId } = setupIdentify();
+      await act(async () => {
+        await server.connected;
+      });
+
+      expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+        expect.objectContaining({
+          connecting: false,
+          connected: true,
+        }),
+      );
+    });
+
+    it('should save the client name', async () => {
+      expect.assertions(2);
+      setupIdentify();
+      await act(async () => {
+        await server.connected;
+      });
+
+      expect(saveName).toHaveBeenCalledTimes(1);
+      expect(saveName).toHaveBeenCalledWith('my-client-name');
+    });
+  });
+
+  describe('when the name is stored in localStorage', () => {
+    let server: WS;
+    const saveName: React.Dispatch<unknown> = jest.fn();
+    beforeEach(() => {
+      server = new WS('ws://my-api.url:1234/pubsub');
+      jest
+        .spyOn(storageHooks, 'useStorageState')
+        .mockReturnValue(['my-stored-name' as unknown, saveName, undefined]);
+    });
+
+    it('should set connecting to true', () => {
+      expect.assertions(1);
+      const { getByTestId } = render(<TestComponent />);
+
+      expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+        expect.objectContaining({
+          connecting: true,
+          connected: false,
+        }),
+      );
+    });
+
+    it('should open a socket immediately, using the stored name', async () => {
+      expect.assertions(3);
+      const { getByText } = render(<TestComponent />);
+
+      const res = await server.connected;
+
+      expect(res.url).toBe('ws://my-api.url:1234/pubsub?client-name=my-stored-name');
+
+      act(() => {
+        fireEvent.click(getByText('Say hello!'));
+      });
+
+      await expect(server).toReceiveMessage('Hello world!');
+      expect(server).toHaveReceivedMessages(['Hello world!']);
+    });
+
+    it('should set connecting to false after the socket is connected', async () => {
+      expect.assertions(1);
+      const { getByTestId } = render(<TestComponent />);
+
+      await server.connected;
+
+      expect(JSON.parse(getByTestId('hook-result').innerHTML)).toStrictEqual(
+        expect.objectContaining({
+          connecting: false,
+          connected: true,
+        }),
+      );
+    });
+  });
+});

+ 26 - 13
gmus/src/hooks/socket.ts

@@ -1,9 +1,8 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { useStorageState } from 'react-storage-hooks';
 
 import { socketKeepaliveTimeout } from '../constants/system';
-
-const url = 'ws://localhost:3002/pubsub'; // TODO
+import { getPubsubUrl } from '../utils/url';
 
 export function useSocket(): {
   name: string | null;
@@ -14,44 +13,58 @@ export function useSocket(): {
   connected: boolean;
 } {
   const [name, saveName] = useStorageState<string>(localStorage, 'client-name', '');
+  const [tempName, setTempName] = useState<string>(name);
 
   const [socket, setSocket] = useState<WebSocket | null>(null);
   const [error, setError] = useState<boolean>(false);
 
   const [connecting, setConnecting] = useState<boolean>(false);
 
-  const onIdentify = useCallback(
-    (newName: string) => {
+  useEffect(() => {
+    let cancelled = false;
+    let ws: WebSocket | undefined;
+    if (tempName) {
       setConnecting(true);
 
-      const ws = new WebSocket(`${url}?client-name=${name}`);
+      ws = new WebSocket(`${getPubsubUrl()}?client-name=${tempName}`);
 
       ws.onopen = (): void => {
-        if (ws.readyState === ws.OPEN) {
+        if (!cancelled && ws.readyState === ws.OPEN) {
           setError(false);
           setConnecting(false);
 
-          saveName(newName);
+          saveName(tempName);
 
           setSocket(ws);
         }
       };
 
       ws.onclose = (): void => {
+        if (cancelled) {
+          return;
+        }
+
         setError(false);
         setSocket(null);
       };
-    },
-    [saveName],
-  );
+    } else {
+      setConnecting(false);
+      setError(false);
+    }
+
+    return (): void => {
+      cancelled = true;
+      ws?.close();
+    };
+  }, [tempName, saveName]);
 
   return {
     name,
-    onIdentify,
+    onIdentify: setTempName,
     socket,
     error,
     connecting,
-    connected: socket?.readyState === socket?.OPEN,
+    connected: !!socket && socket?.readyState === socket?.OPEN,
   };
 }
 

+ 27 - 0
gmus/src/utils/url.spec.ts

@@ -0,0 +1,27 @@
+import { getPubsubUrl } from './url';
+
+describe(getPubsubUrl.name, () => {
+  it('should return a websocket URL', () => {
+    expect.assertions(1);
+    expect(getPubsubUrl()).toBe('ws://my-api.url:1234/pubsub');
+  });
+
+  describe.each`
+    case             | apiUrl                     | testCase          | expectedPubsubUrl
+    ${'is https'}    | ${'https://some.api:1876'} | ${'a secure URL'} | ${'wss://some.api:1876/pubsub'}
+    ${'has no port'} | ${'http://some.api'}       | ${'no port'}      | ${'ws://some.api/pubsub'}
+  `('when the URL $case', ({ testCase, apiUrl, expectedPubsubUrl }) => {
+    const envBefore = process.env.REACT_APP_API_URL;
+    beforeAll(() => {
+      process.env.REACT_APP_API_URL = apiUrl;
+    });
+    afterAll(() => {
+      process.env.REACT_APP_API_URL = envBefore;
+    });
+
+    it(`should return ${testCase}`, () => {
+      expect.assertions(1);
+      expect(getPubsubUrl()).toBe(expectedPubsubUrl);
+    });
+  });
+});

+ 6 - 0
gmus/src/utils/url.ts

@@ -0,0 +1,6 @@
+export function getPubsubUrl(): string {
+  const apiUrl = new URL(process.env.REACT_APP_API_URL ?? 'http://localhost:3000');
+  return `${apiUrl.protocol === 'https:' ? 'wss' : 'ws'}://${apiUrl.hostname}${
+    apiUrl.port ? `:${apiUrl.port}` : ''
+  }/pubsub`;
+}

+ 13 - 1
gmus/yarn.lock

@@ -6708,6 +6708,11 @@ jest-watcher@^26.3.0, jest-watcher@^26.6.2:
     jest-util "^26.6.2"
     string-length "^4.0.1"
 
+jest-websocket-mock@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/jest-websocket-mock/-/jest-websocket-mock-2.2.0.tgz#0eed73eb3c14d48b15dd046c9e40e9571377d06e"
+  integrity sha512-lc3wwXOEyNa4ZpcgJtUG3mmKMAq5FAsKYiZph0p/+PAJrAPuX4JCIfJMdJ/urRsLBG51fwm/wlVPNbR6s2nzNw==
+
 jest-worker@^24.9.0:
   version "24.9.0"
   resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5"
@@ -7382,6 +7387,13 @@ mkdirp@^1.0.3, mkdirp@^1.0.4:
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
+mock-socket@^9.0.3:
+  version "9.0.3"
+  resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.0.3.tgz#4bc6d2aea33191e4fed5ec71f039e2bbeb95e414"
+  integrity sha512-SxIiD2yE/By79p3cNAAXyLQWTvEFNEzcAO7PH+DzRqKSFaplAPFjiQLmw8ofmpCsZf+Rhfn2/xCJagpdGmYdTw==
+  dependencies:
+    url-parse "^1.4.4"
+
 move-concurrently@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@@ -10915,7 +10927,7 @@ url-loader@4.1.1:
     mime-types "^2.1.27"
     schema-utils "^3.0.0"
 
-url-parse@^1.4.3:
+url-parse@^1.4.3, url-parse@^1.4.4:
   version "1.4.7"
   resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278"
   integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==