diff --git a/README.md b/README.md index ae7796a..93c7903 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ new Store(dbName, options) | **`options.remote`** | Object | PouchDB instance | Yes (ignores `remoteBaseUrl` from [Store.defaults](#storedefaults)) | **`options.remote`** | Promise | Resolves to either string or PouchDB instance | see above | **`options.PouchDB`** | Constructor | [PouchDB custom builds](https://pouchdb.com/custom.html) | Yes (unless preset using [Store.defaults](#storedefaults))) +| **`options.validate`** | Function | Validation function to execute before DB operations (Can return promise for async validation) | No Returns `store` API. diff --git a/index.js b/index.js index 3ef3c55..8b2356e 100644 --- a/index.js +++ b/index.js @@ -33,6 +33,7 @@ function Store (dbName, options) { dbName: dbName, PouchDB: options.PouchDB, emitter: emitter, + validate: options.validate, get remote () { return options.remote } diff --git a/lib/helpers/add-many.js b/lib/helpers/add-many.js index 10f7f2e..45a91e2 100644 --- a/lib/helpers/add-many.js +++ b/lib/helpers/add-many.js @@ -1,5 +1,6 @@ var clone = require('lodash/clone') var uuid = require('pouchdb-utils').uuid +var validate = require('../validate') var addTimestamps = require('../utils/add-timestamps') var bulkDocs = require('./db-bulk-docs') @@ -17,5 +18,16 @@ module.exports = function addMany (state, docs, prefix) { }) } - return bulkDocs(state, docs) + var validationPromises = docs.map(function (doc) { + return validate(state, doc) + }) + + return Promise.all(validationPromises) + + .then(function () { + return bulkDocs(state, docs) + }) + .catch(function (error) { + throw error + }) } diff --git a/lib/helpers/add-one.js b/lib/helpers/add-one.js index 1247eed..180b871 100644 --- a/lib/helpers/add-one.js +++ b/lib/helpers/add-one.js @@ -4,6 +4,7 @@ var clone = require('lodash/clone') var PouchDBErrors = require('pouchdb-errors') var Promise = require('lie') var uuid = require('pouchdb-utils').uuid +var validate = require('../validate') var internals = addOne.internals = {} internals.addTimestamps = require('../utils/add-timestamps') @@ -26,8 +27,11 @@ function addOne (state, doc, prefix) { delete doc.hoodie - return internals.put(state, internals.addTimestamps(doc)) + return validate(state, doc) + .then(function () { + return internals.put(state, internals.addTimestamps(doc)) + }) .catch(function (error) { if (error.status === 409) { var conflict = new Error('Object with id "' + doc._id + '" already exists') diff --git a/lib/validate.js b/lib/validate.js new file mode 100644 index 0000000..718fe50 --- /dev/null +++ b/lib/validate.js @@ -0,0 +1,40 @@ +module.exports = validate + +var Promise = require('lie') + +function validate (state, doc) { + if (state.validate === undefined) { + return Promise.resolve() + } + + return Promise.resolve() + + .then(function () { + return state.validate(doc) + }) + .catch(function (rejectValue) { + var error = new Error() + + if (rejectValue instanceof Error) { + Object.keys(rejectValue).map(function (key) { + error[key] = rejectValue[key] + }) + + if (rejectValue.message) { + error.message = rejectValue.message + } else { + error.message = 'document validation failed' + } + } else { + if (typeof rejectValue === 'string') { + error.message = rejectValue + } else { + error.message = 'check error value for more details' + error.value = rejectValue + } + } + + error.name = 'ValidationError' + throw error + }) +} diff --git a/tests/integration/add.js b/tests/integration/add.js index 4bf8d65..9a8e2be 100644 --- a/tests/integration/add.js +++ b/tests/integration/add.js @@ -124,7 +124,8 @@ test('adds multiple objects to db', function (t) { var name = uniqueName() var store = new Store(name, { PouchDB: PouchDB, - remote: 'remote-' + name + remote: 'remote-' + name, + validate: function () {} }) store.add({ @@ -164,6 +165,34 @@ test('adds multiple objects to db', function (t) { .catch(t.error) }) +test('fail validation adding multiple objects to db', function (t) { + t.plan(2) + + var name = uniqueName() + var store = new Store(name, { + PouchDB: PouchDB, + remote: 'remote-' + name, + validate: function () { throw new Error('Validation failed for the given docs') } + }) + + store.add([{ + foo: 'bar' + }, { + foo: 'baz' + }, { + _id: 'foo', + foo: 'baz' + }]) + + .then(function () { + t.fail('Expecting ValidationError') + }) + .catch(function (error) { + t.is(error.name, 'ValidationError') + t.is(error.message, 'Validation failed for the given docs') + }) +}) + test('store.add(object) makes createdAt and updatedAt timestamps', function (t) { t.plan(4) diff --git a/tests/unit/add-one-test.js b/tests/unit/add-one-test.js index b8d714a..a7126d9 100644 --- a/tests/unit/add-one-test.js +++ b/tests/unit/add-one-test.js @@ -12,6 +12,7 @@ test('add-one non-409 error', function (t) { simple.mock(addOne.internals, 'addTimestamps') var state = {} + var doc = {} addOne(state, doc) @@ -25,3 +26,174 @@ test('add-one non-409 error', function (t) { simple.restore() }) }) + +test('add-one ValidationError', function (t) { + t.plan(2) + + var state = { + validate: function () { throw new Error('Validation failed for the given docs') } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'Validation failed for the given docs', 'error message matches intent') + }) +}) + +test('add-one validate rejects with Error (without message)', function (t) { + t.plan(2) + + var state = { + validate: function () { return Promise.reject(new Error()) } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'document validation failed', 'error message matches intent') + }) +}) + +test('add-one validate rejects with Error (with message)', function (t) { + t.plan(2) + + var state = { + validate: function () { return Promise.reject(new Error('Validation failed for the given docs')) } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'Validation failed for the given docs', 'error message matches intent') + }) +}) + +test('add-one validate rejects with a string', function (t) { + t.plan(2) + + var state = { + validate: function () { return Promise.reject('Validation failed for the given docs') } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'Validation failed for the given docs', 'error message matches intent') + }) +}) + +test('add-one validate rejects with a value I', function (t) { + t.plan(3) + + var state = { + validate: function () { return Promise.reject(false) } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'check error value for more details', 'error message matches intent') + t.is(error.value, false, 'error.value is false') + }) +}) + +test('add-one validate rejects with a value II', function (t) { + t.plan(3) + + var state = { + validate: function () { return Promise.reject(1) } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'check error value for more details', 'error message matches intent') + t.is(error.value, 1, 'error.value is 1') + }) +}) + +test('add-one validate rejects with a value III', function (t) { + t.plan(4) + + var state = { + validate: function () { return Promise.reject({ failure: true, tries: 1 }) } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'check error value for more details', 'error message matches intent') + t.is(error.value.failure, true, 'error.value.failure is true') + t.is(error.value.tries, 1, 'error.value.tries is 1') + }) +}) + +test('add-one validation fails with custom error', function (t) { + t.plan(4) + + var customError = new Error('custom error message') + + customError.status = 401 + customError.errorCode = 'DB_401' + + var state = { + validate: function () { throw customError } + } + + var doc = {} + addOne(state, doc) + + .then(function () { + t.fail('should throw an ValidationError') + }) + + .catch(function (error) { + t.is(error.name, 'ValidationError', 'validation error name matches') + t.is(error.message, 'custom error message', 'error message matches intent') + t.is(error.status, 401, 'error.status is 401') + t.is(error.errorCode, 'DB_401', 'error.errorCode is DB_401') + }) +})