// Copyright IBM Corp. 2017,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

// This test is written in mocha+should.js

'use strict';

const should = require('./init.js');
const assert = require('assert');

const jdb = require('../');
const ModelBuilder = jdb.ModelBuilder;
const DataSource = jdb.DataSource;
const Memory = require('../lib/connectors/memory');

const ModelDefinition = require('../lib/model-definition');

describe('Model class inheritance', function() {
  let memory;
  beforeEach(function() {
    memory = new DataSource({connector: Memory});
  });

  describe('ModelBaseClass.getMergePolicy()', function() {
    const legacyMergePolicy = {
      description: {replace: true},
      properties: {patch: true},
      hidden: {replace: false},
      protected: {replace: false},
      relations: {patch: true},
      acls: {rank: true},
    };

    const recommendedMergePolicy = {
      description: {replace: true},
      options: {patch: true},
      hidden: {replace: false},
      protected: {replace: false},
      indexes: {patch: true},
      methods: {patch: true},
      mixins: {patch: true},
      relations: {patch: true},
      scope: {replace: true},
      scopes: {patch: true},
      acls: {rank: true},
      __delete: null,
      __default: {replace: true},
    };

    let modelBuilder, base;

    beforeEach(function() {
      modelBuilder = memory.modelBuilder;
      base = modelBuilder.define('base');
    });

    it('returns legacy merge policy by default', function() {
      const mergePolicy = base.getMergePolicy();
      should.deepEqual(mergePolicy, legacyMergePolicy);
    });

    it('returns recommended merge policy when called with option ' +
      '`{configureModelMerge: true}`', function() {
      const mergePolicy = base.getMergePolicy({configureModelMerge: true});
      should.deepEqual(mergePolicy, recommendedMergePolicy);
    });

    it('handles custom merge policy defined via model.settings', function() {
      let mergePolicy;
      const newMergePolicy = {
        relations: {patch: true},
      };

      // saving original getMergePolicy method
      const originalGetMergePolicy = base.getMergePolicy;

      // the injected getMergePolicy method captures the provided configureModelMerge option
      base.getMergePolicy = function(options) {
        mergePolicy = options && options.configureModelMerge;
        return originalGetMergePolicy(options);
      };

      // calling extend() on base model calls base.getMergePolicy() internally
      // child model settings are passed as 3rd parameter
      const child = base.extend('child', {}, {configureModelMerge: newMergePolicy});

      should.deepEqual(mergePolicy, newMergePolicy);

      // restoring original getMergePolicy method
      base.getMergePolicy = originalGetMergePolicy;
    });

    it('can be extended by user', function() {
      const alteredMergePolicy = Object.assign({}, recommendedMergePolicy, {
        __delete: false,
      });
        // extending the builtin getMergePolicy function
      base.getMergePolicy = function(options) {
        const origin = base.base.getMergePolicy(options);
        return Object.assign({}, origin, {
          __delete: false,
        });
      };
      const mergePolicy = base.getMergePolicy({configureModelMerge: true});
      should.deepEqual(mergePolicy, alteredMergePolicy);
    });

    it('is inherited by child model', function() {
      const child = base.extend('child', {}, {configureModelMerge: true});
      // get mergePolicy from child
      const mergePolicy = child.getMergePolicy({configureModelMerge: true});
      should.deepEqual(mergePolicy, recommendedMergePolicy);
    });
  });

  describe('Merge policy WITHOUT flag `configureModelMerge`', function() {
    it('inherits prototype using option.base', function() {
      const modelBuilder = memory.modelBuilder;
      const parent = memory.createModel('parent', {}, {
        relations: {
          children: {
            type: 'hasMany',
            model: 'anotherChild',
          },
        },
      });
      const baseChild = modelBuilder.define('baseChild');
      baseChild.attachTo(memory);
      // the name of this must begin with a letter < b
      // for this test to fail
      const anotherChild = baseChild.extend('anotherChild');

      assert(anotherChild.prototype instanceof baseChild);
    });

    it('ignores inherited options.base', function() {
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base');
      const child = base.extend('child', {}, {base: 'base'});
      const grandChild = child.extend('grand-child');
      assert.equal('child', grandChild.base.modelName);
      assert(grandChild.prototype instanceof child);
    });

    it('ignores inherited options.super', function() {
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base');
      const child = base.extend('child', {}, {super: 'base'});
      const grandChild = child.extend('grand-child');
      assert.equal('child', grandChild.base.modelName);
      assert(grandChild.prototype instanceof child);
    });

    it('allows model extension', function(done) {
      const modelBuilder = new ModelBuilder();

      const User = modelBuilder.define('User', {
        name: String,
        bio: ModelBuilder.Text,
        approved: Boolean,
        joinedAt: Date,
        age: Number,
      });

      const Customer = User.extend('Customer', {customerId: {type: String, id: true}});

      const customer = new Customer({name: 'Joe', age: 20, customerId: 'c01'});

      customer.should.be.type('object').and.have.property('name', 'Joe');
      customer.should.have.property('name', 'Joe');
      customer.should.have.property('age', 20);
      customer.should.have.property('customerId', 'c01');
      customer.should.have.property('bio', undefined);

      // The properties are defined at prototype level
      assert.equal(Object.keys(customer).filter(function(k) {
        // Remove internal properties
        return k.indexOf('__') === -1;
      }).length, 0);
      let count = 0;
      for (const p in customer.toObject()) {
        if (p.indexOf('__') === 0) {
          continue;
        }
        if (typeof customer[p] !== 'function') {
          count++;
        }
      }
      assert.equal(count, 6);
      assert.equal(Object.keys(customer.toObject()).filter(function(k) {
        // Remove internal properties
        return k.indexOf('__') === -1;
      }).length, 6);

      done(null, customer);
    });

    it('allows model extension with merged settings', function(done) {
      const modelBuilder = new ModelBuilder();

      const User = modelBuilder.define('User', {
        name: String,
      }, {
        defaultPermission: 'ALLOW',
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            permission: 'ALLOW',
          },
        ],
        relations: {
          posts: {
            type: 'hasMany',
            model: 'Post',
          },
        },
      });

      const Customer = User.extend('Customer',
        {customerId: {type: String, id: true}}, {
          defaultPermission: 'DENY',
          acls: [
            {
              principalType: 'ROLE',
              principalId: '$unauthenticated',
              permission: 'DENY',
            },
          ],
          relations: {
            orders: {
              type: 'hasMany',
              model: 'Order',
            },
          },
        });

      assert.deepEqual(User.settings, {
        // forceId is set to 'auto' in memory if idProp.generated && forceId !== false
        forceId: 'auto',
        defaultPermission: 'ALLOW',
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            permission: 'ALLOW',
          },
        ],
        relations: {
          posts: {
            type: 'hasMany',
            model: 'Post',
          },
        },
        strict: false,
      });

      assert.deepEqual(Customer.settings, {
        forceId: false,
        defaultPermission: 'DENY',
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            permission: 'ALLOW',
          },
          {
            principalType: 'ROLE',
            principalId: '$unauthenticated',
            permission: 'DENY',
          },
        ],
        relations: {
          posts: {
            type: 'hasMany',
            model: 'Post',
          },
          orders: {
            type: 'hasMany',
            model: 'Order',
          },
        },
        strict: false,
        base: User,
      });

      done();
    });

    it('defines rank of ACLs according to model\'s inheritance rank', function() {
      // a simple test is enough as we already fully tested option `{rank: true}`
      // in tests with flag `configureModelMerge`
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {acls: [
        {
          principalType: 'ROLE',
          principalId: '$everyone',
          property: 'oneMethod',
          permission: 'ALLOW',
        },
      ]});
      const childRank1 = modelBuilder.define('childRank1', {}, {
        base: base,
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            property: 'oneMethod',
            permission: 'DENY',
          },
        ],
      });

      const expectedSettings = {
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            property: 'oneMethod',
            permission: 'ALLOW',
            __rank: 1,
          },
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            property: 'oneMethod',
            permission: 'DENY',
            __rank: 2,
          },
        ],
      };
      should.deepEqual(childRank1.settings.acls, expectedSettings.acls);
    });

    it('replaces baseClass relations with matching subClass relations', function() {
      // merge policy of settings.relations is {patch: true}
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        relations: {
          user: {
            type: 'belongsTo',
            model: 'User',
            foreignKey: 'userId',
          },
        },
      });
      const child = base.extend('child', {}, {
        relations: {
          user: {
            type: 'belongsTo',
            idName: 'id',
            polymorphic: {
              idType: 'string',
              foreignKey: 'userId',
              discriminator: 'principalType',
            },
          },
        },
      });

      const expectedSettings = {
        relations: {
          user: {
            type: 'belongsTo',
            idName: 'id',
            polymorphic: {
              idType: 'string',
              foreignKey: 'userId',
              discriminator: 'principalType',
            },
          },
        },
      };

      should.deepEqual(child.settings.relations, expectedSettings.relations);
    });
  });

  describe('Merge policy WITH flag `configureModelMerge: true`', function() {
    it('`{__delete: null}` allows deleting base model settings by assigning ' +
        'null value at sub model level', function() {
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        anyParam: {oneKey: 'this should be removed'},
      });
      const child = base.extend('child', {}, {
        anyParam: null,
        configureModelMerge: true,
      });

      const expectedSettings = {};

      should.deepEqual(child.settings.description, expectedSettings.description);
    });

    it('`{rank: true}` defines rank of array elements ' +
        'according to model\'s inheritance rank', function() {
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {acls: [
        {
          principalType: 'ROLE',
          principalId: '$everyone',
          property: 'oneMethod',
          permission: 'ALLOW',
        },
      ]});
      const childRank1 = modelBuilder.define('childRank1', {}, {
        base: base,
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$owner',
            property: 'anotherMethod',
            permission: 'ALLOW',
          },
        ],
        configureModelMerge: true,
      });
      const childRank2 = childRank1.extend('childRank2', {}, {});
      const childRank3 = childRank2.extend('childRank3', {}, {
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            property: 'oneMethod',
            permission: 'DENY',
          },
        ],
        configureModelMerge: true,
      });

      const expectedSettings = {
        acls: [
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            property: 'oneMethod',
            permission: 'ALLOW',
            __rank: 1,
          },
          {
            principalType: 'ROLE',
            principalId: '$owner',
            property: 'anotherMethod',
            permission: 'ALLOW',
            __rank: 2,
          },
          {
            principalType: 'ROLE',
            principalId: '$everyone',
            property: 'oneMethod',
            permission: 'DENY',
            __rank: 4,
          },
        ],
      };
      should.deepEqual(childRank3.settings.acls, expectedSettings.acls);
    });

    it('`{replace: true}` replaces base model array with sub model matching ' +
        'array', function() {
      // merge policy of settings.description is {replace: true}
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        description: ['base', 'model', 'description'],
      });
      const child = base.extend('child', {}, {
        description: ['this', 'is', 'child', 'model', 'description'],
        configureModelMerge: true,
      });

      const expectedSettings = {
        description: ['this', 'is', 'child', 'model', 'description'],
      };

      should.deepEqual(child.settings.description, expectedSettings.description);
    });

    it('`{replace:true}` is applied on array parameters not defined in merge policy', function() {
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        unknownArrayParam: ['this', 'should', 'be', 'replaced'],
      });
      const child = base.extend('child', {}, {
        unknownArrayParam: ['this', 'should', 'remain', 'after', 'merge'],
        configureModelMerge: true,
      });

      const expectedSettings = {
        unknownArrayParam: ['this', 'should', 'remain', 'after', 'merge'],
      };

      should.deepEqual(child.settings.description, expectedSettings.description);
    });

    it('`{replace:true}` is applied on object {} parameters not defined in mergePolicy', function() {
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        unknownObjectParam: {oneKey: 'this should be replaced'},
      });
      const child = base.extend('child', {}, {
        unknownObjectParam: {anotherKey: 'this should remain after merge'},
        configureModelMerge: true,
      });

      const expectedSettings = {
        unknownObjectParam: {anotherKey: 'this should remain after merge'},
      };

      should.deepEqual(child.settings.description, expectedSettings.description);
    });

    it('`{replace: false}` adds distinct members of matching arrays from ' +
        'base model and sub model', function() {
      // merge policy of settings.hidden is {replace: false}
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        hidden: ['firstProperty', 'secondProperty'],
      });
      const child = base.extend('child', {}, {
        hidden: ['secondProperty', 'thirdProperty'],
        configureModelMerge: true,
      });

      const expectedSettings = {
        hidden: ['firstProperty', 'secondProperty', 'thirdProperty'],
      };

      should.deepEqual(child.settings.hidden, expectedSettings.hidden);
    });

    it('`{patch: true}` adds distinct inner properties of matching objects ' +
        'from base model and sub model', function() {
      // merge policy of settings.relations is {patch: true}
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        relations: {
          someOtherRelation: {
            type: 'hasMany',
            model: 'someOtherModel',
            foreignKey: 'otherModelId',
          },
        },
      });
      const child = base.extend('child', {}, {
        relations: {
          someRelation: {
            type: 'belongsTo',
            model: 'someModel',
            foreignKey: 'modelId',
          },
        },
        configureModelMerge: true,
      });

      const expectedSettings = {
        relations: {
          someRelation: {
            type: 'belongsTo',
            model: 'someModel',
            foreignKey: 'modelId',
          },
          someOtherRelation: {
            type: 'hasMany',
            model: 'someOtherModel',
            foreignKey: 'otherModelId',
          },
        },
      };

      should.deepEqual(child.settings.relations, expectedSettings.relations);
    });

    it('`{patch: true}` replaces baseClass inner properties with matching ' +
        'subClass inner properties', function() {
      // merge policy of settings.relations is {patch: true}
      const modelBuilder = memory.modelBuilder;
      const base = modelBuilder.define('base', {}, {
        relations: {
          user: {
            type: 'belongsTo',
            model: 'User',
            foreignKey: 'userId',
          },
        },
      });
      const child = base.extend('child', {}, {
        relations: {
          user: {
            type: 'belongsTo',
            idName: 'id',
            polymorphic: {
              idType: 'string',
              foreignKey: 'userId',
              discriminator: 'principalType',
            },
          },
        },
        configureModelMerge: true,
      });

      const expectedSettings = {
        relations: {
          user: {
            type: 'belongsTo',
            idName: 'id',
            polymorphic: {
              idType: 'string',
              foreignKey: 'userId',
              discriminator: 'principalType',
            },
          },
        },
      };

      should.deepEqual(child.settings.relations, expectedSettings.relations);
    });
  });
});