From 24042df94f1f46a30f3d1b1db8c90294393d2310 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com>
Date: Thu, 26 Oct 2023 00:28:06 +0200
Subject: [PATCH] feat: add map middleware and tests

---
 .../map/middleware/checkIfActionValid.test.ts | 121 ++++++++++++++++++
 .../map/middleware/checkIfIsActionValid.ts    |  19 +++
 .../map/middleware/getUpdatedModel.test.ts    |  53 ++++++++
 src/redux/map/middleware/getUpdatedModel.ts   |  15 +++
 .../map/middleware/map.middleware.test.ts     | 106 +++++++++++++++
 src/redux/map/middleware/map.middleware.ts    |  28 ++++
 6 files changed, 342 insertions(+)
 create mode 100644 src/redux/map/middleware/checkIfActionValid.test.ts
 create mode 100644 src/redux/map/middleware/checkIfIsActionValid.ts
 create mode 100644 src/redux/map/middleware/getUpdatedModel.test.ts
 create mode 100644 src/redux/map/middleware/getUpdatedModel.ts
 create mode 100644 src/redux/map/middleware/map.middleware.test.ts
 create mode 100644 src/redux/map/middleware/map.middleware.ts

diff --git a/src/redux/map/middleware/checkIfActionValid.test.ts b/src/redux/map/middleware/checkIfActionValid.test.ts
new file mode 100644
index 00000000..ed62dfc9
--- /dev/null
+++ b/src/redux/map/middleware/checkIfActionValid.test.ts
@@ -0,0 +1,121 @@
+import { RootState } from '@/redux/store';
+import { Loading } from '@/types/loadingState';
+import { MAP_DATA_INITIAL_STATE, MIDDLEWARE_ALLOWED_ACTIONS } from '../map.constants';
+import { checkIfIsActionValid } from './checkIfIsActionValid';
+
+const state: Pick<RootState, 'map'> = {
+  map: {
+    data: {
+      ...MAP_DATA_INITIAL_STATE,
+      modelId: 2137,
+    },
+    loading: 'idle' as Loading,
+    error: { name: '', message: '' },
+  },
+};
+
+describe('checkIfIsActionValid - util', () => {
+  describe('when action type is allowed', () => {
+    describe('when action payload has model id equal to current', () => {
+      const modelId = 2137;
+
+      it.each(MIDDLEWARE_ALLOWED_ACTIONS)('should return false', actionType => {
+        expect(
+          checkIfIsActionValid(
+            {
+              type: actionType,
+              payload: {
+                modelId,
+              },
+            },
+            state as RootState,
+          ),
+        ).toBe(false);
+      });
+    });
+
+    describe('when action payload has model id different than current', () => {
+      const modelId = 997;
+
+      it.each(MIDDLEWARE_ALLOWED_ACTIONS)('should return true', actionType => {
+        expect(
+          checkIfIsActionValid(
+            {
+              type: actionType,
+              payload: {
+                modelId,
+              },
+            },
+            state as RootState,
+          ),
+        ).toBe(true);
+      });
+    });
+
+    describe('when action payload has NOT model id', () => {
+      it.each(MIDDLEWARE_ALLOWED_ACTIONS)('should return true', actionType => {
+        expect(
+          checkIfIsActionValid(
+            {
+              type: actionType,
+              payload: {},
+            },
+            state as RootState,
+          ),
+        ).toBe(true);
+      });
+    });
+  });
+
+  describe('when action type is NOT allowed', () => {
+    describe('when action payload has model id equal to current', () => {
+      const modelId = 2137;
+
+      it('should return false', () => {
+        expect(
+          checkIfIsActionValid(
+            {
+              type: 'some/other/action',
+              payload: {
+                modelId,
+              },
+            },
+            state as RootState,
+          ),
+        ).toBe(false);
+      });
+    });
+
+    describe('when action payload has model id different than current', () => {
+      const modelId = 997;
+
+      it('should return false', () => {
+        expect(
+          checkIfIsActionValid(
+            {
+              type: 'some/other/action',
+              payload: {
+                modelId,
+              },
+            },
+            state as RootState,
+          ),
+        ).toBe(false);
+      });
+    });
+
+    describe('when action payload has NOT model id', () => {
+      it('should return false', () => {
+        expect(
+          checkIfIsActionValid(
+            {
+              type: 'some/other/action',
+              payload: {},
+            },
+            state as RootState,
+          ),
+        ).toBe(false);
+      });
+    });
+  });
+});
diff --git a/src/redux/map/middleware/checkIfIsActionValid.ts b/src/redux/map/middleware/checkIfIsActionValid.ts
new file mode 100644
index 00000000..38596143
--- /dev/null
+++ b/src/redux/map/middleware/checkIfIsActionValid.ts
@@ -0,0 +1,19 @@
+import type { RootState } from '@/redux/store';
+import { MIDDLEWARE_ALLOWED_ACTIONS } from '../map.constants';
+import { MiddlewareAllowedAction } from '../map.types';
+
+export const checkIfIsActionValid = (
+  action: MiddlewareAllowedAction,
+  state: RootState,
+): boolean => {
+  const isAllowedAction = MIDDLEWARE_ALLOWED_ACTIONS.some(allowedAction =>
+    action.type.includes(allowedAction),
+  );
+
+  const { modelId: currentModelId } = state.map.data;
+  const payload = action?.payload || {};
+  const payloadModelId = 'modelId' in payload ? payload.modelId : null;
+  const isModelIdTheSame = currentModelId === payloadModelId;
+
+  return isAllowedAction && !isModelIdTheSame;
+};
diff --git a/src/redux/map/middleware/getUpdatedModel.test.ts b/src/redux/map/middleware/getUpdatedModel.test.ts
new file mode 100644
index 00000000..65cd7f25
--- /dev/null
+++ b/src/redux/map/middleware/getUpdatedModel.test.ts
@@ -0,0 +1,53 @@
+/* eslint-disable no-magic-numbers */
+import { modelsFixture } from '@/models/fixtures/modelsFixture';
+import { RootState } from '@/redux/store';
+import { MIDDLEWARE_ALLOWED_ACTIONS } from '../map.constants';
+import { getUpdatedModel } from './getUpdatedModel';
+
+const state: Pick<RootState, 'models'> = {
+  models: {
+    data: modelsFixture,
+    loading: 'idle',
+    error: { name: '', message: '' },
+  },
+};
+
+describe('getUpdatedModel - util', () => {
+  describe('when payload has valid modelId', () => {
+    const model = modelsFixture[0];
+    const action = {
+      type: MIDDLEWARE_ALLOWED_ACTIONS[0],
+      payload: {
+        modelId: model.idObject,
+      },
+    };
+
+    it('returns model object', () => {
+      expect(getUpdatedModel(action, state as RootState)).toStrictEqual(model);
+    });
+  });
+
+  describe('when payload has invalid modelId', () => {
+    const action = {
+      type: MIDDLEWARE_ALLOWED_ACTIONS[0],
+      payload: {
+        modelId: null,
+      },
+    };
+
+    it('returns undefined', () => {
+      expect(getUpdatedModel(action, state as RootState)).toStrictEqual(undefined);
+    });
+  });
+
+  describe('when payload does not have modelId', () => {
+    const action = {
+      type: MIDDLEWARE_ALLOWED_ACTIONS[0],
+      payload: {},
+    };
+
+    it('returns undefined', () => {
+      expect(getUpdatedModel(action, state as RootState)).toStrictEqual(undefined);
+    });
+  });
+});
diff --git a/src/redux/map/middleware/getUpdatedModel.ts b/src/redux/map/middleware/getUpdatedModel.ts
new file mode 100644
index 00000000..60c26a24
--- /dev/null
+++ b/src/redux/map/middleware/getUpdatedModel.ts
@@ -0,0 +1,15 @@
+import { modelsDataSelector } from '@/redux/models/models.selectors';
+import type { RootState } from '@/redux/store';
+import { MapModel } from '@/types/models';
+import { MiddlewareAllowedAction } from '../map.types';
+
+export const getUpdatedModel = (
+  action: MiddlewareAllowedAction,
+  state: RootState,
+): MapModel | undefined => {
+  const models = modelsDataSelector(state);
+  const payload = action?.payload || {};
+  const payloadModelId = 'modelId' in payload ? payload.modelId : null;
+
+  return models.find(model => model.idObject === payloadModelId);
+};
diff --git a/src/redux/map/middleware/map.middleware.test.ts b/src/redux/map/middleware/map.middleware.test.ts
new file mode 100644
index 00000000..7698a04f
--- /dev/null
+++ b/src/redux/map/middleware/map.middleware.test.ts
@@ -0,0 +1,106 @@
+/* eslint-disable no-magic-numbers */
+import { modelsFixture } from '@/models/fixtures/modelsFixture';
+import type { RootState } from '@/redux/store';
+import { Loading } from '@/types/loadingState';
+import { MAP_DATA_INITIAL_STATE, MIDDLEWARE_ALLOWED_ACTIONS } from '../map.constants';
+import * as checkIfIsActionValid from './checkIfIsActionValid';
+import * as getUpdatedModel from './getUpdatedModel';
+import { mapMiddleware } from './map.middleware';
+
+jest.mock('./checkIfIsActionValid', () => {
+  return {
+    __esModule: true,
+    ...jest.requireActual('./checkIfIsActionValid'),
+  };
+});
+
+jest.mock('./getUpdatedModel', () => {
+  return {
+    __esModule: true,
+    ...jest.requireActual('./getUpdatedModel'),
+  };
+});
+
+const defaultSliceState = {
+  data: [],
+  loading: 'idle' as Loading,
+  error: { name: '', message: '' },
+};
+
+const checkIfIsActionValidSpy = jest.spyOn(checkIfIsActionValid, 'checkIfIsActionValid');
+const getUpdatedModelSpy = jest.spyOn(getUpdatedModel, 'getUpdatedModel');
+
+describe('map middleware', () => {
+  describe('on action', () => {
+    const doDispatch = jest.fn();
+    const doGetState = jest.fn().mockImplementation(
+      (): Pick<RootState, 'models' | 'map'> => ({
+        map: {
+          ...defaultSliceState,
+          data: MAP_DATA_INITIAL_STATE,
+        },
+        models: {
+          ...defaultSliceState,
+          data: modelsFixture,
+        },
+      }),
+    );
+
+    const handler = mapMiddleware({
+      dispatch: doDispatch,
+      getState: doGetState,
+    });
+
+    describe('when action is valid', () => {
+      const next = jest.fn();
+
+      describe('and when model is valid', () => {
+        it.each(MIDDLEWARE_ALLOWED_ACTIONS)('should run next action', actionType => {
+          const model = modelsFixture[0];
+          const action = {
+            payload: {
+              modelId: model.idObject,
+            },
+            type: actionType,
+          };
+
+          handler(next)(action);
+          expect(checkIfIsActionValidSpy).toHaveLastReturnedWith(true);
+          expect(getUpdatedModelSpy).toHaveLastReturnedWith(model);
+          expect(next).toBeCalled();
+        });
+      });
+
+      describe('and when model is NOT valid', () => {
+        it.each(MIDDLEWARE_ALLOWED_ACTIONS)('should run next action', actionType => {
+          const action = {
+            payload: {
+              modelId: null,
+            },
+            type: actionType,
+          };
+
+          handler(next)(action);
+          expect(checkIfIsActionValidSpy).toHaveLastReturnedWith(true);
+          expect(getUpdatedModelSpy).toHaveLastReturnedWith(undefined);
+          expect(next).toBeCalled();
+        });
+      });
+    });
+
+    describe('when action is NOT valid', () => {
+      it('should run next action', () => {
+        const next = jest.fn();
+        const action = {
+          payload: {},
+          type: 'some/other/action',
+        };
+
+        handler(next)(action);
+        expect(checkIfIsActionValidSpy).toHaveLastReturnedWith(false);
+        expect(getUpdatedModelSpy).toHaveLastReturnedWith(undefined);
+        expect(next).toBeCalled();
+      });
+    });
+  });
+});
diff --git a/src/redux/map/middleware/map.middleware.ts b/src/redux/map/middleware/map.middleware.ts
new file mode 100644
index 00000000..93b9e3ff
--- /dev/null
+++ b/src/redux/map/middleware/map.middleware.ts
@@ -0,0 +1,28 @@
+import { getUpdatedMapData } from '@/utils/map/getUpdatedMapData';
+import { Middleware, MiddlewareAPI } from '@reduxjs/toolkit';
+import type { AppDispatch, RootState } from '../../store';
+import { setMapData } from '../map.slice';
+import { MiddlewareAllowedAction } from '../map.types';
+import { checkIfIsActionValid } from './checkIfIsActionValid';
+import { getUpdatedModel } from './getUpdatedModel';
+
+/* prettier-ignore */
+export const mapMiddleware: Middleware =
+  ({ getState, dispatch }: MiddlewareAPI<AppDispatch, RootState>) =>
+    (next: AppDispatch) =>
+    // eslint-disable-next-line consistent-return
+      (action: MiddlewareAllowedAction) => {
+        const state = getState();
+        const isActionValid = checkIfIsActionValid(action, state);
+        const updatedModel = getUpdatedModel(action, state);
+        const returnValue = next(action);
+
+        if (!isActionValid || !updatedModel) {
+          return returnValue;
+        }
+
+        const updatedMapData = getUpdatedMapData({ model: updatedModel });
+        dispatch(setMapData(updatedMapData));
+
+        return returnValue;
+      };
-- 
GitLab