Home Manual Reference Source Test

spec/model-spec.js

/* eslint quote-props: 0 */
import Model from '../lib/model';
import {isTempId} from '../lib/utils';
import Attributes from '../lib/attributes';

class SubSubmodel extends Model {
  static attributes = Object.assign({}, Model.attributes, {
    'value': Attributes.Number({
      modelKey: 'value',
      jsonKey: 'value',
    }),
  });
}

class SubmodelItem extends Model {}

class Submodel extends Model {
  static attributes = Object.assign({}, Model.attributes, {
    'testBoolean': Attributes.Boolean({
      modelKey: 'testBoolean',
      jsonKey: 'test_boolean',
    }),
    'testCollection': Attributes.Collection({
      modelKey: 'testCollection',
      jsonKey: 'test_collection',
      itemClass: SubmodelItem,
    }),
    'testJoinedData': Attributes.JoinedData({
      modelKey: 'testJoinedData',
      jsonKey: 'test_joined_data',
    }),
    'testNumber': Attributes.Number({
      modelKey: 'testNumber',
      jsonKey: 'test_number',
    }),
    'testString': Attributes.String({
      modelKey: 'testString',
      jsonKey: 'test_string',
    }),
    'testArray': Attributes.Collection({
      itemClass: SubSubmodel,
      modelKey: 'testArray',
      jsonKey: 'test_array',
    }),
  });
}

