acdlite/flummox

View on GitHub
src/__tests__/Flux-test.js

Summary

Maintainability
D
3 days
Test Coverage
import { Flux, Store, Actions } from '../Flux';
import sinon from 'sinon';

function createSerializableStore(serializedState) {
  return class SerializableStore extends Store {
    static serialize() {
      return serializedState;
    }
    static deserialize(stateString) {
      return {
        stateString,
        deserialized: true
      };
    }
  };
}

describe('Flux', () => {

  describe('#createStore()', () => {
    it('throws if key already exists', () => {
      const flux = new Flux();
      class TestStore extends Store {}

      flux.createStore('ExampleStore', TestStore);
      expect(flux.createStore.bind(flux, 'ExampleStore', TestStore)).to.throw(
        'You\'ve attempted to create multiple stores with key ExampleStore. '
      + 'Keys must be unique.'
      );
    });

    it('throws if Store is not a prototype of class', () => {
      const flux = new Flux();
      class ForgotToExtendStore {}

      expect(flux.createStore.bind(flux, 'Flux', ForgotToExtendStore)).to.throw(
        'You\'ve attempted to create a store from the class '
      + 'ForgotToExtendStore, which does not have the base Store class in its '
      + 'prototype chain. Make sure you\'re using the `extends` keyword: '
      + '`class ForgotToExtendStore extends Store { ... }`'
      );
    });

    it('registers store\'s handler with central dispatcher', () => {
      class ExampleStore extends Store {}

      const spy1 = sinon.spy();
      const spy2 = sinon.spy();

      ExampleStore.prototype.foo = 'bar';
      ExampleStore.prototype.handler = function(_payload) {
        spy1(_payload);
        spy2(this.foo);
      };

      const flux = new Flux();
      flux.createStore('ExampleStore', ExampleStore);

      const payload = 'foobar';
      flux.dispatch('actionId', payload);
      expect(spy1.getCall(0).args[0].body).to.equal('foobar');
      expect(spy2.calledWith('bar')).to.be.true;
    });

    it('returns the created store instance', () => {
      class ExampleStore extends Store {}

      const flux = new Flux();
      const store = flux.createStore('ExampleStore', ExampleStore);
      expect(store).to.be.an.instanceOf(ExampleStore);
    });
  });

  describe('#getStore()', () => {
    it('retrieves store for key', () => {
      const flux = new Flux();
      class TestStore extends Store {}

      flux.createStore('ExampleStore', TestStore);
      expect(flux.getStore('ExampleStore')).to.be.an.instanceOf(Store);
      expect(flux.getStore('NonexistentStore')).to.be.undefined;
    });
  });

  describe('#removeStore()', () => {
    it('throws if key does not exist', () => {
      const flux = new Flux();
      class TestStore extends Store {}

      flux.createStore('ExampleStore', TestStore);
      expect(flux.removeStore.bind(flux, 'NonexistentStore')).to.throw(
        'You\'ve attempted to remove store with key NonexistentStore which does not exist.'
      );
    });

    it('deletes store instance', () => {
      const flux = new Flux();
      class TestStore extends Store {}

      let store = flux.createStore('ExampleStore', TestStore);
      expect(flux.dispatcher.$Dispatcher_callbacks[store._token]).to.be.function;
      flux.removeStore('ExampleStore');
      expect(flux._stores.ExampleStore).to.be.undefined;
      expect(flux.dispatcher.$Dispatcher_callbacks[store._token]).to.be.undefined;
    });
  });

  describe('#createActions()', () => {
    it('throws if key already exists', () => {
      class TestActions extends Actions {}

      const flux = new Flux();
      flux.createActions('ExampleActions', TestActions);

      expect(flux.createActions.bind(flux, 'ExampleActions', Actions)).to.throw(
        'You\'ve attempted to create multiple actions with key ExampleActions. '
      + 'Keys must be unique.'
      );
    });

    it('throws if Actions is a class without base Actions class in its prototype chain', () => {
      const flux = new Flux();
      class ForgotToExtendActions {}

      expect(flux.createActions.bind(flux, 'Flux', ForgotToExtendActions))
        .to.throw(
          'You\'ve attempted to create actions from the class '
        + 'ForgotToExtendActions, which does not have the base Actions class '
        + 'in its prototype chain. Make sure you\'re using the `extends` '
        + 'keyword: `class ForgotToExtendActions extends Actions { ... }`'
      );
    });

    it('accepts plain old JavaScript object', () => {
      const flux = new Flux();

      flux.createActions('foobar', {
        foo() {
          return 'bar';
        },

        bar() {
          return 'baz';
        }
      });

      expect(flux.getActions('foobar')).to.be.an.instanceof(Actions);
      expect(flux.getActions('foobar').foo()).to.equal('bar');
      expect(flux.getActions('foobar').bar()).to.equal('baz');
    });

    it('returns the created action\'s instance', () => {
      class TestActions extends Actions {}

      const flux = new Flux();
      const actions = flux.createActions('TestActions', TestActions);
      expect(actions).to.be.an.instanceOf(TestActions);
    });
  });

  describe('#getActions()', () => {
    class TestActions extends Actions {}

    it('retrieves actions for key', () => {
      const flux = new Flux();
      flux.createActions('TestActions', TestActions);

      expect(flux.getActions('TestActions')).to.be.an.instanceOf(Actions);
      expect(flux.getActions('NonexistentActions')).to.be.undefined;
    });

  });

  describe('#getActionIds() / #getConstants()', () => {
    class TestActions extends Actions {
      getFoo() {}
    }

    it('retrives ids of actions for key', () => {
      const flux = new Flux();
      flux.createActions('TestActions', TestActions);

      expect(flux.getActionIds('TestActions').getFoo).to.be.a('string');
      expect(flux.getActionIds('NonexistentActions')).to.be.undefined;

      expect(flux.getConstants('TestActions').getFoo).to.be.a('string');
      expect(flux.getConstants('NonexistentActions')).to.be.undefined;
    });
  });

  describe('#removeActions()', () => {
    it('throws if key does not exist', () => {
      const flux = new Flux();
      class TestActions extends Actions {
      }

      flux.createActions('TestActions', TestActions);
      expect(flux.removeActions.bind(flux, 'NonexistentActions')).to.throw(
        'You\'ve attempted to remove actions with key NonexistentActions which does not exist.'
      );
    });

    it('deletes actions instance', () => {
      const flux = new Flux();
      class TestActions extends Store {
      }

      flux.createStore('TestActions', TestActions);
      flux.removeStore('TestActions');
      expect(flux._actions.TestActions).to.be.undefined;
    });
  });

  describe('#getAllActionIds() / #getAllConstants()', () => {
    class TestFooActions extends Actions {
      getFoo() {}
      getBar() {}
    }

    class TestBarActions extends Actions {
      getFoo() {}
      getBar() {}
    }

    it('retrives ids of all actions', () => {
      let flux = new Flux();
      flux.createActions('TestFooActions', TestFooActions);
      flux.createActions('TestBarActions', TestBarActions);

      expect(flux.getAllActionIds()).to.be.an('array');
      expect(flux.getAllActionIds()[0]).to.be.a('string');
      expect(flux.getAllActionIds()).to.have.length(4);

      expect(flux.getAllConstants()).to.be.an('array');
      expect(flux.getAllConstants()[0]).to.be.a('string');
      expect(flux.getAllConstants()).to.have.length(4);
    });
  });

  describe('#dispatch()', () => {

    it('delegates to dispatcher', () => {
      const flux = new Flux();
      const dispatch = sinon.spy();
      flux.dispatcher = { dispatch };
      const actionId = 'actionId';

      flux.dispatch(actionId, 'foobar');

      expect(dispatch.firstCall.args[0]).to.deep.equal({
        actionId,
        body: 'foobar'
      });
    });

    it('emits dispatch event', () => {
      const flux = new Flux();
      const listener = sinon.spy();

      flux.addListener('dispatch', listener);

      const actionId = 'actionId';

      flux.dispatch(actionId, 'foobar');

      expect(listener.calledOnce).to.be.true;
      expect(listener.firstCall.args[0]).to.deep.equal({
        actionId,
        body: 'foobar'
      });
    });
  });

  describe('#dispatchAsync()', () => {

    it('delegates to dispatcher', async function() {
      const flux = new Flux();
      const dispatch = sinon.spy();
      flux.dispatcher = { dispatch };
      const actionId = 'actionId';

      await flux.dispatchAsync(actionId, Promise.resolve('foobar'));

      expect(dispatch.callCount).to.equal(2);
      expect(dispatch.firstCall.args[0]).to.deep.equal({
        actionId,
        async: 'begin'
      });
      expect(dispatch.secondCall.args[0]).to.deep.equal({
        actionId,
        body: 'foobar',
        async: 'success'
      });
    });

    it('emits dispatch event', async function() {
      const flux = new Flux();
      const listener = sinon.spy();

      flux.addListener('dispatch', listener);

      const actionId = 'actionId';

      await flux.dispatchAsync(actionId, Promise.resolve('foobar'));

      expect(listener.calledTwice).to.be.true;
      expect(listener.firstCall.args[0]).to.deep.equal({
        actionId,
        async: 'begin'
      });
      expect(listener.secondCall.args[0]).to.deep.equal({
        actionId,
        async: 'success',
        body: 'foobar'
      });
    });

    it('resolves to value of given promise', done => {
      const flux = new Flux();
      const dispatch = sinon.spy();
      flux.dispatcher = { dispatch };
      const actionId = 'actionId';

      expect(flux.dispatchAsync(actionId, Promise.resolve('foobar')))
        .to.eventually.equal('foobar')
        .notify(done);
    });

    it('dispatches with error if promise rejects', async function() {
      const flux = new Flux();
      const dispatch = sinon.spy();
      flux.dispatcher = { dispatch };
      const actionId = 'actionId';

      const error = new Error('error');

      await flux.dispatchAsync(actionId, Promise.reject(error));

      expect(dispatch.callCount).to.equal(2);
      expect(dispatch.firstCall.args[0]).to.deep.equal({
        actionId,
        async: 'begin'
      });
      expect(dispatch.secondCall.args[0]).to.deep.equal({
        actionId,
        error,
        async: 'failure'
      });
    });

    it('emit errors that occur as result of dispatch', async function() {
      class ExampleStore extends Store {}

      const flux = new Flux();
      const listener = sinon.spy();
      flux.addListener('error', listener);

      const actionId = 'actionId';
      const store = flux.createStore('example', ExampleStore);

      store.registerAsync(
        actionId,
        null,
        () => {
          throw new Error('success error');
        },
        () => {
          throw new Error('failure error');
        }
      );

      await expect(flux.dispatchAsync(actionId, Promise.resolve('foobar')))
        .to.be.rejectedWith('success error');
      expect(listener.calledOnce).to.be.true;
      expect(listener.firstCall.args[0].message).to.equal('success error');

      await expect(flux.dispatchAsync(actionId, Promise.reject(new Error('foobar'))))
        .to.be.rejectedWith('failure error');
      expect(listener.calledTwice).to.be.true;
      expect(listener.secondCall.args[0].message).to.equal('failure error');
    });

  });

  describe('#removeAllStoreListeners', () => {
    it('removes all listeners from stores', () => {
      class TestStore extends Store {}

      const flux = new Flux();
      const storeA = flux.createStore('storeA', TestStore);
      const storeB = flux.createStore('storeB', TestStore);

      const listener = function() {};

      storeA.addListener('change', listener);
      storeA.addListener('change', listener);
      storeB.addListener('change', listener);
      storeB.addListener('change', listener);

      expect(storeA.listeners('change').length).to.equal(2);
      expect(storeB.listeners('change').length).to.equal(2);

      flux.removeAllStoreListeners();

      expect(storeA.listeners('change').length).to.equal(0);
      expect(storeB.listeners('change').length).to.equal(0);
    });
  });

  describe('#serialize()', () => {

    it('returns state of all the stores as a JSON string', () => {
      const flux = new Flux();

      flux.createStore('foo', createSerializableStore('foo state'));
      flux.createStore('bar', createSerializableStore('bar state'));
      flux.createStore('baz', createSerializableStore('baz state'));

      expect(JSON.parse(flux.serialize())).to.deep.equal({
        foo: 'foo state',
        bar: 'bar state',
        baz: 'baz state'
      });
    });

    it('ignores stores whose classes do not implement .serialize()', () => {
      const flux = new Flux();
      class TestStore extends Store {}

      flux.createStore('foo', createSerializableStore('foo state'));
      flux.createStore('bar', createSerializableStore('bar state'));
      flux.createStore('baz', TestStore);

      expect(JSON.parse(flux.serialize())).to.deep.equal({
        foo: 'foo state',
        bar: 'bar state'
      });
    });

    it('warns if any store classes .serialize() returns a non-string', () => {
      const flux = new Flux();
      const warn = sinon.spy(console, 'warn');

      flux.createStore('foo', createSerializableStore({}));
      flux.serialize();

      expect(warn.firstCall.args[0]).to.equal(
        'The store with key \'foo\' was not serialized because the static '
      + 'method `SerializableStore.serialize()` returned a non-string with '
      + 'type \'object\'.'
      );

      console.warn.restore();
    });

    it('warns and skips stores whose classes do not implement .deserialize()', () => {
      const flux = new Flux();
      const warn = sinon.spy(console, 'warn');

      class TestStore extends Store {
        static serialize() {
          return 'state string';
        }
      }

      flux.createStore('test', TestStore);
      flux.serialize();

      expect(warn.firstCall.args[0]).to.equal(
        'The class `TestStore` has a `serialize()` method, but no '
      + 'corresponding `deserialize()` method.'
      );

      console.warn.restore();
    });
  });

  describe('#deserialize()', () => {

    it('converts a serialized string into state and uses it to replace state of stores', () => {
      const flux = new Flux();

      flux.createStore('foo', createSerializableStore());
      flux.createStore('bar', createSerializableStore());
      flux.createStore('baz', createSerializableStore());

      flux.deserialize(`{
        "foo": "foo state",
        "bar": "bar state",
        "baz": "baz state"
      }`);

      const fooStore = flux.getStore('foo');
      const barStore = flux.getStore('bar');
      const bazStore = flux.getStore('baz');

      expect(fooStore.state.stateString).to.equal('foo state');
      expect(fooStore.state.deserialized).to.be.true;
      expect(barStore.state.stateString).to.equal('bar state');
      expect(barStore.state.deserialized).to.be.true;
      expect(bazStore.state.stateString).to.equal('baz state');
      expect(bazStore.state.deserialized).to.be.true;
    });

    it('warns and skips if passed string is invalid JSON', () => {
      const flux = new Flux();
      class TestStore extends Store {}


      flux.createStore('foo', TestStore);

      expect(flux.deserialize.bind(flux, 'not JSON')).to.throw(
        'Invalid value passed to `Flux#deserialize()`: not JSON'
      );
    });

    it('warns and skips stores whose classes do not implement .serialize()', () => {
      const flux = new Flux();
      const warn = sinon.spy(console, 'warn');

      class TestStore extends Store {
        static deserialize() {
          return {};
        }
      }

      flux.createStore('test', TestStore);
      flux.deserialize('{"test": "test state"}');

      expect(warn.firstCall.args[0]).to.equal(
        'The class `TestStore` has a `deserialize()` method, but no '
      + 'corresponding `serialize()` method.'
      );

      console.warn.restore();
    });

    it('ignores stores whose classes do not implement .deserialize()', () => {
      const flux = new Flux();
      class TestStore extends Store {}

      flux.createStore('foo', createSerializableStore());
      flux.createStore('bar', createSerializableStore());
      flux.createStore('baz', TestStore);

      flux.deserialize(`{
        "foo": "foo state",
        "bar": "bar state",
        "baz": "baz state"
      }`);

      const fooStore = flux.getStore('foo');
      const barStore = flux.getStore('bar');
      const bazStore = flux.getStore('baz');

      expect(fooStore.state.stateString).to.equal('foo state');
      expect(fooStore.state.deserialized).to.be.true;
      expect(barStore.state.stateString).to.equal('bar state');
      expect(barStore.state.deserialized).to.be.true;
      expect(bazStore.state).to.be.null;
    });

  });

});