dobo 2.35.0 → 2.36.0

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.
@@ -43,6 +43,14 @@ async function sanitizeRecord (record = {}, opts = {}) {
43
43
  if (opts.fmt) {
44
44
  newRecord._fmt = cloneDeep(newRecord)
45
45
  delete newRecord._fmt._ref
46
+ const options = {
47
+ lang: get(opts, 'intl.lang', get(opts, 'req.lang')),
48
+ timeZone: get(opts, 'intl.timeZone', get(opts, 'req.site.setting.bajo.intl.timeZone')),
49
+ emptyValue: get(opts, 'intl.emptyValue', get(opts, 'req.site.setting.bajo.intl.emptyValue')),
50
+ unitSys: get(opts, 'intl.unitSys', get(opts, 'req.site.setting.bajo.intl.unitSys')),
51
+ formatter: get(opts, 'intl.formatter', get(opts, 'req.site.setting.bajo.intl.formatter'))
52
+ }
53
+
46
54
  for (const key in newRecord) {
47
55
  const prop = this.getProperty(key)
48
56
  if (!prop) continue
@@ -56,19 +64,13 @@ async function sanitizeRecord (record = {}, opts = {}) {
56
64
  else if (isFunction(prop.format)) newRecord._fmt[key] = await prop.format.call(this, value, newRecord, opts)
57
65
  else if (isString(prop.format)) newRecord._fmt[key] = await callHandler(this.plugin, this, value, newRecord, opts)
58
66
  else {
59
- const options = {
60
- lang: get(opts, 'intl.lang', get(opts, 'req.lang')),
61
- datetime: get(opts, 'intl.datetime', get(opts, 'req.site.setting.bajo.intl.datetime')),
62
- date: get(opts, 'intl.date', get(opts, 'req.site.setting.bajo.intl.date')),
63
- time: get(opts, 'intl.time', get(opts, 'req.site.setting.bajo.intl.time')),
64
- timeZone: get(opts, 'intl.timeZone', get(opts, 'req.site.setting.bajo.intl.timeZone')),
65
- emptyValue: get(opts, 'intl.emptyValue', get(opts, 'req.site.setting.bajo.intl.emptyValue')),
66
- unitSys: get(opts, 'intl.unitSys', get(opts, 'req.site.setting.bajo.intl.unitSys')),
67
+ const _options = {
68
+ ...options,
69
+ field: key,
67
70
  latitude: ['lat', 'latitude'].includes(key),
68
- longitude: ['lon', 'lng', 'longitude'].includes(key),
69
- field: key
71
+ longitude: ['lon', 'lng', 'longitude'].includes(key)
70
72
  }
71
- newRecord._fmt[key] = format(value, prop.type, options)
73
+ newRecord._fmt[key] = format(value, prop.type, _options)
72
74
  }
73
75
  }
74
76
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "dobo",
3
- "version": "2.35.0",
3
+ "version": "2.36.0",
4
4
  "description": "DBMS for Bajo Framework",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "build-doc": "jsdoc -c .jsdoc.conf.json",
8
- "test": "mocha"
8
+ "test": "mocha --recursive"
9
9
  },
10
10
  "type": "module",
11
11
  "bajo": {
@@ -39,7 +39,9 @@
39
39
  "uuid": "^13.0.0"
40
40
  },
41
41
  "devDependencies": {
42
+ "chai": "^6.2.2",
42
43
  "clean-jsdoc-theme": "^4.3.0",
43
- "jsdoc-plugin-intersection": "^1.0.4"
44
+ "jsdoc-plugin-intersection": "^1.0.4",
45
+ "mocha": "^11.7.6"
44
46
  }
45
47
  }
@@ -0,0 +1,12 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ export const runNodeInline = async (code, cwd) => {
4
+ return await new Promise((resolve) => {
5
+ const child = spawn(process.execPath, ['--input-type=module', '-e', code], { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
6
+ let stdout = ''
7
+ let stderr = ''
8
+ child.stdout.on('data', d => { stdout += d.toString() })
9
+ child.stderr.on('data', d => { stderr += d.toString() })
10
+ child.on('close', (exitCode) => resolve({ code: exitCode, stdout, stderr }))
11
+ })
12
+ }
@@ -0,0 +1,25 @@
1
+ /* global describe, it */
2
+
3
+ import { expect } from 'chai'
4
+ import { runNodeInline } from './_run.js'
5
+
6
+ describe('e2e factory process', () => {
7
+ it('builds dobo class and executes runtime methods in separate process', async function () {
8
+ this.timeout(10000)
9
+
10
+ const code = `
11
+ import factory from './index.js'
12
+ import { createAppStub } from './test/unit/_stub.js'
13
+ const app = createAppStub('/tmp/dobo-e2e')
14
+ const Dobo = await factory.call({ app }, 'dobo')
15
+ const dobo = new Dobo()
16
+ const rows = [{ g: 'a', n: 1 }, { g: 'a', n: 3 }]
17
+ const agg = dobo.calcAggregate({ data: rows, group: 'g', field: 'n', aggregates: ['count', 'avg'] })
18
+ console.log('E2E_OK:' + (Array.isArray(agg) && agg.length === 1 && agg[0].count === 2))
19
+ `
20
+
21
+ const res = await runNodeInline(code, '/mnt/d/Projects/Dobo/dobo')
22
+ expect(res.code).to.equal(0)
23
+ expect(res.stdout).to.include('E2E_OK:true')
24
+ })
25
+ })
@@ -0,0 +1,52 @@
1
+ /* global describe, it */
2
+
3
+ import { expect } from 'chai'
4
+ import { runNodeInline } from './_run.js'
5
+
6
+ describe('e2e model-adapter process', () => {
7
+ it('creates model and adapter classes and runs wrapper calls in separate process', async function () {
8
+ this.timeout(12000)
9
+
10
+ const code = `
11
+ import adapterFactory from './lib/factory/adapter.js'
12
+ import modelFactory from './lib/factory/model.js'
13
+ import { createAppStub, Base } from './test/unit/_stub.js'
14
+
15
+ const app = createAppStub('/tmp/dobo-e2e-model-adapter')
16
+ app.bajo.runHook = async () => {}
17
+ app.dobo = {
18
+ ns: 'dobo',
19
+ t: (x) => x,
20
+ error: (m) => new Error(m),
21
+ checkAggregateParams: () => {},
22
+ checkHistogramParams: () => {}
23
+ }
24
+
25
+ const plugin = new Base('dobo', app)
26
+ plugin.error = (m) => new Error(m)
27
+ await adapterFactory.call(plugin)
28
+ await modelFactory.call(plugin)
29
+
30
+ const adapter = new app.baseClass.DoboAdapter(plugin, 'memory')
31
+ const conn = { name: 'default', adapter }
32
+ const model = new app.baseClass.DoboModel(plugin, {
33
+ name: 'Order',
34
+ connection: conn,
35
+ properties: [{ name: 'id', type: 'string', maxLength: 50 }, { name: 'name', type: 'string' }],
36
+ indexes: [{ name: 'uidx_id', type: 'unique', fields: ['id'] }],
37
+ hooks: []
38
+ })
39
+
40
+ adapter.createRecord = async (m, body) => ({ data: { ...body } })
41
+ adapter.getRecord = async (m, id) => ({ data: { _id: id, name: 'x' } })
42
+
43
+ const created = await adapter._createRecord(model, { id: 'e1', name: 'ok' }, { noUniqueCheck: true, noIdCheck: true })
44
+ const got = await adapter._getRecord(model, 'e1', {})
45
+ console.log('E2E_MODEL_ADAPTER_OK:' + (created.data.id === 'e1' && got.data.id === 'e1'))
46
+ `
47
+
48
+ const res = await runNodeInline(code, '/mnt/d/Projects/Dobo/dobo')
49
+ expect(res.code).to.equal(0)
50
+ expect(res.stdout).to.include('E2E_MODEL_ADAPTER_OK:true')
51
+ })
52
+ })
@@ -0,0 +1,33 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { createAppStub } from '../unit/_stub.js'
6
+
7
+ describe('integration aspect 01 - start connections', () => {
8
+ let dobo
9
+
10
+ beforeEach(async () => {
11
+ const app = createAppStub('/tmp/dobo-int-01')
12
+ const Dobo = await factory.call({ app }, 'dobo')
13
+ dobo = new Dobo()
14
+ })
15
+
16
+ it('starts all, single, and selected connections', async () => {
17
+ const calls = []
18
+ const c1 = { name: 'primary', connect: async () => calls.push('primary'), adapter: { plugin: { ns: 'dobo' }, name: 'memory' } }
19
+ const c2 = { name: 'audit', connect: async () => calls.push('audit'), adapter: { plugin: { ns: 'dobo' }, name: 'memory' } }
20
+ dobo.connections = [c1, c2]
21
+
22
+ await dobo.start('all')
23
+ expect(calls).to.deep.equal(['primary', 'audit'])
24
+
25
+ calls.length = 0
26
+ await dobo.start('audit')
27
+ expect(calls).to.deep.equal(['audit'])
28
+
29
+ calls.length = 0
30
+ await dobo.start(['primary'])
31
+ expect(calls).to.deep.equal(['primary'])
32
+ })
33
+ })
@@ -0,0 +1,34 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { createAppStub } from '../unit/_stub.js'
6
+
7
+ describe('integration aspect 02 - query and regex', () => {
8
+ let dobo
9
+
10
+ beforeEach(async () => {
11
+ const app = createAppStub('/tmp/dobo-int-02')
12
+ const Dobo = await factory.call({ app }, 'dobo')
13
+ dobo = new Dobo()
14
+ })
15
+
16
+ it('parses query text and preserves regex via json conversion', () => {
17
+ const model = {
18
+ scanables: ['title'],
19
+ sortables: ['title'],
20
+ properties: [{ name: 'title', type: 'string' }],
21
+ adapter: { idField: { name: '_id' } }
22
+ }
23
+
24
+ const q1 = dobo.parseQuery('title:~\'book\'', model)
25
+ expect(q1).to.be.an('object')
26
+
27
+ const q2 = dobo.parseQuery('{"id":"x"}', model)
28
+ expect(q2._id).to.equal('x')
29
+
30
+ const str = dobo.replaceRegexInJson({ title: /book/i })
31
+ const obj = dobo.reviveRegexInJson(str)
32
+ expect(obj.title).to.be.instanceOf(RegExp)
33
+ })
34
+ })
@@ -0,0 +1,88 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import adapterFactory from '../../lib/factory/adapter.js'
5
+ import modelFactory from '../../lib/factory/model.js'
6
+ import { createAppStub, Base } from '../unit/_stub.js'
7
+
8
+ describe('integration aspect 03 - model adapter flow', () => {
9
+ let app
10
+ let plugin
11
+ let model
12
+ let adapter
13
+ const store = {}
14
+
15
+ beforeEach(async () => {
16
+ app = createAppStub('/tmp/dobo-int-03')
17
+ app.bajo.runHook = async () => {}
18
+ app.dobo = {
19
+ ns: 'dobo',
20
+ t: (x) => x,
21
+ error: (msg, payload) => {
22
+ const err = new Error(msg)
23
+ err.payload = payload
24
+ return err
25
+ },
26
+ getDefaultValues: () => ({ warnings: true }),
27
+ checkAggregateParams: () => {},
28
+ checkHistogramParams: () => {}
29
+ }
30
+
31
+ plugin = new Base('dobo', app)
32
+ plugin.error = (msg, ...args) => new Error(`${msg}:${args.join(',')}`)
33
+ await adapterFactory.call(plugin)
34
+ await modelFactory.call(plugin)
35
+
36
+ adapter = new app.baseClass.DoboAdapter(plugin, 'memory')
37
+ const conn = { name: 'default', adapter }
38
+
39
+ model = new app.baseClass.DoboModel(plugin, {
40
+ name: 'Order',
41
+ connection: conn,
42
+ properties: [
43
+ { name: 'id', type: 'string', maxLength: 50 },
44
+ { name: 'name', type: 'string' },
45
+ { name: 'amount', type: 'double' },
46
+ { name: 'createdAt', type: 'datetime' }
47
+ ],
48
+ indexes: [{ name: 'uidx_id', type: 'unique', fields: ['id'] }],
49
+ hooks: []
50
+ })
51
+
52
+ adapter.getRecord = async (m, id) => ({ data: store[id] ? { ...store[id] } : null })
53
+ adapter.createRecord = async (m, body) => {
54
+ const key = body._id ?? body.id
55
+ store[key] = { ...body }
56
+ return { data: { ...body } }
57
+ }
58
+ adapter.updateRecord = async (m, id, body) => {
59
+ const oldData = { ...(store[id] ?? {}) }
60
+ store[id] = { ...(store[id] ?? {}), ...body }
61
+ return { oldData, data: { ...store[id] } }
62
+ }
63
+ adapter.findRecord = async () => ({ data: Object.values(store), count: Object.keys(store).length })
64
+ adapter.removeRecord = async (m, id) => {
65
+ const oldData = store[id] ? { ...store[id] } : null
66
+ delete store[id]
67
+ return { oldData }
68
+ }
69
+ adapter.clearRecord = async () => {
70
+ for (const k in store) delete store[k]
71
+ return { data: true }
72
+ }
73
+ })
74
+
75
+ it('executes create/find/update/remove lifecycle through adapter wrappers', async () => {
76
+ const c = await adapter._createRecord(model, { id: 'o1', name: 'book', amount: 10 }, { noUniqueCheck: true, noIdCheck: true })
77
+ expect(c.data.id).to.equal('o1')
78
+
79
+ const f = await adapter._findRecord(model, {}, {})
80
+ expect(f.count).to.equal(1)
81
+
82
+ const u = await adapter._updateRecord(model, 'o1', { amount: 15 }, { _data: { ...store.o1 } })
83
+ expect(u.data.amount).to.equal(15)
84
+
85
+ const r = await adapter._removeRecord(model, 'o1', { _data: { ...store.o1 } })
86
+ expect(r.oldData.id).to.equal('o1')
87
+ })
88
+ })
@@ -0,0 +1,244 @@
1
+ import path from 'node:path'
2
+
3
+ export const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]'
4
+
5
+ export const camelCase = (s = '') => {
6
+ return String(s)
7
+ .replace(/^[\s_-]+|[\s_-]+$/g, '')
8
+ .split(/[\s_.-]+/)
9
+ .map((p, i) => i === 0 ? (p[0] ? p[0].toLowerCase() + p.slice(1) : '') : (p[0] ? p[0].toUpperCase() + p.slice(1) : ''))
10
+ .join('')
11
+ }
12
+
13
+ export const pascalCase = (s = '') => {
14
+ const cc = camelCase(s)
15
+ return cc[0] ? cc[0].toUpperCase() + cc.slice(1) : cc
16
+ }
17
+
18
+ export const find = (arr = [], matcher = {}) => {
19
+ if (typeof matcher === 'function') return arr.find(matcher)
20
+ return arr.find(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
21
+ }
22
+ export const filter = (arr = [], matcher = {}) => {
23
+ if (typeof matcher === 'function') return arr.filter(matcher)
24
+ return arr.filter(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
25
+ }
26
+ export const map = (arr = [], fn) => {
27
+ if (typeof fn === 'function') return arr.map(fn)
28
+ if (typeof fn === 'string') return arr.map(item => item?.[fn])
29
+ return [...arr]
30
+ }
31
+ export const pick = (obj = {}, keys = []) => keys.reduce((a, k) => { if (Object.prototype.hasOwnProperty.call(obj, k)) a[k] = obj[k]; return a }, {})
32
+ export const groupBy = (arr = [], key) => arr.reduce((a, item) => { const k = item?.[key]; a[k] = a[k] ?? []; a[k].push(item); return a }, {})
33
+ export const isEmpty = (v) => {
34
+ if (v === null || v === undefined) return true
35
+ if (typeof v === 'string') return v.trim() === ''
36
+ if (Array.isArray(v)) return v.length === 0
37
+ if (isPlainObject(v)) return Object.keys(v).length === 0
38
+ return false
39
+ }
40
+ export const uniq = (arr = []) => [...new Set(arr)]
41
+ export const without = (arr = [], ...vals) => {
42
+ const flat = vals.flat()
43
+ return arr.filter(item => !flat.includes(item))
44
+ }
45
+ export const trim = (s = '') => String(s).trim()
46
+ export const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj ?? {}, k)
47
+ export const keys = (obj = {}) => Object.keys(obj)
48
+ export const defaults = (obj = {}, src = {}) => {
49
+ for (const k of Object.keys(src)) if (obj[k] === undefined) obj[k] = src[k]
50
+ return obj
51
+ }
52
+ export const isFunction = (v) => typeof v === 'function'
53
+ export const kebabCase = (s = '') => String(s).replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[\s_.]+/g, '-').toLowerCase()
54
+ export const chunk = (arr = [], size = 1) => {
55
+ const out = []
56
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
57
+ return out
58
+ }
59
+ export const last = (arr = []) => arr[arr.length - 1]
60
+ export const forOwn = (obj = {}, fn) => {
61
+ for (const k of Object.keys(obj)) fn(obj[k], k)
62
+ }
63
+ export const findIndex = (arr = [], matcher = {}) => {
64
+ if (typeof matcher === 'function') return arr.findIndex(matcher)
65
+ return arr.findIndex(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
66
+ }
67
+ export const omit = (obj = {}, drop = []) => {
68
+ const d = Array.isArray(drop) ? drop : [drop]
69
+ const out = {}
70
+ for (const k in obj) if (!d.includes(k)) out[k] = obj[k]
71
+ return out
72
+ }
73
+ export const cloneDeep = (obj) => JSON.parse(JSON.stringify(obj))
74
+ export const pullAt = (arr = [], indexes = []) => {
75
+ const idxs = [...indexes].sort((a, b) => b - a)
76
+ for (const idx of idxs) if (Number.isInteger(idx) && idx >= 0 && idx < arr.length) arr.splice(idx, 1)
77
+ return arr
78
+ }
79
+ export const orderBy = (arr = [], fields = []) => {
80
+ const [field] = fields
81
+ return [...arr].sort((a, b) => {
82
+ if (a[field] === b[field]) return 0
83
+ return a[field] > b[field] ? 1 : -1
84
+ })
85
+ }
86
+
87
+ export const defaultsDeep = (...items) => {
88
+ const out = {}
89
+ const apply = (target, src) => {
90
+ if (!isPlainObject(src)) return target
91
+ for (const k of Object.keys(src)) {
92
+ const tv = target[k]
93
+ const sv = src[k]
94
+ if (isPlainObject(sv)) {
95
+ target[k] = apply(isPlainObject(tv) ? tv : {}, sv)
96
+ } else if (target[k] === undefined) {
97
+ target[k] = sv
98
+ }
99
+ }
100
+ return target
101
+ }
102
+ for (let i = 0; i < items.length; i++) apply(out, items[i])
103
+ return out
104
+ }
105
+
106
+ export const get = (obj, p, fallback) => {
107
+ const parts = String(p).split('.')
108
+ let cur = obj
109
+ for (const part of parts) {
110
+ if (cur == null) return fallback
111
+ cur = cur[part]
112
+ }
113
+ return cur === undefined ? fallback : cur
114
+ }
115
+
116
+ let gid = 0
117
+ export const generateId = () => `id-${++gid}`
118
+ export const isSet = (v) => v !== undefined && v !== null
119
+
120
+ export class Base {
121
+ constructor (pkgName, app) {
122
+ this.pkgName = pkgName
123
+ this.app = app
124
+ this.ns = camelCase(pkgName)
125
+ this.alias = this.ns
126
+ this.log = {
127
+ trace: () => {},
128
+ debug: () => {},
129
+ info: () => {},
130
+ warn: () => {},
131
+ error: () => {}
132
+ }
133
+ }
134
+
135
+ t = (text) => text
136
+
137
+ error = (msg, ...args) => new Error(`${msg}:${args.join(',')}`)
138
+
139
+ getConfig = (pathLike) => get(this.config, pathLike)
140
+ }
141
+
142
+ export class Tools {
143
+ constructor (plugin) {
144
+ this.plugin = plugin
145
+ this.app = plugin.app
146
+ }
147
+
148
+ async dispose () {
149
+ this.plugin = null
150
+ this.app = null
151
+ }
152
+
153
+ bindThis (...fns) {
154
+ for (const fn of fns) {
155
+ if (!fn?.name) continue
156
+ this[fn.name] = fn.bind(this)
157
+ }
158
+ }
159
+ }
160
+
161
+ export const createAppStub = (root = '/tmp/dobo-test') => {
162
+ const app = {
163
+ dir: root,
164
+ t: (text) => text,
165
+ lib: {
166
+ _: {
167
+ find,
168
+ filter,
169
+ isString: (v) => typeof v === 'string',
170
+ isArray: Array.isArray,
171
+ isFunction,
172
+ map,
173
+ get,
174
+ pick,
175
+ groupBy,
176
+ isEmpty,
177
+ isNaN: Number.isNaN,
178
+ trim,
179
+ isPlainObject,
180
+ uniq,
181
+ without,
182
+ defaults,
183
+ findIndex,
184
+ chunk,
185
+ last,
186
+ forOwn,
187
+ kebabCase,
188
+ keys,
189
+ has,
190
+ camelCase,
191
+ isNumber: (v) => typeof v === 'number' && !Number.isNaN(v),
192
+ omit,
193
+ cloneDeep,
194
+ pullAt,
195
+ orderBy
196
+ },
197
+ aneka: {
198
+ pascalCase,
199
+ camelCase,
200
+ defaultsDeep,
201
+ generateId,
202
+ isSet
203
+ },
204
+ dayjs: (value) => {
205
+ const d = value instanceof Date ? value : new Date(value)
206
+ return {
207
+ isValid: () => !Number.isNaN(d.getTime()),
208
+ toDate: () => new Date(d.getTime()),
209
+ format: (pattern) => {
210
+ const y = d.getUTCFullYear()
211
+ const m = String(d.getUTCMonth() + 1).padStart(2, '0')
212
+ const day = String(d.getUTCDate()).padStart(2, '0')
213
+ if (pattern === 'YYYY-MM-DD') return `${y}-${m}-${day}`
214
+ if (pattern === 'YYYY-MM') return `${y}-${m}`
215
+ if (pattern === 'YYYY') return `${y}`
216
+ return d.toISOString()
217
+ }
218
+ }
219
+ },
220
+ fs: {
221
+ ensureDirSync: () => {}
222
+ }
223
+ },
224
+ baseClass: {
225
+ Base,
226
+ Tools
227
+ },
228
+ bajo: {
229
+ breakNsPath: (v) => {
230
+ const i = v.indexOf(':')
231
+ return i === -1 ? { ns: v, path: '' } : { ns: v.slice(0, i), path: v.slice(i + 1) }
232
+ },
233
+ join: (arr = []) => arr.join(', '),
234
+ runHook: async () => {},
235
+ eachPlugins: async () => {},
236
+ buildCollections: async () => [],
237
+ importModule: async () => {},
238
+ callHandler: async (scope, handler, ...args) => typeof handler === 'function' ? handler.call(scope, ...args) : undefined,
239
+ readConfig: async () => ({})
240
+ },
241
+ getPluginDataDir: (name) => path.join(root, 'plugins', name)
242
+ }
243
+ return app
244
+ }
@@ -0,0 +1,58 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import actionFactory from '../../lib/factory/action.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo action class (unit)', () => {
8
+ let app
9
+ let plugin
10
+
11
+ beforeEach(async () => {
12
+ app = createAppStub('/tmp/dobo-action')
13
+ plugin = { app }
14
+ await actionFactory.call(plugin)
15
+ })
16
+
17
+ it('creates chainable action class and runs model handlers', async () => {
18
+ const calls = []
19
+ const model = {
20
+ plugin,
21
+ getRecord: async (...args) => {
22
+ calls.push(args)
23
+ return { ok: true }
24
+ }
25
+ }
26
+
27
+ const action = new app.baseClass.DoboAction(model, 'getRecord', 'abc')
28
+ const resp = await action
29
+ .noHook()
30
+ .dataOnly(false)
31
+ .run({ noCache: false })
32
+
33
+ expect(resp.ok).to.equal(true)
34
+ expect(calls).to.have.length(1)
35
+ expect(calls[0][0]).to.equal('abc')
36
+ expect(calls[0][1]).to.be.an('object')
37
+ })
38
+
39
+ it('builder methods for create/update payload set expected positional args', async () => {
40
+ const model = {
41
+ plugin,
42
+ updateRecord: async (id, body, options) => ({ id, body, options })
43
+ }
44
+
45
+ const action = new app.baseClass.DoboAction(model)
46
+ const result = await action.updateRecord('id-1', { name: 'A' }).run()
47
+ expect(result.id).to.equal('id-1')
48
+ expect(result.body.name).to.equal('A')
49
+ })
50
+
51
+ it('dispose clears action references', async () => {
52
+ const model = { plugin, getRecord: async () => ({}) }
53
+ const action = new app.baseClass.DoboAction(model, 'getRecord', 'x')
54
+ await action.dispose()
55
+ expect(action.model).to.equal(null)
56
+ expect(action._options).to.equal(null)
57
+ })
58
+ })
@@ -0,0 +1,37 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import connectionFactory from '../../lib/factory/connection.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo connection class (unit)', () => {
8
+ let app
9
+ let plugin
10
+
11
+ beforeEach(async () => {
12
+ app = createAppStub('/tmp/dobo-connection')
13
+ app.baseClass.DoboNullAdapter = class DoboNullAdapter {}
14
+ app.baseClass.DoboAdapter = class DoboAdapter {}
15
+ plugin = {
16
+ app,
17
+ getAdapter: () => ({ sanitizeConnection: async () => {}, connect: async () => ({ client: true }) })
18
+ }
19
+ await connectionFactory.call(plugin)
20
+ })
21
+
22
+ it('creates DoboConnection, initializes adapter and connects', async () => {
23
+ const conn = new app.baseClass.DoboConnection(plugin, { name: 'default', adapter: 'dobo:memory', models: ['User'] })
24
+ await conn.initAdapter('dobo:memory')
25
+ expect(conn.adapter).to.be.an('object')
26
+ await conn.connect(true)
27
+ expect(conn.connected).to.equal(true)
28
+ expect(conn.client).to.deep.equal({ client: true })
29
+ })
30
+
31
+ it('dispose clears adapter reference', async () => {
32
+ const conn = new app.baseClass.DoboConnection(plugin, { name: 'default', adapter: 'dobo:memory' })
33
+ conn.adapter = { ok: true }
34
+ await conn.dispose()
35
+ expect(conn.adapter).to.equal(null)
36
+ })
37
+ })
@@ -0,0 +1,136 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo core class (unit)', () => {
8
+ let app
9
+ let Dobo
10
+ let dobo
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-unit')
14
+ Dobo = await factory.call({ app }, 'dobo')
15
+ dobo = new Dobo()
16
+ })
17
+
18
+ it('creates plugin class with expected static values and defaults', () => {
19
+ expect(Dobo.aggregateTypes).to.include('count')
20
+ expect(Dobo.histogramTypes).to.include('yearly')
21
+ expect(Dobo.idTypes).to.include('string')
22
+ expect(dobo.config.default.filter.limit).to.equal(25)
23
+ expect(dobo.adapters).to.deep.equal([])
24
+ expect(dobo.connections).to.deep.equal([])
25
+ expect(dobo.features).to.deep.equal([])
26
+ expect(dobo.models).to.deep.equal([])
27
+ })
28
+
29
+ it('property key helpers include type-specific fields', () => {
30
+ expect(dobo.getPropertyKeysByType('string')).to.include('maxLength')
31
+ expect(dobo.getPropertyKeysByType('text')).to.include('textType')
32
+ expect(dobo.getPropertyKeysByType('smallint')).to.include('autoInc')
33
+
34
+ const keys = dobo.getAllPropertyKeys({ constructor: { propertyKeys: ['xFlag'] } })
35
+ expect(keys).to.include('name')
36
+ expect(keys).to.include('validator')
37
+ expect(keys).to.include('xFlag')
38
+ })
39
+
40
+ it('connection, adapter, feature and model lookups work', () => {
41
+ dobo.connections = [{ name: 'default' }, { name: 'report' }]
42
+ expect(dobo.getConnection('report').name).to.equal('report')
43
+ expect(dobo.getConnection('none', true)).to.equal(undefined)
44
+
45
+ dobo.adapters = [
46
+ { name: 'memory', plugin: { ns: 'dobo' } },
47
+ { name: 'proxy', plugin: { ns: 'ext' } }
48
+ ]
49
+ expect(dobo.getAdapter('memory').name).to.equal('memory')
50
+ expect(dobo.getAdapter('ext:proxy').name).to.equal('proxy')
51
+ expect(dobo.getAdapter('missing', true)).to.equal(undefined)
52
+
53
+ dobo.features = [{ name: 'createdAt', plugin: { ns: 'dobo' } }]
54
+ expect(dobo.getFeature('createdAt').name).to.equal('createdAt')
55
+
56
+ dobo.models = [{ name: 'Order' }, { name: 'Customer' }]
57
+ expect(dobo.getModel('Order').name).to.equal('Order')
58
+ expect(dobo.getModel('order').name).to.equal('Order')
59
+ expect(dobo.getModel('none', true)).to.equal(undefined)
60
+ })
61
+
62
+ it('sanitize helpers and aggregate calculators work', () => {
63
+ expect(dobo.sanitizeBoolean('true')).to.equal(true)
64
+ expect(dobo.sanitizeBoolean(false)).to.equal(false)
65
+ expect(dobo.sanitizeFloat('12.4')).to.equal(12.4)
66
+ expect(dobo.sanitizeInt('12')).to.equal(12)
67
+ expect(dobo.sanitizeString(44)).to.equal('44')
68
+ expect(dobo.sanitizeObject('{"a":1}')).to.deep.equal({ a: 1 })
69
+
70
+ const rows = [
71
+ { grp: 'a', price: 10 },
72
+ { grp: 'a', price: 30 },
73
+ { grp: 'b', price: 20 }
74
+ ]
75
+ const agg = dobo.calcAggregate({ data: rows, group: 'grp', field: 'price', aggregates: ['count', 'avg', 'min', 'max'] })
76
+ expect(agg).to.have.length(2)
77
+ const itemA = agg.find(i => i.grp === 'a')
78
+ expect(itemA.count).to.equal(2)
79
+ expect(itemA.max).to.equal(30)
80
+ })
81
+
82
+ it('histogram and query parsers work with valid input', () => {
83
+ const rows = [
84
+ { createdAt: '2026-01-01T00:00:00Z', score: 5 },
85
+ { createdAt: '2026-01-01T12:00:00Z', score: 7 },
86
+ { createdAt: '2026-01-02T00:00:00Z', score: 10 }
87
+ ]
88
+ const hist = dobo.calcHistogram({ data: rows, type: 'daily', group: 'createdAt', field: 'score', aggregates: ['count'] })
89
+ expect(hist).to.have.length(2)
90
+ expect(hist[0]).to.have.property('date')
91
+
92
+ const model = {
93
+ scanables: ['name'],
94
+ sortables: ['name'],
95
+ properties: [{ name: 'name', type: 'string' }],
96
+ adapter: { idField: { name: '_id' } }
97
+ }
98
+ const parsed = dobo.parseQuery('name:~\'john\'', model)
99
+ expect(parsed).to.be.an('object')
100
+
101
+ const parsedAny = dobo.parseQuery('john*', model)
102
+ expect(parsedAny).to.be.an('object')
103
+ })
104
+
105
+ it('regex json helpers and model hooks work', async () => {
106
+ const input = { n: /abc/gi }
107
+ const text = dobo.replaceRegexInJson(input, true)
108
+ const revived = dobo.reviveRegexInJson(text, true)
109
+ expect(revived.n).to.be.instanceOf(RegExp)
110
+ expect(revived.n.flags).to.include('g')
111
+
112
+ const calls = []
113
+ const model = {
114
+ hooks: [
115
+ { name: 'beforeCreateRecord', level: 2, handler: async () => calls.push(2) },
116
+ { name: 'beforeCreateRecord', level: 1, handler: async () => calls.push(1) }
117
+ ]
118
+ }
119
+ await dobo.runModelHook(model, 'beforeCreateRecord', {})
120
+ expect(calls).to.deep.equal([1, 2])
121
+ })
122
+
123
+ it('default values and hard-cap last page handler work', () => {
124
+ dobo.config.default.filter = { limit: 10, maxLimit: 50, maxPage: 30, sort: [] }
125
+ dobo.config.default.hardCap = 100
126
+ dobo.config.default.warnings = true
127
+
128
+ const def = dobo.getDefaultValues()
129
+ expect(def.limit).to.equal(10)
130
+ expect(def.hardCap).to.equal(100)
131
+
132
+ const result = dobo.handleLastPage({ count: 300, limit: 10, page: 25 })
133
+ expect(result.count).to.equal(100)
134
+ expect(result.data).to.deep.equal([])
135
+ })
136
+ })
@@ -0,0 +1,25 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import featureFactory from '../../lib/factory/feature.js'
5
+ import { createAppStub } from './_stub.js'
6
+
7
+ describe('dobo feature class (unit)', () => {
8
+ let app
9
+ let plugin
10
+
11
+ beforeEach(async () => {
12
+ app = createAppStub('/tmp/dobo-feature')
13
+ plugin = { app }
14
+ await featureFactory.call(plugin)
15
+ })
16
+
17
+ it('creates DoboFeature and disposes cleanly', async () => {
18
+ const feat = new app.baseClass.DoboFeature(plugin, { name: 'createdAt', handler: async () => ({}) })
19
+ expect(feat.name).to.equal('createdAt')
20
+ expect(feat.handler).to.be.a('function')
21
+ await feat.dispose()
22
+ expect(feat.name).to.equal(null)
23
+ expect(feat.handler).to.equal(null)
24
+ })
25
+ })
@@ -0,0 +1,115 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import modelFactory from '../../lib/factory/model.js'
5
+ import { createAppStub, Base } from './_stub.js'
6
+
7
+ describe('dobo model class (unit)', () => {
8
+ let app
9
+ let plugin
10
+ let model
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-model-unit')
14
+ app.dobo = {
15
+ models: [],
16
+ sanitizeDate: (v) => v,
17
+ sanitizeByType: (v) => v,
18
+ checkAggregateParams: () => {},
19
+ checkHistogramParams: () => {},
20
+ runModelHook: async () => {},
21
+ parseQuery: (q) => q
22
+ }
23
+ app.baseClass.DoboAction = class DoboAction {
24
+ constructor (m, name, ...args) {
25
+ this.model = m
26
+ this.name = name
27
+ this.args = args
28
+ }
29
+ }
30
+
31
+ plugin = new Base('dobo', app)
32
+ await modelFactory.call(plugin)
33
+
34
+ const options = {
35
+ name: 'Order',
36
+ baseName: 'order',
37
+ connection: {
38
+ name: 'default',
39
+ adapter: { idField: { name: '_id', type: 'string', maxLength: 50 } }
40
+ },
41
+ properties: [
42
+ { name: 'id', type: 'string', maxLength: 50 },
43
+ { name: 'title', type: 'string', maxLength: 50 },
44
+ { name: 'total', type: 'double' },
45
+ { name: 'virtualTitle', type: 'string', virtual: true }
46
+ ],
47
+ indexes: [{ name: 'idx_title', type: 'index', fields: ['title'] }],
48
+ hooks: []
49
+ }
50
+ model = new app.baseClass.DoboModel(plugin, options)
51
+ app.dobo.models.push(model)
52
+ })
53
+
54
+ it('builds aliases, action and property getters correctly', () => {
55
+ const act = model.action('getRecord', 'id-1')
56
+ expect(act.name).to.equal('getRecord')
57
+ expect(model.getProperty('title').type).to.equal('string')
58
+ expect(model.getField('title').name).to.equal('title')
59
+ expect(model.hasProperty('title')).to.equal(true)
60
+ expect(model.hasField('ghost')).to.equal(false)
61
+ expect(model.getIndexes()).to.have.length(1)
62
+ })
63
+
64
+ it('returns filtered property lists (all, virtual, non-virtual)', () => {
65
+ expect(model.getProperties().length).to.equal(4)
66
+ expect(model.getProperties({ noVirtual: true }).length).to.equal(3)
67
+ expect(model.getProperties({ noVirtual: true, namesOnly: true })).to.include('title')
68
+ expect(model.getVirtualProperties(true)).to.deep.equal(['virtualTitle'])
69
+ expect(model.getNonVirtualProperties(true)).to.include('id')
70
+ })
71
+
72
+ it('sanitizes and syncs id field', () => {
73
+ model.properties[0].type = 'integer'
74
+ expect(model.sanitizeId('12')).to.equal(12)
75
+
76
+ model.syncIdField({ name: 'id', type: 'string', maxLength: 20 })
77
+ expect(model.properties[0].maxLength).to.equal(20)
78
+ })
79
+
80
+ it('buildPropValues supports array and req translation hooks', async () => {
81
+ const prop = {
82
+ name: 'status',
83
+ values: ['draft', { value: 'done', text: 'done' }]
84
+ }
85
+ const req = {
86
+ te: (k) => k === 'statusDone',
87
+ t: () => 'Selesai'
88
+ }
89
+ const vals = await model.buildPropValues(prop, { req })
90
+ expect(vals).to.have.length(2)
91
+ expect(vals[1].text).to.equal('Selesai')
92
+ })
93
+
94
+ it('_simpleLookup handles string/object/array definitions', async () => {
95
+ const userModel = {
96
+ findOneRecord: async () => ({ id: 'u1', username: 'john' })
97
+ }
98
+ app.dobo.getModel = () => userModel
99
+
100
+ const a = await model._simpleLookup('User:username:id=u1', {})
101
+ expect(a).to.equal('john')
102
+
103
+ const b = await model._simpleLookup({ model: 'User', field: 'id', query: 'id={id}' }, { id: 'u9' })
104
+ expect(b).to.equal('u1')
105
+
106
+ const c = await model._simpleLookup(['User', 'id', 'id=u5'], {})
107
+ expect(c).to.equal('u1')
108
+ })
109
+
110
+ it('dispose clears connection and adapter references', async () => {
111
+ await model.dispose()
112
+ expect(model.connection).to.equal(null)
113
+ expect(model.adapter).to.equal(null)
114
+ })
115
+ })
@@ -0,0 +1,40 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { sanitizeAll } from '../../lib/helper.js'
6
+ import { createAppStub } from './_stub.js'
7
+
8
+ describe('helper sanitizeAll (unit)', () => {
9
+ let app
10
+ let dobo
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-helper-all')
14
+ const Dobo = await factory.call({ app }, 'dobo')
15
+ dobo = new Dobo()
16
+ app.dobo = dobo
17
+ dobo.fatal = (msg, ...args) => { throw new Error(`${msg}:${args.join(',')}`) }
18
+ })
19
+
20
+ it('normalizes properties, indexes, sortables and hidden fields', async () => {
21
+ const schema = {
22
+ name: 'Order',
23
+ properties: [
24
+ { name: 'id', type: 'string', index: 'primary' },
25
+ { name: 'name', type: 'string', minLength: '0', maxLength: '20' },
26
+ { name: 'amount', type: 'double' }
27
+ ],
28
+ indexes: [{ name: 'idx_name', type: 'index', fields: ['name'] }],
29
+ hidden: ['name', 'ghost']
30
+ }
31
+
32
+ await sanitizeAll.call(dobo, schema)
33
+
34
+ expect(schema.sortables).to.include('name')
35
+ expect(schema.hidden).to.deep.equal(['name'])
36
+ const nameProp = schema.properties.find(p => p.name === 'name')
37
+ expect(nameProp.maxLength).to.equal(20)
38
+ expect(nameProp.minLength).to.equal(undefined)
39
+ })
40
+ })
@@ -0,0 +1,64 @@
1
+ /* global describe, it, beforeEach */
2
+
3
+ import { expect } from 'chai'
4
+ import factory from '../../index.js'
5
+ import { sanitizeRef } from '../../lib/helper.js'
6
+ import { createAppStub } from './_stub.js'
7
+
8
+ describe('helper sanitizeRef (unit)', () => {
9
+ let app
10
+ let dobo
11
+
12
+ beforeEach(async () => {
13
+ app = createAppStub('/tmp/dobo-helper-ref')
14
+ const Dobo = await factory.call({ app }, 'dobo')
15
+ dobo = new Dobo()
16
+ app.dobo = dobo
17
+ dobo.fatal = (msg, ...args) => { throw new Error(`${msg}:${args.join(',')}`) }
18
+ })
19
+
20
+ it('normalizes reference fields and filters unknown refs', async () => {
21
+ const user = {
22
+ name: 'User',
23
+ properties: [{ name: 'id' }, { name: 'name' }]
24
+ }
25
+ const order = {
26
+ name: 'Order',
27
+ properties: [
28
+ {
29
+ name: 'userId',
30
+ ref: {
31
+ user: { model: 'User', field: 'id', fields: ['name'] },
32
+ missing: { model: 'Unknown', field: 'id' }
33
+ }
34
+ }
35
+ ]
36
+ }
37
+
38
+ await sanitizeRef.call(dobo, order, [user, order])
39
+
40
+ expect(order.properties[0].ref.user.type).to.equal('1:1')
41
+ expect(order.properties[0].ref.user.searchField).to.equal('id')
42
+ expect(order.properties[0].ref.user.labelField).to.equal('id')
43
+ expect(order.properties[0].ref.user.fields).to.include('id')
44
+ expect(order.properties[0].ref.missing).to.equal(undefined)
45
+ })
46
+
47
+ it('throws when duplicate ref keys exist across properties', async () => {
48
+ const schema = {
49
+ name: 'Order',
50
+ properties: [
51
+ { name: 'a', ref: { rel: { model: 'User' } } },
52
+ { name: 'b', ref: { rel: { model: 'User' } } }
53
+ ]
54
+ }
55
+ const user = { name: 'User', properties: [{ name: 'id' }] }
56
+
57
+ let err
58
+ try {
59
+ await sanitizeRef.call(dobo, schema, [schema, user])
60
+ } catch (e) { err = e }
61
+ expect(err).to.be.instanceOf(Error)
62
+ expect(err.message).to.include('duplicateRefKeys')
63
+ })
64
+ })
package/wiki/CHANGES.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changes
2
2
 
3
+ ## 2026-07-31
4
+
5
+ - [2.36.0] Add test suite
6
+ - [2.36.0] Add parameter `options` to `model.sanitizeRecord()`
7
+
3
8
  ## 2026-07-29
4
9
 
5
10
  - [2.35.0] Add parameter `options` to `adapter.sanitizeRecord()` to allow passing additional options for sanitization