describe("Model", function modelSpecs() {
  describe("constructor", () => {
    it("should accept a hash of attributes and assign them to the new Model", () => {
      const attrs = {
        id: "A",
        testNumber: "B",
      };
      const m = new Submodel(attrs);
      expect(m.id).toBe(attrs.id);
      expect(m.testNumber).toBe(attrs.testNumber);
    });

    it("automatically assigns an id to the model if no id is provided", () => {
      const m = new Model();
      expect(isTempId(m.id)).toBe(true);
    });
  });

  describe("attributes", () => {
    it("should return the attributes of the class", () => {
      const m = new Model();
      expect(m.attributes()).toEqual(m.constructor.attributes);
    })

    it("should guard against accidental modification", () => {
      const m = new Model();
      Object.assign(m.attributes(), {other: 1});
      expect(m.attributes()).toEqual(m.constructor.attributes);
    })
  });

  describe("clone", () =>
    it("should return a deep copy of the object", () => {
      const old = new Submodel({testNumber: 4, testArray: [new SubSubmodel({value: 2}), new SubSubmodel({value: 6})]});
      const clone = old.clone();

      // Check entire trees are equivalent
      expect(old.toJSON()).toEqual(clone.toJSON());
      // Check object identity has changed
      expect(old.constructor.name).toEqual(clone.constructor.name);
      expect(old.testArray).not.toBe(clone.testArray);
      // Check classes
      expect(old.testArray[0]).not.toBe(clone.testArray[0]);
      expect(old.testArray[0].constructor.name).toEqual(clone.testArray[0].constructor.name);
    })

  );

  describe("fromJSON", () => {
    beforeEach(() => {
      this.json = {
        'id': '1234',
        'test_number': 4,
        'test_boolean': true,
        'daysOld': 4,
        'test_string': 'bla',
      };
      this.m = new Submodel();
    });

    it("should assign attribute values by calling through to attribute fromJSON functions", () => {
      spyOn(Submodel.attributes.testString, 'fromJSON').and.callFake(() => 'inflated value!');
      this.m.fromJSON(this.json);
      expect(Submodel.attributes.testString.fromJSON.calls.count()).toBe(1);
      expect(this.m.testString).toBe('inflated value!');
    });

    it("should not touch attributes that are missing in the json", () => {
      this.m.fromJSON(this.json);
      expect(this.m.object).toBe(undefined);

      this.m.object = 'abc';
      this.m.fromJSON(this.json);
      expect(this.m.object).toBe('abc');
    });

    it("should not do anything with extra JSON keys", () => {
      this.m.fromJSON(this.json);
      expect(this.m.daysOld).toBe(undefined);
    });

    it("should maintain empty string as empty strings", () => {
      expect(this.m.testString).toBe(undefined);
      this.m.fromJSON({test_string: ''});
      expect(this.m.testString).toBe('');
    });

    describe("Attributes.Number", () =>
      it("should read number attributes and coerce them to numeric values", () => {
        this.m.fromJSON({'test_number': 4});
        expect(this.m.testNumber).toBe(4);

        this.m.fromJSON({'test_number': '4'});
        expect(this.m.testNumber).toBe(4);

        this.m.fromJSON({'test_number': 'lolz'});
        expect(this.m.testNumber).toBe(null);

        this.m.fromJSON({'test_number': 0});
        expect(this.m.testNumber).toBe(0);
      })

    );

    describe("Attributes.JoinedData", () =>
      it("should read joined data attributes and coerce them to string values", () => {
        this.m.fromJSON({'test_joined_data': null});
        expect(this.m.testJoinedData).toBe(null);

        this.m.fromJSON({'test_joined_data': ''});
        expect(this.m.testJoinedData).toBe('');

        this.m.fromJSON({'test_joined_data': 'lolz'});
        expect(this.m.testJoinedData).toBe('lolz');
      })

    );

    describe("Attributes.Collection", () => {
      it("should parse and inflate items", () => {
        this.m.fromJSON({'test_collection': [{id: '123'}]});
        expect(this.m.testCollection.length).toBe(1);
        expect(this.m.testCollection[0].id).toBe('123');
        expect(this.m.testCollection[0].constructor.name).toBe('SubmodelItem');
      });

      it("should be fine with malformed arrays", () => {
        this.m.fromJSON({'test_collection': [null]});
        expect(this.m.testCollection.length).toBe(0);
        this.m.fromJSON({'test_collection': []});
        expect(this.m.testCollection.length).toBe(0);
        this.m.fromJSON({'test_collection': null});
        expect(this.m.testCollection.length).toBe(0);
      });
    });

    describe("Attributes.Boolean", () =>
      it("should read `true` or true and coerce everything else to false", () => {
        this.m.fromJSON({'test_boolean': true});
        expect(this.m.testBoolean).toBe(true);

        this.m.fromJSON({'test_boolean': 'true'});
        expect(this.m.testBoolean).toBe(true);

        this.m.fromJSON({'test_boolean': 4});
        expect(this.m.testBoolean).toBe(false);

        this.m.fromJSON({'test_boolean': '4'});
        expect(this.m.testBoolean).toBe(false);

        this.m.fromJSON({'test_boolean': false});
        expect(this.m.testBoolean).toBe(false);

        this.m.fromJSON({'test_boolean': 0});
        expect(this.m.testBoolean).toBe(false);

        this.m.fromJSON({'test_boolean': null});
        expect(this.m.testBoolean).toBe(false);
      })

    );
  });

  describe("toJSON", () => {
    beforeEach(() => {
      this.model = new Submodel({
        id: "1234",
        testString: "ACD",
      });
      return;
    });

    it("should return a JSON object and call attribute toJSON functions to map values", () => {
      spyOn(Submodel.attributes.testString, 'toJSON').and.callFake(() => 'inflated value!');

      const json = this.model.toJSON();
      expect(json instanceof Object).toBe(true);
      expect(json.id).toBe('1234');
      expect(json.test_string).toBe('inflated value!');
    });

    it("should surface any exception one of the attribute toJSON functions raises", () => {
      spyOn(Submodel.attributes.testString, 'toJSON').and.callFake(() => {
        throw new Error("Can't convert value into JSON format");
      });
      expect(() => { this.model.toJSON(); }).toThrow();
    });
  });

  describe("matches", () => {
    beforeEach(() => {
      this.model = new Model({
        id: "1234",
        testString: "ACD",
      });

      this.truthyMatcher = {evaluate() { return true; }};
      this.falsyMatcher = {evaluate() { return false; }};
    });

    it("should run the matchers and return true iff all matchers pass", () => {
      expect(this.model.matches([this.truthyMatcher, this.truthyMatcher])).toBe(true);
      expect(this.model.matches([this.truthyMatcher, this.falsyMatcher])).toBe(false);
      expect(this.model.matches([this.falsyMatcher, this.truthyMatcher])).toBe(false);
    });

    it("should pass itself as an argument to the matchers", () => {
      spyOn(this.truthyMatcher, 'evaluate').and.callFake(param => {
        expect(param).toBe(this.model);
      });
      this.model.matches([this.truthyMatcher]);
    });
  });
});