'use strict';
var fetchBuilder = require('../../');
var sinon = require('sinon');
var expect = require('expectations');

describe('fetchBuilder', function () {

  it('throws when fetch is not a function', function () {
    expect(function () {
      fetchBuilder();
    }).toThrow({
      name: 'ArgumentError',
      message: 'fetch must be a function'
    });
  });

  it('throws when default is not an object', function () {
    expect(function () {
      fetchBuilder(function () { }, 'this is a string, not an object');
    }).toThrow({
      name: 'ArgumentError',
      message: 'defaults must be an object'
    });
  });

  it('returns fetchRetry when provided valid constructor arguments', function () {
    expect(typeof fetchBuilder(function () { }, {retries: 1})).toBe('function');
  });
});

describe('fetch-retry', function () {

  var fetch;
  var fetchRetry;

  var deferred1;
  var deferred2;
  var deferred3;
  var deferred4;

  var thenCallback;
  var catchCallback;

  var clock;
  var delay;

  beforeEach(function () {
    delay = 1000;
    clock = sinon.useFakeTimers();
  });

  afterEach(function () {
    clock.restore();
  });

  beforeEach(function () {
    deferred1 = defer();
    deferred2 = defer();
    deferred3 = defer();
    deferred4 = defer();

    fetch = sinon.stub();
    fetch.onCall(0).returns(deferred1.promise);
    fetch.onCall(1).returns(deferred2.promise);
    fetch.onCall(2).returns(deferred3.promise);
    fetch.onCall(3).returns(deferred4.promise);

    fetchRetry = fetchBuilder(fetch);
  });

  describe('#input', function () {

    var expectedUrl = 'http://some-url.com';

    beforeEach(function () {
      fetchRetry(expectedUrl);
    });

    it('passes #input to fetch', function () {
      expect(fetch.getCall(0).args[0]).toBe(expectedUrl);
    });

  });

  describe('#init', function () {

    describe('when #init is provided', function () {

      var init;

      beforeEach(function () {
        init = {
          retries: 3,
          whatever: 'something'
        };

        fetchRetry('http://someUrl', init);
      });

      it('passes init to fetch', function () {
        expect(fetch.getCall(0).args[1]).toEqual(init);
      });

      describe('when #init.retryOn is not an array or function', () => {

        it('throws exception', () => {
          expect(function () {
            init.retryOn = 503;
            fetchRetry('http://someUrl', init);
          }).toThrow({
            name: 'ArgumentError',
            message: 'retryOn property expects an array or function'
          });
        });

      });

    });

    describe('when #init is undefined or null', function () {

      [undefined, null].forEach(function (testCase) {

        beforeEach(function () {
          fetchRetry('http://someUrl', testCase);
        });

        it('does not pass through init to fetch', function () {
          expect(fetch.getCall(0).args[1]).toEqual(undefined);
        });

      });

    });

  });

  describe('#init.retries', function () {

    describe('when #init.retries=3 (default)', function () {

      beforeEach(function () {
        thenCallback = sinon.spy();
        catchCallback = sinon.spy();

        fetchRetry('http://someurl')
          .then(thenCallback)
          .catch(catchCallback);
      });

      describe('when first call is a success', function () {

        beforeEach(function () {
          deferred1.resolve({ status: 200 });
        });

        describe('when resolved', function () {

          it('invokes the then callback', function () {
            expect(thenCallback.called).toBe(true);
          });

          it('calls fetch once', function () {
            expect(fetch.callCount).toBe(1);
          });

        });

      });

      describe('when first call is a failure', function () {

        beforeEach(function () {
          deferred1.reject();
        });

        describe('when second call is a success', function () {

          beforeEach(function () {
            clock.tick(delay);
            deferred2.resolve({ status: 200 });
          });

          describe('when resolved', function () {

            it('invokes the then callback', function () {
              expect(thenCallback.called).toBe(true);
            });

            it('calls fetch twice', function () {
              expect(fetch.callCount).toBe(2);
            });

          });

        });

        describe('when second call is a failure', function () {

          beforeEach(function () {
            deferred2.reject();
            clock.tick(delay);
          });

          describe('when third call is a success', function () {

            beforeEach(function () {
              deferred3.resolve({ status: 200 });
              clock.tick(delay);
            });

            describe('when resolved', function () {

              it('invokes the then callback', function () {
                expect(thenCallback.called).toBe(true);
              });

              it('calls fetch three times', function () {
                expect(fetch.callCount).toBe(3);
              });

            });

          });

          describe('when third call is a failure', function () {

            beforeEach(function () {
              deferred3.reject();
              clock.tick(delay);
            });

            describe('when fourth call is a success', function () {

              beforeEach(function () {
                deferred4.resolve({ status: 200 });
                clock.tick(delay);
              });

              describe('when resolved', function () {

                it('invokes the then callback', function () {
                  expect(thenCallback.called).toBe(true);
                });

                it('calls fetch four times', function () {
                  expect(fetch.callCount).toBe(4);
                });

              });

            });

            describe('when fourth call is a failure', function () {

              beforeEach(function () {
                deferred4.reject();
                clock.tick(delay);
              });

              describe('when rejected', function () {

                it('invokes the catch callback', function () {
                  expect(catchCallback.called).toBe(true);
                });

                it('does not call fetch again', function () {
                  expect(fetch.callCount).toBe(4);
                });

              });

            });

          });

        });

      });

    });

    describe('when #defaults.retries is not a a positive integer', () => {
      ['1', -1, 'not a number', null].forEach(invalidRetries => {
        it('throws error', () => {
          const expectedError = {
            name: 'ArgumentError',
            message: 'retries must be a positive integer'
          };
          expect(() => {
            var fetchRetryWithDefaults = fetchBuilder(fetch, {retries: invalidRetries});
            fetchRetryWithDefaults('http://someurl');
          }).toThrow(expectedError);
        });
      });
    });

    describe('when #defaults.retryDelay is not a a positive integer', () => {

      ['1', -1, 'not a number', null].forEach(invalidDelay => {

        it('throws error', () => {
          const expectedError = {
            name: 'ArgumentError',
            message: 'retryDelay must be a positive integer or a function returning a positive integer'
          };
          expect(() => {
            var fetchRetryWithDefaults = fetchBuilder(fetch, { retryDelay: invalidDelay });
            fetchRetryWithDefaults('http://someurl');
          }).toThrow(expectedError);
        });

      });

    });

    describe('when #defaults.retryDelay is a function', function () {

      var defaults;
      var retryDelay;

      beforeEach(function () {
        retryDelay = sinon.stub().returns(5000);
        defaults = {
          retryDelay: retryDelay
        };

        thenCallback = sinon.spy();

        var fetchRetryWithDefaults = fetchBuilder(fetch, defaults);
        fetchRetryWithDefaults('http://someUrl')
          .then(thenCallback);
      });
    });

    describe('when #defaults.retryOn is not an array or function', function () {

      var defaults = {};

      describe('when #defaults.retryOn is not an array or function', () => {

        it('throws exception', () => {
          expect(function () {
            defaults.retryOn = 503;
            var fetchRetryWithDefaults = fetchBuilder(fetch, defaults);
            fetchRetryWithDefaults('http://someUrl');
          }).toThrow({
            name: 'ArgumentError',
            message: 'retryOn property expects an array or function'
          });
        });

      });

    });

    describe('when #defaults.retries=0', function () {
      beforeEach(function () {
        thenCallback = sinon.spy();
        catchCallback = sinon.spy();

        var fetchRetryWithDefaults = fetchBuilder(fetch, {retries: 0});

        fetchRetryWithDefaults('http://someurl')
          .then(thenCallback)
          .catch(catchCallback);
      });

      describe('when first call is a failure', function () {
        beforeEach(function () {
          deferred1.reject();
        });

        describe('when rejected', function () {

          it('invokes the catch callback', function () {
            expect(catchCallback.called).toBe(true);
          });

          it('does not call fetch again', function () {
            expect(fetch.callCount).toBe(1);
          });
        });
      });
    });

    describe('when #init.retries=1', function () {

      beforeEach(function () {
        thenCallback = sinon.spy();
        catchCallback = sinon.spy();

        fetchRetry('http://someurl', { retries: 1 })
          .then(thenCallback)
          .catch(catchCallback);
      });

      describe('when first call is a success', function () {

        beforeEach(function () {
          deferred1.resolve({ status: 200 });
        });

        describe('when resolved', function () {

          it('invokes the then callback', function () {
            expect(thenCallback.called).toBe(true);
          });

          it('calls fetch once', function () {
            expect(fetch.callCount).toBe(1);
          });

        });

      });

      describe('when first call is a failure', function () {

        beforeEach(function () {
          deferred1.reject();
          clock.tick(delay);
        });

        describe('when second call is a success', function () {

          beforeEach(function () {
            deferred2.resolve({ status: 200 });
            clock.tick(delay);
          });

          describe('when resolved', function () {

            it('invokes the then callback', function () {
              expect(thenCallback.called).toBe(true);
            });

            it('calls fetch twice', function () {
              expect(fetch.callCount).toBe(2);
            });

          });

        });

        describe('when second call is a failure', function () {

          beforeEach(function () {
            deferred2.reject();
            clock.tick(delay);
          });

          describe('when rejected', function () {

            it('invokes the catch callback', function () {
              expect(catchCallback.called).toBe(true);
            });

            it('does not call fetch again', function () {
              expect(fetch.callCount).toBe(2);
            });

          });

        });

      });

    });

    describe('when #init.retries=0', function () {

      beforeEach(function () {
        thenCallback = sinon.spy();
        catchCallback = sinon.spy();

        fetchRetry('http://someurl', { retries: 0 })
          .then(thenCallback)
          .catch(catchCallback);
      });

      describe('when first call is a success', function () {

        beforeEach(function () {
          deferred1.resolve({ status: 200 });
        });

        describe('when resolved', function () {

          it('invokes the then callback', function () {
            expect(thenCallback.called).toBe(true);
          });

          it('calls fetch once', function () {
            expect(fetch.callCount).toBe(1);
          });

        });

      });

      describe('when first call is a failure', function () {

        beforeEach(function () {
          deferred1.reject();
        });

        describe('when rejected', () => {

          it('invokes the catch callback', function () {
            expect(catchCallback.called).toBe(true);
          });

        });

      });

    });

    describe('when #init.retries is not a a positive integer', () => {

      ['1', -1, 'not a number', null].forEach(invalidRetries => {

        it('throws error', () => {
          const expectedError = {
            name: 'ArgumentError',
            message: 'retries must be a positive integer'
          };
          expect(() => {
            fetchRetry('http://someurl', { retries: invalidRetries });
          }).toThrow(expectedError);
        });

      });

    });

  });

  describe('#init.retryDelay', function () {

    describe('when #init.retryDelay is a number', function () {

      var init;
      var retryDelay;

      beforeEach(function () {
        retryDelay = 5000;
        init = {
          retryDelay: retryDelay
        };

        thenCallback = sinon.spy();

        fetchRetry('http://someUrl', init)
          .then(thenCallback);
      });

      describe('when first call is unsuccessful', function () {

        beforeEach(function () {
          deferred1.reject();
        });

        describe('after specified time', function () {

          beforeEach(function () {
            clock.tick(retryDelay);
          });

          it('invokes fetch again', function () {
            expect(fetch.callCount).toBe(2);
          });

        });

        describe('after less than specified time', function () {

          beforeEach(function () {
            clock.tick(1000);
          });

          it('does not invoke fetch again', function () {
            expect(fetch.callCount).toBe(1);
          });

        });

      });

    });

    describe('when #init.retryDelay is 0', function () {

      var init;
      var retryDelay;

      beforeEach(function () {
        retryDelay = 0;
        init = {
          retryDelay: retryDelay
        };

        thenCallback = sinon.spy();

        fetchRetry('http://someUrl', init)
          .then(thenCallback);
      });

      describe('when first call is unsuccessful', function () {

        beforeEach(function () {
          deferred1.reject();
        });

        describe('after one event loop tick', function () {

          beforeEach(function () {
            clock.tick(0);
          });

          it('invokes fetch again', function () {
            expect(fetch.callCount).toBe(2);
          });

        });

      });

    });

    describe('when #init.retryDelay is not a a positive integer', () => {

      ['1', -1, 'not a number', null].forEach(invalidDelay => {

        it('throws error', () => {
          const expectedError = {
            name: 'ArgumentError',
            message: 'retryDelay must be a positive integer or a function returning a positive integer'
          };
          expect(() => {
            fetchRetry('http://someurl', { retryDelay: invalidDelay });
          }).toThrow(expectedError);
        });

      });

    });

    describe('when #init.retryDelay is a function', function () {

      var init;
      var retryDelay;

      beforeEach(function () {
        retryDelay = sinon.stub().returns(5000);
        init = {
          retryDelay: retryDelay
        };

        thenCallback = sinon.spy();

        fetchRetry('http://someUrl', init)
          .then(thenCallback);
      });

      describe('when first call is unsuccessful', function () {

        beforeEach(function () {
          deferred1.reject(new Error('first error'));
        });

        describe('when the second call is a success', function () {

          beforeEach(function () {
            deferred2.resolve({ status: 200 });
            clock.tick(5000);
          });

          it('invokes the retryDelay function', function () {
            expect(retryDelay.called).toBe(true);
            expect(retryDelay.lastCall.args[0]).toEqual(0);
            expect(retryDelay.lastCall.args[1].message).toEqual('first error');
          });


        });

        describe('when second call is a failure', function () {

          beforeEach(function () {
            deferred2.reject(new Error('second error'));
            clock.tick(5000);
          });

          describe('when the third call is a success', function () {

            beforeEach(function () {
              deferred3.resolve({ status: 200 });
              clock.tick(5000);
            });

            it('invokes the retryDelay function again', function () {
              expect(retryDelay.callCount).toBe(2);
              expect(retryDelay.lastCall.args[0]).toEqual(1);
              expect(retryDelay.lastCall.args[1].message).toEqual('second error');
            });

          });

        });

      });

    });

  });

  describe('#init.retryOn', () => {

    describe('when #init.retryOn is an array', () => {

      var init;
      var retryOn;

      beforeEach(function () {
        retryOn = [503, 404];
        init = {
          retryOn: retryOn
        };

        thenCallback = sinon.spy();
        catchCallback = sinon.spy();

        fetchRetry('http://someUrl', init)
          .then(thenCallback)
          .catch((catchCallback));
      });

      describe('when first fetch is resolved with status code specified in retryOn array', () => {

        beforeEach(() => {
          deferred1.resolve({ status: 503 });
        });

        describe('after specified delay', () => {

          beforeEach(() => {
            clock.tick(delay);
          });

          it('retries fetch', () => {
            expect(fetch.callCount).toBe(2);
          });

          describe('when second fetch resolves with a different status code', () => {

            beforeEach(() => {
              deferred2.resolve({ status: 200 });
            });

            describe('when resolved', () => {

              it('invokes the then callback', function () {
                expect(thenCallback.called).toBe(true);
              });

              it('has called fetch twice', function () {
                expect(fetch.callCount).toBe(2);
              });

            });

          });

        });

      });

    });

    describe('when #init.retryOn is a function', function () {

      var init;
      var retryOn;
      var fetchRetryChain;

      beforeEach(function () {
        retryOn = sinon.stub();
        init = {
          retryOn: retryOn
        };

        thenCallback = sinon.spy();
        catchCallback = sinon.spy();

        fetchRetryChain = fetchRetry('http://someUrl', init)
          .then(thenCallback)
          .catch((catchCallback));
      });

      describe('when first attempt is rejected due to network error', function () {

        describe('when #retryOn() returns true', () => {

          beforeEach(function () {
            retryOn.returns(true);
            deferred1.reject(new Error('first error'));
          });

          describe('when rejected', function () {

            it('invokes #retryOn function with an error', function () {
              expect(retryOn.called).toBe(true);
              expect(retryOn.lastCall.args.length).toBe(3);
              expect(retryOn.lastCall.args[0]).toBe(0);
              expect(retryOn.lastCall.args[1] instanceof Error).toBe(true);
              expect(retryOn.lastCall.args[2]).toBe(null);
            });

            describe('after specified time', function () {

              beforeEach(function () {
                clock.tick(delay);
              });

              it('invokes fetch again', function () {
                expect(fetch.callCount).toBe(2);
              });

              describe('when the second call is unsuccessful', function () {

                beforeEach(function () {
                  deferred2.reject(new Error('second error'));
                  clock.tick(delay);
                });

                describe('when rejected', function () {

                  it('invokes the #retryOn function twice', function () {
                    expect(retryOn.callCount).toBe(2);
                    expect(retryOn.lastCall.args[0]).toBe(1);
                  });

                });

              });

            });

          });

        });

        describe('when #retryOn() returns false', () => {

          beforeEach(function () {
            retryOn.returns(false);
            deferred1.reject(new Error('first error'));
          });

          describe('when rejected', function () {

            it('invokes #retryOn function with an error', function () {
              expect(retryOn.called).toBe(true);
              expect(retryOn.lastCall.args.length).toBe(3);
              expect(retryOn.lastCall.args[0]).toBe(0);
              expect(retryOn.lastCall.args[1] instanceof Error).toBe(true);
              expect(retryOn.lastCall.args[2]).toBe(null);
            });

            describe('after specified time', function () {

              beforeEach(function () {
                clock.tick(delay);
              });

              it('invokes the catch callback', function () {
                expect(catchCallback.called).toBe(true);
              });

              it('does not call fetch again', function () {
                expect(fetch.callCount).toBe(1);
              });

            });

          });

        });

      });

      describe('when first attempt is resolved', function () {

        describe('when #retryOn() returns true', () => {

          beforeEach(function () {
            retryOn.returns(true);
            deferred1.resolve({ status: 200 });
          });

          describe('after specified delay', () => {

            beforeEach(function () {
              clock.tick(delay);
            });

            it('calls fetch again', function () {
              expect(fetch.callCount).toBe(2);
            });

            describe('when second call is resolved', () => {

              beforeEach(function () {
                deferred2.resolve({ status: 200 });
                clock.tick(delay);
              });

              it('invokes the #retryOn function with the response', function () {
                expect(retryOn.called).toBe(true);
                expect(retryOn.lastCall.args.length).toBe(3);
                expect(retryOn.lastCall.args[0]).toBe(0);
                expect(retryOn.lastCall.args[1]).toBe(null);
                expect(retryOn.lastCall.args[2]).toEqual({ status: 200 });
              });

            });

          });

        });

        describe('when #retryOn() returns false', () => {

          beforeEach(function () {
            retryOn.returns(false);
            deferred1.resolve({ status: 502 });
          });

          describe('when resolved', () => {

            it('invokes the then callback', function () {
              expect(thenCallback.called).toBe(true);
            });

            it('calls fetch 1 time only', function () {
              expect(fetch.callCount).toBe(1);
            });

          });

        });

      });

      describe('when first attempt is resolved with Promise', function() {

        describe('when #retryOn() returns Promise with true resolve', () => {

          beforeEach(function() {
            retryOn.resolves(true);
            deferred1.resolve({ status: 200 });
          });

          describe('after specified delay', () => {

            beforeEach(function() {
              clock.tick(delay);
            });

            it('calls fetch again', function() {
              expect(fetch.callCount).toBe(2);
            });

            describe('when second call is resolved', () => {

              beforeEach(function() {
                deferred2.resolve({ status: 200 });
                clock.tick(delay);
              });

              it('invokes the #retryOn function with the response', function() {
                expect(retryOn.called).toBe(true);
                expect(retryOn.lastCall.args.length).toBe(3);
                expect(retryOn.lastCall.args[0]).toBe(0);
                expect(retryOn.lastCall.args[1]).toBe(null);
                expect(retryOn.lastCall.args[2]).toEqual({ status: 200 });
              });

            });

          });

        });

        describe('when #retryOn() returns Promise with false resolve', () => {

          beforeEach(function() {
            retryOn.resolves(false);
            deferred1.resolve({ status: 502 });
          });

          describe('when resolved', () => {

            it('invokes the then callback', function() {
              expect(thenCallback.called).toBe(true);
            });

            it('calls fetch 1 time only', function() {
              expect(fetch.callCount).toBe(1);
            });

          });

        });

        describe('when #retryOn() throws an error', () => {

          beforeEach(function() {
            retryOn.throws();
          });

          describe('when rejected', () => {

            beforeEach(function() {
              deferred1.reject();
            });

            it('retryOn called only once', () => {
              return fetchRetryChain.finally(() => {
                expect(retryOn.callCount).toBe(1);
              });
            });

            it('invokes the catch callback', function() {
              return fetchRetryChain.finally(() => {
                expect(catchCallback.called).toBe(true);
              });
            });

            it('called fetch', function() {
              expect(fetch.callCount).toBe(1);
            });

          });

          describe('when resolved', () => {

            beforeEach(function() {
              deferred1.resolve({ status: 200 });
            });

            it('retryOn called only once', () => {
              return fetchRetryChain.finally(() => {
                expect(retryOn.callCount).toBe(1);
              });
            });

            it('invokes the catch callback', function() {
              return fetchRetryChain.finally(() => {
                expect(catchCallback.called).toBe(true);
              });
            });

            it('called fetch', function() {
              expect(fetch.callCount).toBe(1);
            });

          });

        });

        describe('when #retryOn() returns a Promise that rejects', () => {

          beforeEach(function() {
            retryOn.rejects();
          });

          describe('when rejected', () => {

            beforeEach(function() {
              deferred1.reject();
            });

            it('retryOn called only once', () => {
              return fetchRetryChain.finally(() => {
                expect(retryOn.callCount).toBe(1);
              });
            });

            it('invokes the catch callback', function() {
              return fetchRetryChain.finally(() => {
                expect(catchCallback.called).toBe(true);
              });
            });

            it('called fetch', function() {
              expect(fetch.callCount).toBe(1);
            });

          });
          describe('when resolved', () => {

            beforeEach(function() {
              deferred1.resolve({ status: 200 });
            });

            it('retryOn called only once', () => {
              return fetchRetryChain.finally(() => {
                expect(retryOn.callCount).toBe(1);
              });
            });

            it('invokes the catch callback', function() {
              return fetchRetryChain.finally(() => {
                expect(catchCallback.called).toBe(true);
              });
            });

            it('called fetch', function() {
              expect(fetch.callCount).toBe(1);
            });

          });

        });

      });

    });

    describe('when #init.retryOn is not an array or function', function () {

      var init;

      describe('when #init.retryOn is not an array or function', () => {

        it('throws exception', () => {
          expect(function () {
            init.retryOn = 503;
            fetchRetry('http://someUrl', init);
          }).toThrow({
            name: 'ArgumentError',
            message: 'retryOn property expects an array or function'
          });
        });

      });

    });

  });

});

function defer() {
  var resolve, reject;
  // eslint-disable-next-line no-undef
  var promise = new Promise(function () {
    resolve = arguments[0];
    reject = arguments[1];
  });
  return {
    resolve: resolve,
    reject: reject,
    promise: promise
  };
}
