diff --git a/README.md b/README.md index 7805795..b5792fe 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,22 @@ [![Circle CI](https://circleci.com/gh/redgeoff/slouch.svg?style=svg&circle-token=ae7548ebc7e23a051ed03dbc3209c5e0529e260a)](https://circleci.com/gh/redgeoff/slouch) -## Install - - $ npm install couch-slouch +An API for CouchDB that does the heavy lifting ## Slouch is a good alternative to nano as: - - Don't have to create an instance for each DB - - Native promises - - Support for iterators - - More CouchDB support, e.g. admin functions + - You don't have to create an instance for each DB + - Supports native promises + - Supports iterators - Automatically throttles connections to DB to avoid max_dbs_open errors - Automatically persists connections with exponential backoff in case DB restarts or connection is dropped - - Also works in the browser - - Upserts, "get and put", support for optionally ignoring conflicts, missing docs, etc... + - Works in node and in the browser + - Provides upserts and "get and update" functions + - Support for optionally ignoring conflicts, missing docs, etc... + - Designed for both CouchDB 1 and CouchDB 2 + + +## Install + + $ npm install couch-slouch diff --git a/TESTING.md b/TESTING.md index eb0ad0e..0e6b4aa 100644 --- a/TESTING.md +++ b/TESTING.md @@ -7,7 +7,18 @@ Install CouchDB locally. You can easily run CouchDB via docker with: $ ./run-couchdb-docker.sh -Note: if you are not running ubuntu, you will probably have to configure the `common` variable +Notes: +- If you are not running ubuntu, you will probably have to configure the `common` variable +- If you are running the tests against a CouchDB instance on another box then you will need to enable CORs, e.g. ./enable-cors.sh and you will also need to change the `host` entry in test/spec/config.json + + +## Resetting the DB + +If your DB accumulates a lot of junk data and you want to clear it, you can do so with: + + $ npm run reset-db + +Warning: this will delete all your databases! ## Test in node diff --git a/package.json b/package.json index ee8aea7..aa1c593 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "couch-slouch", "version": "0.0.1", - "description": "Interface with CouchDB without the heavy lifting", + "description": "An API for CouchDB that does the heavy lifting", "main": "index.js", "repository": { "type": "git", @@ -21,7 +21,7 @@ "beautify": "beautify-proj -i test -o . -c beautify.json && beautify-proj -i scripts -o . -c beautify.json", "jshint": "jshint -c .jshintrc test scripts", "node-test": "istanbul test --dir cache/coverage/node node_modules/mocha/bin/_mocha test/node.js", - "node-full-test": "npm run node-test --coverage && istanbul check-coverage --lines 0 --function 0 --statements 0 --branches 0", + "node-full-test": "npm run node-test --coverage && istanbul check-coverage --lines 100 --function 100 --statements 100 --branches 100", "browser-server": "node_modules/gofur/scripts/browser/serve.js -c cache -t test/browser.js", "browser-test": "node_modules/gofur/scripts/browser/test.js -c cache -t test/browser.js", "browser-test-firefox": "node_modules/gofur/scripts/browser/test.js -c cache -t test/browser.js -b selenium:firefox", @@ -30,11 +30,12 @@ "browser-coverage-server": "node_modules/gofur/scripts/browser-coverage/serve.js -c cache -t test/browser.js", "browser-coverage-test": "node_modules/gofur/scripts/browser-coverage/test.js -c cache -t test/browser.js", "browser-coverage-report": "istanbul report --dir cache/coverage/browser --root cache/coverage/browser lcov", - "browser-coverage-check": "istanbul check-coverage --lines 0 --function 0 --statements 0 --branches 0 cache/coverage/browser/coverage.json", + "browser-coverage-check": "istanbul check-coverage --lines 100 --function 100 --statements 100 --branches 100 cache/coverage/browser/coverage.json", "browser-coverage-full-test": "npm run browser-coverage-test && npm run browser-coverage-report && npm run browser-coverage-check", "test": "npm run assert-beautified && npm run jshint && npm run node-full-test && npm run browser-coverage-full-test", "TODO-build": "", - "TODO-test-in-sauce": "" + "TODO-test-in-sauce": "", + "reset-db": "./reset-db.js" }, "dependencies": { "backoff-promise": "0.0.2", @@ -53,6 +54,7 @@ "gofur": "^0.0.7", "istanbul": "^0.4.5", "jshint": "^2.9.4", + "memorystream": "^0.3.1", "mocha": "^3.4.2" }, "greenkeeper": { diff --git a/reset-db.js b/reset-db.js new file mode 100755 index 0000000..694afc8 --- /dev/null +++ b/reset-db.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +'use strict'; + +var Slouch = require('./scripts'), + utils = require('./test/utils'), + slouch = new Slouch(utils.couchDBURL()); + +slouch.system.reset(); diff --git a/scripts/attachment.js b/scripts/attachment.js new file mode 100644 index 0000000..a1fb9fe --- /dev/null +++ b/scripts/attachment.js @@ -0,0 +1,54 @@ +'use strict'; + +var promisedRequest = require('./request'); + +var Attachment = function (slouch) { + this._slouch = slouch; +}; + +// TODO +// Attachment.prototype.create = function (dbName, docId, attachmentName, data, contentType, rev) { +// var formData = { +// custom_file: { +// value: data, +// options: { +// filename: attachmentName, +// contentType: contentType +// } +// } +// }; +// +// return promisedRequest.request({ +// uri: this._slouch._url + '/' + dbName + '/' + docId + '/' + attachmentName + +// '?rev=' + encodeURIComponent(rev), +// method: 'PUT', +// // raw: true, +// // encoding: null, +// formData: formData +// }).then(function (response) { +// return response.body; +// }); +// }; + +Attachment.prototype.get = function (dbName, docId, attachmentName) { + return promisedRequest.request({ + uri: this._slouch._url + '/' + dbName + '/' + docId + '/' + attachmentName, + method: 'GET', + raw: true, + encoding: null + }).then(function (response) { + return response.body; + }); +}; + +Attachment.prototype.destroy = function (dbName, docId, attachmentName, rev) { + return promisedRequest.request({ + uri: this._slouch._url + '/' + dbName + '/' + docId + '/' + attachmentName, + method: 'DELETE', + qs: { + rev: rev + } + }); +}; + +module.exports = Attachment; diff --git a/scripts/auth.js b/scripts/auth.js deleted file mode 100644 index e139f8a..0000000 --- a/scripts/auth.js +++ /dev/null @@ -1,24 +0,0 @@ -'use strict'; - -var Auth = function (slouch) { - this._slouch = slouch; -}; - -Auth.prototype.onlyRoleCanView = function (dbName, role) { - return this._slouch.security.set(dbName, { - admins: { - names: ['_admin'], - roles: [] - }, - members: { - names: [], - roles: [role] - } - }); -}; - -Auth.prototype.onlyAdminCanView = function (dbName) { - return this.onlyRoleCanView(dbName, '_admin'); -}; - -module.exports = Auth; diff --git a/scripts/config.js b/scripts/config.js index 5b0b0f2..558af13 100644 --- a/scripts/config.js +++ b/scripts/config.js @@ -5,11 +5,12 @@ var request = require('./request'), var Config = function (slouch) { this._slouch = slouch; + this._req = request; }; Config.prototype._couchDB2Request = function (node, path, opts, parseBody) { - opts.uri = this._url + '/_node/' + node + '/_config/' + path; - return request.request(opts, parseBody); + opts.uri = this._slouch._url + '/_node/' + node + '/_config/' + path; + return this._req.request(opts, parseBody); }; // Warning: as per https://github.com/klaemo/docker-couchdb/issues/42#issuecomment-169610897, this @@ -34,13 +35,13 @@ Config.prototype._couchDB2Requests = function (path, opts, parseBody, maxNumNode }; Config.prototype._couchDB1Request = function (path, opts, parseBody) { - opts.uri = this._url + '/_config/' + path; - return request.request(opts, parseBody); + opts.uri = this._slouch._url + '/_config/' + path; + return this._req.request(opts, parseBody); }; Config.prototype._request = function (path, opts, parseBody, maxNumNodes) { var self = this; - return self._slouch._system.isCouchDB1().then(function (isCouchDB1) { + return self._slouch.system.isCouchDB1().then(function (isCouchDB1) { if (isCouchDB1) { return self._couchDB1Request(path, opts, parseBody); } else { @@ -49,10 +50,16 @@ Config.prototype._request = function (path, opts, parseBody, maxNumNodes) { }); }; +Config.prototype.get = function (path) { + return this._request(path, { + method: 'GET' + }, true); +}; + Config.prototype.set = function (path, value) { return this._request(path, { method: 'PUT', - body: JSON.stringify(value) + body: JSON.stringify(this._toString(value)) }); }; @@ -74,6 +81,16 @@ Config.prototype.setCouchHttpdAuthTimeout = function (timeoutSecs) { return this.set('couch_httpd_auth/timeout', timeoutSecs + ''); }; +Config.prototype._toString = function (value) { + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } else if (typeof value === 'string') { + return value; + } else { + return value + ''; // convert to string + } +}; + Config.prototype.setCouchHttpdAuthAllowPersistentCookies = function (allow) { return this.set('couch_httpd_auth/allow_persistent_cookies', allow); }; diff --git a/scripts/db.js b/scripts/db.js index bf36ba9..4db21ab 100644 --- a/scripts/db.js +++ b/scripts/db.js @@ -1,13 +1,12 @@ 'use strict'; var promisedRequest = require('./request'), - FilteredStreamIterator = require('quelle').FilteredStreamIterator, PersistentStreamIterator = require('quelle').PersistentStreamIterator, - StreamIterator = require('quelle').StreamIterator, - sporks = require('sporks'); + request = require('request'); var DB = function (slouch) { this._slouch = slouch; + this._request = request; }; DB.prototype._create = function (dbName) { @@ -74,22 +73,22 @@ DB.prototype.changes = function (dbName, params) { url: this._slouch._url + '/' + dbName + '/_changes', method: 'GET', qs: params - }, jsonStreamParseStr, indefinite); + }, jsonStreamParseStr, indefinite, this._request); }; DB.prototype.view = function (dbName, viewDocId, view, params) { return new PersistentStreamIterator({ - url: this._slouch_url + '/' + dbName + '/' + viewDocId + '/_view/' + view, + url: this._slouch._url + '/' + dbName + '/' + viewDocId + '/_view/' + view, qs: params }, 'rows.*'); }; DB.prototype.viewArray = function (dbName, viewDocId, view, params) { return promisedRequest.request({ - url: this._slouch_url + '/' + dbName + '/' + viewDocId + '/_view/' + view, + url: this._slouch._url + '/' + dbName + '/' + viewDocId + '/_view/' + view, qs: params - }); + }, true); }; // Use a JSONStream so that we don't have to load a large JSON structure into memory @@ -101,12 +100,13 @@ DB.prototype.all = function () { DB.prototype.replicate = function (params) { return promisedRequest.request({ - url: this._url + '/_replicate', + url: this._slouch._url + '/_replicate', method: 'POST', json: params }); }; +// TODO: support fromDBName and toDbName also being URLs DB.prototype.copy = function (fromDBName, toDBName) { var self = this; return self.create(toDBName).then(function () { @@ -121,71 +121,4 @@ DB.prototype.copy = function (fromDBName, toDBName) { }); }; -// Use a JSONStream so that we don't have to load a large JSON structure into memory -DB.prototype.updates = function (params) { - - var indefinite = false, - jsonStreamParseStr = null; - - if (params && params.feed === 'continuous') { - indefinite = true; - jsonStreamParseStr = undefined; - } else { - jsonStreamParseStr = 'results.*'; - } - - return new PersistentStreamIterator({ - url: this._slouch._url + '/_db_updates', - method: 'GET', - qs: params - }, jsonStreamParseStr, indefinite); - -}; - -DB.prototype.updatesViaGlobalChanges = function (params) { - var self = this, - iterator = new StreamIterator(); - - self.get('_global_changes').then(function (dbDoc) { - var clonedParams = sporks.clone(params); - clonedParams.since = dbDoc.update_seq; - - // We pipe to the returned iterator so that the function can return an iterator who's content is - // deferred. - self.changes('_global_changes', clonedParams).pipe(iterator); - }); - - return new FilteredStreamIterator(iterator, function (item) { - // Repackage the item so that it is compatible with _db_updates. - var parts = item.id.split(':'); - return { - db_name: parts[1], - type: parts[0] - }; - }); -}; - -// The _db_updates feed in CouchDB does not include any history, i.e. any updates before when we -// start listening to the feed. CouchDB 2 on the other hand stores the complete history in the -// _global_changes database. We use the _changes feed on the _global_changes database to provide a -// backwards compatible API. -DB.prototype.updatesNoHistory = function (params) { - var self = this, - iterator = new StreamIterator(); - - self._slouch._system.isCouchDB1().then(function (isCouchDB1) { - if (isCouchDB1) { - return self.updates(params); - } else { - return self.updatesViaGlobalChanges(params); - } - }).then(function (_iterator) { - // We pipe to the returned iterator so that the function can return an iterator who's content is - // deferred. - _iterator.pipe(iterator); - }); - - return iterator; -}; - module.exports = DB; diff --git a/scripts/doc.js b/scripts/doc.js index c4a94a8..cb6d96d 100644 --- a/scripts/doc.js +++ b/scripts/doc.js @@ -35,8 +35,7 @@ Doc.prototype.ignoreMissing = function (promiseFactory) { }); }; -// Use to create doc -Doc.prototype.post = function (dbName, doc) { +Doc.prototype.create = function (dbName, doc) { return promisedRequest.request({ uri: this._slouch._url + '/' + dbName, method: 'POST', @@ -46,30 +45,29 @@ Doc.prototype.post = function (dbName, doc) { }); }; -Doc.prototype.postAndIgnoreConflict = function (dbName, doc) { +Doc.prototype.createAndIgnoreConflict = function (dbName, doc) { var self = this; return self.ignoreConflict(function () { - return self.post(dbName, doc); + return self.create(dbName, doc); }); }; -// Use to update doc -Doc.prototype.put = function (dbName, doc) { +Doc.prototype.update = function (dbName, doc) { return promisedRequest.request({ uri: this._slouch._url + '/' + dbName + '/' + doc._id, method: 'PUT', body: JSON.stringify(doc) }).then(function () { - // Return doc so that callers like getMergePut have an automatic way to get the data that was - // put + // Return doc so that callers like getMergeUpdate have an automatic way to get the data that was + // update return doc; }); }; -Doc.prototype.putIgnoreConflict = function (dbName, doc) { +Doc.prototype.updateIgnoreConflict = function (dbName, doc) { var self = this; return self.ignoreConflict(function () { - return self.put(dbName, doc); + return self.update(dbName, doc); }); }; @@ -87,6 +85,14 @@ Doc.prototype.getIgnoreMissing = function (dbName, id) { }); }; +Doc.prototype.exists = function (dbName, id) { + return this.get(dbName, id).then(function () { + return true; + }).catch(function () { + return false; + }); +}; + Doc.prototype.createOrUpdate = function (dbName, doc) { var self = this, @@ -97,14 +103,14 @@ Doc.prototype.createOrUpdate = function (dbName, doc) { // Use the latest rev so that we can attempt to update the doc without a conflict clonedDoc._rev = _doc._rev; - return self.put(dbName, clonedDoc); + return self.update(dbName, clonedDoc); }).catch(function (err) { - if (self.isMissingError(err)) { // missing? This can be expected on the first put + if (self.isMissingError(err)) { // missing? This can be expected on the first update // The doc is missing so we attempt to create the doc w/o a rev number - return self.post(dbName, doc); + return self.create(dbName, doc); } else { @@ -123,18 +129,19 @@ Doc.prototype.createOrUpdateIgnoreConflict = function (dbName, doc) { }); }; -Doc.prototype.upsert = function (dbName, doc) { +Doc.prototype._persistThroughConflicts = function (promiseFactory) { var self = this, i = 0; - var _upsert = function () { - return self.createOrUpdate(dbName, doc).catch(function (err) { + var run = function () { + + return promiseFactory().catch(function (err) { if (err.error === 'conflict' && i++ < self.maxRetries) { // conflict? // Retry - return _upsert(); + return run(); } else { @@ -144,12 +151,20 @@ Doc.prototype.upsert = function (dbName, doc) { } }); + }; - return _upsert(); + return run(); }; -Doc.prototype.getMergePut = function (dbName, doc) { +Doc.prototype.upsert = function (dbName, doc) { + var self = this; + return self._persistThroughConflicts(function () { + return self.createOrUpdate(dbName, doc); + }); +}; + +Doc.prototype.getMergeUpdate = function (dbName, doc) { var self = this; @@ -159,7 +174,7 @@ Doc.prototype.getMergePut = function (dbName, doc) { clonedDoc = sporks.merge(clonedDoc, doc); - return self.put(dbName, clonedDoc); + return self.update(dbName, clonedDoc); }); }; @@ -184,68 +199,29 @@ Doc.prototype.getMergeCreateOrUpdate = function (dbName, doc) { }); }; -Doc.prototype.getMergePutIgnoreConflict = function (dbName, doc) { +Doc.prototype.getMergeUpdateIgnoreConflict = function (dbName, doc) { var self = this; return self.ignoreConflict(function () { - return self.getMergePut(dbName, doc); + return self.getMergeUpdate(dbName, doc); }); }; Doc.prototype.getMergeUpsert = function (dbName, doc) { - - var self = this, - i = 0; - - var _upsert = function () { - return self.getMergeCreateOrUpdate(dbName, doc).catch(function (err) { - - if (err.error === 'conflict' && i++ < self.maxRetries) { // conflict? - - // Retry - return _upsert(); - - } else { - - // Unexpected error - throw err; - - } - - }); - }; - - return _upsert(); + var self = this; + return self._persistThroughConflicts(function () { + return self.getMergeCreateOrUpdate(dbName, doc); + }); }; Doc.prototype.getModifyUpsert = function (dbName, docId, onGetPromiseFactory) { - - var self = this, - i = 0; - - var _upsert = function () { + var self = this; + return self._persistThroughConflicts(function () { return self.get(dbName, docId).then(function (doc) { return onGetPromiseFactory(doc); }).then(function (modifiedDoc) { - return self.put(dbName, modifiedDoc); - }).catch(function (err) { - - if (err.error === 'conflict' && i++ < self.maxRetries) { // conflict? - - // Retry - return _upsert(); - - } else { - - // Unexpected error - throw err; - - } - + return self.update(dbName, modifiedDoc); }); - }; - - return _upsert(); - + }); }; Doc.prototype.allArray = function (dbName, params) { @@ -269,12 +245,11 @@ Doc.prototype.destroyAllNonDesign = function (dbName) { return this.destroyAll(dbName, true); }; -Doc.prototype.destroyAll = function (dbName, keepDesignDocs, exceptDBNames) { +Doc.prototype.destroyAll = function (dbName, keepDesignDocs) { var self = this; return self.all(dbName).each(function (doc) { - if ((!keepDesignDocs || doc.id.indexOf('_design') === -1) && - (!exceptDBNames || exceptDBNames.indexOf(doc.id) !== -1)) { + if (!keepDesignDocs || doc.id.indexOf('_design') === -1) { return self.destroy(dbName, doc.id, doc.value.rev); } }); @@ -297,34 +272,23 @@ Doc.prototype.destroyIgnoreConflict = function (dbName, docId, docRev) { }); }; -Doc.prototype.getAnddestroy = function (dbName, docId) { +Doc.prototype.getAndDestroy = function (dbName, docId) { var self = this; return self.get(dbName, docId).then(function (doc) { return self.destroy(dbName, docId, doc._rev); }); }; -Doc.prototype.markDocAsDestroyed = function (dbName, docId) { - return this.getMergePut(dbName, { +Doc.prototype.markAsDestroyed = function (dbName, docId) { + return this.getMergeUpdate(dbName, { _id: docId, _deleted: true }); }; // Just for formalizing the setting of the _deleted flag -Doc.prototype.setDeleted = function (doc) { +Doc.prototype.setDestroyed = function (doc) { doc._deleted = true; }; -Doc.prototype.getAttachment = function (dbName, docId, attachmentName) { - return promisedRequest.request({ - uri: this._slouch._url + '/' + dbName + '/' + docId + '/' + attachmentName, - method: 'GET', - raw: true, - encoding: null - }).then(function (response) { - return response.body; - }); -}; - module.exports = Doc; diff --git a/scripts/index.js b/scripts/index.js index d48142a..1c9d128 100644 --- a/scripts/index.js +++ b/scripts/index.js @@ -1,6 +1,6 @@ 'use strict'; -var Auth = require('./auth'), +var Attachment = require('./attachment'), Config = require('./config'), DB = require('./db'), Doc = require('./doc'), @@ -14,7 +14,7 @@ var Auth = require('./auth'), var Slouch = function (url) { this._url = url; - this.auth = new Auth(this); + this.attachment = new Attachment(this); this.config = new Config(this); this.db = new DB(this); this.doc = new Doc(this); diff --git a/scripts/request-class.js b/scripts/request-class.js index 70a4c52..fc4420e 100644 --- a/scripts/request-class.js +++ b/scripts/request-class.js @@ -14,6 +14,7 @@ QueryString.prototype.unescape = function (s) { var RequestClass = function () { this._throttler = new Throttler(RequestClass.DEFAULT_CONNECTIONS); + this._req = req; }; // For debugging all traffic @@ -49,7 +50,7 @@ RequestClass.prototype._request = function (opts, parseBody) { var self = this, selfArguments = arguments; - return req.apply(this, arguments).then(function (response) { + return self._req.apply(this, arguments).then(function (response) { var err = null; diff --git a/scripts/security.js b/scripts/security.js index d716de9..57eefc4 100644 --- a/scripts/security.js +++ b/scripts/security.js @@ -32,4 +32,21 @@ Security.prototype.get = function (dbName) { }, true); }; +Security.prototype.onlyRoleCanView = function (dbName, role) { + return this.set(dbName, { + admins: { + names: ['_admin'], + roles: [] + }, + members: { + names: [], + roles: [role] + } + }); +}; + +Security.prototype.onlyAdminCanView = function (dbName) { + return this.onlyRoleCanView(dbName, '_admin'); +}; + module.exports = Security; diff --git a/scripts/system.js b/scripts/system.js index b25ff28..0df9524 100644 --- a/scripts/system.js +++ b/scripts/system.js @@ -1,11 +1,17 @@ 'use strict'; var promisedRequest = require('./request'), + FilteredStreamIterator = require('quelle').FilteredStreamIterator, + PersistentStreamIterator = require('quelle').PersistentStreamIterator, + StreamIterator = require('quelle').StreamIterator, sporks = require('sporks'), - Promise = require('sporks/scripts/promise'); + Promise = require('sporks/scripts/promise'), + request = require('request'); var System = function (slouch) { this._slouch = slouch; + this._couchDB1 = null; + this._request = request; }; System.prototype._isCouchDB1 = function () { @@ -64,4 +70,82 @@ System.prototype.reset = function (exceptDBNames) { }); }; +// Use a JSONStream so that we don't have to load a large JSON structure into memory +System.prototype.updates = function (params) { + var indefinite = false, + jsonStreamParseStr = null; + + if (params && params.feed === 'continuous') { + indefinite = true; + jsonStreamParseStr = undefined; + } else { + jsonStreamParseStr = 'results.*'; + } + + return new PersistentStreamIterator({ + url: this._slouch._url + '/_db_updates', + method: 'GET', + qs: params + }, jsonStreamParseStr, indefinite, this._request); +}; + +System.prototype._cloneParams = function (params) { + return params ? sporks.clone(params) : {}; +}; + +System.prototype._itemToUpdate = function (item) { + if (item.id) { + // Repackage the item so that it is compatible with _db_updates. + var parts = item.id.split(':'); + return { + db_name: parts[1], + type: parts[0] + }; + } else { + // Ignore items that don't have ids + return undefined; + } +}; + +System.prototype.updatesViaGlobalChanges = function (params) { + var self = this, + iterator = new StreamIterator(); + + self._slouch.db.get('_global_changes').then(function (dbDoc) { + var clonedParams = self._cloneParams(params); + clonedParams.since = dbDoc.update_seq; + + // We pipe to the returned iterator so that the function can return an iterator who's content is + // deferred. + self._slouch.db.changes('_global_changes', clonedParams).pipe(iterator); + }); + + return new FilteredStreamIterator(iterator, function (item) { + return self._itemToUpdate(item); + }); +}; + +// The _db_updates feed in CouchDB does not include any history, i.e. any updates before when we +// start listening to the feed. CouchDB 2 on the other hand stores the complete history in the +// _global_changes database. We use the _changes feed on the _global_changes database to provide a +// backwards compatible API. +System.prototype.updatesNoHistory = function (params) { + var self = this, + iterator = new StreamIterator(); + + self._slouch.system.isCouchDB1().then(function (isCouchDB1) { + if (isCouchDB1) { + return self.updates(params); + } else { + return self.updatesViaGlobalChanges(params); + } + }).then(function (_iterator) { + // We pipe to the returned iterator so that the function can return an iterator who's content is + // deferred. + _iterator.pipe(iterator); + }); + + return iterator; +}; + module.exports = System; diff --git a/scripts/user.js b/scripts/user.js index 83ab76a..32d7fb0 100644 --- a/scripts/user.js +++ b/scripts/user.js @@ -8,6 +8,7 @@ var NotAuthenticatedError = require('./not-authenticated-error'), var User = function (slouch) { this._slouch = slouch; this._dbName = '_users'; + this._request = request; }; User.prototype.toUserId = function (username) { @@ -20,7 +21,7 @@ User.prototype.toUsername = function (userId) { User.prototype._insert = function (username, user) { user._id = this.toUserId(username); - return this._slouch.doc.put(this._dbName, user); + return this._slouch.doc.update(this._dbName, user); }; User.prototype.create = function (username, password, roles, metadata) { @@ -48,13 +49,14 @@ User.prototype.addRole = function (username, role) { var self = this; return self.get(username).then(function (user) { user.roles.push(role); - return self._update(username, user).catch(function (err) { - if (err.statusCode === 409) { // conflict? Try again - return self.addRole(username, role); - } else { - throw err; - } - }); + return self._update(username, user); + }); +}; + +User.prototype.upsertRole = function (username, role) { + var self = this; + return self._slouch.doc._persistThroughConflicts(function () { + return self.addRole(username, role); }); }; @@ -66,6 +68,13 @@ User.prototype.removeRole = function (username, role) { }); }; +User.prototype.downsertRole = function (username, role) { + var self = this; + return self._slouch.doc._persistThroughConflicts(function () { + return self.removeRole(username, role); + }); +}; + User.prototype.setPassword = function (username, password) { var self = this; return self.get(username).then(function (user) { @@ -94,7 +103,7 @@ User.prototype.destroy = function (username) { }; User.prototype.authenticate = function (username, password) { - return this.postSession({ + return this.createSession({ name: username, password: password }).then(function (response) { @@ -106,21 +115,24 @@ User.prototype.authenticate = function (username, password) { }); }; -User.prototype.postSession = function (doc) { - return request.request({ - uri: this._slouch_url + '/_session', +User.prototype.createSession = function (doc) { + return this._request.request({ + uri: this._slouch._url + '/_session', method: 'POST', json: doc }); }; +// TODO: get authenticate() and authenticated() working properly in the browser. For now, we +// have to fake the responses as it appears that the session cookie is not being propogated from +// the session post to the session get. User.prototype.authenticated = function (cookie) { // Specify a URL w/o a username and password as we want to check to make sure that the cookie is // for a current session var parts = url.parse(this._slouch._url); var _url = parts.protocol + '//' + parts.host + parts.pathname; - return request.request({ + return this._request.request({ uri: _url + '/_session', method: 'GET', headers: { diff --git a/test/couch.png b/test/couch.png new file mode 100644 index 0000000..e5e312a Binary files /dev/null and b/test/couch.png differ diff --git a/test/node-and-browser.js b/test/node-and-browser.js index 81702d0..c8e860e 100644 --- a/test/node-and-browser.js +++ b/test/node-and-browser.js @@ -4,4 +4,11 @@ var chai = require('chai'); chai.use(require('chai-as-promised')); chai.should(); -require('./spec'); +describe('slouch', function () { + + // Sometimes the DB gets a little backed up so we need more time for our tests + this.timeout(10000); + + require('./spec'); + +}); diff --git a/test/spec/attachment.js b/test/spec/attachment.js new file mode 100644 index 0000000..3dd285e --- /dev/null +++ b/test/spec/attachment.js @@ -0,0 +1,90 @@ +'use strict'; + +var Slouch = require('../../scripts'), + utils = require('../utils'); + +describe('attachment', function () { + + var slouch = null, + db = null; + + // Base64 encoded 10px by 10px black PNG. Source: http://png-pixel.com + var base64Data = [ + 'iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVO', + 'RK5CYII=' + ].join(''); + + // TODO: will be needed when support binary attachments + // // As per https://stackoverflow.com/a/14573049/2831606, we need an abstraction as the API can + // // differ + // var bufferFrom = function () { + // if (typeof Buffer.from === 'function') { + // // Node 5.10+ + // return Buffer.from(base64Data, 'base64'); + // } else { + // // older Node versions + // return new Buffer(base64Data, 'base64'); + // } + // }; + + beforeEach(function () { + slouch = new Slouch(utils.couchDBURL()); + db = slouch.db; + return utils.createDB(); + }); + + afterEach(function () { + return utils.destroyDB(); + }); + + var createBase64Attachment = function () { + return slouch.doc.update(utils.createdDB, { + _id: 'foo', + _attachments: { + 'my_image.png': { + data: base64Data, + content_type: 'image/png' + } + } + }); + }; + + // TODO + // it('should create attachment', function () { + // var data = bufferFrom(base64Data); + // return slouch.doc.create(utils.createdDB, { + // _id: 'foo' + // }).then(function () { + // return slouch.doc.get(utils.createdDB, 'foo'); + // }).then(function (doc) { + // return slouch.attachment.create(utils.createdDB, 'foo', 'my_file.png', data, 'image/png', + // doc._rev); + // }); + // }); + + it('should create attachment from base 64 data', function () { + return createBase64Attachment().then(function () { + return slouch.doc.get(utils.createdDB, 'foo'); + }).then(function (doc) { + doc._attachments['my_image.png'].content_type.should.eql('image/png'); + + return slouch.attachment.get(utils.createdDB, 'foo', 'my_image.png'); + }).then(function (attachment) { + var base64Attach = new Buffer(attachment).toString('base64'); + base64Attach.should.eql(base64Data); + }); + }); + + it('should destroy attachment', function () { + return createBase64Attachment().then(function () { + return slouch.doc.get(utils.createdDB, 'foo'); + }).then(function (doc) { + return slouch.attachment.destroy(utils.createdDB, 'foo', 'my_image.png', doc._rev); + }).then(function () { + return slouch.doc.get(utils.createdDB, 'foo'); + }).then(function (doc) { + (doc._attachments === undefined).should.eql(true); + }); + }); + +}); diff --git a/test/spec/config.js b/test/spec/config.js new file mode 100644 index 0000000..8bc6c04 --- /dev/null +++ b/test/spec/config.js @@ -0,0 +1,117 @@ +'use strict'; + +// Note: we need to make sure that we have complete test coverage on both CouchDB 1 and CouchDB 2, +// but also want to test against the actual DB. + +var Slouch = require('../../scripts'), + utils = require('../utils'), + sporks = require('sporks'), + Promise = require('sporks/scripts/promise'); + +describe('config', function () { + + var slouch = null, + config = null, + requests = null; + + beforeEach(function () { + slouch = new Slouch(utils.couchDBURL()); + config = slouch.config; + requests = []; + }); + + var fakeIsCouchDB1 = function (isCouchDB1) { + slouch.system.isCouchDB1 = function () { + return Promise.resolve(isCouchDB1); + }; + }; + + var mockRequest = function (nodes) { + config._req = { + request: function (opts) { + requests.push(opts); + return Promise.resolve(); + } + }; + + slouch.membership.get = function () { + return Promise.resolve({ + cluster_nodes: nodes + }); + }; + }; + + var shouldSet = function (isCouchDB1, nodes) { + fakeIsCouchDB1(isCouchDB1); + mockRequest(nodes); + return config.set('foo', 'bar').then(function () { + // Sanity test + JSON.parse(requests[0].body).should.eql('bar'); + }); + }; + + it('should set for couchdb 1', function () { + return shouldSet(true); + }); + + it('should set for couchdb 2', function () { + return shouldSet(false, ['node1']); + }); + + it('should set for couchdb 2 when multiple nodes', function () { + return shouldSet(false, ['node1', 'node2']); + }); + + it('should request for max nodes', function () { + fakeIsCouchDB1(false); + mockRequest(['node1', 'node2', 'node3']); + return config._request('path', {}, true, 2); + }); + + it('should couch_httpd_auth/timeout', function () { + return config.setCouchHttpdAuthTimeout(3600); + }); + + it('should get and unset', function () { + return config.setLogLevel('warning').then(function () { + return config.get('log/level'); + }).then(function (value) { + value.should.eql('warning'); + + return config.unset('log/level'); + }).then(function () { + return sporks.shouldThrow(function () { + return config.get('log/level'); + }); + }); + }); + + it('should unset ignore missing', function () { + return config.setLogLevel('info').then(function () { + return config.unset('log/level'); + }).then(function () { + return config.unsetIgnoreMissing('log/level'); + }); + }); + + it('should set couch_httpd_auth/allow_persistent_cookies', function () { + return config.setCouchHttpdAuthAllowPersistentCookies(false).then(function () { + return config.setCouchHttpdAuthAllowPersistentCookies(true); + }); + }); + + it('should set compaction rule', function () { + return config.setCompactionRule('_default', + '[{db_fragmentation, "70%"}, {view_fragmentation, "60%"}, {from, "06:00"}, {to, "10:00"}]' + ); + }); + + it('should set max_dbs_open', function () { + return config.setCouchDBMaxDBsOpen(500); + }); + + it('should set httpd/max_connections', function () { + return config.setHttpdMaxConnections(2048); + }); + +}); diff --git a/test/spec/db.js b/test/spec/db.js index ec4bd57..0d9269c 100644 --- a/test/spec/db.js +++ b/test/spec/db.js @@ -1,25 +1,85 @@ 'use strict'; var Slouch = require('../../scripts'), - utils = require('../utils'); + utils = require('../utils'), + sporks = require('sporks'), + Promise = require('sporks/scripts/promise'); describe('db', function () { - var db = new Slouch(utils.couchDBURL()).db; + var slouch = null, + db = null, + dbsToDestroy = null; beforeEach(function () { - return db.create('testdb'); + slouch = new Slouch(utils.couchDBURL()); + db = slouch.db; + dbsToDestroy = []; + return utils.createDB().then(function () { + dbsToDestroy.push(utils.createdDB); + }); }); afterEach(function () { - return db.destroy('testdb'); + var promises = []; + dbsToDestroy.forEach(function (name) { + promises.push(db.destroy(name)); + }); + return Promise.all(promises); }); + var createDocs = function () { + return slouch.doc.create(utils.createdDB, { + thing: 'jam' + }).then(function () { + return slouch.doc.create(utils.createdDB, { + thing: 'clean', + fun: false + }); + }).then(function () { + return slouch.doc.create(utils.createdDB, { + thing: 'code' + }); + }); + }; + + var createView = function () { + return slouch.doc.create(utils.createdDB, { + _id: '_design/myview', + views: { + fun: { + map: [ + 'function(doc) {', + 'if (doc.fun !== false) {', + 'emit(doc._id, null);', + '}', + '}' + ].join(' ') + } + } + }); + }; + + var verifyAllDocs = function (dbName) { + var docs = {}; + return slouch.doc.all(dbName, { + include_docs: true + }).each(function (doc) { + docs[doc.doc.thing] = true; + }).then(function () { + docs.should.eql({ + jam: true, + clean: true, + code: true + }); + }); + }; + it('should check if exists', function () { - return db.exists('testdb').then(function (exists) { + return db.exists(utils.createdDB).then(function (exists) { exists.should.eql(true); }).then(function () { - return db.exists('testdb2'); + return db.exists(utils.createdDB + '_2'); }).then(function (exists) { exists.should.eql(false); }); @@ -45,4 +105,116 @@ describe('db', function () { }); }); + it('create should not throw if DB is created', function () { + var err = new Error(); + + // Fake error + db._create = function () { + return sporks.promiseError(err); + }; + + return sporks.shouldThrow(function () { + return db.create('missing-db'); + }, err); + }); + + it('create should throw if DB is not created', function () { + var err = new Error(); + + // Fake error + db._create = function () { + return sporks.promiseError(err); + }; + + return utils.createDB(); + }); + + it('should get db', function () { + return db.get(utils.createdDB).then(function (_db) { + _db.db_name.should.eql(utils.createdDB); + }); + }); + + it('should get changes', function () { + var changes = {}; + return createDocs().then(function () { + return db.changes(utils.createdDB, { + include_docs: true + }).each(function (change) { + // Use associative array as order is not guaranteed + changes[change.doc.thing] = true; + }); + }).then(function () { + changes.should.eql({ + jam: true, + clean: true, + code: true + }); + }); + }); + + it('should get view', function () { + var docs = {}; + return createDocs().then(function () { + return createView(); + }).then(function () { + return db.view(utils.createdDB, '_design/myview', 'fun', { + include_docs: true + }).each(function (doc) { + // Use associative array as order is not guaranteed + docs[doc.doc.thing] = true; + }); + }).then(function () { + docs.should.eql({ + jam: true, + code: true + }); + }); + }); + + it('should get view array', function () { + var docs = {}; + return createDocs().then(function () { + return createView(); + }).then(function () { + return db.viewArray(utils.createdDB, '_design/myview', 'fun', { + include_docs: true + }).then(function (_docs) { + _docs.rows.forEach(function (_doc) { + // Use associative array as order is not guaranteed + docs[_doc.doc.thing] = true; + }); + }); + }).then(function () { + docs.should.eql({ + jam: true, + code: true + }); + }); + }); + + it('should replicate', function () { + return createDocs().then(function () { + return db.create(utils.createdDB + '_2'); + }).then(function () { + dbsToDestroy.push(utils.createdDB + '_2'); + return db.replicate({ + source: slouch._url + '/' + utils.createdDB, + target: slouch._url + '/' + utils.createdDB + '_2' + }); + }).then(function () { + return verifyAllDocs(utils.createdDB + '_2'); + }); + }); + + it('should copy', function () { + return createDocs().then(function () { + return db.create(utils.createdDB + '_2'); + }).then(function () { + return db.copy(utils.createdDB, utils.createdDB + '_2'); + }).then(function () { + return verifyAllDocs(utils.createdDB + '_2'); + }); + }); + }); diff --git a/test/spec/doc.js b/test/spec/doc.js index e3a12bd..c4e4459 100644 --- a/test/spec/doc.js +++ b/test/spec/doc.js @@ -2,35 +2,99 @@ var Slouch = require('../../scripts'), utils = require('../utils'), - sporks = require('sporks'); + sporks = require('sporks'), + Promise = require('sporks/scripts/promise'); describe('doc', function () { - var slouch = new Slouch(utils.couchDBURL()), - db = slouch.db; + var slouch = null, + db = null, + defaultGet = null, + defaultUpdate = null, + conflictDoc = null; beforeEach(function () { - return db.create('testdb'); + slouch = new Slouch(utils.couchDBURL()); + db = slouch.db; + return utils.createDB(); }); afterEach(function () { - return db.destroy('testdb'); + return utils.destroyDB(); }); - it('should post, put and get doc', function () { + var createDocs = function () { + return slouch.doc.create(utils.createdDB, { + _id: '1', + thing: 'jam' + }).then(function () { + return slouch.doc.create(utils.createdDB, { + thing: 'clean', + fun: false + }); + }).then(function () { + return slouch.doc.create(utils.createdDB, { + thing: 'code' + }); + }); + }; + + var fakeConflict = function (numConflicts) { + + defaultUpdate = slouch.doc.update; + + var i = 0; + + // Fake resolution of conflict + slouch.doc.update = function () { + if (numConflicts && i++ > numConflicts) { + // Resolve after a few attempts + slouch.doc.get = defaultGet; + } + return defaultUpdate.apply(this, arguments); + }; + + return createDocs().then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + + conflictDoc = doc; + + return slouch.doc.createOrUpdate(utils.createdDB, { + _id: '1', + thing: 'dance' + }).then(function () { + + defaultGet = slouch.doc.get; + + // Fake conflict + slouch.doc.get = function () { + return Promise.resolve({ + _id: '1', + _rev: doc._rev, + thing: 'dance' + }); + }; + + }); + + }); + }; + + it('should create, update and get doc', function () { var doc = { thing: 'play' }; - return slouch.doc.post('testdb', doc).then(function (_doc) { + return slouch.doc.create(utils.createdDB, doc).then(function (_doc) { doc._id = _doc.id; - return slouch.doc.get('testdb', doc._id); + return slouch.doc.get(utils.createdDB, doc._id); }).then(function (body) { doc._rev = body._rev; doc.priority = 'medium'; - return slouch.doc.put('testdb', doc); + return slouch.doc.update(utils.createdDB, doc); }).then(function () { - return slouch.doc.get('testdb', doc._id); + return slouch.doc.get(utils.createdDB, doc._id); }).then(function (body) { doc._rev = body._rev; body.should.eql(doc); @@ -38,21 +102,21 @@ describe('doc', function () { }); it('should destroy all non-design docs', function () { - return slouch.doc.post('testdb', { + return slouch.doc.create(utils.createdDB, { thing: 'play' }).then(function () { - return slouch.doc.post('testdb', { + return slouch.doc.create(utils.createdDB, { thing: 'write' }); }).then(function () { - return slouch.doc.post('testdb', { + return slouch.doc.create(utils.createdDB, { _id: '_design/mydesign', foo: 'bar' }); }).then(function () { - return slouch.doc.destroyAllNonDesign('testdb'); + return slouch.doc.destroyAllNonDesign(utils.createdDB); }).then(function () { - return slouch.doc.allArray('testdb'); + return slouch.doc.allArray(utils.createdDB); }).then(function (body) { body.total_rows.should.eql(1); }); @@ -63,13 +127,289 @@ describe('doc', function () { var doc = { thing: 'play' }; - return slouch.doc.post('testdb', doc).then(function (_doc) { + return slouch.doc.create(utils.createdDB, doc).then(function (_doc) { doc._id = _doc.id; doc.priority = 'medium'; // Generates conflict as no rev provided - return slouch.doc.put('testdb', doc); + return slouch.doc.update(utils.createdDB, doc); }); }); }); + it('should ignore conflict when updateting', function () { + return slouch.doc.create(utils.createdDB, { + _id: '1', + thing: 'jam' + }).then(function () { + return slouch.doc.updateIgnoreConflict(utils.createdDB, { + _id: '1', + thing: 'clean' + }); + }); + }); + + it('should only ignore conflicts', function () { + return sporks.shouldThrow(function () { + return slouch.doc.updateIgnoreConflict('missingdb', { + thing: 'clean' + }); + }); + }); + + it('should ignore missing docs', function () { + return slouch.doc.getIgnoreMissing(utils.createdDB, 'missingid'); + }); + + it('should check if doc exists', function () { + return createDocs().then(function () { + return slouch.doc.exists(utils.createdDB, '1'); + }).then(function (exists) { + exists.should.eql(true); + }); + }); + + it('should throw if not missing', function () { + return sporks.shouldThrow(function () { + return slouch.doc.ignoreMissing(function () { + return sporks.promiseError(new Error()); + }); + }); + }); + + it('should create and ignore conflict', function () { + return fakeConflict().then(function () { + return slouch.doc.createAndIgnoreConflict(utils.createdDB, { + _id: '1', + thing: 'dance' + }); + }); + }); + + it('should create when creating or updating', function () { + return slouch.doc.createOrUpdate(utils.createdDB, { + _id: '1', + thing: 'jam' + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.thing.should.eql('jam'); + }); + }); + + it('should update when creating or updating', function () { + return createDocs().then(function () { + return slouch.doc.createOrUpdate(utils.createdDB, { + _id: '1', + thing: 'dance' + }); + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.thing.should.eql('dance'); + }); + }); + + it('should throw when creating or updating', function () { + // Fake error + slouch.doc.get = function () { + return Promise.resolve({ + _rev: 'bad-rev' + }); + }; + + return createDocs().then(function () { + return sporks.shouldThrow(function () { + return slouch.doc.createOrUpdate(utils.createdDB, { + _id: '1', + thing: 'jam' + }); + }); + }); + }); + + it('should ignore conflict when creating or updating', function () { + return fakeConflict().then(function () { + return slouch.doc.createOrUpdateIgnoreConflict(utils.createdDB, { + _id: '1', + thing: 'sing' + }); + }); + }); + + it('should upsert when conflict', function () { + return fakeConflict(3).then(function () { + return slouch.doc.upsert(utils.createdDB, { + _id: '1', + thing: 'dance' + }); + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.thing.should.eql('dance'); + }); + }); + + it('upsert should fail after max retries', function () { + slouch.maxRetries = 3; + + return fakeConflict().then(function () { + return sporks.shouldThrow(function () { + return slouch.doc.upsert(utils.createdDB, { + _id: '1', + thing: 'dance' + }); + }); + }); + }); + + it('should get, merge and update', function () { + return createDocs().then(function () { + return slouch.doc.getMergeUpdate(utils.createdDB, { + _id: '1', + priority: 'high' + }); + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.thing.should.eql('jam'); + doc.priority.should.eql('high'); + }); + }); + + it('should get, merge, create or update when doc existing', function () { + return createDocs().then(function () { + return slouch.doc.getMergeCreateOrUpdate(utils.createdDB, { + _id: '1', + priority: 'high' + }); + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.thing.should.eql('jam'); + doc.priority.should.eql('high'); + }); + }); + + it('should get, merge, create or update when missing', function () { + return slouch.doc.getMergeCreateOrUpdate(utils.createdDB, { + _id: '1', + priority: 'high' + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.priority.should.eql('high'); + }); + }); + + it('should ignore conflict when getting, merging and updating', function () { + return fakeConflict().then(function () { + return slouch.doc.getMergeUpdateIgnoreConflict(utils.createdDB, { + _id: '1', + thing: 'sing' + }); + }); + }); + + it('should get, merge and upsert when conflict', function () { + return fakeConflict(3).then(function () { + return slouch.doc.getMergeUpsert(utils.createdDB, { + _id: '1', + priority: 'high' + }); + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.thing.should.eql('dance'); + doc.priority.should.eql('high'); + }); + }); + + it('get, merge and upsert should fail after max retries', function () { + slouch.maxRetries = 3; + + return fakeConflict().then(function () { + return sporks.shouldThrow(function () { + return slouch.doc.getMergeUpsert(utils.createdDB, { + _id: '1', + thing: 'dance' + }); + }); + }); + }); + + it('should get, modify and upsert when conflict', function () { + return fakeConflict(3).then(function () { + return slouch.doc.getModifyUpsert(utils.createdDB, '1', function (doc) { + return Promise.resolve({ + _id: '1', + _rev: doc._rev, + thing: doc.thing, + priority: 'high' + }); + }); + }).then(function () { + return slouch.doc.get(utils.createdDB, '1'); + }).then(function (doc) { + doc._id.should.eql('1'); + doc.thing.should.eql('dance'); + doc.priority.should.eql('high'); + }); + }); + + it('get, modify and upsert should fail after max retries', function () { + slouch.maxRetries = 3; + + return fakeConflict().then(function () { + return sporks.shouldThrow(function () { + return slouch.doc.getModifyUpsert(utils.createdDB, '1', function (doc) { + return Promise.resolve({ + _id: '1', + _rev: doc._rev, + thing: doc.thing, + priority: 'high' + }); + }); + }); + }); + }); + + it('should ignore conflicts when destroying', function () { + return fakeConflict().then(function () { + return slouch.doc.destroyIgnoreConflict(utils.createdDB, '1', conflictDoc._rev); + }); + }); + + it('should get and destroy', function () { + return createDocs().then(function () { + return slouch.doc.getAndDestroy(utils.createdDB, '1'); + }).then(function () { + return slouch.doc.exists(); + }).then(function (exists) { + exists.should.eql(false); + }); + }); + + it('should mark as destroyed', function () { + return createDocs().then(function () { + return slouch.doc.markAsDestroyed(utils.createdDB, '1'); + }).then(function () { + return slouch.doc.exists(); + }).then(function (exists) { + exists.should.eql(false); + }); + }); + + it('should set destroyed', function () { + var doc = {}; + slouch.doc.setDestroyed(doc); + doc._deleted.should.eql(true); + }); + }); diff --git a/test/spec/exclude-design-docs-iterator.js b/test/spec/exclude-design-docs-iterator.js index ec4c736..486bd6d 100644 --- a/test/spec/exclude-design-docs-iterator.js +++ b/test/spec/exclude-design-docs-iterator.js @@ -5,26 +5,25 @@ var Slouch = require('../../scripts'), describe('exclude-design-docs-iterator', function () { - var slouch = new Slouch(utils.couchDBURL()), - db = slouch.db; + var slouch = new Slouch(utils.couchDBURL()); beforeEach(function () { - return db.create('testdb'); + return utils.createDB(); }); afterEach(function () { - return db.destroy('testdb'); + return utils.destroyDB(); }); var createDocs = function () { - return slouch.doc.post('testdb', { + return slouch.doc.create(utils.createdDB, { thing: 'play' }).then(function () { - return slouch.doc.post('testdb', { + return slouch.doc.create(utils.createdDB, { thing: 'write' }); }).then(function () { - return slouch.doc.post('testdb', { + return slouch.doc.create(utils.createdDB, { _id: '_design/mydesign', thing: 'design' }); @@ -34,7 +33,7 @@ describe('exclude-design-docs-iterator', function () { it('should filter', function () { var docs = []; return createDocs().then(function () { - return new slouch.ExcludeDesignDocsIterator(slouch.doc.all('testdb', { + return new slouch.ExcludeDesignDocsIterator(slouch.doc.all(utils.createdDB, { include_docs: true })).each(function (doc) { docs.push({ diff --git a/test/spec/faked-stream-iterator.js b/test/spec/faked-stream-iterator.js new file mode 100644 index 0000000..904c0b9 --- /dev/null +++ b/test/spec/faked-stream-iterator.js @@ -0,0 +1,21 @@ +'use strict'; + +var Promise = require('sporks/scripts/promise'); + +var FakedStreamIterator = function (items) { + this._items = items; + this._i = 0; +}; + +FakedStreamIterator.prototype.each = function (onItem) { + var self = this; + if (self._i < self._items.length) { + return onItem(self._items[self._i++]).then(function () { + return self.each(onItem); + }); + } else { + return Promise.resolve(); + } +}; + +module.exports = FakedStreamIterator; diff --git a/test/spec/index.js b/test/spec/index.js index ce91d3f..e3c99b6 100644 --- a/test/spec/index.js +++ b/test/spec/index.js @@ -1,8 +1,12 @@ 'use strict'; +require('./attachment'); +require('./config'); require('./db'); require('./doc'); require('./exclude-design-docs-iterator'); +require('./request-class'); require('./request'); require('./security'); +require('./system'); require('./user'); diff --git a/test/spec/request-class.js b/test/spec/request-class.js new file mode 100644 index 0000000..8501864 --- /dev/null +++ b/test/spec/request-class.js @@ -0,0 +1,94 @@ +'use strict'; + +var RequestClass = require('../../scripts/request-class'), + sporks = require('sporks'), + Backoff = require('backoff-promise'), + Promise = require('sporks/scripts/promise'); + +describe('request-class', function () { + + var consoleLog = console.log, + request = null; + + beforeEach(function () { + request = new RequestClass(); + + // Shorten the backoff + request._newBackoff = function () { + return new Backoff(10); + }; + }); + + afterEach(function () { + // Restore console log + console.log = consoleLog; + + RequestClass.LOG_EVERYTHING = false; + }); + + it('should log', function () { + RequestClass.LOG_EVERYTHING = true; + + var logged = null; + + // Spy + console.log = function (str) { + logged = str; + }; + + request._log('foo'); + logged.should.eql('foo'); + }); + + it('should get 404 status code', function () { + request._getStatusCode({ + reason: 'Could not open source database' + }).should.eql(404); + }); + + it('should handle malformed error', function () { + // Fake + request._req = function () { + return Promise.resolve(null); + }; + + return sporks.shouldThrow(function () { + return request._request(); + }); + }); + + it('should reconnect when all DBs active', function () { + var err = new Error('all_dbs_active'); + request._shouldReconnect(err).should.eql(true); + }); + + it('should throw error reach max retries', function () { + var err = new Error('all_dbs_active'); + + // Fake + request._request = function () { + return sporks.promiseError(err); + }; + + return sporks.shouldThrow(function () { + return request.request(); + }, err); + }); + + it('should ignore default_authentication_handler errors when requesting', function () { + var err = new Error('default_authentication_handler'); + + // Fake + request._request = function () { + return sporks.promiseError(err); + }; + + return request.request(); + }); + + it('should set max connections', function () { + request.setMaxConnections(2); + request._throttler.getMaxConcurrentProcesses().should.eql(2); + }); + +}); diff --git a/test/spec/request.js b/test/spec/request.js index a811ba6..ea76c21 100644 --- a/test/spec/request.js +++ b/test/spec/request.js @@ -20,7 +20,7 @@ describe('request', function () { it('should handle ENOTFOUND errors', function () { // Shorten the backoff as in a browser we just a "Failed to Fetch" error which triggers a retry request._newBackoff = function () { - return new Backoff(10); + return new Backoff(1); }; return sporks.shouldThrow(function () { diff --git a/test/spec/security.js b/test/spec/security.js index f33ecd0..7d34152 100644 --- a/test/spec/security.js +++ b/test/spec/security.js @@ -5,15 +5,14 @@ var Slouch = require('../../scripts'), describe('security', function () { - var slouch = new Slouch(utils.couchDBURL()), - db = slouch.db; + var slouch = new Slouch(utils.couchDBURL()); beforeEach(function () { - return db.create('testdb'); + return utils.createDB(); }); afterEach(function () { - return db.destroy('testdb'); + return utils.destroyDB(); }); it('should set and get security', function () { @@ -27,11 +26,15 @@ describe('security', function () { 'roles': ['producer', 'consumer'] } }; - return slouch.security.set('testdb', security).then(function () { - return slouch.security.get('testdb'); + return slouch.security.set(utils.createdDB, security).then(function () { + return slouch.security.get(utils.createdDB); }).then(function (_security) { _security.should.eql(security); }); }); + it('only admin can view', function () { + return slouch.security.onlyAdminCanView(utils.createdDB); + }); + }); diff --git a/test/spec/system.js b/test/spec/system.js new file mode 100644 index 0000000..595681f --- /dev/null +++ b/test/spec/system.js @@ -0,0 +1,281 @@ +'use strict'; + +var Slouch = require('../../scripts'), + utils = require('../utils'), + FakedStreamIterator = require('./faked-stream-iterator'), + Promise = require('sporks/scripts/promise'), + sporks = require('sporks'), + MemoryStream = require('memorystream'); +// FakedJSONRequest = require('quelle/test/spec/faked-json-request'); +// events = require('events'); + +describe('system', function () { + + var slouch = null, + db = null, + system = null, + destroyed = null, + created = null, + defaultGet = null, + iteratorToAbort = null; + + beforeEach(function () { + slouch = new Slouch(utils.couchDBURL()); + db = slouch.db; + system = slouch.system; + destroyed = []; + created = []; + iteratorToAbort = null; + return utils.createDB(); + }); + + afterEach(function () { + if (iteratorToAbort) { + iteratorToAbort.abort(); + } + system.get = defaultGet; + return utils.destroyDB(); + }); + + var fakeCouchDBVersion = function (version) { + defaultGet = system.get; + system.get = function () { + return Promise.resolve({ + version: [version] + }); + }; + }; + + var fakeDBAll = function (dbs) { + slouch.db.all = function () { + return new FakedStreamIterator(dbs); + }; + }; + + var fakeCreateAndDestroy = function () { + slouch.db.create = function (dbName) { + created.push(dbName); + return Promise.resolve(); + }; + + slouch.db.destroy = function (dbName) { + destroyed.push(dbName); + return Promise.resolve(); + }; + }; + + var isPhantomJS = function () { + return global.navigator && global.navigator.userAgent.indexOf('PhantomJS') !== -1; + }; + + // TODO: why do continuous requests just hang in PhantomJS, but not in any other browser? + var fakeContinuousUpdatesIfPhantomJS = function (item) { + if (isPhantomJS()) { + system._request = function () { + var stream = new MemoryStream(); + stream.write(JSON.stringify(item)); + stream.abort = function () {}; + return stream; + }; + + db._request = system._request; + } + }; + + it('should clone params when falsy', function () { + system._cloneParams().should.eql({}); + }); + + it('should check if couchdb 1', function () { + // We run the tests on both CouchDB 1 and 2 and so we don't care about the version. In the + // future, we could pass a paramter to our test scripts that would allow us to test this better. + return system.isCouchDB1(); + }); + + it('should detect couchdb 1', function () { + fakeCouchDBVersion('1'); + return system.isCouchDB1().then(function (is1) { + is1.should.eql(true); + }); + }); + + it('should cache if couchdb 1', function () { + fakeCouchDBVersion('1'); + return system.isCouchDB1().then(function () { + return system.isCouchDB1(); + }).then(function (is1) { + is1.should.eql(true); + }); + }); + + it('should reset when couchdb 1', function () { + fakeCouchDBVersion('1'); + fakeDBAll(['_replicator', 'testa', 'testb']); + fakeCreateAndDestroy(); + + return system.reset().then(function () { + created.should.eql(['_replicator']); + destroyed.should.eql(['_replicator', 'testa', 'testb']); + + created = []; + destroyed = []; + + // Now try the exceptDBNames param + return system.reset(['testb']); + }).then(function () { + created.should.eql(['_replicator']); + destroyed.should.eql(['_replicator', 'testa']); + }); + }); + + it('should reset when not couchdb', function () { + fakeCouchDBVersion('2'); + fakeDBAll(['_replicator', '_global_changes', '_users', 'testa', 'testb']); + fakeCreateAndDestroy(); + + return system.reset().then(function () { + created.should.eql(['_replicator', '_global_changes', '_users']); + destroyed.should.eql(['_replicator', '_global_changes', '_users', 'testa', + 'testb' + ]); + }); + }); + + it('should listen for updates', function () { + var hasUpdate = false; + return slouch.doc.create(utils.createdDB, { + foo: 'bar' + }).then(function () { + return system.updates().each(function (update) { + if (update.db_name === utils.createdDB) { + hasUpdate = true; + } + }); + }).then(function () { + hasUpdate.should.eql(true); + }); + }); + + it('should listen for updates continuously', function () { + fakeContinuousUpdatesIfPhantomJS({ + db_name: utils.createdDB, + type: 'updated' + }); + + var promise = new Promise(function (resolve, reject) { + iteratorToAbort = system.updates({ + feed: 'continuous' + }); + + iteratorToAbort.each(function (update) { + if (update.db_name === utils.createdDB && update.type === 'updated') { + resolve(); + } + }).catch(function (err) { + reject(err); + }); + }); + + // Use timeout to create on the next click, after we start listening to the updates + return sporks.timeout().then(function () { + return slouch.doc.create(utils.createdDB, { + foo: 'bar' + }); + }).then(function () { + return promise; + }); + }); + + it('should listen for updates no history when couchdb 1', function () { + fakeCouchDBVersion('1'); + fakeContinuousUpdatesIfPhantomJS({ + db_name: utils.createdDB, + type: 'updated' + }); + + var promise = new Promise(function (resolve, reject) { + iteratorToAbort = system.updatesNoHistory({ + feed: 'continuous' + }); + + iteratorToAbort.each(function (update) { + if (update.db_name === utils.createdDB && update.type === 'updated') { + resolve(); + } + }).catch(function (err) { + reject(err); + }); + }); + + // Use timeout to create on the next click, after we start listening to the updates + return sporks.timeout().then(function () { + return slouch.doc.create(utils.createdDB, { + foo: 'bar' + }); + }).then(function () { + return promise; + }); + }); + + it('should listen for updates no history when couchdb 2', function () { + fakeCouchDBVersion('2'); + fakeContinuousUpdatesIfPhantomJS({ + id: 'updated:' + utils.createdDB + }); + + // Mock get regardless of version of CouchDB + var defaultGet = db.get; + db.get = function (name) { + var args = sporks.toArgsArray(arguments); + if (name === '_global_changes') { + args[0] = utils.createdDB; + } + return defaultGet.apply(this, args); + }; + + // Mock changes regardless of version of CouchDB + var defaultChanges = db.changes; + db.changes = function (name) { + var args = sporks.toArgsArray(arguments); + if (name === '_global_changes') { + args[0] = utils.createdDB; + } + return defaultChanges.apply(this, args); + }; + + var promise = new Promise(function (resolve, reject) { + iteratorToAbort = system.updatesNoHistory({ + feed: 'continuous' + }); + + iteratorToAbort.each(function (update) { + if (update.db_name === utils.createdDB && update.type === 'updated') { + resolve(); + } + }).catch(function (err) { + reject(err); + }); + }); + + // Use timeout to create on the next click, after we start listening to the updates + return sporks.timeout().then(function () { + return slouch.doc.create(utils.createdDB, { + _id: 'updated:' + utils.createdDB + }); + }).then(function () { + return promise; + }); + }); + + it('should clone params', function () { + var params = { + foo: 'bar' + }; + system._cloneParams(params).should.eql(params); + }); + + it('should return undefined if item missing id', function () { + (system._itemToUpdate({}) === undefined).should.eql(true); + }); + +}); diff --git a/test/spec/user.js b/test/spec/user.js index 505749b..00dfa3a 100644 --- a/test/spec/user.js +++ b/test/spec/user.js @@ -1,29 +1,53 @@ 'use strict'; var Slouch = require('../../scripts'), - utils = require('../utils'); + utils = require('../utils'), + sporks = require('sporks'), + Promise = require('sporks/scripts/promise'); describe('user', function () { - var slouch = new Slouch(utils.couchDBURL()), - user = slouch.user; + var slouch = null, + user = null, + defaultUpdate = null, + username = null, + defaultRequest = null; beforeEach(function () { - return user.create('testusername', 'testpassword', ['testrole1'], { + slouch = new Slouch(utils.couchDBURL()); + user = slouch.user; + username = 'test_' + utils.nextId(); + defaultRequest = user._request.request; + return user.create(username, 'testpassword', ['testrole1'], { firstName: 'Jill', email: 'test@example.com' }); }); afterEach(function () { - return user.destroy('testusername'); + user._request.request = defaultRequest; + return user.destroy(username); }); + var fakeConflict = function () { + var i = 0; + defaultUpdate = user._update; + user._update = function () { + if (i++ < 3) { + var err = new Error(); + err.error = 'conflict'; + return sporks.promiseError(err); + } else { + return defaultUpdate.apply(this, arguments); + } + }; + }; + // NOTE: create and destroy tested by beforeEach() and afterEach() it('should not create when already exists', function () { var err = null; - return user.create('testusername', 'testpassword').catch(function (_err) { + return user.create(username, 'testpassword').catch(function (_err) { err = _err; }).then(function () { (err === null).should.eql(false); @@ -31,33 +55,64 @@ describe('user', function () { }); it('should get', function () { - return user.get('testusername').then(function (_user) { - _user._id.should.eql('org.couchdb.user:testusername'); - _user.name.should.eql('testusername'); + return user.get(username).then(function (_user) { + _user._id.should.eql('org.couchdb.user:' + username); + _user.name.should.eql(username); _user.roles.should.eql(['testrole1']); _user.type.should.eql('user'); _user.metadata.should.eql({ firstName: 'Jill', email: 'test@example.com' }); + user.toUsername(_user._id).should.eql(username); }); }); it('should add role', function () { - return user.addRole('testusername', 'testrole2').then(function () { - return user.get('testusername'); + return user.addRole(username, 'testrole2').then(function () { + return user.get(username); }).then(function (_user) { _user.roles.should.eql(['testrole1', 'testrole2']); }); }); + it('should upsert role', function () { + fakeConflict(); + return user.upsertRole(username, 'testrole2').then(function () { + return user.get(username); + }).then(function (_user) { + _user.roles.should.eql(['testrole1', 'testrole2']); + }); + }); + + it('should remove role', function () { + return user.addRole(username, 'testrole2').then(function () { + return user.removeRole(username, 'testrole1'); + }).then(function () { + return user.get(username); + }).then(function (_user) { + _user.roles.should.eql(['testrole2']); + }); + }); + + it('should downsert role', function () { + return user.addRole(username, 'testrole2').then(function () { + fakeConflict(); + return user.downsertRole(username, 'testrole1'); + }).then(function () { + return user.get(username); + }).then(function (_user) { + _user.roles.should.eql(['testrole2']); + }); + }); + it('should set password', function () { var origUser = null; - return user.get('testusername').then(function (_user) { + return user.get(username).then(function (_user) { origUser = _user; - return user.setPassword('testusername', 'testpassword2'); + return user.setPassword(username, 'testpassword2'); }).then(function () { - return user.get('testusername'); + return user.get(username); }).then(function (_user) { // Make sure password changed (_user.derived_key === origUser.derived_key).should.eql(false); @@ -65,13 +120,65 @@ describe('user', function () { }); it('should set metadata', function () { - return user.setMetadata('testusername', { + return user.setMetadata(username, { firstName: 'Jack' }).then(function () { - return user.get('testusername'); + return user.get(username); }).then(function (_user) { _user.metadata.firstName.should.eql('Jack'); }); }); + it('should authenticate and get session', function () { + + // TODO: get authenticate() and authenticated() working properly in the browser. For now, we + // have to fake the responses as it appears that the session cookie is not being propogated from + // the session post to the session get. + if (global.window) { // in browser? + user._request.request = function () { + if (arguments['0'].uri.indexOf('_session') !== -1) { + return Promise.resolve({ + headers: { + 'set-cookie': [ + 'some-cookie' + ] + }, + body: JSON.stringify({ + userCtx: { + name: username, + roles: ['testrole1'] + }, + cookie: 'some-cookie' + }) + }); + } else { + return defaultRequest.apply(this, arguments); + } + }; + } + + return user.authenticateAndGetSession(username, 'testpassword').then(function (session) { + // Sanity check + session.userCtx.name.should.eql(username); + session.userCtx.roles.should.eql(['testrole1']); + (session.cookie === undefined).should.eql(false); + }); + }); + + it('should not authenticate and get session', function () { + var err = new Error(); + err.name = 'NotAuthenticatedError'; + return sporks.shouldThrow(function () { + return user.authenticateAndGetSession(username, 'bad-password'); + }, err); + }); + + it('should not be authenticated when cookie missing', function () { + var err = new Error(); + err.name = 'NotAuthenticatedError'; + return sporks.shouldThrow(function () { + return user.authenticated('bad-cookie'); + }, err); + }); + }); diff --git a/test/utils.js b/test/utils.js index 996dbcc..7b6109a 100644 --- a/test/utils.js +++ b/test/utils.js @@ -1,12 +1,32 @@ 'use strict'; -var config = require('./config.json'); +var config = require('./config.json'), + Slouch = require('../scripts'); -var Utils = function () {}; +var Utils = function () { + this._dbId = 0; + this.createdDB = null; + this._slouch = new Slouch(this.couchDBURL()); +}; Utils.prototype.couchDBURL = function () { return config.couchdb.scheme + '://' + config.couchdb.username + ':' + config.couchdb.password + '@' + config.couchdb.host + ':' + config.couchdb.port; }; +Utils.prototype.nextId = function () { + return this._dbId++; +}; + +// Use unique DB names for each tests as there can be race conditions where a DB is destroyed, but +// has not yet been fully released. +Utils.prototype.createDB = function () { + this.createdDB = 'test_' + this.nextId(); + return this._slouch.db.create(this.createdDB); +}; + +Utils.prototype.destroyDB = function () { + return this._slouch.db.destroy(this.createdDB); +}; + module.exports = new Utils();