UNPKG

@trust/model

Version:

A PouchDB adapter for the json-document modelling framework.

1,346 lines (1,127 loc) 47.6 kB
'use strict' /** * Dependencies * @ignore */ const cwd = process.cwd() const path = require('path') const chai = require('chai') const chaiAsPromised = require('chai-as-promised') const sinon = require('sinon') const sinonChai = require('sinon-chai') /** * Assertions * @ignore */ chai.use(chaiAsPromised) chai.should() chai.use(sinonChai) let expect = chai.expect /** * Code Under Test * @ignore */ const { DocumentModel } = require(path.join(cwd, 'src')) const ModelSchema = require(path.join(cwd, 'src', 'ModelSchema')) const PouchDB = require('pouchdb') const { JSONSchema, JSONDocument } = require('@trust/json-document') const { OperationError, InvalidConfigurationError, ValidationError, InternalError } = require(path.join(cwd, 'src', 'errors')) /** * Constants * @ignore */ let dbName = 'test/widgets' let remoteDbName = 'test/remote' /** * Tests * @ignore */ describe('DocumentModel', () => { /** * static member schema */ describe('static member schema', () => { it('should be an instance of JSONSchema', () => { DocumentModel.schema.should.be.instanceOf(JSONSchema) }) it('should equal ModelSchema', () => { DocumentModel.schema.should.deep.equal(ModelSchema) }) describe('extended class', () => { it('should equal ModelSchema if not overriden', () => { class Widgets extends DocumentModel {} Widgets.schema.should.deep.equal(ModelSchema) }) it('should not equal ModelSchema if overriden', () => { class Widgets extends DocumentModel { static get schema () { return new JSONSchema({}) } } Widgets.schema.should.not.deep.equal(ModelSchema) }) }) }) /** * static member database */ describe('static member database', () => { let klass const index = 'some_index' const query = { name: 'some_query', query: 'query body' } beforeEach(() => { class Widgets extends DocumentModel { static get indexes () { return [index] } static get queries () { return [query] } } klass = Widgets sinon.stub(klass, 'createIndex') sinon.stub(klass, 'createQuery') }) afterEach(() => { klass.createIndex.restore() klass.createQuery.restore() }) describe('setDatabase', () => { it('should configure the database with a string argument', () => { return klass.setDatabase(dbName).then(() => klass.database.should.be.instanceOf(PouchDB)) }) it('should configure the database with an object argument', () => { return klass.setDatabase({ name: dbName }).then(() => klass.database.should.be.instanceOf(PouchDB)) }) it('should close a previous database connection if a new one is created', () => { let stub = sinon.stub().usingPromise(Promise).resolves() klass.internalDatabase = { close: stub } return klass.setDatabase(dbName).then(() => stub.should.have.been.calledOnce) }) it('should throw InvalidConfigurationError with invalid (boolean) option', () => { return expect(() => klass.setDatabase(false).to.throw(InvalidConfigurationError, 'Model Widgets database options invalid')) }) it('should throw InvalidConfigurationError with invalid (number) option', () => { return expect(() => klass.setDatabase(2).to.throw(InvalidConfigurationError, 'Model Widgets database options invalid')) }) it('should throw InvalidConfigurationError with name unspecified', () => { return expect(() => klass.setDatabase({}).to.throw(InvalidConfigurationError, 'Model Widgets database options invalid')) }) it('should throw InvalidConfigurationError with invalid (boolean) name with object argument', () => { return expect(() => klass.setDatabase({ name: false }).to.throw(InvalidConfigurationError, 'Model Widgets database options invalid')) }) it('should throw InvalidConfigurationError with invalid (number) name with object argument', () => { return expect(() => klass.setDatabase({ name: 2 }).to.throw(InvalidConfigurationError, 'Model Widgets database options invalid')) }) it('should create database indices', () => { return klass.setDatabase(dbName).then(() => { klass.createIndex.should.have.been.calledOnce klass.createIndex.should.have.been.calledWith(index) }) }) it('should create database map-reduce queries', () => { return klass.setDatabase(dbName).then(() => { klass.createQuery.should.have.been.calledOnce klass.createQuery.should.have.been.calledWith(query.name, query.query) }) }) it('should throw if it cannot create the database', () => { expect(() => klass.setDatabase(123)).to.throw(InvalidConfigurationError, 'Model Widgets database options invalid') }) describe('database setup promise', () => { it('should reject if database index creation fails', () => { klass.createIndex.restore() sinon.stub(klass, 'createIndex').usingPromise(Promise).rejects() return klass.setDatabase(dbName).should.eventually.be.rejectedWith(InvalidConfigurationError, 'Model Widgets index configuration invalid') }) it('should reject if database query creation fails', () => { klass.createQuery.restore() sinon.stub(klass, 'createQuery').usingPromise(Promise).rejects() return klass.setDatabase(dbName).should.eventually.be.rejectedWith(InvalidConfigurationError, 'Model Widgets map-reduce query configuration invalid') }) }) }) describe('get', () => { it('should throw if database not configured', () => { expect(() => klass.database).to.throw(OperationError, 'Model Widgets has no database set') }) it('should not throw if database is configured', () => { return klass.setDatabase(dbName) expect(() => klass.database).to.not.throw() }) it('should return a reference to the database', () => { return klass.setDatabase(dbName) klass.database.name.should.equal(dbName) }) }) }) /** * sync */ describe('static member sync', () => { let klass let defaultReplicationOptions = { live: true, retry: true } let replicationOptions = { live: true, retry: true, foo: 'bar' } beforeEach(() => { class Widgets extends DocumentModel {} Widgets.setDatabase(dbName) klass = Widgets }) describe('setSync', () => { it('should create a replication emitter', () => { klass.sync.length.should.equal(0) klass.setSync(remoteDbName) klass.sync.length.should.equal(1) }) it('should return a replication object', () => { klass.setSync(remoteDbName).constructor.name.should.equal('Sync') }) it('should use default replication options if none are provided', () => { sinon.stub(klass.internalDatabase, 'sync').returns({ on: () => {}}) klass.setSync(remoteDbName) expect(klass.internalDatabase.sync.firstCall.args[1]).to.deep.equal(defaultReplicationOptions) }) it('should prefer provided replication options over the default', () => { sinon.stub(klass.internalDatabase, 'sync').returns({ on: () => {}}) klass.setSync(remoteDbName, replicationOptions) expect(klass.internalDatabase.sync.firstCall.args[1]).to.deep.equal(replicationOptions) }) it('should register an error handler', () => { let errorStub = sinon.stub() sinon.stub(klass.internalDatabase, 'sync').returns({ on: errorStub }) klass.setSync(remoteDbName) expect(klass.internalDatabase.sync.firstCall.args[1]).to.deep.equal(defaultReplicationOptions) errorStub.firstCall.args[0].should.equal('error') errorStub.firstCall.args[1].should.be.a('function') }) it('should throw with invalid (boolean) option', () => { expect(() => klass.setSync(false)).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (number) option', () => { expect(() => klass.setSync(2)).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with name unspecified with object option', () => { expect(() => klass.setSync({})).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (boolean) name with object option', () => { expect(() => klass.setSync({ name: false })).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (number) name with object option', () => { expect(() => klass.setSync({ name: 2 })).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InternalError if replication instance emits an error', () => { let error = 'fubar' klass.setSync(remoteDbName) expect(() => klass.sync[0].emit('error', new Error(error))).to.throw(InternalError, error) }) }) describe('replicateTo', () => { it('should create a replication emitter', () => { klass.sync.length.should.equal(0) klass.replicateTo(remoteDbName) klass.sync.length.should.equal(1) }) it('should return a replication object', () => { klass.replicateTo(remoteDbName).constructor.name.should.equal('Replication') }) it.skip('should use default replication options if none are provided', () => { sinon.stub(klass.internalDatabase.replicate, 'to').returns({ on: () => {}}) klass.replicateTo(remoteDbName) expect(klass.internalDatabase.replicate.to.firstCall.args[1]).to.deep.equal(defaultReplicationOptions) }) it.skip('should prefer provided replication options over the default', () => { sinon.stub(klass.internalDatabase.replicate, 'to').returns({ on: () => {}}) klass.replicateTo(remoteDbName, replicationOptions) expect(klass.internalDatabase.replicate.to.firstCall.args[1]).to.deep.equal(replicationOptions) }) it.skip('should register an error handler', () => { let errorStub = sinon.stub() sinon.stub(klass.internalDatabase.replicate, 'to').returns({ on: errorStub }) klass.replicateTo(remoteDbName) expect(klass.internalDatabase.replicate.to.firstCall.args[1]).to.deep.equal(defaultReplicationOptions) errorStub.firstCall.args[0].should.equal('error') errorStub.firstCall.args[1].toString().should.equal((error => { throw new InternalError(error.message, error.stack) }).toString()) }) it('should throw InvalidConfigurationError with invalid (boolean) option', () => { expect(() => klass.replicateTo(false)).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (number) option', () => { expect(() => klass.replicateTo(2)).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with name unspecified with object option', () => { expect(() => klass.replicateTo({})).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (boolean) name with object option', () => { expect(() => klass.replicateTo({ name: false })).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (number) name with object option', () => { expect(() => klass.replicateTo({ name: 2 })).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InternalError if replication instance emits an error', () => { let error = 'fubar' klass.replicateTo(remoteDbName) expect(() => klass.sync[0].emit('error', new Error(error))).to.throw(InternalError, error) }) }) describe('replicateFrom', () => { it('should create a replication emitter', () => { klass.sync.length.should.equal(0) klass.replicateFrom(remoteDbName) klass.sync.length.should.equal(1) }) it('should return a replication object', () => { klass.replicateFrom(remoteDbName).constructor.name.should.equal('Replication') }) it.skip('should use default replication options if none are provided', () => { sinon.stub(klass.internalDatabase.replicate, 'from').returns({ on: () => {}}) klass.replicateFrom(remoteDbName) expect(klass.internalDatabase.replicate.from.firstCall.args[1]).to.deep.equal(defaultReplicationOptions) }) it.skip('should prefer provided replication options over the default', () => { sinon.stub(klass.internalDatabase.replicate, 'from').returns({ on: () => {}}) klass.replicateTo(remoteDbName, replicationOptions) expect(klass.internalDatabase.replicate.from.firstCall.args[1]).to.deep.equal(replicationOptions) }) it.skip('should register an error handler', () => { let errorStub = sinon.stub() sinon.stub(klass.internalDatabase.replicate, 'from').returns({ on: errorStub }) klass.replicateFrom(remoteDbName) expect(klass.internalDatabase.replicate.from.firstCall.args[1]).to.deep.equal(defaultReplicationOptions) errorStub.firstCall.args[0].should.equal('error') errorStub.firstCall.args[1].toString().should.equal((error => { throw new InternalError(error.message, error.stack) }).toString()) }) it('should throw InvalidConfigurationError with invalid (boolean) option', () => { expect(() => klass.replicateFrom(false)).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (number) option', () => { expect(() => klass.replicateFrom(2)).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with name unspecified with object option', () => { expect(() => klass.replicateFrom({})).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (boolean) name with object option', () => { expect(() => klass.replicateFrom({ name: false })).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InvalidConfigurationError with invalid (number) name with object option', () => { expect(() => klass.replicateFrom({ name: 2 })).to.throw(InvalidConfigurationError, 'Model Widgets remote database options invalid') }) it('should throw InternalError if replication instance emits an error', () => { let error = 'fubar' klass.replicateFrom(remoteDbName) expect(() => klass.sync[0].emit('error', new Error(error))).to.throw(InternalError, error) }) }) describe('get', () => { it('should set sync if it doesn\'t exist', () => { expect(klass.internalSync).to.be.undefined expect(Array.isArray(klass.sync)).to.be.true expect(Array.isArray(klass.internalSync)).to.be.true }) }) }) /** * changes */ describe('static member changes', () => { let klass beforeEach(() => { class Widgets extends DocumentModel {} Widgets.setDatabase(dbName) klass = Widgets }) describe('setChanges', () => { it('should set the change feed', () => { klass.changes.length.should.equal(0) klass.setChanges({ live: true, include_docs: true, since: 'now' }) }) it('should add an instance of a PouchDB Change Feed to the changes array', () => { klass.changes.length.should.equal(0) klass.setChanges({ live: true, include_docs: true, since: 'now' }) expect(klass.changes[0].constructor.name.startsWith('Changes')).to.be.true }) it('should use default change feed config if none supplied', () => { expect(() => klass.setChanges()).to.not.throw() }) it('should return the new change feed', () => { klass.setChanges({ live: true, include_docs: true, since: 'now' }) klass.changes[0].should.have.nested.property('constructor.name') expect(klass.changes[0].constructor.name.startsWith('Changes')).to.be.true }) }) describe('get', () => { it('should be an empty array before a change feed is created', () => { expect(Array.isArray(klass.changes)).to.be.true klass.changes.length.should.equal(0) }) it('should not be an empty array after a change feed is created', () => { klass.setChanges({ live: true, include_docs: true, since: 'now' }) klass.changes.length.should.equal(1) }) }) }) /** * indexes */ describe('static member indexes', () => { it('should be an array', () => { expect(Array.isArray(DocumentModel.indexes)).to.be.true }) it('should be empty', () => { DocumentModel.indexes.length.should.equal(0) }) describe('extended class', () => { it('should deep equal model indexes if not overriden', () => { class Widgets extends DocumentModel {} Widgets.indexes.should.deep.equal(DocumentModel.indexes) }) }) }) /** * queries */ describe('static member queries', () => { it('should be an array', () => { expect(Array.isArray(DocumentModel.queries)).to.be.true }) it('should be empty', () => { DocumentModel.queries.length.should.equal(0) }) describe('extended class', () => { it('should deep equal model queries if not overriden', () => { class Widgets extends DocumentModel {} Widgets.queries.should.deep.equal(DocumentModel.queries) }) }) }) /** * static query */ describe('static query', () => { let klass let docs = [{ _id: 'foo' }, { _id: 'bar' }] let error_message = 'fubar' before(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should proxy the call to the database', () => { klass.internalDatabase = { query: sinon.stub().usingPromise(Promise).resolves({ docs }) } return klass.query().then(results => { results[0].should.be.instanceOf(klass) results[1].should.be.instanceOf(klass) klass.database.query.should.have.been.calledOnce }) }) it('should reject with InternalError if the query fails', () => { klass.internalDatabase = { query: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return klass.query().should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * static find */ describe('static find', () => { let klass let options = {} let docs = [{ _id: 'foo' }, { _id: 'bar' }] let error_message = 'fubar' before(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should attach the selector to the options', () => { klass.internalDatabase = { find: sinon.stub().withArgs(options).usingPromise(Promise).resolves({ docs }) } klass.find(options).then(() => { options.selector.should.not.be.undefined }) }) it('should proxy the call to the database', () => { klass.internalDatabase = { find: sinon.stub().usingPromise(Promise).resolves({ docs }) } return klass.find().then(results => { results[0].should.deep.equal(docs[0]) results[1].should.deep.equal(docs[1]) klass.database.find.should.have.been.calledOnce }) }) it('should resolve an array of instances of the extended model', () => { klass.internalDatabase = { find: sinon.stub().usingPromise(Promise).resolves({ docs }) } return klass.find().then(results => { results[0].should.be.instanceOf(klass) results[1].should.be.instanceOf(klass) klass.database.find.should.have.been.calledOnce }) }) it('should reject with InternalError if the find fails', () => { klass.internalDatabase = { find: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return klass.find().should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * static get */ describe('static get', () => { let klass let get_doc = { _id: 'foo' } let error_message = 'fubar' before(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should proxy the call to the database', () => { klass.internalDatabase = { get: sinon.stub().usingPromise(Promise).resolves(get_doc) } return klass.get().then(doc => { doc.should.deep.equal(get_doc) klass.database.get.should.have.been.calledOnce }) }) it('should resolve an instance of the extended model', () => { klass.internalDatabase = { get: sinon.stub().usingPromise(Promise).resolves(get_doc) } return klass.get().then(doc => { doc.should.be.instanceOf(klass) klass.database.get.should.have.been.calledOnce }) }) it('should return null if the get rejects with a 404', () => { let err = new Error(error_message) err.status = 404 klass.internalDatabase = { get: sinon.stub().usingPromise(Promise).rejects(err) } return klass.get().should.eventually.equal(null) }) it('should reject with InternalError if get rejects with any other status', () => { klass.internalDatabase = { get: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return klass.get().should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * static post */ describe('static post', () => { let klass let post_doc_with_rev = { _id: 'foo', _rev: '3-z' } let post_result_with_rev = { id: 'foo', rev: '3-z' } let error_message = 'fubar' beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should validate the data before storing it', () => { sinon.stub(klass.prototype, 'validate').returns({ valid: true }) klass.internalDatabase = { post: sinon.stub().usingPromise(Promise).resolves(post_result_with_rev) } return klass.post().then(doc => { klass.prototype.validate.should.have.been.calledOnce klass.database.post.should.have.been.calledOnce klass.prototype.validate.restore() }) }) it('should throw if data is invalid', () => { sinon.stub(klass.prototype, 'validate').returns({ valid: false }) return klass.setDatabase(dbName) return klass.post().should.eventually.rejectedWith(ValidationError, 'Invalid document') }) it('should proxy the call to the database', () => { klass.internalDatabase = { post: sinon.stub().usingPromise(Promise).resolves(post_result_with_rev) } return klass.post().then(doc => { doc.should.deep.equal(post_doc_with_rev) klass.database.post.should.have.been.calledOnce }) }) it('should resolve an instance of the extended model', () => { klass.internalDatabase = { post: sinon.stub().usingPromise(Promise).resolves(post_result_with_rev) } return klass.post().then(doc => { doc.should.be.instanceOf(klass) klass.database.post.should.have.been.calledOnce }) }) it('should reject with InternalError if post rejects with any other status', () => { klass.internalDatabase = { post: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return klass.post().should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * static put */ describe('static put', () => { let klass let put_doc = { _id: 'foo' } let put_result_with_rev = { _id: 'foo', _rev: '3-z' } let error_message = 'fubar' beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets return klass.setDatabase(dbName) }) it('should proxy member put', () => { sinon.stub(klass.prototype, 'put').usingPromise(Promise).resolves(put_result_with_rev) return klass.put(put_doc).then(result => { klass.prototype.put.should.have.been.called }) klass.prototype.put.restore() }) }) /** * static delete */ describe('static delete', () => { let klass let _id = 'foo' let delete_doc = { _id } let delete_doc_with_rev = { _id, _rev: '3-z' } let delete_result = { ok: true } let error_message = 'fubar' beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets return klass.setDatabase(dbName) }) it('should proxy member delete', () => { sinon.stub(klass.prototype, 'delete').usingPromise(Promise).resolves(delete_result) return klass.delete(delete_doc_with_rev).then(result => { klass.prototype.delete.should.have.been.called result.should.deep.equal(delete_result) klass.prototype.delete.restore() }) }) it('should call static get first if string arg', () => { let deleteStub = sinon.stub().returns(delete_result) let instance_with_delete_stub = Object.assign({}, delete_doc_with_rev) instance_with_delete_stub.delete = deleteStub sinon.stub(klass, 'get').usingPromise(Promise).resolves(instance_with_delete_stub) return klass.delete(_id).then(result => { klass.get.should.have.been.calledWith(_id) result.should.deep.equal(delete_result) klass.get.restore() }) }) it('should call static get first if object arg has no _rev', () => { let deleteStub = sinon.stub().returns(delete_result) let instance_with_delete_stub = Object.assign({}, delete_doc_with_rev) instance_with_delete_stub.delete = deleteStub sinon.stub(klass, 'get').usingPromise(Promise).resolves(instance_with_delete_stub) return klass.delete(delete_doc).then(result => { klass.get.should.have.been.calledWith(_id) result.should.deep.equal(delete_result) klass.get.restore() }) }) it('should proxy member delete after calling static get', () => { let deleteStub = sinon.stub().returns(delete_result) let instance_with_delete_stub = Object.assign({}, delete_doc_with_rev) instance_with_delete_stub.delete = deleteStub sinon.stub(klass, 'get').usingPromise(Promise).resolves(instance_with_delete_stub) return klass.delete(_id).then(result => { klass.get.should.have.been.calledWith(_id) deleteStub.should.have.been.called result.should.deep.equal(delete_result) klass.get.restore() }) }) }) /** * static createIndex */ describe('static createIndex', () => { let klass let index = 'index' beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets klass.internalDatabase = { createIndex: sinon.stub().usingPromise(Promise).resolves() } }) it('should proxy database createIndex', () => { return klass.createIndex(index).then(result => { klass.database.createIndex.should.have.been.calledWith(index) }) }) }) /** * static getIndexes */ describe('static getIndexes', () => { let klass let indexes = ['foo', 'bar'] let indexes_result = { indexes } beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets klass.internalDatabase = { getIndexes: sinon.stub().usingPromise(Promise).resolves(indexes_result) } }) it('should proxy database getIndexes', () => { return klass.getIndexes().then(result => { klass.database.getIndexes.should.have.been.called }) }) it('should map results to the index array', () => { return klass.getIndexes().then(result => { result.should.deep.equal(indexes) }) }) }) /** * static createQuery */ describe('static createQuery', () => { let klass let query_name = 'query' let db_query_name = '_design/query' let query_map = () => {} let query_reduce = () => {} let db_query_map = { _id: db_query_name, views: { [query_name]: { map: query_map } } } let db_query_both = { _id: db_query_name, views: { [query_name]: { map: query_map, reduce: query_reduce } } } let result_created = { _rev: '1-x' } let result_updated = { _rev: '2-z' } beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets klass.internalDatabase = { } }) it('should reject if at least one function is not provided', () => { return klass.createQuery(query_name).should.eventually.be.rejectedWith(InvalidConfigurationError, 'createQuery requires at least a map function') }) it('should reject if fn argument is a boolean', () => { return klass.createQuery(query_name, false).should.eventually.be.rejectedWith(InvalidConfigurationError, 'createQuery requires at least a map function') }) it('should reject if fn argument is a number', () => { return klass.createQuery(query_name, 2).should.eventually.be.rejectedWith(InvalidConfigurationError, 'createQuery requires at least a map function') }) it('should call static put with normalized input (map)', () => { sinon.stub(klass, 'put').usingPromise(Promise).resolves(result_created) let stub = klass.put return klass.createQuery(query_name, query_map).then(result => { stub.lastCall.args[0].should.deep.equal(db_query_map) result.should.deep.equal({ result: 'created' }) stub.restore() }) }) it('should call static put with normalized input (map+reduce)', () => { sinon.stub(klass, 'put').usingPromise(Promise).resolves(result_created) let stub = klass.put return klass.createQuery(query_name, { map: query_map, reduce: query_reduce }).then(result => { stub.lastCall.args[0].should.deep.equal(db_query_both) result.should.deep.equal({ result: 'created' }) stub.restore() }) }) it('should return "created" if query is a new document', () => { sinon.stub(klass, 'put').usingPromise(Promise).resolves(result_created) return klass.createQuery(query_name, { map: query_map, reduce: query_reduce }).then(result => { result.should.deep.equal({ result: 'created' }) klass.put.restore() }) }) it('should return "updated" if query is an existing document', () => { sinon.stub(klass, 'put').usingPromise(Promise).resolves(result_updated) return klass.createQuery(query_name, { map: query_map, reduce: query_reduce }).then(result => { result.should.deep.equal({ result: 'updated' }) klass.put.restore() }) }) }) /** * static close */ describe('static close', () => { let klass let dbResolve = { foo: 'bar' } let changesResolve = { foo: 'baz' } let sync1Resolve = { foo: 'qux' } let sync2Resolve = { foo: 'bar' } let resolveData = [dbResolve, [changesResolve], [sync1Resolve, sync2Resolve]] beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets klass.internalDatabase = { close: sinon.stub().usingPromise(Promise).resolves(dbResolve) } klass.internalSync = [{ cancel: sinon.stub().usingPromise(Promise).resolves(sync1Resolve) }, { cancel: sinon.stub().usingPromise(Promise).resolves(sync2Resolve) }] klass.internalChanges = [{ cancel: sinon.stub().usingPromise(Promise).resolves(changesResolve) }] }) it('should cancel change feed', () => { return klass.close().then(() => { klass.changes[0].cancel.should.have.been.called }) }) it('should cancel all sync instances', () => { return klass.close().then(() => { klass.sync[0].cancel.should.have.been.called klass.sync[1].cancel.should.have.been.called }) }) it('should close the database connection', () => { return klass.close().then(() => { klass.database.close.should.have.been.called }) }) it('should return all promises', () => { return klass.close().then(results => { results.should.deep.equal(resolveData) }) }) it('should reject if cancel or close rejects', () => { let error_message = 'fubar' let error = new Error(error_message) klass.internalDatabase = { close: sinon.stub().usingPromise(Promise).rejects(error) } return klass.close().should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * member put */ describe('member put', () => { let klass let put_doc = { _id: 'foo', _rev: '3-z' } let put_result = { id: 'foo', rev: '3-z' } let error_message = 'fubar' beforeEach(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should validate the data before storing it', () => { sinon.stub(klass.prototype, 'validate').returns({ valid: true }) klass.internalDatabase = { put: sinon.stub().usingPromise(Promise).resolves(put_result) } return new klass(put_doc).put().then(doc => { klass.prototype.validate.should.have.been.calledOnce klass.database.put.should.have.been.calledOnce klass.prototype.validate.restore() }) }) it('should throw if data is invalid', () => { sinon.stub(klass.prototype, 'validate').returns({ valid: false }) return klass.setDatabase(dbName).then(() => { return klass.post().should.eventually.rejectedWith(ValidationError, 'Invalid document') }) }) it('should proxy the call to the database', () => { klass.internalDatabase = { put: sinon.stub().usingPromise(Promise).resolves(put_result) } return new klass(put_doc).put().then(doc => { doc._rev.should.equal(put_doc._rev) klass.database.put.should.have.been.calledOnce }) }) it('should resolve an instance of the extended model', () => { klass.internalDatabase = { put: sinon.stub().usingPromise(Promise).resolves(put_result) } return new klass(put_doc).put().then(doc => { doc.should.be.instanceOf(klass) klass.database.put.should.have.been.calledOnce }) }) it('should get and retry if the put rejects with a 409', () => { let err = new Error(error_message) err.status = 409 klass.internalDatabase = { put: sinon.stub().usingPromise(Promise).onFirstCall().rejects(err) .onSecondCall().resolves(put_result), get: sinon.stub().usingPromise(Promise).resolves(put_doc) } let data = {} return new klass(put_doc).put(data).then(() => { klass.database.put.should.have.been.calledTwice klass.database.get.should.have.been.calledOnce }) }) it('should reject with InternalError if put rejects with any other status', () => { klass.internalDatabase = { put: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return new klass(put_doc).put().should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * member delete */ describe('member delete', () => { let klass let delete_doc = { _id: 'foo', _rev: '3-z' } let delete_result = { ok: true } let error_message = 'fubar' before(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should proxy the call to the database', () => { klass.internalDatabase = { remove: sinon.stub().usingPromise(Promise).resolves(delete_result) } return new klass(delete_doc).delete().then(() => { klass.database.remove.should.have.been.calledOnce }).should.eventually.be.fulfilled }) it('should resolve a boolean', () => { klass.internalDatabase = { remove: sinon.stub().usingPromise(Promise).resolves(delete_result) } return new klass(delete_doc).delete().then(result => { klass.database.remove.should.have.been.calledOnce result.should.be.true }) }) it('should get and retry if the delete rejects with a 409', () => { let err = new Error(error_message) err.status = 409 klass.internalDatabase = { remove: sinon.stub().usingPromise(Promise).onFirstCall().rejects(err) .onSecondCall().resolves(delete_result), get: sinon.stub().usingPromise(Promise).resolves(delete_doc) } return new klass(delete_doc).delete().then(result => { klass.database.remove.should.have.been.calledTwice klass.database.get.should.have.been.calledOnce result.should.be.true }) }) it('should reject with InternalError if delete rejects with any other status', () => { klass.internalDatabase = { remove: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return new klass(delete_doc).delete().should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * member getAttachment */ describe('member getAttachment', () => { let klass let get_doc = { _id: 'foo' } let attachment_name = 'att.txt' let attachment = 'some data' let error_message = 'fubar' before(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should create an attachment object if non-exists', () => { klass.internalDatabase = { getAttachment: sinon.stub().usingPromise(Promise).resolves(attachment) } let instance = new klass(get_doc) return new klass(get_doc).getAttachment(attachment_name).then(doc => { doc._attachments.should.deep.equal({ [attachment_name]: attachment }) }) }) it('should not override an existing attachment object', () => { klass.internalDatabase = { getAttachment: sinon.stub().usingPromise(Promise).resolves(attachment) } let _attachments = { foo: 'bar' } return new klass({ _id: 'foo', _attachments }).getAttachment(attachment_name).then(doc => { doc._attachments.foo.should.equal('bar') }) }) it('should proxy the call to the database', () => { klass.internalDatabase = { getAttachment: sinon.stub().usingPromise(Promise).resolves(attachment) } return new klass(get_doc).getAttachment(attachment_name).then(doc => { doc._attachments[attachment_name].should.equal(attachment) klass.database.getAttachment.should.have.been.calledOnce }) }) it('should resolve an instance of the extended model', () => { klass.internalDatabase = { getAttachment: sinon.stub().usingPromise(Promise).resolves(attachment) } return new klass(get_doc).getAttachment(attachment_name).then(doc => { doc.should.be.instanceOf(klass) klass.database.getAttachment.should.have.been.calledOnce }) }) it('should get and retry if the get rejects with a 404', () => { let err = new Error(error_message) err.status = 404 klass.internalDatabase = { getAttachment: sinon.stub().usingPromise(Promise).rejects(err) } return new klass(get_doc).getAttachment(attachment_name).then(doc => { expect(doc._attachments[attachment_name]).to.be.null }) }) it('should not override an existing attachment object if retry occurs', () => { let err = new Error(error_message) err.status = 404 klass.internalDatabase = { getAttachment: sinon.stub().usingPromise(Promise).rejects(err) } let input = { _id: 'foo', _attachments: { foo: 'bar' } } return new klass(input).getAttachment(attachment_name).then(doc => { expect(doc._attachments[attachment_name]).to.be.null doc._attachments.foo.should.equal('bar') }) }) it('should reject with InternalError if getAttachment rejects with any other status', () => { klass.internalDatabase = { getAttachment: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return new klass(get_doc).getAttachment(attachment_name).should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * member putAttachment */ describe('member putAttachment', () => { let klass let data = Buffer.from('some data') let doc = { _id: 'foo', _rev: '3-z' } let attachment_name = 'att.txt' let attachment = { content_type: 'text/plain', data } let put_result = { rev: '4-x' } let error_message = 'fubar' before(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should resolve an instance of the extended model', () => { klass.internalDatabase = { putAttachment: sinon.stub().usingPromise(Promise).resolves(put_result) } return new klass(doc).putAttachment(attachment_name, attachment).then(result_doc => { result_doc.should.be.instanceOf(klass) klass.database.putAttachment.should.have.been .calledWith(doc._id, attachment_name, doc._rev, attachment.data, attachment.content_type) }) }) it('should proxy the call to the database', () => { klass.internalDatabase = { putAttachment: sinon.stub().usingPromise(Promise).resolves(put_result) } return new klass(doc).putAttachment(attachment_name, attachment).then(result_doc => { result_doc._attachments[attachment_name].should.deep.equal({ content_type: attachment.content_type, data: data.toString('base64') }) klass.database.putAttachment.should.have.been .calledWith(doc._id, attachment_name, doc._rev, attachment.data, attachment.content_type) }) }) it('should get and retry if the putAttachment rejects with a 409', () => { let err = new Error(error_message) err.status = 409 klass.internalDatabase = { putAttachment: sinon.stub().usingPromise(Promise).onFirstCall().rejects(err) .onSecondCall().resolves(put_result), get: sinon.stub().usingPromise(Promise).resolves(doc) } return new klass(doc).putAttachment(attachment_name, attachment).then(() => { klass.database.putAttachment.should.have.been.calledTwice klass.database.get.should.have.been.calledOnce }) }) it('should not override an existing attachment object if retry occurs', () => { let err = new Error(error_message) err.status = 409 klass.internalDatabase = { putAttachment: sinon.stub().usingPromise(Promise).onFirstCall().rejects(err) .onSecondCall().resolves(put_result), get: sinon.stub().usingPromise(Promise).resolves(doc) } let input = Object.assign({}, doc, { _attachments: { foo: 'bar' } }) return new klass(input).putAttachment(attachment_name, attachment).then(result => { klass.database.putAttachment.should.have.been.calledTwice klass.database.get.should.have.been.calledOnce result._attachments.foo.should.equal('bar') }) }) it('should reject with InternalError if putAttachment rejects with any other status', () => { klass.internalDatabase = { putAttachment: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return new klass(doc).putAttachment(attachment_name, attachment).should.eventually.be.rejectedWith(InternalError, error_message) }) }) /** * member deleteAttachment */ describe('member deleteAttachment', () => { let klass let data = Buffer.from('some data') let attachment_name = 'att.txt' let attachment = { content_type: 'text/plain', data } let doc = { _id: 'foo', _rev: '3-z', _attachments: { [attachment_name]: attachment } } let delete_result = { ok: true, rev: '4-x' } let error_message = 'fubar' before(() => { class Widgets extends DocumentModel {} klass = Widgets }) it('should resolve an instance of the extended model', () => { klass.internalDatabase = { removeAttachment: sinon.stub().usingPromise(Promise).resolves(delete_result) } return new klass(doc).deleteAttachment(attachment_name).then(result_doc => { result_doc.should.be.instanceOf(klass) klass.database.removeAttachment.should.have.been .calledWith(doc._id, attachment_name, doc._rev) }) }) it('should proxy the call to the database', () => { klass.internalDatabase = { removeAttachment: sinon.stub().usingPromise(Promise).resolves(delete_result) } return new klass(doc).deleteAttachment(attachment_name).then(result_doc => { expect(result_doc._attachments[attachment_name]).to.be.undefined klass.database.removeAttachment.should.have.been .calledWith(doc._id, attachment_name, doc._rev) }) }) it('should get and retry if the deleteAttachment rejects with a 409', () => { let err = new Error(error_message) err.status = 409 klass.internalDatabase = { removeAttachment: sinon.stub().usingPromise(Promise).onFirstCall().rejects(err) .onSecondCall().resolves(delete_result), get: sinon.stub().usingPromise(Promise).resolves(doc) } return new klass(doc).deleteAttachment(attachment_name).then(() => { klass.database.removeAttachment.should.have.been.calledTwice klass.database.get.should.have.been.calledOnce }) }) it('should not override an existing attachment object if retry occurs', () => { let err = new Error(error_message) err.status = 409 klass.internalDatabase = { removeAttachment: sinon.stub().usingPromise(Promise).onFirstCall().rejects(err) .onSecondCall().resolves(delete_result), get: sinon.stub().usingPromise(Promise).resolves(doc) } let input = Object.assign({}, doc, { _attachments: { foo: 'bar' } }) return new klass(input).deleteAttachment(attachment_name).then(result => { klass.database.removeAttachment.should.have.been.calledTwice klass.database.get.should.have.been.calledOnce result._attachments.foo.should.equal('bar') }) }) it('should reject with InternalError if deleteAttachment rejects with any other status', () => { klass.internalDatabase = { removeAttachment: sinon.stub().usingPromise(Promise).rejects(new Error(error_message)) } return new klass(doc).deleteAttachment(attachment_name).should.eventually.be.rejectedWith(InternalError, error_message) }) }) })