test/unit-tests.js

import chai, { expect } from 'chai';

import asPromised from 'chai-as-promised';

import {
  // Support functions
  parseIfString,
  statusIs,
  statusCodeLT,
  statusCodeGTE,
  statusWithinRange,

  // Redux utils
  actionCreatorOrNew,
  actionTypeIs,
  createAction,
  createThunk,
  createHandler,
  createErrorAction,
  createErrorThunk,
  createReducer,
  createSelector,
  createSetter,
  getPayload,
  fetchCallback,
  reduceReducers,
} from '../src/index';

import {
  shouldBeABoolean,
  shouldBeAFunction,
  shouldBeAnArray,
  shouldBeAnObject,
  shouldBeAString,
  shouldBeFalse,
  shouldBeTrue,
  shouldBeUndefined,
  shouldEqual,
  shouldHaveKeys,
  shouldNotBeNull,
  shouldNotThrow,
  testCases,
  testIfExists,
} from 'how-the-test-was-won';

chai.use(asPromised);

const runErrorCases = callback => {
  describe('an undefined argument', () => {
    shouldNotThrow(callback, undefined);
  });

  describe('a null argument', () => {
    shouldNotThrow(callback, null);
  });

  describe('an empty object', () => {
    shouldNotThrow(callback, {});
  });

  describe('an empty array', () => {
    shouldNotThrow(callback, []);
  });
};

/** @module tests */

describe('Support functions', () => {
  /** @name parseIfString */
  describe('#parseIfString', () => {
    const object = { data: 'i am the one you seek' };

    describe('given a json string', () => {
      const jsonString = '{"data":"i am the one you seek"}';
      const result = parseIfString(jsonString);

      testIfExists(result);
      shouldBeAnObject(result);
      it('should return a correct object', () => {
        expect(result).to.deep.equal(object);
      });
    });

    describe('given a javascript object', () => {
      const result = parseIfString(object);

      testIfExists(result);
      shouldBeAnObject(result);
      it('should return a correct object', () => {
        expect(result).to.deep.equal(object);
      });
    });

    shouldNotThrow(parseIfString, null);
    shouldNotThrow(parseIfString, undefined);
    shouldNotThrow(parseIfString, '');
  });

  describe('#statusIs', () => {
    describe('when passed a status code', () => {
      const predicate = statusIs(200);

      testIfExists(predicate);
      shouldBeAFunction(predicate);

      describe('when the result is passed an object with a status key set to 200', () => {
        const result = predicate({ status: 200 });

        testIfExists(result);
        shouldBeABoolean(result);

        it('should return true', () => {
          expect(result).to.equal(true);
        });
      });

      describe('when the result is passed an object with a status key set to 300', () => {
        const result = predicate({ status: 300 });

        shouldBeABoolean(result);

        it('should return false', () => {
          expect(result).to.equal(false);
        });
      });
    });
  });

  describe('#statusCodeLT', () => {
    describe('when passed value of 10', () => {
      const lessThan = statusCodeLT(10);

      describe('when the resulting function is passed', () => {
        testCases(lessThan,
          [0, { status: 0 }, true],
          [5, { status: 5 }, true],
          [10, { status: 10 }, false],
          [11, { status: 11 }, false],
        );
      });
    });
  });

  describe('#statusCodeGTE', () => {
    const greaterThan = statusCodeGTE(10);

    describe('when the resulting function is passed', () => {
      testCases(greaterThan,
        [0, { status: 0 }, false],
        [5, { status: 5 }, false],
        [10, { status: 10 }, true],
        [11, { status: 11 }, true],
      );
    });
  });

  describe('#statusWithinRange', () => {
    describe('when passed a lower bound of 1 and an upper bound of 10', () => {
      const ranger = statusWithinRange(1, 10);

      testIfExists(ranger);
      shouldBeAFunction(ranger);

      describe('when the resulting function is passed', () => {
        testCases(ranger,
          [1, { status: 1 }, true],
          [2, { status: 2 }, true],
          [3, { status: 3 }, true],
          [11, { status: 11 }, false],
          [13, { status: 13 }, false],
          [15, { status: 15 }, false],
        );
      });
    });
  });
});

