// Copyright IBM Corp. 2016,2019. All Rights Reserved.
// Node module: loopback-datasource-juggler
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT

'use strict';

const should = require('./init.js');
const DataSource = require('../lib/datasource.js').DataSource;

describe('DataSource', function() {
  it('clones settings to prevent surprising changes in passed args', () => {
    const config = {connector: 'memory'};

    const ds = new DataSource(config);
    ds.settings.extra = true;

    config.should.eql({connector: 'memory'});
  });

  it('reports helpful error when connector init throws', function() {
    const throwingConnector = {
      name: 'loopback-connector-throwing',
      initialize: function(ds, cb) {
        throw new Error('expected test error');
      },
    };

    (function() {
      // this is what LoopBack does
      return new DataSource({
        name: 'dsname',
        connector: throwingConnector,
      });
    }).should.throw(/loopback-connector-throwing/);
  });

  it('reports helpful error when connector init via short name throws', function() {
    (function() {
      // this is what LoopBack does
      return new DataSource({
        name: 'dsname',
        connector: 'throwing',
      });
    }).should.throw(/expected test error/);
  });

  it('reports helpful error when connector init via long name throws', function() {
    (function() {
      // this is what LoopBack does
      return new DataSource({
        name: 'dsname',
        connector: 'loopback-connector-throwing',
      });
    }).should.throw(/expected test error/);
  });

  /**
   * new DataSource(dsName, settings) without settings.name
   */
  it('should retain the name assigned to it', function() {
    const dataSource = new DataSource('myDataSource', {
      connector: 'memory',
    });

    dataSource.name.should.equal('myDataSource');
  });

  /**
   * new DataSource(dsName, settings)
   */
  it('should allow the name assigned to it to take precedence over the settings name', function() {
    const dataSource = new DataSource('myDataSource', {
      name: 'defaultDataSource',
      connector: 'memory',
    });

    dataSource.name.should.equal('myDataSource');
  });

  /**
   * new DataSource(settings) with settings.name
   */
  it('should retain the name from the settings if no name is assigned', function() {
    const dataSource = new DataSource({
      name: 'defaultDataSource',
      connector: 'memory',
    });

    dataSource.name.should.equal('defaultDataSource');
  });

  /**
   * new DataSource(undefined, settings)
   */
  it('should retain the name from the settings if name is undefined', function() {
    const dataSource = new DataSource(undefined, {
      name: 'defaultDataSource',
      connector: 'memory',
    });

    dataSource.name.should.equal('defaultDataSource');
  });

  /**
   * new DataSource(settings) without settings.name
   */
  it('should use the connector name if no name is provided', function() {
    const dataSource = new DataSource({
      connector: 'memory',
    });

    dataSource.name.should.equal('memory');
  });

  /**
   * new DataSource(connectorInstance)
   */
  it('should accept resolved connector', function() {
    const mockConnector = {
      name: 'loopback-connector-mock',
      initialize: function(ds, cb) {
        ds.connector = mockConnector;
        return cb(null);
      },
    };
    const dataSource = new DataSource(mockConnector);

    dataSource.name.should.equal('loopback-connector-mock');
    dataSource.connector.should.equal(mockConnector);
  });

  /**
   * new DataSource(dsName, connectorInstance)
   */
  it('should accept dsName and resolved connector', function() {
    const mockConnector = {
      name: 'loopback-connector-mock',
      initialize: function(ds, cb) {
        ds.connector = mockConnector;
        return cb(null);
      },
    };
    const dataSource = new DataSource('myDataSource', mockConnector);

    dataSource.name.should.equal('myDataSource');
    dataSource.connector.should.equal(mockConnector);
  });

  /**
   * new DataSource(connectorInstance, settings)
   */
  it('should accept resolved connector and settings', function() {
    const mockConnector = {
      name: 'loopback-connector-mock',
      initialize: function(ds, cb) {
        ds.connector = mockConnector;
        return cb(null);
      },
    };
    const dataSource = new DataSource(mockConnector, {name: 'myDataSource'});

    dataSource.name.should.equal('myDataSource');
    dataSource.connector.should.equal(mockConnector);
  });

  it('should set states correctly with eager connect', function(done) {
    const mockConnector = {
      name: 'loopback-connector-mock',
      initialize: function(ds, cb) {
        ds.connector = mockConnector;
        this.connect(cb);
      },

      connect: function(cb) {
        process.nextTick(function() {
          cb(null);
        });
      },
    };
    const dataSource = new DataSource(mockConnector);
    // DataSource is instantiated
    // connected: false, connecting: false, initialized: false
    dataSource.connected.should.be.false();
    dataSource.connecting.should.be.false();
    dataSource.initialized.should.be.false();

    dataSource.on('initialized', function() {
      // DataSource is initialized with lazyConnect
      // connected: false, connecting: false, initialized: true
      dataSource.connected.should.be.false();
      dataSource.connecting.should.be.false();
      dataSource.initialized.should.be.true();
    });

    dataSource.on('connected', function() {
      // DataSource is now connected
      // connected: true, connecting: false
      dataSource.connected.should.be.true();
      dataSource.connecting.should.be.false();
    });

    // Call connect() in next tick so that we'll receive initialized event
    // first
    process.nextTick(function() {
      // At this point, the datasource is already connected by
      // connector's (mockConnector) initialize function
      dataSource.connect(function() {
        // DataSource is now connected
        // connected: true, connecting: false
        dataSource.connected.should.be.true();
        dataSource.connecting.should.be.false();
        done();
      });
      // As the datasource is already connected, no connecting will happen
      // connected: true, connecting: false
      dataSource.connected.should.be.true();
      dataSource.connecting.should.be.false();
    });
  });

  it('should set states correctly with deferred connect', function(done) {
    const mockConnector = {
      name: 'loopback-connector-mock',
      initialize: function(ds, cb) {
        ds.connector = mockConnector;
        // Explicitly call back with false to denote connection is not ready
        process.nextTick(function() {
          cb(null, false);
        });
      },

      connect: function(cb) {
        process.nextTick(function() {
          cb(null);
        });
      },
    };
    const dataSource = new DataSource(mockConnector);
    // DataSource is instantiated
    // connected: false, connecting: false, initialized: false
    dataSource.connected.should.be.false();
    dataSource.connecting.should.be.false();
    dataSource.initialized.should.be.false();

    dataSource.on('initialized', function() {
      // DataSource is initialized with lazyConnect
      // connected: false, connecting: false, initialized: true
      dataSource.connected.should.be.false();
      dataSource.connecting.should.be.false();
      dataSource.initialized.should.be.true();
    });

    dataSource.on('connected', function() {
      // DataSource is now connected
      // connected: true, connecting: false
      dataSource.connected.should.be.true();
      dataSource.connecting.should.be.false();
    });

    // Call connect() in next tick so that we'll receive initialized event
    // first
    process.nextTick(function() {
      dataSource.connect(function() {
        // DataSource is now connected
        // connected: true, connecting: false
        dataSource.connected.should.be.true();
        dataSource.connecting.should.be.false();
        done();
      });
      // As the datasource is not connected, connecting will happen
      // connected: false, connecting: true
      dataSource.connected.should.be.false();
      dataSource.connecting.should.be.true();
    });
  });

  it('should set states correctly with lazyConnect = true', function(done) {
    const mockConnector = {
      name: 'loopback-connector-mock',
      initialize: function(ds, cb) {
        ds.connector = mockConnector;
        process.nextTick(function() {
          cb(null);
        });
      },

      connect: function(cb) {
        process.nextTick(function() {
          cb(null);
        });
      },
    };
    const dataSource = new DataSource(mockConnector, {lazyConnect: true});
    // DataSource is instantiated
    // connected: false, connecting: false, initialized: false
    dataSource.connected.should.be.false();
    dataSource.connecting.should.be.false();
    dataSource.initialized.should.be.false();

    dataSource.on('initialized', function() {
      // DataSource is initialized with lazyConnect
      // connected: false, connecting: false, initialized: true
      dataSource.connected.should.be.false();
      dataSource.connecting.should.be.false();
      dataSource.initialized.should.be.true();
    });

    dataSource.on('connected', function() {
      // DataSource is now connected
      // connected: true, connecting: false
      dataSource.connected.should.be.true();
      dataSource.connecting.should.be.false();
    });

    // Call connect() in next tick so that we'll receive initialized event
    // first
    process.nextTick(function() {
      dataSource.connect(function() {
        // DataSource is now connected
        // connected: true, connecting: false
        dataSource.connected.should.be.true();
        dataSource.connecting.should.be.false();
        done();
      });
      // DataSource is now connecting
      // connected: false, connecting: true
      dataSource.connected.should.be.false();
      dataSource.connecting.should.be.true();
    });
  });

  it('provides stop() API calling disconnect', function(done) {
    const mockConnector = {
      name: 'loopback-connector-mock',
      initialize: function(ds, cb) {
        ds.connector = mockConnector;
        process.nextTick(function() {
          cb(null);
        });
      },
    };

    const dataSource = new DataSource(mockConnector);
    dataSource.on('connected', function() {
      // DataSource is now connected
      // connected: true, connecting: false
      dataSource.connected.should.be.true();
      dataSource.connecting.should.be.false();

      dataSource.stop(() => {
        dataSource.connected.should.be.false();
        done();
      });
    });
  });

  describe('deleteModelByName()', () => {
    it('removes the model from ModelBuilder registry', () => {
      const ds = new DataSource('ds', {connector: 'memory'});

      ds.createModel('TestModel');
      Object.keys(ds.modelBuilder.models)
        .should.containEql('TestModel');
      Object.keys(ds.modelBuilder.definitions)
        .should.containEql('TestModel');

      ds.deleteModelByName('TestModel');

      Object.keys(ds.modelBuilder.models)
        .should.not.containEql('TestModel');
      Object.keys(ds.modelBuilder.definitions)
        .should.not.containEql('TestModel');
    });

    it('removes the model from connector registry', () => {
      const ds = new DataSource('ds', {connector: 'memory'});

      ds.createModel('TestModel');
      Object.keys(ds.connector._models)
        .should.containEql('TestModel');

      ds.deleteModelByName('TestModel');

      Object.keys(ds.connector._models)
        .should.not.containEql('TestModel');
    });
  });

  describe('execute', () => {
    let ds;
    beforeEach(() => ds = new DataSource('ds', {connector: 'memory'}));

    it('calls connnector to execute the command', async () => {
      let called = 'not called';
      ds.connector.execute = function(command, args, options, callback) {
        called = {command, args, options};
        callback(null, 'a-result');
      };

      const result = await ds.execute(
        'command',
        ['arg1', 'arg2'],
        {'a-flag': 'a-value'},
      );

      result.should.be.equal('a-result');
      called.should.be.eql({
        command: 'command',
        args: ['arg1', 'arg2'],
        options: {'a-flag': 'a-value'},
      });
    });

    it('supports shorthand version (cmd)', async () => {
      let called = 'not called';
      ds.connector.execute = function(command, args, options, callback) {
        // copied from loopback-connector/lib/sql.js
        if (typeof args === 'function' && options === undefined && callback === undefined) {
          // execute(sql, callback)
          options = {};
          callback = args;
          args = [];
        }

        called = {command, args, options};
        callback(null, 'a-result');
      };

      const result = await ds.execute('command');
      result.should.be.equal('a-result');
      called.should.be.eql({
        command: 'command',
        args: [],
        options: {},
      });
    });

    it('supports shorthand version (cmd, args)', async () => {
      let called = 'not called';
      ds.connector.execute = function(command, args, options, callback) {
        // copied from loopback-connector/lib/sql.js
        if (typeof options === 'function' && callback === undefined) {
          // execute(sql, params, callback)
          callback = options;
          options = {};
        }

        called = {command, args, options};
        callback(null, 'a-result');
      };

      await ds.execute('command', ['arg1', 'arg2']);
      called.should.be.eql({
        command: 'command',
        args: ['arg1', 'arg2'],
        options: {},
      });
    });

    it('converts multiple callbacks arguments into a promise resolved with an array', async () => {
      ds.connector.execute = function() {
        const callback = arguments[arguments.length - 1];
        callback(null, 'result1', 'result2');
      };
      const result = await ds.execute('command');
      result.should.eql(['result1', 'result2']);
    });

    it('allows args as object', async () => {
      let called = 'not called';
      ds.connector.execute = function(command, args, options, callback) {
        called = {command, args, options};
        callback();
      };

      // See https://www.npmjs.com/package/loopback-connector-neo4j-graph
      const command = 'MATCH (u:User {email: {email}}) RETURN u';
      await ds.execute(command, {email: 'alice@example.com'}, {options: true});
      called.should.be.eql({
        command,
        args: {email: 'alice@example.com'},
        options: {options: true},
      });
    });

    it('supports MongoDB version (collection, cmd, args, options)', async () => {
      let called = 'not called';
      ds.connector.execute = function(...params) {
        const callback = params.pop();
        called = params;
        callback(null, 'a-result');
      };

      const result = await ds.execute(
        'collection',
        'command',
        ['arg1', 'arg2'],
        {options: true},
      );

      result.should.equal('a-result');
      called.should.be.eql([
        'collection',
        'command',
        ['arg1', 'arg2'],
        {options: true},
      ]);
    });

    it('supports free-form version (...params)', async () => {
      let called = 'not called';
      ds.connector.execute = function(...params) {
        const callback = params.pop();
        called = params;
        callback(null, 'a-result');
      };

      const result = await ds.execute(
        'arg1',
        'arg2',
        'arg3',
        'arg4',
        {options: true},
      );

      result.should.equal('a-result');
      called.should.be.eql([
        'arg1',
        'arg2',
        'arg3',
        'arg4',
        {options: true},
      ]);
    });

    it('throws NOT_IMPLEMENTED when no connector is provided', () => {
      ds.connector = undefined;
      return ds.execute('command').should.be.rejectedWith({
        code: 'NOT_IMPLEMENTED',
      });
    });

    it('throws NOT_IMPLEMENTED for connectors not implementing execute', () => {
      ds.connector.execute = undefined;
      return ds.execute('command').should.be.rejectedWith({
        code: 'NOT_IMPLEMENTED',
      });
    });
  });

  describe('automigrate', () => {
    it('reports connection errors (immediate connect)', async () => {
      const dataSource = new DataSource({
        connector: givenConnectorFailingOnConnect(),
      });
      dataSource.define('MyModel');
      await dataSource.automigrate().should.be.rejectedWith(/test failure/);
    });

    it('reports connection errors (lazy connect)', () => {
      const dataSource = new DataSource({
        connector: givenConnectorFailingOnConnect(),
        lazyConnect: true,
      });
      dataSource.define('MyModel');
      return dataSource.automigrate().should.be.rejectedWith(/test failure/);
    });

    function givenConnectorFailingOnConnect() {
      return givenMockConnector({
        connect: function(cb) {
          process.nextTick(() => cb(new Error('test failure')));
        },
        automigrate: function(models, cb) {
          cb(new Error('automigrate should not have been called'));
        },
      });
    }
  });

  describe('autoupdate', () => {
    it('reports connection errors (immediate connect)', async () => {
      const dataSource = new DataSource({
        connector: givenConnectorFailingOnConnect(),
      });
      dataSource.define('MyModel');
      await dataSource.autoupdate().should.be.rejectedWith(/test failure/);
    });

    it('reports connection errors (lazy connect)', () => {
      const dataSource = new DataSource({
        connector: givenConnectorFailingOnConnect(),
        lazyConnect: true,
      });
      dataSource.define('MyModel');
      return dataSource.autoupdate().should.be.rejectedWith(/test failure/);
    });

    function givenConnectorFailingOnConnect() {
      return givenMockConnector({
        connect: function(cb) {
          process.nextTick(() => cb(new Error('test failure')));
        },
        autoupdate: function(models, cb) {
          cb(new Error('autoupdate should not have been called'));
        },
      });
    }
  });

  describe('deleteAllModels', () => {
    it('removes all model definitions', () => {
      const ds = new DataSource({connector: 'memory'});
      ds.define('Category');
      ds.define('Product');

      Object.keys(ds.modelBuilder.definitions)
        .should.deepEqual(['Category', 'Product']);
      Object.keys(ds.modelBuilder.models)
        .should.deepEqual(['Category', 'Product']);
      Object.keys(ds.connector._models)
        .should.deepEqual(['Category', 'Product']);

      ds.deleteAllModels();

      Object.keys(ds.modelBuilder.definitions).should.be.empty();
      Object.keys(ds.modelBuilder.models).should.be.empty();
      Object.keys(ds.connector._models).should.be.empty();
    });

    it('preserves the connector instance', () => {
      const ds = new DataSource({connector: 'memory'});
      const connector = ds.connector;
      ds.deleteAllModels();
      ds.connector.should.equal(connector);
    });
  });

  describe('getMaxOfflineRequests', () => {
    let ds;
    beforeEach(() => ds = new DataSource('ds', {connector: 'memory'}));

    it('sets the default maximum number of event listeners to 16', () => {
      ds.getMaxOfflineRequests().should.be.eql(16);
    });

    it('uses provided number of listeners', () => {
      ds.settings.maxOfflineRequests = 17;
      ds.getMaxOfflineRequests().should.be.eql(17);
    });

    it('throws an error if a non-number is provided for the max number of listeners', () => {
      ds.settings.maxOfflineRequests = '17';

      (function() {
        return ds.getMaxOfflineRequests();
      }).should.throw('maxOfflineRequests must be a number');
    });
  });
});

function givenMockConnector(props) {
  const connector = {
    name: 'loopback-connector-mock',
    initialize: function(ds, cb) {
      ds.connector = connector;
      if (ds.settings.lazyConnect) {
        cb(null, false);
      } else {
        connector.connect(cb);
      }
    },
    ...props,
  };
  return connector;
}