Home Reference Source Test Repository

test/ModelTest.js

import {expect} from 'chai';
import sinon from 'sinon';
import Model from '../src/Eloquent/Model';
import Builder from '../src/Eloquent/Builder';
import RestConnection from '../src/Connection/RestConnection';

/** @test {Model} */
describe('Model', () => {

    let Person; // the "class"
    let person; // an instance of Person
    let attributes; // dummy data for the person
    let connection;

    beforeEach('setup model and connection', () => {
        attributes = {
            name: 'Dave',
            email: '[email protected]'
        };
        connection = new RestConnection('api/people');

        Person = class extends Model {};
        Person.prototype.connection = connection;

        person = new Person(attributes);
        person.exists = true;
    });

    context('data', () => {
        it('is made available as public properties', () => {
            expect(person.name).to.equal('Dave');
        });

        /** @test {Model#getAttributes} */
        it('can be retreived as plain object ', () => {
            expect(person.getAttributes()).to.eql(attributes)
        });

        /** @test {Model#setAttributes} */
        it('can be changed', () => {
            person.setAttribute('name', 'Dorothy');
            expect(person.name).to.equal('Dorothy');

            person.name = 'Doris';
            expect(person.getAttribute('name')).to.equal('Doris');
        });

        /** @test {Model#getDirty} */
        it('tracks any changes', () => {
            person.name = 'Donna';
            expect(person.getDirty()).to.eql({
                name: 'Donna'
            });
        });

        context('when the column is a date', () => {

            beforeEach('setupPersonWithTimestamp', () => {
                person = new Person({ created_at: '2015-11-23T12:11:03+0000' });
            })

            it('is cast to a Date object', () => {
                expect(person.created_at).to.be.an.instanceOf(Date);
            });

            it('does not cast to Date if null', () => {
                let noOne = new Person({ created_at: null });
                expect(noOne.created_at).to.be.null;
            });

            it('can be configured at run-time on the class object', () => {
                let Dog = class extends Model {};
                Dog.dates = ['birthday'];

                let buster = new Dog({ birthday: '2015-11-23T12:11:03+0000' });

                expect(buster.birthday).to.be.an.instanceOf(Date);
            });

            it('is cloned', () => {
                person.created_at.setFullYear(1999);
                expect(person.created_at).not.to.equal(person.original.created_at);
            });

            it('is cast to a UNIX timestamp when converted to JSON', () => {
                let asJSON = JSON.stringify(person);
                expect(JSON.parse(asJSON).created_at).to.be.a('number');
            });
        });

        /** @test {Model#fill} */
        it('fills the model from an attributes object', () => {
            person.fill({ name: 'Bob' });
            expect(person.name).to.equal('Bob');
        });
    });

    describe('query builder', () => {

        /** @test {Model#newQuery} */
        it('can be created from a model instance', () => {
            expect(person.newQuery()).to.be.an.instanceOf(Builder);
        });

        /** @test {Model#query} */
        it('can be created from a model class (statically)', () => {
            expect(Person.query()).to.be.an.instanceOf(Builder);
        });

        it('has its methods proxied at boot', () => {
            class Rabbit extends Model {}
            expect(Rabbit.find).not.to.be.a.function;
            Rabbit.boot();
            expect(Rabbit.find).to.be.a.function;
        });
    });

    /** @test {Model#hydrate} */
    describe('hydrate()', () => {
        it('creates an array of models from an array of plain objects', () => {
            let person1 = attributes;
            let person2 = { name: 'Donald', email: '[email protected]' };

            let hydrated = person.hydrate([person1, person2]);

            expect(hydrated).to.have.length(2);
            expect(hydrated[0]).to.be.an.instanceOf(Person);
            expect(hydrated[1]).to.be.an.instanceOf(Person);
            expect(hydrated[0].name).to.equal('Dave');
            expect(hydrated[1].name).to.equal('Donald');
        });
    });

    /** @test {Model#all} */
    it('can fetch all models', () => {
        sinon.stub(connection, 'read').resolves([{ name: 'Alice' }]);
        return expect(Person.all()).to.eventually.eql([ new Person({ name: 'Alice'} )]);
    });

    /** @test {Model#boot} */
    describe('boot()', function () {
        let Dog;

        it('is called once per model', function () {
            Dog = class extends Model {};
            let spy = sinon.spy(Dog, 'boot');
            new Dog();
            new Dog();
            expect(spy).to.have.been.calledOnce;
        });

        describe('scoped method', () => {

            let builder = new Builder;

            beforeEach('setupModelWithScopes', () => {
                Dog = class extends Model {};
                Dog.scopes = ['ofBreed'];
                Dog.boot();
                Dog.prototype.newQuery = sinon.stub().returns(builder);
            });

            it('is added to the class', () => {
                expect(Dog.ofBreed).to.be.a('function');
            });

            it('is added to the prototype', () => {
                let rover = new Dog({ name: 'rover' });
                expect(rover.ofBreed).to.be.a('function');
            });

            it('returns a builder object', () => {
                expect(Dog.ofBreed('terrier')).to.be.an.instanceOf(Builder);
            });

            it('calls scope() on the builder', () => {
                sinon.stub(builder, 'scope');
                Dog.ofBreed('terrier');
                expect(builder.scope).to.have.been.calledWith('ofBreed', ['terrier']);
            });
        });
    });

    /** @test {Model#create} */
    describe('create()', () => {
        it('news up an instance with the given attributes and saves it', () => {
            let stub = sinon.stub(Person.prototype, 'save').resolves();

            let saveRequest = Person.create({ name: 'Flibble' });

            expect(stub).to.have.been.called;
            return expect(saveRequest).to.eventually.be.an.instanceOf(Person);
        });
    });

    /** @test {Model#save} */
    describe('save()', () => {

        context('on a non-existent model', () => {

            beforeEach('stubInsert', () => {
                sinon.stub(connection, 'create').resolves(attributes);
                person.exists = false;
            });

            it('calls insert() on the query builder', () => {
                person.save();
                expect(connection.create).to.have.been.calledWith(person.getAttributes());
            });

            it('updates the instance to include new attributes from the server', () => {
                connection.create.resolves(Object.assign(attributes, { id: 2 }));
                return person.save().then(() => expect(person.id).to.equal(2));
            });
        });

        context('on an existing model', () => {

            beforeEach('stub connection.update', () => {
                sinon.stub(connection, 'update').resolves({ serverSays: 'Hello' });
            });

            it('calls update() on the connection', () => {
                person.save();
                expect(connection.update).to.have.been.calledWith(person.getKey(), person.getDirty());
            });

            it('updates the instance to include new attributes from the server', () => {
                return person.save().then(() => expect(person.serverSays).to.equal('Hello'));
            });
        });
    });

    /** @test {Model#update} */
    it('updates the model attributes and saves it', () => {
        sinon.stub(person, 'save');

        person.update({ name: 'Delia' });

        expect(person.save).to.have.been.called;
        expect(person.name).to.equal('Delia');
    });

    /** @test {Model#delete} */
    it('deletes the model', () => {
        sinon.stub(connection, 'delete').resolves(true);
        person.id = 5;

        return person.delete().then(response => {
            expect(person.exists).to.equal(false);
            expect(connection.delete).to.have.been.calledWith(5);
        });
    });

    describe('eventing', () => {
        let eventNames = [
            'creating', 'created', 'updating', 'updated',
            'saving', 'saved', 'deleting', 'deleted'
        ];
        let observer;

        eventNames.forEach(observable => {
            it(`registers a ${observable} event handler`, () => {
                let handler = function () {};
                Person[observable](handler);
                expect(Person.events[observable]).to.contain(handler);
            });
        })

        beforeEach('stub connection', () => {
            observer = sinon.stub();
            sinon.stub(connection, 'create').resolves();
            sinon.stub(connection, 'update').resolves();
            sinon.stub(connection, 'delete').resolves();
        });

        it('can have any number of observers', () => {
            let observer2 = sinon.spy();
            Person.creating(observer);
            Person.creating(observer2);

            Person.create({ name: 'Dave' });

            expect(observer).to.have.been.called.once;
            expect(observer2).to.have.been.called.once;
        });

        context('when a new model is created', () => {

            it('fires the creating event beforehand', () => {
                Person.creating(observer);

                Person.create({ name: 'Dave' });

                expect(observer).to.have.been.called;
                expect(observer.args[0][0]).to.be.an.instanceOf(Person);
            });

            it('cancels the creation if event handler returns false', () => {
                observer.returns(false);
                Person.creating(observer);

                let request = Person.create({ name: 'Dave' });

                expect(connection.create).not.to.have.been.called;
                return expect(request).to.eventually.be.rejectedWith('cancelled');
            });

            it('fires the created event afterwards', () => {
                Person.created(observer);

                let request = Person.create({ name: 'Dave' });

                expect(observer).not.to.have.been.called; // yet
                return request.then(person => {
                    expect(person.exists).to.be.true;
                    expect(observer).to.have.been.called.once;
                    expect(observer.args[0][0]).to.be.an.instanceOf(Person);
                })
            });
        });

        context('whenever a model is saved', () => {

            it('fires the saving event beforehand', () => {
                let person = new Person({ name: 'Dave' });
                Person.saving(observer);

                person.save();

                expect(observer).to.have.been.called;
                expect(observer.args[0][0]).to.equal(person);
                expect(observer.args[0][0].exists).to.equal(false);
            });

            it('fires the saved event afterwards', () => {
                let person = new Person({ name: 'Dave' });
                Person.saved(observer);

                return person.save().then(success => {
                    expect(observer).to.have.been.called;
                    expect(observer.args[0][0]).to.equal(person);
                    expect(observer.args[0][0].exists).to.equal(true);
                })
            });

        });

        context('when a model is updated', () => {

            it('fires the updating event beforehand', () => {
                Person.updating(observer);

                person.update({ name: 'Not Dave' });

                expect(observer).to.have.been.called;
                expect(observer.args[0][0]).to.equal(person);
            });

            it('fires the updated event afterwards', () => {
                Person.updated(observer);

                return person.update({ name: 'Not Dave' }).then(success => {
                    expect(observer).to.have.been.called;
                    expect(observer.args[0][0]).to.equal(person);
                    expect(observer.args[0][0].exists).to.equal(true);
                })
            });
        });

        context('when a model is deleted', () => {

            it('fires the deleting event beforehand', () => {
                Person.deleting(observer);

                person.delete();

                expect(observer).to.have.been.called;
                expect(observer.args[0][0]).to.equal(person);
            });

            it('fires the deleted event afterwards', () => {
                Person.deleted(observer);
                connection.delete.resolves(true);

                return person.delete().then(success => {
                    expect(observer).to.have.been.called;
                    expect(observer.args[0][0]).to.equal(person);
                    expect(observer.args[0][0].exists).to.equal(false);
                })
            });
        });
    });

    describe('relationships', () => {
        /** @test {Model#load} */
        describe('eager loading', () => {
            let Comment;
            let Profile;

            beforeEach('stub related models', () => {

                Comment = class extends Model {};
                Profile = class extends Model {};

                Person.relations = {
                    comments: 'Comment',
                    profile: 'Profile'
                };

                // This would usually be handled by the container:
                Person.prototype._getRelatedClass = function (name) {
                    if (name == 'Comment') return Comment;
                    if (name == 'Profile') return Profile;
                    throw new Error('Cannot make '+name);
                };

                // Mock response
                sinon.stub(connection, 'read').resolves([
                    {
                        name: 'Dave',
                        comments: [
                            { body: 'Hello' }
                        ],
                        profile: {
                            website: 'URL'
                        }
                    }
                ]);
            });

            it('resolves with the original model', () => {
                return expect(person.load('comments')).to.eventually.equal(person);
            });

            it('attaches the returned attributes to the model', () => {
                expect(person.comments).not.to.be.ok;
                return person.load('comments').then(person => {
                    expect(person.comments).to.be.ok;
                });
            });

            it('does not clobber dirty attributes', () => {
                let request = person.load('comments');
                person.name = 'I CHANGED';

                return request.then(result => {
                    expect(result.name).to.equal('I CHANGED');
                })
            });

            it('hydrates the correct model for a [*]Many relation', () => {
                return person.load('comments').then(person => {
                    expect(person.comments).to.have.length(1);
                    expect(person.comments[0]).to.be.an.instanceOf(Comment);
                    expect(person.comments[0].body).to.equal('Hello');
                });
            });

            it('hydrates the correct model for a [*]One relation', () => {
                return person.load('profile').then(person => {
                    expect(person.profile).to.be.an.instanceOf(Profile);
                    expect(person.profile.website).to.equal('URL');
                });
            });

            it('can load multiple relations at the same time', () => {
                return person.load('profile', 'comments').then(person => {
                    expect(person.profile).to.be.an.instanceOf(Profile);
                    expect(person.comments).to.have.length(1);
                    expect(person.comments[0]).to.be.an.instanceOf(Comment);
                });
            });
        });

        it('does not include relations in getAttributes / getDirty', () => {
            Person.relations.comments = 'Comment';
            person.name = '';
            person.comments = [{ body: 'first' }];

            expect(person.getDirty()).to.eql({ name: '' });
            expect(person.getAttributes()).not.to.have.key('comments');
        });
    });

});