describe('Redux Utils', () => {
  const TEST_ACTION_TYPE = 'TEST_ACTION_TYPE';
  const dispatchedAction = {
    type: TEST_ACTION_TYPE,
    payload: { actionTestKey: 'actionTestValue' },
  };

  /** @name createReducer */
  describe('#createReducer', () => {
    describe('given a defaultState of type "Object" and an actionMap', () => {
      const defaultStateObject = {
        stateKey1: 'test1',
        stateKey2: 'test2',
      };
      const expectedResultState = {
        stateKey1: 'test1',
        stateKey2: 'test2',
        actionTestKey: 'actionTestValue',
        reducerTestKey: 'reducerTestValue',
      };
      const actionMap = {
        [TEST_ACTION_TYPE]: (state, action) => ({
          ...state,
          ...action.payload,
          reducerTestKey: 'reducerTestValue',
        }),
      };
      const reducer = createReducer(defaultStateObject, actionMap);

      describe('the reducer function returned', () => {
        testIfExists(reducer);
        shouldBeAFunction(reducer);
      });

      describe('given an "undefined" state and a test action', () => {
        describe('the result of invoking the reducer', () => {
          const result = reducer(undefined, dispatchedAction);

          testIfExists(result);
          shouldBeAnObject(result);

          it('should equal the default state with new keys per reducer', () => {
            expect(result).to.deep.equal(expectedResultState);
          });
        });
      });

      describe('given an existing state with keys prefilled', () => {
        describe('the result of reducers initial run', () => {
          const existingState = {
            stateKey1: 'test1',
            stateKey2: 'test2',
            actionTestKey: 'overwriteMe',
            reducerTestKey: 'overwriteMe',
          };
          const result = reducer(existingState, dispatchedAction);

          testIfExists(result);
          shouldBeAnObject(result);

          it('should return a computed state with overwritten keys per reducer', () => {
            expect(result).to.deep.equal(expectedResultState);
          });
        });
      });
    });

    describe('given a defaultState "Array" and an actionMap', () => {
      const defaultStateArray = ['test1', 'test2'];
      const expectedResultState = ['test1', 'test2', { actionTestKey: 'actionTestValue' }];
      const actionMap = {
        [TEST_ACTION_TYPE]: (state, action) => ([
          ...state,
          action.payload,
        ]),
      };
      const reducer = createReducer(defaultStateArray, actionMap);

      describe('the reducer function returned', () => {
        testIfExists(reducer);
        shouldBeAFunction(reducer);
      });

      describe('given an "undefined" state and a test action', () => {
        describe('the result of invoking the reducer', () => {
          const result = reducer(undefined, dispatchedAction);

          testIfExists(result);
          shouldBeAnArray(result);

          it('should equal the default state concatenated to the handler result', () => {
            expect(result).to.deep.equal(expectedResultState);
          });
        });
      });
    });

    describe('given a defaultState "String" and an actionMap', () => {
      const defaultStateString = 'defaultStateTestString';
      const expectedResultState = 'actionTestValue';
      const actionMap = {
        [TEST_ACTION_TYPE]: (state, action) => action.payload.actionTestKey,
      };
      const reducer = createReducer(defaultStateString, actionMap);

      describe('the reducer function returned', () => {
        testIfExists(reducer);
        shouldBeAFunction(reducer);
      });

      describe('given an "undefined" state and a test action', () => {
        describe('the result of invoking the reducer', () => {
          const result = reducer(undefined, dispatchedAction);

          testIfExists(result);
          shouldBeAString(result);

          it('should equal the string from handler result', () => {
            expect(result).to.equal(expectedResultState);
          });
        });
      });
    });

    describe('given a defaultState "Boolean" and an actionMap', () => {
      const defaultStateBool = true;
      const actionMap = {
        [TEST_ACTION_TYPE]: (state, action) => !state, // eslint-disable-line
      };
      const reducer = createReducer(defaultStateBool, actionMap);

      describe('the reducer function returned', () => {
        testIfExists(reducer);
        shouldBeAFunction(reducer);
      });

      describe('given an "undefined" state and a test action', () => {
        describe('the result of invoking the reducer', () => {
          const result = reducer(undefined, dispatchedAction);

          shouldBeABoolean(result);

          it('should flip the default state boolean', () => {
            expect(result).to.equal(false);
          });
        });
      });
    });
  });

  /** @name reduceReducers */
  describe('#reduceReducers', () => {
    it('combines multiple reducers into a single reducer', () => {
      const reducer = reduceReducers(
        (prev, curr) => ({ ...prev, A: prev.A + curr }),
        (prev, curr) => ({ ...prev, B: prev.B * curr }),
      );

      expect(reducer({ A: 1, B: 2 }, 3)).to.deep.equal({ A: 4, B: 6 });
      expect(reducer({ A: 5, B: 8 }, 13)).to.deep.equal({ A: 18, B: 104 });
    });

    it('chains multiple reducers into a single reducer', () => {
      const addReducer = (prev, curr) => ({ ...prev, A: prev.A + curr });
      const multReducer = (prev, curr) => ({ ...prev, A: prev.A * curr });
      const reducerAddMult = reduceReducers(addReducer, multReducer);
      const reducerMultAdd = reduceReducers(multReducer, addReducer);

      expect(reducerAddMult({ A: 1, B: 2 }, 3)).to.deep.equal({ A: 12, B: 2 });
      expect(reducerMultAdd({ A: 1, B: 2 }, 3)).to.deep.equal({ A: 6, B: 2 });
    });
  });

  /** @name createAction */
  describe('#createAction', () => {
    describe('given the first arg (specified action Type)', () => {
      const creator = createAction(TEST_ACTION_TYPE);

      describe('the creator function returned', () => {
        testIfExists(creator);
        shouldBeAFunction(creator);
      });

      describe('given a payload passed to the function created, the result', () => {
        const payload = { testPayloadKey: 'testPayloadVal' };
        const meta = { testMetaKey: 'testMetaVal' };

        const createdAction = creator(payload, meta);

        testIfExists(createdAction);
        shouldBeAnObject(createdAction);

        it('should have a "type" and "payload" key', () => {
          expect(createdAction).to.contain.all.keys('type', 'payload', 'meta');
        });

        it('should have a "type" value of TEST_ACTION_TYPE', () => {
          expect(createdAction.type).to.equal(TEST_ACTION_TYPE);
        });

        it('should retain the payload passed to it', () => {
          expect(createdAction.payload).to.deep.equal(payload);
        });

        it('should retain the meta passed to it', () => {
          expect(createdAction.meta).to.deep.equal(meta);
        });
      });
    });
  });

  /** @name actionTypeIs */
  describe('#actionTypeIs', () => {
    const type = 'TYPE';

    describe('given an action with a matching type', () => {
      const action = { type };
      const result = actionTypeIs(action, type);

      shouldBeTrue(result);
    });

    describe('given an action with a non matching type', () => {
      const action = { type: 'blah' };
      const result = actionTypeIs(action, type);

      shouldBeFalse(result);
    });

    describe('given an action that is a function', () => {
      const action = () => {};
      const result = actionTypeIs(action, type);

      shouldBeFalse(result);
    });

    describe('given an empty object action', () => {
      const action = {};
      const result = actionTypeIs(action, type);

      shouldBeFalse(result);
    });

    describe('given undefined action', () => {
      const action = undefined;
      const result = actionTypeIs(action, type);

      shouldBeFalse(result);
    });

    describe('given undefined action', () => {
      const action = null;
      const result = actionTypeIs(action, type);

      shouldBeFalse(result);
    });
  });

  /** @name actionCreatorOrNew */
  describe('#actionCreatorOrNew', () => {
    const type = TEST_ACTION_TYPE;
    const payload = 'Oh Hai Mark';
    const expectedAction = { type, payload, meta: {} };

    describe('given a valid type', () => {
      const creator = actionCreatorOrNew(type);

      testIfExists(creator);
      shouldBeAFunction(creator);

      describe(`when invoked with the string ${payload}`, () => {
        const result = creator(payload);

        testIfExists(result);
        shouldBeAnObject(result);

        it('should return the expected action object', () => {
          expect(result).to.deep.equal(expectedAction);
        });
      });
    });

    describe('given a valid actionCreator', () => {
      const creator = createAction(type);
      const result = actionCreatorOrNew(creator);

      testIfExists(result);
      shouldBeAFunction(result);

      it('should return identity', () => {
        expect(result).to.equal(creator);
      });
    });
  });

  /** @name createThunk */
  describe('#createThunk', () => {
    describe('given the first arg (specified action Type)', () => {
      const creator = createThunk(TEST_ACTION_TYPE);

      describe('the creator function returned', () => {
        testIfExists(creator);
        shouldBeAFunction(creator);
      });

      describe('given a payload passed to the function created, the result', () => {
        const payload = { testPayloadKey: 'testPayloadVal' };
        const meta = { testMetaKey: 'testMetaVal' };

        const dispatch = d => d;
        const createdAction = creator(payload, meta);

        testIfExists(createdAction);
        shouldBeAFunction(createdAction);

        const createdActionResult = createdAction(dispatch);

        describe('when the resulting thunk is resolved it', () => {
          it('should be a promise', () => {
            expect(createdActionResult).to.be.a('promise');
          });

          it('should resolve with "type" value of TEST_ACTION_TYPE', () => {
            expect(createdActionResult).to.eventually.contain.all.keys([{
              type: TEST_ACTION_TYPE,
            }]);
          });

          it('should retain the meta passed to it', () => {
            expect(createdActionResult).to.eventually.contain.all.keys([{ meta }]);
          });

          it('should retain the payload passed to it', () => {
            expect(createdActionResult).to.eventually.contain.all.keys({ payload });
          });
        });
      });
    });
  });

  /** @name createErrorAction */
  describe('#createErrorAction', () => {
    describe('given the first arg (specified action Type)', () => {
      const message = 'this totally sucked';
      const creator = createErrorAction(TEST_ACTION_TYPE, message);

      describe('the creator function returned', () => {
        testIfExists(creator);
        shouldBeAFunction(creator);
      });

      describe('when a payload passed to the function created, the result', () => {
        const payload = { testPayloadKey: 'testPayloadVal' };
        const meta = { testMetaKey: 'testMetaVal' };

        const createdAction = creator(payload, meta);

        testIfExists(createdAction);
        shouldBeAnObject(createdAction);

        shouldHaveKeys(createdAction,
          'type',
          'meta',
          'error',
          'payload',
          'message',
        );

        it(`should have a "type" value of ${TEST_ACTION_TYPE}`, () => {
          expect(createdAction.type).to.equal(TEST_ACTION_TYPE);
        });

        it('should retain the payload passed to it', () => {
          expect(createdAction.payload).to.deep.equal(payload);
        });

        it('should retain the meta passed to it', () => {
          expect(createdAction.meta).to.deep.equal(meta);
        });

        it('should have an error key equal to true', () => {
          expect(createdAction.error).to.equal(true);
        });

        it(`should have an message key equal to ${message}`, () => {
          expect(createdAction.message).to.equal(message);
        });
      });

      describe('when a null payload is passed to the function created, the result', () => {
        const createdAction = creator(null, null);

        testIfExists(createdAction);
        shouldBeAnObject(createdAction);

        shouldHaveKeys(createdAction,
          'type',
          'meta',
          'error',
          'payload',
          'message',
        );

        it(`should have a "type" value of ${TEST_ACTION_TYPE}`, () => {
          expect(createdAction.type).to.equal(TEST_ACTION_TYPE);
        });

        it('should have an error key equal to true', () => {
          expect(createdAction.error).to.equal(true);
        });

        it(`should have an message key equal to ${message}`, () => {
          expect(createdAction.message).to.equal(message);
        });
      });
    });
  });

  /** @name createThunkError */
  describe('#createErrorThunk', () => {
    describe('given the first arg (specified action Type)', () => {
      const message = 'this totally sucked';
      const creator = createErrorThunk(TEST_ACTION_TYPE, message);

      describe('the creator function returned', () => {
        testIfExists(creator);
        shouldBeAFunction(creator);
      });

      describe('given a payload passed to the function created, the result', () => {
        const payload = { testPayloadKey: 'testPayloadVal' };
        const meta = { testMetaKey: 'testMetaVal' };

        const dispatch = d => d;
        const createdAction = creator(payload, meta);

        testIfExists(createdAction);
        shouldBeAFunction(createdAction);

        const createdActionResult = createdAction(dispatch);

        describe('when the resulting thunk is resolved it', () => {
          it('should be a promise', () => {
            expect(createdActionResult).to.be.a('promise');
          });

          it(`should resolve with "type" value of ${TEST_ACTION_TYPE}`, () => {
            expect(createdActionResult).to.eventually.contain.all.keys([{
              type: TEST_ACTION_TYPE,
            }]);
          });

          it('should retain the meta passed to it', () => {
            expect(createdActionResult).to.eventually.contain.all.keys([{ meta }]);
          });

          it('should retain the payload passed to it', () => {
            expect(createdActionResult).to.eventually.contain.all.keys({ payload });
          });

          it('should retain the message passed to it', () => {
            expect(createdActionResult).to.eventually.contain.all.keys({ message });
          });
        });
      });
    });
  });

  /** @name createHandler */
  describe('#createHandler', () => {
    const key = 'testKey';

    describe(`given the key ${key}`, () => {
      const handler = createHandler(key);

      testIfExists(handler);
      shouldBeAFunction(handler);

      describe('when the resulting function is passed a payload', () => {
        const state = {};
        const list = [1, 2, 3, 4];
        const action = { payload: { list } };
        const result = handler(state, action);

        testIfExists(result);
        shouldHaveKeys(result, key);
        it('should form the expected addition to existing state', () => {
          expect(result[key]).to.deep.equal({ list });
        });
      });
    });
  });

  describe('#getPayload', () => {
    describe('given a state and action object where action has a payload', () => {
      const payload = 'i drink coffee please';
      const action = { payload, type: 'COFFEE_ACTION' };
      const state = { a: 1, b: 2, c: 3 };
      const result = getPayload(state, action);

      testIfExists(result);
      shouldBeAString(result);
      shouldEqual(payload, result);
    });
  });

  describe('Lens Functions', () => {
    const testObj = {
      simpleKey: 'value',
      objectKey: {
        key1: 'value1',
        key2: 'value2',
        nestedObjectKey: {
          key3: 'value3',
          key4: 'value4',
        },
      },
    };

    /** @name createSelector */
    describe('#createSelector', () => {
      describe('when passed a single prop name', () => {
        const selector = createSelector('simpleKey');

        describe('the return value should be a function', () => {
          testIfExists(selector);
          shouldBeAFunction(selector);
        });

        describe('given an object with a top level key, the selector', () => {
          const selected = selector(testObj);

          testIfExists(selected);
          shouldBeAString(selected);
          it('should get the correct value for the "simpleKey" property', () => {
            expect(selected).to.equal(testObj.simpleKey);
          });
        });

        describe('given an undefined value', () => {
          shouldNotThrow(selector, undefined);
        });

        describe('given an empty object the result', () => {
          const result = selector({});

          shouldBeUndefined(result);
          shouldNotBeNull(result);
          shouldNotThrow(selector, {});
        });
      });

      describe('when passed an array of property names (path to key)', () => {
        const selector = createSelector(['objectKey', 'nestedObjectKey', 'key4']);

        describe('the return value should be a function', () => {
          testIfExists(selector);
          shouldBeAFunction(selector);
        });

        describe('given a deep nested key, the selector', () => {
          const selected = selector(testObj);

          testIfExists(selected);
          shouldBeAString(selected);
          it('should get the correct value for the property at the specified path', () => {
            expect(selected).to.equal(testObj.objectKey.nestedObjectKey.key4);
          });
        });

        describe('given an undefined value', () => {
          const result = selector(undefined);

          shouldBeUndefined(result);
          shouldNotBeNull(result);
        });

        describe('given an empty object the result', () => {
          const result = selector({});

          shouldBeUndefined(result);
          shouldNotBeNull(result);
          shouldNotThrow(selector, {});
        });
      });
    });

    /** @name createSetter */
    describe('#createSetter', () => {
      describe('when passed a single property name', () => {
        const setter = createSetter('simpleKey');

        describe('the return value should be a function', () => {
          testIfExists(setter);
          shouldBeAFunction(setter);
        });

        describe('given an object with a top level key, the setter', () => {
          const written = setter('overwritten', testObj);

          testIfExists(written);
          shouldBeAnObject(written);
          it('should return the testObj with an overwritten "simpleKey" property', () => {
            expect(written).to.deep.equal({
              ...testObj,
              simpleKey: 'overwritten',
            });
          });
        });
      });

      describe('when passed an array of property names (path to key)', () => {
        const setter = createSetter(['objectKey', 'nestedObjectKey', 'key4']);

        describe('the return value should be a function', () => {
          testIfExists(setter);
          shouldBeAFunction(setter);
        });

        describe('given an object with a deep nested key, the setter', () => {
          const written = setter('overwritten', testObj);

          testIfExists(written);
          shouldBeAnObject(written);
          it('should return the equivalent of manually rewriting the key on testObj ', () => {
            expect(written).to.deep.equal({
              ...testObj,
              objectKey: {
                ...testObj.objectKey,
                nestedObjectKey: {
                  ...testObj.objectKey.nestedObjectKey,
                  key4: 'overwritten',
                },
              },
            });
          });
        });
      });
    });
  });

  /** @name fetchCallback */
  describe('#fetchCallback', () => {
    const makeOkResponse = (data = {}, meta = {}) => ({
      value: `{"data":${JSON.stringify(data)},"meta":${JSON.stringify(meta)}}`,
      status: 200,
    });

    const testFetchHandler = (data, meta) => ({ data, meta });

    const data = 'i am the data you seek';
    const meta = 'i am the data about the data you seek';
    const url = 'http://www.testy-pants.com';
    const okResponse = makeOkResponse(data, meta);

    const noAuthResponse = {
      value: okResponse.value,
      status: 401,
      headers: {
        get(prop) {
          return prop === 'location' ? url : '';
        },
      },
    };

    const testCallback = ({
      keys,
      callback,
      returnObject,
      responseObject,
      redirectResponse,
    }) => {
      testIfExists(callback);
      shouldBeAFunction(callback);

      describe('when the resulting function is passed', () => {
        runErrorCases(callback);

        describe('a valid response object', () => {
          const result = callback(responseObject);

          shouldNotThrow(callback, responseObject);
          testIfExists(result);
          shouldBeAnObject(result);
          shouldHaveKeys(result, ...keys);

          it('should correctly return the data property and process it', () => {
            expect(result).to.deep.equal(returnObject);
          });
        });

        if (redirectResponse) {
          describe('a 401 response object with location header', () => {
            const result = callback(noAuthResponse);

            testIfExists(result);
            shouldBeAnObject(result);
            shouldHaveKeys(result, ...keys);

            it('should return the expected result', () => {
              expect(result).to.deep.equal(redirectResponse);
            });
          });
        }
      });
    };

    describe('when passed a valid function to handle response data', () => {
      const callback = fetchCallback(testFetchHandler);

      describe('when passed a response with a "data" value that is', () => {
        const keys = ['data', 'meta'];

        describe('a non-empty object', () => {
          const returnObject = { data, meta };
          const responseObject = okResponse;
          const redirectResponse = { meta: {}, data: { redirect_to: url } };

          testCallback({
            keys,
            callback,
            returnObject,
            responseObject,
            redirectResponse,
          });
        });

        describe('a false boolean value', () => {
          const returnObject = { data: false, meta: {} };
          const responseObject = makeOkResponse(false);

          testCallback({
            keys,
            callback,
            returnObject,
            responseObject,
          });
        });
      });
    });

    describe('when passed a valid action type string', () => {
      const callback = fetchCallback(TEST_ACTION_TYPE);
      const responseObject = okResponse;

      const returnObject = {
        type: TEST_ACTION_TYPE,
        payload: data,
        meta,
      };

      const redirectResponse = {
        type: TEST_ACTION_TYPE,
        payload: { redirect_to: 'http://www.testy-pants.com' },
        meta: {},
      };

      testCallback({
        callback,
        returnObject,
        responseObject,
        redirectResponse,
        keys: ['type', 'payload', 'meta'],
      });
    });
  });
});