monastery 1.27.2 → 1.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/readme.md CHANGED
@@ -71,6 +71,12 @@ $ DEBUG=monastery:info
71
71
  $ DEBUG=monastery:*
72
72
  ```
73
73
 
74
+ To run isolated tests with Jest:
75
+
76
+ ```bash
77
+ npm run dev -- -t 'Model indexes'
78
+ ```
79
+
74
80
  ## Contributing
75
81
 
76
82
  Coming soon...
package/docs/schema.md CHANGED
@@ -173,13 +173,13 @@ You are able to define custom validation rules to use. (`this` will refer to the
173
173
  ```js
174
174
  schema.rules = {
175
175
  // Basic definition
176
- isGrandMaster: function(value, ruleArgument, fieldName, model) {
176
+ isGrandMaster: function(value, ruleArgument, path, model) {
177
177
  return (value == 'Martin Luther')? true : false
178
178
  },
179
179
  // Full definition
180
180
  isGrandMaster: {
181
- message: (value, ruleArgument, fieldName, model) => 'Only grand masters are permitted'
182
- fn: function(value, ruleArgument, fieldName, model) {
181
+ message: (value, ruleArgument, path, model) => 'Only grand masters are permitted'
182
+ fn: function(value, ruleArgument, path, model) {
183
183
  return (value == 'Martin Luther' || this.age > 100)? true : false
184
184
  }
185
185
  }
@@ -209,17 +209,39 @@ You are able to define custom error messages for each validation rule.
209
209
 
210
210
  ```js
211
211
  schema.messages = {
212
- "name": {
212
+ 'name': {
213
213
  required: 'Sorry, even a monk cannot be nameless'
214
- type: 'Sorry, your name needs to be a string, like it is so'
214
+ type: 'Sorry, your name needs to be a string'
215
215
  },
216
- "address.city": {
217
- minLength: (value, ruleArgument, fieldName, model) => {
216
+ 'address.city': {
217
+ minLength: (value, ruleArgument, path, model) => {
218
218
  return `Is your city of residence really only ${ruleArgument} characters long?`
219
219
  }
220
220
  },
221
- "pets.[].name": {
222
- required: `Your pet's name needs to be a string, like it is so.`
221
+ // You can assign custom error messages for all subdocument fields in an array
222
+ // e.g. pets = [{ name: { type: 'string' }}]
223
+ 'pets.name': {
224
+ required: `Your pet's name needs to be a string.`
225
+ }
226
+ // To target a specific array item
227
+ 'pets.0.name': {
228
+ required: `You first pet needs a name`
229
+ }
230
+ // You can also target any rules set on the array or sub arrays
231
+ // e.g.
232
+ // let arrayWithSchema = (array, schema) => { array.schema = schema; return array }
233
+ // petGroups = arrayWithSchema(
234
+ // [arrayWithSchema(
235
+ // [{ name: { type: 'string' }}],
236
+ // { minLength: 1 }
237
+ // )],
238
+ // { minLength: 1 }
239
+ // )
240
+ 'petGroups': {
241
+ minLength: `Please add at least one pet pet group.`
242
+ }
243
+ 'petGroups.$': {
244
+ minLength: `Please add at least one pet into your pet group.`
223
245
  }
224
246
  }
225
247
  ```
@@ -265,7 +287,7 @@ let schema = {
265
287
 
266
288
  afterFind: [function(data) {// Synchronous
267
289
  data = data || {}
268
- data.name = data.firstName + " " + data.lastName
290
+ data.name = data.firstName + ' ' + data.lastName
269
291
  }]
270
292
  }
271
293
 
@@ -85,6 +85,34 @@ module.exports = {
85
85
  })
86
86
  },
87
87
 
88
+ _getMostSpecificKeyMatchingPath: function(object, path) {
89
+ /**
90
+ * Get all possible array variation matches from the object, and return the most specifc key
91
+ * @param {object} object - e.g. { 'pets.1.name', 'pets.$.name', 'pets.name', .. }
92
+ * @path {string} path - must be a specifc path, e.g. 'pets.1.name'
93
+ * @return most specific key in object
94
+ *
95
+ * 1. Get all viable messages keys, e.g. (key)dogs.$ == (path)dogs.1
96
+ * 2. Order array key list by scoring, i.e. [0-9]=2, $=1, ''=0
97
+ * 3. Return first
98
+ */
99
+ let keys = []
100
+ let pathExpand = path.replace(/\.([0-9]+)/g, '(.$1|.\\$|)').replace(/\./g, '\\.')
101
+ let pathreg = new RegExp(`^${pathExpand}$`)
102
+
103
+ for (let key in object) {
104
+ if (key.match(pathreg)) {
105
+ let score = (key.match(/\.[0-9]+/g)||[]).length * 1001
106
+ score += (key.match(/\.\$/g)||[]).length * 1000
107
+ keys.push({ score: score, key: key })
108
+ }
109
+ }
110
+
111
+ if (!keys.length) return
112
+ else if (keys.length == 1) return keys[0].key
113
+ return keys.sort((a, b) => a.score - b.score).reverse()[0].key // descending
114
+ },
115
+
88
116
  _validateFields: function(dataRoot, fields, data, opts, path) {
89
117
  /**
90
118
  * Recurse through and retrieve any errors and valid data
@@ -223,17 +251,18 @@ module.exports = {
223
251
 
224
252
  _validateRule: function(dataRoot, ruleName, field, ruleArg, value, path) {
225
253
  //this.debug(path, field, ruleName, ruleArg, value)
226
- path = path.replace(/^\./, '[]')
254
+ // Remove [] from the message path, and simply ignore non-numeric children to test for all array items
227
255
  ruleArg = ruleArg === true? undefined : ruleArg
228
256
  let rule = this.rules[ruleName] || rules[ruleName]
229
257
  let fieldName = path.match(/[^\.]+$/)[0]
230
- let ruleMessagePath = path.replace(/\.[0-9]+(\.|$)/g, '.[]$1')
231
- let ruleMessage = this.messages[ruleMessagePath] && this.messages[ruleMessagePath][ruleName]
258
+ let ruleMessageKey = this._getMostSpecificKeyMatchingPath(this.messages, path)
259
+ let ruleMessage = ruleMessageKey && this.messages[ruleMessageKey][ruleName]
232
260
  if (!ruleMessage) ruleMessage = rule.message
233
261
 
262
+
234
263
  if (ruleName !== 'required') {
235
264
  // Ignore undefined when not testing 'required'
236
- if (typeof value === 'undefined') return
265
+ if (typeof value === 'undefined') return ////////////////////////////////////////
237
266
 
238
267
  // Ignore null if not testing required
239
268
  if (value === null && !field.isObject && !field.isArray) return
@@ -246,9 +275,9 @@ module.exports = {
246
275
  if (value === '' && rule.ignoreEmptyString) return
247
276
 
248
277
  // Rule failed
249
- if (!rule.fn.call(dataRoot, value, ruleArg, fieldName, this)) return {
278
+ if (!rule.fn.call(dataRoot, value, ruleArg, path, this)) return {
250
279
  detail: util.isFunction(ruleMessage)
251
- ? ruleMessage.call(dataRoot, value, ruleArg, fieldName, this)
280
+ ? ruleMessage.call(dataRoot, value, ruleArg, path, this)
252
281
  : ruleMessage,
253
282
  meta: { rule: ruleName, model: this.name, field: fieldName, detailLong: rule.messageLong },
254
283
  status: '400',
package/lib/model.js CHANGED
@@ -6,10 +6,11 @@ let validate = require('./model-validate')
6
6
  let Model = module.exports = function(name, opts, manager) {
7
7
  /**
8
8
  * Setup a model (aka monk collection)
9
- * Todo: convert into a promise
10
9
  * @param {string} name
11
10
  * @param {object} opts - see mongodb colleciton documentation
12
- * @this Model
11
+ * @param {boolean} opts.waitForIndexes
12
+ * @this model
13
+ * @return Promise(model) | this
13
14
  */
14
15
  if (!(this instanceof Model)) {
15
16
  return new Model(name, opts, this)
@@ -25,10 +26,6 @@ let Model = module.exports = function(name, opts, manager) {
25
26
  opts = opts || {}
26
27
  Object.assign(this, {
27
28
  ...(opts.methods || {}),
28
- name: name,
29
- manager: manager,
30
- error: manager.error,
31
- info: manager.info,
32
29
  afterFind: opts.afterFind || [],
33
30
  afterInsert: (opts.afterInsert || []).concat(opts.afterInsertUpdate || []),
34
31
  afterUpdate: (opts.afterUpdate || []).concat(opts.afterInsertUpdate || []),
@@ -37,12 +34,16 @@ let Model = module.exports = function(name, opts, manager) {
37
34
  beforeUpdate: (opts.beforeUpdate || []).concat(opts.beforeInsertUpdate || []),
38
35
  beforeRemove: opts.beforeRemove || [],
39
36
  beforeValidate: opts.beforeValidate || [],
40
- findBL: opts.findBL || ['password'],
37
+ error: manager.error,
38
+ info: manager.info,
41
39
  insertBL: opts.insertBL || [],
42
- updateBL: opts.updateBL || [],
43
- messages: opts.messages || {},
44
40
  fields: { ...(util.deepCopy(opts.fields) || {}) },
45
- rules: { ...(opts.rules || {}) }
41
+ findBL: opts.findBL || ['password'],
42
+ manager: manager,
43
+ messages: opts.messages || {},
44
+ name: name,
45
+ rules: { ...(opts.rules || {}) },
46
+ updateBL: opts.updateBL || [],
46
47
  })
47
48
 
48
49
  // Run before model hooks
@@ -94,13 +95,13 @@ let Model = module.exports = function(name, opts, manager) {
94
95
  // Add model to manager.model
95
96
  this.manager.model[name] = this
96
97
 
97
- // Ensure field indexes exist in mongodb
98
+ // Setup/Ensure field indexes exist in MongoDB
98
99
  let errHandler = err => {
99
100
  if (err.type == 'info') this.info(err.detail)
100
101
  else this.error(err)
101
102
  }
102
- if (opts.promise) return this._setupIndexes().catch(errHandler)
103
- else this._setupIndexes().catch(errHandler)
103
+ if (opts.waitForIndexes) return this._setupIndexes().catch(errHandler).then(() => this)
104
+ else this._setupIndexes().catch(errHandler) // returns this
104
105
  }
105
106
 
106
107
  Model.prototype._getFieldlist = function(fields, path) {
@@ -243,7 +244,7 @@ Model.prototype._setupIndexes = function(fields) {
243
244
  * @link https://docs.mongodb.com/manual/reference/command/createIndexes/
244
245
  * @link https://mongodb.github.io/node-mongodb-native/2.1/api/Collection.html#createIndexes
245
246
  * @param {object} <fields>
246
- * @return Promise( {array} indexes | {string} error )
247
+ * @return Promise( {array} indexes ensured | {string} error )
247
248
  *
248
249
  * MongoDB index structures = [
249
250
  * true = { name: 'name_1', key: { name: 1 } },
@@ -267,6 +268,7 @@ Model.prototype._setupIndexes = function(fields) {
267
268
 
268
269
  // Find all indexes
269
270
  recurseFields(fields || model.fields, '')
271
+ // console.log(2, indexes, fields)
270
272
  if (hasTextIndex) indexes.push(textIndex)
271
273
  if (!indexes.length) return Promise.resolve([]) // No indexes defined
272
274
 
@@ -313,6 +315,7 @@ Model.prototype._setupIndexes = function(fields) {
313
315
  })
314
316
  .then(response => {
315
317
  model.info('db index(s) created for ' + model.name)
318
+ return indexes
316
319
  })
317
320
 
318
321
  function recurseFields(fields, parentPath) {
package/lib/rules.js CHANGED
@@ -5,6 +5,7 @@ let validator = require('validator')
5
5
  module.exports = {
6
6
 
7
7
  required: {
8
+ runOnUndefined: true, // integrate
8
9
  message: 'This field is required.',
9
10
  fn: function(x) {
10
11
  if (util.isArray(x) && !x.length) return false
@@ -12,7 +13,7 @@ module.exports = {
12
13
  }
13
14
  },
14
15
 
15
- // Rules below ignore null
16
+ // Type rules below ignore undefined
16
17
 
17
18
  'isBoolean': {
18
19
  message: 'Value was not a boolean.',
@@ -25,12 +26,6 @@ module.exports = {
25
26
  return typeof x === 'boolean'
26
27
  }
27
28
  },
28
- 'isNotEmptyString': {
29
- message: 'Value was an empty string.',
30
- fn: function(x) {
31
- return x !== ''
32
- }
33
- },
34
29
  'isArray': {
35
30
  message: 'Value was not an array.',
36
31
  tryParse: function(x) {
@@ -74,8 +69,8 @@ module.exports = {
74
69
  'isInteger': {
75
70
  message: 'Value was not an integer.',
76
71
  tryParse: function(x) {
77
- if (util.isString(x) && x.match(/^[\+-]?[0-9]+$/)) return x // keep string nums intact
78
- return isNaN(parseInt(x)) || x===true || x===false || x===''? x : parseInt(x)
72
+ if (util.isString(x) && x.match(/^[\+-][0-9]+$/)) return x // keep string nums intact
73
+ return isNaN(parseInt(x)) || (!x && x!==0) || x===true? x : parseInt(x)
79
74
  },
80
75
  fn: function(x) {
81
76
  if (util.isString(x) && x.match(/^[\+-]?[0-9]+$/)) return true
@@ -86,7 +81,7 @@ module.exports = {
86
81
  message: 'Value was not a number.',
87
82
  tryParse: function(x) {
88
83
  if (util.isString(x) && x.match(/^[\+-][0-9]+$/)) return x // keep string nums intact
89
- return isNaN(Number(x)) || x===true || x===false || x===''? x : Number(x)
84
+ return isNaN(Number(x)) || (!x && x!==0) || x===true? x : Number(x)
90
85
  },
91
86
  fn: function(x) {
92
87
  if (util.isString(x) && x.match(/^[\+-][0-9]+$/)) return true
@@ -132,6 +127,8 @@ module.exports = {
132
127
  return util.isObject(x) && ObjectId.isValid(x)/*x.get_inc*/? true : false
133
128
  }
134
129
  },
130
+
131
+
135
132
  'max': {
136
133
  message: (x, arg) => 'Value was greater than the configured maximum (' + arg + ')',
137
134
  fn: function(x, arg) {
@@ -146,8 +143,16 @@ module.exports = {
146
143
  return x >= arg
147
144
  }
148
145
  },
146
+ 'isNotEmptyString': {
147
+ message: 'Value was an empty string.',
148
+ fn: function(x) {
149
+ return x !== ''
150
+ }
151
+ },
152
+
153
+ // Rules below ignore undefined & empty strings
154
+ // (e.g. an empty email field can be saved that isn't required)
149
155
 
150
- // Rules below ignore null & empty strings
151
156
  'enum': {
152
157
  ignoreEmptyString: true,
153
158
  message: (x, arg) => 'Invalid enum value',
@@ -157,10 +162,10 @@ module.exports = {
157
162
  }
158
163
  }
159
164
  },
160
- 'hasAgreed': {
161
- message: (x, arg) => 'Please agree to the terms and conditions.',
162
- fn: function(x, arg) { return !x }
163
- },
165
+ // 'hasAgreed': {
166
+ // message: (x, arg) => 'Please agree to the terms and conditions.',
167
+ // fn: function(x, arg) { return !x }
168
+ // },
164
169
  'isAfter': {
165
170
  ignoreEmptyString: true,
166
171
  message: (x, arg) => 'Value was before the configured time (' + arg + ')',
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "monastery",
3
3
  "description": "⛪ A straight forward MongoDB ODM built around Monk",
4
4
  "author": "Ricky Boyce",
5
- "version": "1.27.2",
5
+ "version": "1.28.2",
6
6
  "license": "MIT",
7
7
  "repository": "github:boycce/monastery",
8
8
  "homepage": "https://boycce.github.io/monastery/",
package/test/model.js CHANGED
@@ -116,27 +116,76 @@ module.exports = function(monastery, opendb) {
116
116
  })
117
117
 
118
118
  test('Model indexes', async (done) => {
119
- // Setup
120
119
  // Need to test different types of indexes
121
120
  let db = (await opendb(null)).db
122
- let user = db.model('user', {})
123
- let user2 = db.model('user2', {})
124
121
 
125
- // Text index setup
126
- let setupIndex1 = await user._setupIndexes({
127
- name: { type: 'string', index: 'text' }
122
+ // Drop previously tested collections
123
+ if ((await db._db.listCollections().toArray()).find(o => o.name == 'userIndexRaw')) {
124
+ await db._db.collection('userIndexRaw').drop()
125
+ }
126
+ if ((await db._db.listCollections().toArray()).find(o => o.name == 'userIndex')) {
127
+ await db._db.collection('userIndex').drop()
128
+ }
129
+
130
+ // Unique & text index (after model initialisation, in serial)
131
+ let userIndexRawModel = db.model('userIndexRaw', {})
132
+ let setupIndex1 = await userIndexRawModel._setupIndexes({
133
+ email: { type: 'string', index: 'unique' },
134
+ })
135
+ let setupIndex2 = await userIndexRawModel._setupIndexes({
136
+ name: { type: 'string', index: 'text' },
137
+ })
138
+ let indexes = await db._db.collection('userIndexRaw').indexes()
139
+ expect(indexes[0]).toMatchObject({ v: 2, key: { _id: 1 }, name: '_id_' })
140
+ expect(indexes[1]).toMatchObject({ v: 2, unique: true, key: { email: 1 }, name: 'email_1' })
141
+ expect(indexes[2]).toMatchObject({
142
+ v: 2,
143
+ key: { _fts: 'text', _ftsx: 1 },
144
+ name: 'text',
145
+ weights: { name: 1 },
146
+ default_language: 'english',
147
+ language_override: 'language',
148
+ textIndexVersion: 3
149
+ })
150
+
151
+ // Unique & text index
152
+ let userIndexModel = await db.model('userIndex', {
153
+ waitForIndexes: true,
154
+ fields: {
155
+ email: { type: 'string', index: 'unique' },
156
+ name: { type: 'string', index: 'text' },
157
+ }
158
+ })
159
+
160
+ let indexes2 = await db._db.collection('userIndex').indexes()
161
+ expect(indexes2[0]).toMatchObject({ v: 2, key: { _id: 1 }, name: '_id_' })
162
+ expect(indexes2[1]).toMatchObject({ v: 2, unique: true, key: { email: 1 }, name: 'email_1' })
163
+ expect(indexes2[2]).toMatchObject({
164
+ v: 2,
165
+ key: { _fts: 'text', _ftsx: 1 },
166
+ name: 'text',
167
+ weights: { name: 1 },
168
+ default_language: 'english',
169
+ language_override: 'language',
170
+ textIndexVersion: 3
128
171
  })
129
172
 
130
173
  // No text index change error, i.e. new Error("Index with name: text already exists with different options")
131
- await expect(user._setupIndexes({
174
+ await expect(userIndexModel._setupIndexes({
132
175
  name: { type: 'string', index: 'text' },
133
176
  name2: { type: 'string', index: 'text' }
134
- })).resolves.toEqual(undefined)
177
+ })).resolves.toEqual([{
178
+ "key": { "name": "text", "name2": "text" },
179
+ "name": "text",
180
+ }])
135
181
 
136
182
  // Text index on a different model
137
- await expect(user2._setupIndexes({
138
- name: { type: 'string', index: 'text' }
139
- })).resolves.toEqual(undefined)
183
+ await expect(userIndexRawModel._setupIndexes({
184
+ name2: { type: 'string', index: 'text' }
185
+ })).resolves.toEqual([{
186
+ "key": {"name2": "text"},
187
+ "name": "text",
188
+ }])
140
189
 
141
190
  db.close()
142
191
  done()
@@ -147,7 +196,7 @@ module.exports = function(monastery, opendb) {
147
196
  // with text indexes are setup at the same time
148
197
  let db = (await opendb(null)).db
149
198
  await db.model('user3', {
150
- promise: true,
199
+ waitForIndexes: true,
151
200
  fields: {
152
201
  location: {
153
202
  index: '2dsphere',
package/test/validate.js CHANGED
@@ -1,3 +1,5 @@
1
+ let validate = require('../lib/model-validate')
2
+
1
3
  module.exports = function(monastery, opendb) {
2
4
 
3
5
  test('Validation basic errors', async () => {
@@ -227,7 +229,40 @@ module.exports = function(monastery, opendb) {
227
229
  .rejects.toContainEqual(error)
228
230
  })
229
231
 
230
- test('Validation messages', async () => {
232
+ test('Validation getMostSpecificKeyMatchingPath', async () => {
233
+ let fn = validate._getMostSpecificKeyMatchingPath
234
+ let mock = {
235
+ 'cats.name': true,
236
+ 'cats.name': true,
237
+
238
+ 'dogs.name': true,
239
+ 'dogs.$.name': true,
240
+
241
+ 'pigs.name': true,
242
+ 'pigs.$.name': true,
243
+ 'pigs.1.name': true,
244
+ 'pigs.2.name': true,
245
+
246
+ 'gulls.$': true,
247
+ 'gulls.$.$': true,
248
+ 'gulls.name': true,
249
+ 'gulls.$.name': true,
250
+ }
251
+ // subdocument
252
+ expect(fn(mock, 'cats.name')).toEqual('cats.name')
253
+ // array subdocuments
254
+ expect(fn(mock, 'cats.1.name')).toEqual('cats.name')
255
+ expect(fn(mock, 'dogs.1.name')).toEqual('dogs.$.name')
256
+ expect(fn(mock, 'dogs.2.name')).toEqual('dogs.$.name')
257
+ expect(fn(mock, 'pigs.1.name')).toEqual('pigs.1.name')
258
+ expect(fn(mock, 'pigs.2.name')).toEqual('pigs.2.name')
259
+ expect(fn(mock, 'pigs.3.name')).toEqual('pigs.$.name')
260
+ // array
261
+ expect(fn(mock, 'gulls.1.2')).toEqual('gulls.$.$')
262
+ expect(fn(mock, 'gulls.1')).toEqual('gulls.$')
263
+ })
264
+
265
+ test('Validation default messages', async () => {
231
266
  // Setup
232
267
  let db = (await opendb(false)).db
233
268
  let user = db.model('user', {
@@ -238,66 +273,236 @@ module.exports = function(monastery, opendb) {
238
273
  animals: [{
239
274
  name: { type: 'string', minLength: 4 }
240
275
  }]
276
+ }
277
+ })
278
+
279
+ let mock = {
280
+ status: '400',
281
+ title: 'name',
282
+ detail: 'Value needs to be at least 4 characters long.',
283
+ meta: { rule: 'minLength', model: 'user', field: 'name' }
284
+ }
285
+
286
+ // basic error
287
+ await expect(user.validate({ name: 'ben' })).rejects.toContainEqual(
288
+ mock
289
+ )
290
+
291
+ // subdocument error
292
+ await expect(user.validate({ dog: { name: 'ben' } })).rejects.toContainEqual({
293
+ ...mock,
294
+ title: 'dog.name'
295
+ })
296
+
297
+ // array error
298
+ await expect(user.validate({ dogNames: ['ben'] })).rejects.toContainEqual({
299
+ ...mock,
300
+ title: 'dogNames.0',
301
+ meta: { ...mock.meta, field: '0' }
302
+ })
303
+
304
+ // subdocument in an array error
305
+ await expect(user.validate({ animals: [{ name: 'ben' }] })).rejects.toContainEqual({
306
+ ...mock,
307
+ title: 'animals.0.name'
308
+ })
309
+
310
+ // subdocument in an array error (different index)
311
+ await expect(user.validate({ animals: [{ name: 'carla' }, { name: 'ben' }] })).rejects.toContainEqual({
312
+ ...mock,
313
+ title: 'animals.1.name'
314
+ })
315
+ })
316
+
317
+ test('Validation custom messages', async () => {
318
+ // Setup
319
+ // Todo: Setup testing for array array subdocument field messages
320
+ let db = (await opendb(false)).db
321
+ let arrayWithSchema = (array, schema) => { array.schema = schema; return array }
322
+ let user = db.model('user', {
323
+ fields: {
324
+ name: { type: 'string', minLength: 4 },
325
+ dog: { name: { type: 'string', minLength: 4 }},
326
+ dogNames: [{ type: 'string', minLength: 4 }],
241
327
  },
242
328
  messages: {
243
329
  'name': { minLength: 'Oops min length is 4' },
244
330
  'dog.name': { minLength: 'Oops min length is 4' },
245
- 'dogNames.[]': { minLength: 'Oops min length is 4' },
246
- 'animals.[].name': { minLength: 'Oops min length is 4' }
331
+ 'dogNames': { minLength: 'Oops min length is 4' },
247
332
  }
248
333
  })
249
334
 
250
- // Basic error
251
- await expect(user.validate({
252
- name: 'ben'
253
- })).rejects.toContainEqual({
335
+ let mock = {
254
336
  status: '400',
255
337
  title: 'name',
256
338
  detail: 'Oops min length is 4',
257
339
  meta: { rule: 'minLength', model: 'user', field: 'name' }
258
- })
340
+ }
259
341
 
342
+ // basic error
343
+ await expect(user.validate({ name: 'ben' })).rejects.toContainEqual(
344
+ mock
345
+ )
260
346
  // subdocument error
261
- await expect(user.validate({
262
- dog: { name: 'ben' }
263
- })).rejects.toContainEqual({
264
- status: '400',
265
- title: 'dog.name',
266
- detail: 'Oops min length is 4',
267
- meta: { rule: 'minLength', model: 'user', field: 'name' }
347
+ await expect(user.validate({ dog: { name: 'ben' } })).rejects.toContainEqual({
348
+ ...mock,
349
+ title: 'dog.name'
268
350
  })
269
-
270
351
  // array error
271
- await expect(user.validate({
272
- dogNames: ['ben']
273
- })).rejects.toContainEqual({
352
+ await expect(user.validate({ dogNames: ['ben'] })).rejects.toContainEqual({
353
+ ...mock,
354
+ title: 'dogNames.0',
355
+ meta: { ...mock.meta, field: '0' }
356
+ })
357
+ })
358
+
359
+ test('Validation custom messages for arrays', async () => {
360
+ // Setup
361
+ // Todo: Setup testing for array array subdocument field messages
362
+ let db = (await opendb(false)).db
363
+ let arrayWithSchema = (array, schema) => { array.schema = schema; return array }
364
+ let user = db.model('user', {
365
+ fields: {
366
+ dogNames: arrayWithSchema([
367
+ arrayWithSchema([{ type: 'string' }], { minLength: 1 })
368
+ ], { minLength: 1 }),
369
+ catNames: [{
370
+ name: { type: 'string', minLength: 4 }
371
+ }],
372
+ pigNames: [[{
373
+ name: { type: 'string', minLength: 4 },
374
+ }]],
375
+ },
376
+ messages: {
377
+ 'dogNames': { minLength: 'add one dog name' },
378
+ 'dogNames.$': { minLength: 'add one sub dog name' },
379
+
380
+ 'catNames.name': { minLength: 'min length error (name)' },
381
+ 'catNames.1.name': { minLength: 'min length error (1)' },
382
+ 'catNames.2.name': { minLength: 'min length error (2)' },
383
+
384
+ 'pigNames.name': { minLength: 'min length error (name)' },
385
+ 'pigNames.$.name': { minLength: 'min length error ($)' },
386
+ 'pigNames.1.name': { minLength: 'min length error (1)' },
387
+ 'pigNames.2.name': { minLength: 'min length error (2)' },
388
+ 'pigNames.0.2.name': { minLength: 'min length error (deep 0 2)' },
389
+ 'pigNames.$.2.name': { minLength: 'min length error (deep $ 2)' },
390
+ }
391
+ })
392
+
393
+ // Empty array
394
+ await expect(user.validate({ dogNames: [] })).rejects.toContainEqual({
395
+ status: '400',
396
+ title: 'dogNames',
397
+ detail: 'add one dog name',
398
+ meta: { rule: 'minLength', model: 'user', field: 'dogNames' }
399
+ })
400
+ // Empty sub array
401
+ await expect(user.validate({ dogNames: [['carla']] })).resolves.toEqual({ dogNames: [['carla']] })
402
+ await expect(user.validate({ dogNames: [[]] })).rejects.toContainEqual({
274
403
  status: '400',
275
404
  title: 'dogNames.0',
276
- detail: 'Oops min length is 4',
405
+ detail: 'add one sub dog name',
277
406
  meta: { rule: 'minLength', model: 'user', field: '0' }
278
407
  })
279
408
 
280
- // subdocument in an array error
281
- await expect(user.validate({
282
- animals: [{ name: 'ben' }]
283
- })).rejects.toContainEqual({
409
+
410
+ // array-subdocument-field error (loose match)
411
+ await expect(user.validate({ catNames: [{ name: 'ben' }] })).rejects.toContainEqual({
284
412
  status: '400',
285
- title: 'animals.0.name',
286
- detail: 'Oops min length is 4',
413
+ title: 'catNames.0.name',
414
+ detail: 'min length error (name)',
415
+ meta: { rule: 'minLength', model: 'user', field: 'name' }
416
+ })
417
+ // array-subdocument-1-field error
418
+ await expect(user.validate({ catNames: [{ name: 'carla' }, { name: 'ben' }] })).rejects.toContainEqual({
419
+ status: '400',
420
+ title: 'catNames.1.name',
421
+ detail: 'min length error (1)',
422
+ meta: { rule: 'minLength', model: 'user', field: 'name' }
423
+ })
424
+ // array-subdocument-2-field error
425
+ await expect(user.validate({ catNames: [{ name: 'carla' }, { name: 'carla' }, { name: 'ben' }] })).rejects.toContainEqual({
426
+ status: '400',
427
+ title: 'catNames.2.name',
428
+ detail: 'min length error (2)',
287
429
  meta: { rule: 'minLength', model: 'user', field: 'name' }
288
430
  })
289
431
 
290
- // subdocument in an array error (different index)
291
- await expect(user.validate({
292
- animals: [{ name: 'carla' }, { name: 'ben' }]
293
- })).rejects.toContainEqual({
432
+
433
+ // array-subdocument-field error (loose $ match)
434
+ await expect(user.validate({ pigNames: [[{ name: 'ben' }]] })).rejects.toContainEqual({
294
435
  status: '400',
295
- title: 'animals.1.name',
296
- detail: 'Oops min length is 4',
436
+ title: 'pigNames.0.0.name',
437
+ detail: 'min length error ($)',
438
+ meta: { rule: 'minLength', model: 'user', field: 'name' }
439
+ })
440
+ // array-subdocument-1-field error
441
+ await expect(user.validate({ pigNames: [[{ name: 'carla' }, { name: 'ben' }]] })).rejects.toContainEqual({
442
+ status: '400',
443
+ title: 'pigNames.0.1.name',
444
+ detail: 'min length error (1)',
445
+ meta: { rule: 'minLength', model: 'user', field: 'name' }
446
+ })
447
+ // array-subdocument-0-2-field error
448
+ await expect(user.validate({ pigNames: [[{ name: 'carla' }, { name: 'carla' }, { name: 'ben' }]] })).rejects.toContainEqual({
449
+ status: '400',
450
+ title: 'pigNames.0.2.name',
451
+ detail: 'min length error (deep 0 2)',
452
+ meta: { rule: 'minLength', model: 'user', field: 'name' }
453
+ })
454
+ // array-subdocument-2-0-field error (fallback)
455
+ await expect(user.validate({ pigNames: [[],[],[{ name: 'carla' },{ name: 'carla' },{ name: 'ben' }]] })).rejects.toContainEqual({
456
+ status: '400',
457
+ title: 'pigNames.2.2.name',
458
+ detail: 'min length error (deep $ 2)',
459
+ meta: { rule: 'minLength', model: 'user', field: 'name' }
460
+ })
461
+ // array-subdocument-2-0-field error (lower fallback)
462
+ await expect(user.validate({ pigNames: [[],[],[{ name: 'ben' }]] })).rejects.toContainEqual({
463
+ status: '400',
464
+ title: 'pigNames.2.0.name',
465
+ detail: 'min length error (2)',
297
466
  meta: { rule: 'minLength', model: 'user', field: 'name' }
298
467
  })
299
468
  })
300
469
 
470
+ test('Validation custom rules', async () => {
471
+ // Setup
472
+ let db = (await opendb(false)).db
473
+ let user = db.model('user', {
474
+ fields: {
475
+ name: { type: 'string', bigName: 8 },
476
+ animals: [{
477
+ name: { type: 'string', bigName: 8 }
478
+ }]
479
+ },
480
+ rules: {
481
+ bigName: function(value, ruleArg) {
482
+ return value.length >= ruleArg
483
+ }
484
+ }
485
+ })
486
+
487
+ // Basic field
488
+ await expect(user.validate({ name: 'benjamin' })).resolves.toEqual({ name: 'benjamin' })
489
+ await expect(user.validate({ name: 'ben' })).rejects.toContainEqual({
490
+ status: '400',
491
+ title: 'name',
492
+ detail: 'Invalid data property for rule "bigName".',
493
+ meta: { rule: 'bigName', model: 'user', field: 'name' }
494
+ })
495
+
496
+ // subdocument in an array
497
+ await expect(user.validate({ animals: [{ name: 'benjamin' }] })).resolves.toEqual({ animals: [{ name: 'benjamin' }] })
498
+ await expect(user.validate({ animals: [{ name: 'ben' }] })).rejects.toContainEqual({
499
+ status: '400',
500
+ title: 'animals.0.name',
501
+ detail: 'Invalid data property for rule "bigName".',
502
+ meta: { rule: 'bigName', model: 'user', field: 'name' }
503
+ })
504
+ })
505
+
301
506
  test('Validated data', async () => {
302
507
  // Setup
303
508
  let db = (await opendb(false)).db
@@ -399,18 +604,20 @@ module.exports = function(monastery, opendb) {
399
604
  })
400
605
  })
401
606
 
402
- test('Schema rules', async () => {
607
+ test('Schema default rules', async () => {
403
608
  // Setup
404
609
  let db = (await opendb(false)).db
405
610
  let user = db.model('user', { fields: {
406
611
  name: { type: 'string', minLength: 7 },
407
612
  email: { type: 'string', isEmail: true },
408
- names: { type: 'string', enum: ['Martin', 'Luther'] }
613
+ names: { type: 'string', enum: ['Martin', 'Luther'] },
614
+ amount: { type: 'number' },
409
615
  }})
410
616
  let user2 = db.model('user', { fields: {
411
617
  amount: { type: 'number', required: true },
412
618
  }})
413
619
 
620
+
414
621
  // MinLength
415
622
  await expect(user.validate({ name: 'Martin Luther' })).resolves.toEqual({name: 'Martin Luther'})
416
623
  await expect(user.validate({ name: 'Carl' })).rejects.toContainEqual({
@@ -450,18 +657,33 @@ module.exports = function(monastery, opendb) {
450
657
  }
451
658
  })
452
659
 
660
+ // Number valid
661
+ await expect(user2.validate({ amount: 0 })).resolves.toEqual({ amount: 0 }) // required
662
+ await expect(user.validate({ amount: '0' })).resolves.toEqual({ amount: 0 }) // required
663
+ await expect(user.validate({ amount: undefined })).resolves.toEqual({}) // not required
664
+ await expect(user.validate({ amount: null })).resolves.toEqual({ amount: null }) // not required
665
+
453
666
  // Number required
454
- await expect(user2.validate({ amount: 0 })).resolves.toEqual({ amount: 0 })
455
- await expect(user2.validate({ amount: '' })).rejects.toContainEqual({
667
+ let mock1 = {
456
668
  detail: 'This field is required.',
457
669
  status: '400',
458
670
  title: 'amount',
459
- meta: {
460
- model: 'user',
461
- field: 'amount',
462
- rule: 'required'
463
- }
464
- })
671
+ meta: { model: 'user', field: 'amount', rule: 'required' }
672
+ }
673
+ await expect(user2.validate({})).rejects.toContainEqual(mock1)
674
+ await expect(user2.validate({ amount: '' })).rejects.toContainEqual(mock1)
675
+ await expect(user2.validate({ amount: undefined })).rejects.toContainEqual(mock1)
676
+ await expect(user2.validate({ amount: null })).rejects.toContainEqual(mock1)
677
+
678
+ // Number invalid
679
+ let mock2 = {
680
+ detail: 'Value was not a number.',
681
+ status: '400',
682
+ title: 'amount',
683
+ meta: { model: 'user', field: 'amount', rule: 'isNumber' }
684
+ }
685
+ await expect(user.validate({ amount: false })).rejects.toContainEqual(mock2)
686
+ await expect(user.validate({ amount: 'bad' })).rejects.toContainEqual(mock2)
465
687
  })
466
688
 
467
689
  test('Schema default objects', async (done) => {