adapt-authoring-contentplugin 1.1.0 → 1.2.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.
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
on: push
|
|
3
|
+
jobs:
|
|
4
|
+
default:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-node@v4
|
|
11
|
+
with:
|
|
12
|
+
node-version: 'lts/*'
|
|
13
|
+
cache: 'npm'
|
|
14
|
+
- run: npm ci
|
|
15
|
+
- run: npm test
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-contentplugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Module for managing framework plugins",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-contentplugin",
|
|
6
6
|
"repository": "github:adapt-security/adapt-authoring-contentplugin",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test 'tests/**/*.spec.js'"
|
|
9
|
+
},
|
|
7
10
|
"main": "index.js",
|
|
8
11
|
"type": "module",
|
|
9
12
|
"dependencies": {
|
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { describe, it, beforeEach, mock, afterEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'fs/promises'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import os from 'os'
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Stub out the external dependency so we can import ContentPluginModule
|
|
9
|
+
// without the full app runtime. The module registers itself via
|
|
10
|
+
// `import AbstractApiModule from 'adapt-authoring-api'`, so we hook
|
|
11
|
+
// into the Node loader via --loader / --import won't work here. Instead
|
|
12
|
+
// we build a thin stand-in and dynamically patch the prototype after import.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
// We cannot import ContentPluginModule directly because it tries to resolve
|
|
16
|
+
// 'adapt-authoring-api'. Instead, we recreate just enough of the class to
|
|
17
|
+
// exercise every *public* method in isolation.
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a minimal ContentPluginModule-like instance whose methods come
|
|
22
|
+
* straight from the real source file. We read the file, strip the import
|
|
23
|
+
* of the abstract base, and evaluate the class body so we can test each
|
|
24
|
+
* method individually without needing the full app dependency tree.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Helper: create a fresh mock instance that behaves like ContentPluginModule
|
|
28
|
+
function createInstance (overrides = {}) {
|
|
29
|
+
const instance = {
|
|
30
|
+
// ---- data ----
|
|
31
|
+
collectionName: 'contentplugins',
|
|
32
|
+
root: 'contentplugins',
|
|
33
|
+
schemaName: 'contentplugin',
|
|
34
|
+
pluginSchemas: {},
|
|
35
|
+
newPlugins: [],
|
|
36
|
+
routes: [],
|
|
37
|
+
|
|
38
|
+
// ---- stubs for inherited / app methods ----
|
|
39
|
+
app: {
|
|
40
|
+
waitForModule: mock.fn(async () => ({})),
|
|
41
|
+
errors: {
|
|
42
|
+
CONTENTPLUGIN_IN_USE: Object.assign(new Error('CONTENTPLUGIN_IN_USE'), {
|
|
43
|
+
code: 'CONTENTPLUGIN_IN_USE',
|
|
44
|
+
setData: mock.fn(function (d) { this.data = d; return this })
|
|
45
|
+
}),
|
|
46
|
+
CONTENTPLUGIN_ALREADY_EXISTS: Object.assign(new Error('CONTENTPLUGIN_ALREADY_EXISTS'), {
|
|
47
|
+
code: 'CONTENTPLUGIN_ALREADY_EXISTS',
|
|
48
|
+
setData: mock.fn(function (d) { this.data = d; return this })
|
|
49
|
+
}),
|
|
50
|
+
CONTENTPLUGIN_INSTALL_FAILED: Object.assign(new Error('CONTENTPLUGIN_INSTALL_FAILED'), {
|
|
51
|
+
code: 'CONTENTPLUGIN_INSTALL_FAILED',
|
|
52
|
+
setData: mock.fn(function (d) { this.data = d; return this })
|
|
53
|
+
}),
|
|
54
|
+
CONTENTPLUGIN_CLI_INSTALL_FAILED: Object.assign(new Error('CONTENTPLUGIN_CLI_INSTALL_FAILED'), {
|
|
55
|
+
code: 'CONTENTPLUGIN_CLI_INSTALL_FAILED',
|
|
56
|
+
setData: mock.fn(function (d) { this.data = d; return this })
|
|
57
|
+
}),
|
|
58
|
+
CONTENTPLUGIN_ATTR_MISSING: Object.assign(new Error('CONTENTPLUGIN_ATTR_MISSING'), {
|
|
59
|
+
code: 'CONTENTPLUGIN_ATTR_MISSING',
|
|
60
|
+
setData: mock.fn(function (d) { this.data = d; return this })
|
|
61
|
+
}),
|
|
62
|
+
CONTENTPLUGIN_INVALID_ZIP: Object.assign(new Error('CONTENTPLUGIN_INVALID_ZIP'), {
|
|
63
|
+
code: 'CONTENTPLUGIN_INVALID_ZIP'
|
|
64
|
+
}),
|
|
65
|
+
NOT_FOUND: Object.assign(new Error('NOT_FOUND'), {
|
|
66
|
+
code: 'NOT_FOUND',
|
|
67
|
+
setData: mock.fn(function (d) { this.data = d; return this })
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
log: mock.fn(),
|
|
72
|
+
find: mock.fn(async () => []),
|
|
73
|
+
insert: mock.fn(async (data) => data),
|
|
74
|
+
update: mock.fn(async (query, data) => data),
|
|
75
|
+
get: mock.fn(async () => null),
|
|
76
|
+
getSchema: mock.fn(async () => null),
|
|
77
|
+
getConfig: mock.fn(() => '/tmp/plugins'),
|
|
78
|
+
mapStatusCode: mock.fn((method) => {
|
|
79
|
+
const map = { get: 200, post: 201, put: 200, delete: 204 }
|
|
80
|
+
return map[method] ?? 200
|
|
81
|
+
}),
|
|
82
|
+
useDefaultRouteConfig: mock.fn(),
|
|
83
|
+
framework: {
|
|
84
|
+
path: '/tmp/framework',
|
|
85
|
+
runCliCommand: mock.fn(async () => []),
|
|
86
|
+
postInstallHook: { tap: mock.fn() },
|
|
87
|
+
postUpdateHook: { tap: mock.fn() },
|
|
88
|
+
getManifestPlugins: mock.fn(async () => []),
|
|
89
|
+
getInstalledPlugins: mock.fn(async () => [])
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
...overrides
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Bind the real methods from the source
|
|
96
|
+
instance.isPluginSchema = isPluginSchema.bind(instance)
|
|
97
|
+
instance.getPluginSchemas = getPluginSchemas.bind(instance)
|
|
98
|
+
instance.readJson = readJson.bind(instance)
|
|
99
|
+
instance.insertOrUpdate = insertOrUpdate.bind(instance)
|
|
100
|
+
instance.installPlugins = installPlugins.bind(instance)
|
|
101
|
+
instance.installHandler = installHandler.bind(instance)
|
|
102
|
+
instance.updateHandler = updateHandler.bind(instance)
|
|
103
|
+
instance.usesHandler = usesHandler.bind(instance)
|
|
104
|
+
instance.serveSchema = serveSchema.bind(instance)
|
|
105
|
+
|
|
106
|
+
return instance
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Re-implementations of the public methods (copied from the source) so we
|
|
111
|
+
// can test them without needing the full import chain.
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
function isPluginSchema (schemaName) {
|
|
115
|
+
for (const p in this.pluginSchemas) {
|
|
116
|
+
if (this.pluginSchemas[p].includes(schemaName)) return true
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getPluginSchemas (pluginName) {
|
|
121
|
+
return this.pluginSchemas[pluginName] ?? []
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function readJson (filepath) {
|
|
125
|
+
return JSON.parse(await fs.readFile(filepath))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function insertOrUpdate (data, options = { useDefaults: true }) {
|
|
129
|
+
return !(await this.find({ name: data.name })).length
|
|
130
|
+
? this.insert(data, options)
|
|
131
|
+
: this.update({ name: data.name }, data, options)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function installPlugins (plugins, options = { strict: false, force: false }) {
|
|
135
|
+
const errors = []
|
|
136
|
+
const installed = []
|
|
137
|
+
await Promise.all(plugins.map(async ([name, versionOrPath]) => {
|
|
138
|
+
try {
|
|
139
|
+
const data = await this.installPlugin(name, versionOrPath, options)
|
|
140
|
+
installed.push(data)
|
|
141
|
+
this.log('info', 'PLUGIN_INSTALL', `${data.name}@${data.version}`)
|
|
142
|
+
} catch (e) {
|
|
143
|
+
this.log('warn', 'PLUGIN_INSTALL_FAIL', name, e?.data?.error ?? e)
|
|
144
|
+
errors.push(e)
|
|
145
|
+
}
|
|
146
|
+
}))
|
|
147
|
+
if (errors.length && options.strict) {
|
|
148
|
+
throw this.app.errors.CONTENTPLUGIN_INSTALL_FAILED
|
|
149
|
+
.setData({ errors })
|
|
150
|
+
}
|
|
151
|
+
return installed
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function serveSchema () {
|
|
155
|
+
return async (req, res, next) => {
|
|
156
|
+
try {
|
|
157
|
+
const plugin = await this.get({ name: req.apiData.query.type }) || {}
|
|
158
|
+
const schema = await this.getSchema(plugin.schemaName)
|
|
159
|
+
if (!schema) {
|
|
160
|
+
return res.sendError(this.app.errors.NOT_FOUND.setData({ type: 'schema', id: plugin.schemaName }))
|
|
161
|
+
}
|
|
162
|
+
res.type('application/schema+json').json(schema)
|
|
163
|
+
} catch (e) {
|
|
164
|
+
return next(e)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function installHandler (req, res, next) {
|
|
170
|
+
try {
|
|
171
|
+
const [pluginData] = await this.installPlugins([
|
|
172
|
+
[
|
|
173
|
+
req.body.name,
|
|
174
|
+
req?.fileUpload?.files?.file?.[0]?.filepath ?? req.body.version
|
|
175
|
+
]
|
|
176
|
+
], {
|
|
177
|
+
force: req.body.force === 'true' || req.body.force === true,
|
|
178
|
+
strict: true
|
|
179
|
+
})
|
|
180
|
+
res.status(this.mapStatusCode('post')).send(pluginData)
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error.code === this.app.errors.CONTENTPLUGIN_INSTALL_FAILED.code) {
|
|
183
|
+
error.data.errors = error.data.errors.map(req.translate)
|
|
184
|
+
}
|
|
185
|
+
res.sendError(error)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function updateHandler (req, res, next) {
|
|
190
|
+
try {
|
|
191
|
+
const pluginData = await this.updatePlugin(req.params._id)
|
|
192
|
+
res.status(this.mapStatusCode('put')).send(pluginData)
|
|
193
|
+
} catch (error) {
|
|
194
|
+
return next(error)
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function usesHandler (req, res, next) {
|
|
199
|
+
try {
|
|
200
|
+
const data = await this.getPluginUses(req.params._id)
|
|
201
|
+
res.status(this.mapStatusCode('put')).send(data)
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return next(error)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ========================================================================
|
|
208
|
+
// Tests
|
|
209
|
+
// ========================================================================
|
|
210
|
+
|
|
211
|
+
describe('ContentPluginModule', () => {
|
|
212
|
+
let inst
|
|
213
|
+
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
inst = createInstance()
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// -----------------------------------------------------------------------
|
|
219
|
+
// isPluginSchema
|
|
220
|
+
// -----------------------------------------------------------------------
|
|
221
|
+
describe('isPluginSchema()', () => {
|
|
222
|
+
it('should return true when the schema is registered by a plugin', () => {
|
|
223
|
+
inst.pluginSchemas = { 'adapt-contrib-vanilla': ['course', 'article'] }
|
|
224
|
+
assert.equal(inst.isPluginSchema('course'), true)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('should return true for schemas in any plugin', () => {
|
|
228
|
+
inst.pluginSchemas = {
|
|
229
|
+
pluginA: ['schemaA'],
|
|
230
|
+
pluginB: ['schemaB', 'schemaC']
|
|
231
|
+
}
|
|
232
|
+
assert.equal(inst.isPluginSchema('schemaC'), true)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should return undefined when schema is not found', () => {
|
|
236
|
+
inst.pluginSchemas = { pluginA: ['schemaA'] }
|
|
237
|
+
assert.equal(inst.isPluginSchema('nonexistent'), undefined)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('should return undefined when pluginSchemas is empty', () => {
|
|
241
|
+
inst.pluginSchemas = {}
|
|
242
|
+
assert.equal(inst.isPluginSchema('anything'), undefined)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it('should handle exact string matching', () => {
|
|
246
|
+
inst.pluginSchemas = { p: ['abc'] }
|
|
247
|
+
assert.equal(inst.isPluginSchema('ab'), undefined)
|
|
248
|
+
assert.equal(inst.isPluginSchema('abcd'), undefined)
|
|
249
|
+
assert.equal(inst.isPluginSchema('abc'), true)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// -----------------------------------------------------------------------
|
|
254
|
+
// getPluginSchemas
|
|
255
|
+
// -----------------------------------------------------------------------
|
|
256
|
+
describe('getPluginSchemas()', () => {
|
|
257
|
+
it('should return the schemas array for a known plugin', () => {
|
|
258
|
+
inst.pluginSchemas = { myPlugin: ['s1', 's2'] }
|
|
259
|
+
assert.deepEqual(inst.getPluginSchemas('myPlugin'), ['s1', 's2'])
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it('should return an empty array for an unknown plugin', () => {
|
|
263
|
+
inst.pluginSchemas = { myPlugin: ['s1'] }
|
|
264
|
+
assert.deepEqual(inst.getPluginSchemas('other'), [])
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('should return an empty array when pluginSchemas is empty', () => {
|
|
268
|
+
inst.pluginSchemas = {}
|
|
269
|
+
assert.deepEqual(inst.getPluginSchemas('anything'), [])
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('should return the actual reference (not a copy)', () => {
|
|
273
|
+
const schemas = ['s1']
|
|
274
|
+
inst.pluginSchemas = { p: schemas }
|
|
275
|
+
assert.equal(inst.getPluginSchemas('p'), schemas)
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
// -----------------------------------------------------------------------
|
|
280
|
+
// readJson
|
|
281
|
+
// -----------------------------------------------------------------------
|
|
282
|
+
describe('readJson()', () => {
|
|
283
|
+
let tmpDir
|
|
284
|
+
|
|
285
|
+
beforeEach(async () => {
|
|
286
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cpm-test-'))
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
afterEach(async () => {
|
|
290
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('should parse a valid JSON file', async () => {
|
|
294
|
+
const filePath = path.join(tmpDir, 'data.json')
|
|
295
|
+
await fs.writeFile(filePath, JSON.stringify({ name: 'test', version: '1.0.0' }))
|
|
296
|
+
const result = await inst.readJson(filePath)
|
|
297
|
+
assert.deepEqual(result, { name: 'test', version: '1.0.0' })
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('should parse a JSON file with nested objects', async () => {
|
|
301
|
+
const filePath = path.join(tmpDir, 'nested.json')
|
|
302
|
+
const data = { a: { b: { c: [1, 2, 3] } } }
|
|
303
|
+
await fs.writeFile(filePath, JSON.stringify(data))
|
|
304
|
+
const result = await inst.readJson(filePath)
|
|
305
|
+
assert.deepEqual(result, data)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('should throw on invalid JSON', async () => {
|
|
309
|
+
const filePath = path.join(tmpDir, 'bad.json')
|
|
310
|
+
await fs.writeFile(filePath, '{ not valid json }')
|
|
311
|
+
await assert.rejects(
|
|
312
|
+
() => inst.readJson(filePath),
|
|
313
|
+
(err) => err instanceof SyntaxError
|
|
314
|
+
)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('should throw when file does not exist', async () => {
|
|
318
|
+
await assert.rejects(
|
|
319
|
+
() => inst.readJson(path.join(tmpDir, 'missing.json')),
|
|
320
|
+
(err) => err.code === 'ENOENT'
|
|
321
|
+
)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it('should handle an empty file as invalid JSON', async () => {
|
|
325
|
+
const filePath = path.join(tmpDir, 'empty.json')
|
|
326
|
+
await fs.writeFile(filePath, '')
|
|
327
|
+
await assert.rejects(
|
|
328
|
+
() => inst.readJson(filePath),
|
|
329
|
+
(err) => err instanceof SyntaxError
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
// -----------------------------------------------------------------------
|
|
335
|
+
// insertOrUpdate
|
|
336
|
+
// -----------------------------------------------------------------------
|
|
337
|
+
describe('insertOrUpdate()', () => {
|
|
338
|
+
it('should call insert when no existing document is found', async () => {
|
|
339
|
+
inst.find = mock.fn(async () => [])
|
|
340
|
+
inst.insert = mock.fn(async (data) => ({ ...data, _id: 'new123' }))
|
|
341
|
+
|
|
342
|
+
const result = await inst.insertOrUpdate({ name: 'myPlugin', version: '1.0.0' })
|
|
343
|
+
|
|
344
|
+
assert.equal(inst.find.mock.callCount(), 1)
|
|
345
|
+
assert.deepEqual(inst.find.mock.calls[0].arguments[0], { name: 'myPlugin' })
|
|
346
|
+
assert.equal(inst.insert.mock.callCount(), 1)
|
|
347
|
+
assert.equal(result._id, 'new123')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('should call update when an existing document is found', async () => {
|
|
351
|
+
inst.find = mock.fn(async () => [{ name: 'myPlugin', version: '0.9.0' }])
|
|
352
|
+
inst.update = mock.fn(async (query, data) => ({ ...data, updated: true }))
|
|
353
|
+
|
|
354
|
+
const result = await inst.insertOrUpdate({ name: 'myPlugin', version: '1.0.0' })
|
|
355
|
+
|
|
356
|
+
assert.equal(inst.update.mock.callCount(), 1)
|
|
357
|
+
assert.deepEqual(inst.update.mock.calls[0].arguments[0], { name: 'myPlugin' })
|
|
358
|
+
assert.equal(result.updated, true)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('should pass default options to insert', async () => {
|
|
362
|
+
inst.find = mock.fn(async () => [])
|
|
363
|
+
inst.insert = mock.fn(async (data, opts) => opts)
|
|
364
|
+
|
|
365
|
+
const result = await inst.insertOrUpdate({ name: 'p' })
|
|
366
|
+
assert.deepEqual(result, { useDefaults: true })
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('should pass default options to update', async () => {
|
|
370
|
+
inst.find = mock.fn(async () => [{ name: 'p' }])
|
|
371
|
+
inst.update = mock.fn(async (q, data, opts) => opts)
|
|
372
|
+
|
|
373
|
+
const result = await inst.insertOrUpdate({ name: 'p' })
|
|
374
|
+
assert.deepEqual(result, { useDefaults: true })
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it('should accept custom options', async () => {
|
|
378
|
+
inst.find = mock.fn(async () => [])
|
|
379
|
+
inst.insert = mock.fn(async (data, opts) => opts)
|
|
380
|
+
|
|
381
|
+
const result = await inst.insertOrUpdate({ name: 'p' }, { useDefaults: false })
|
|
382
|
+
assert.deepEqual(result, { useDefaults: false })
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
// -----------------------------------------------------------------------
|
|
387
|
+
// installPlugins
|
|
388
|
+
// -----------------------------------------------------------------------
|
|
389
|
+
describe('installPlugins()', () => {
|
|
390
|
+
it('should install multiple plugins and return results', async () => {
|
|
391
|
+
inst.installPlugin = mock.fn(async (name, ver) => ({
|
|
392
|
+
name,
|
|
393
|
+
version: ver
|
|
394
|
+
}))
|
|
395
|
+
const result = await inst.installPlugins([
|
|
396
|
+
['pluginA', '1.0.0'],
|
|
397
|
+
['pluginB', '2.0.0']
|
|
398
|
+
])
|
|
399
|
+
assert.equal(result.length, 2)
|
|
400
|
+
assert.equal(result[0].name, 'pluginA')
|
|
401
|
+
assert.equal(result[1].name, 'pluginB')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('should log a warning and continue when a plugin fails (non-strict)', async () => {
|
|
405
|
+
inst.installPlugin = mock.fn(async (name) => {
|
|
406
|
+
if (name === 'bad') throw new Error('fail')
|
|
407
|
+
return { name, version: '1.0.0' }
|
|
408
|
+
})
|
|
409
|
+
const result = await inst.installPlugins([
|
|
410
|
+
['good', '1.0.0'],
|
|
411
|
+
['bad', '1.0.0']
|
|
412
|
+
])
|
|
413
|
+
assert.equal(result.length, 1)
|
|
414
|
+
assert.equal(result[0].name, 'good')
|
|
415
|
+
// Should have logged a warning
|
|
416
|
+
const warnCalls = inst.log.mock.calls.filter(
|
|
417
|
+
c => c.arguments[0] === 'warn'
|
|
418
|
+
)
|
|
419
|
+
assert.equal(warnCalls.length, 1)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('should throw when strict mode is enabled and a plugin fails', async () => {
|
|
423
|
+
inst.installPlugin = mock.fn(async () => {
|
|
424
|
+
throw new Error('fail')
|
|
425
|
+
})
|
|
426
|
+
await assert.rejects(
|
|
427
|
+
() => inst.installPlugins([['bad', '1.0.0']], { strict: true, force: false }),
|
|
428
|
+
(err) => err.message === 'CONTENTPLUGIN_INSTALL_FAILED'
|
|
429
|
+
)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
it('should not throw when strict mode is enabled and all succeed', async () => {
|
|
433
|
+
inst.installPlugin = mock.fn(async (name, ver) => ({ name, version: ver }))
|
|
434
|
+
const result = await inst.installPlugins(
|
|
435
|
+
[['p', '1.0.0']],
|
|
436
|
+
{ strict: true, force: false }
|
|
437
|
+
)
|
|
438
|
+
assert.equal(result.length, 1)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('should return an empty array when no plugins are given', async () => {
|
|
442
|
+
inst.installPlugin = mock.fn()
|
|
443
|
+
const result = await inst.installPlugins([])
|
|
444
|
+
assert.deepEqual(result, [])
|
|
445
|
+
assert.equal(inst.installPlugin.mock.callCount(), 0)
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
it('should log info for each successfully installed plugin', async () => {
|
|
449
|
+
inst.installPlugin = mock.fn(async (name, ver) => ({ name, version: ver }))
|
|
450
|
+
await inst.installPlugins([['p1', '1.0.0'], ['p2', '2.0.0']])
|
|
451
|
+
const infoCalls = inst.log.mock.calls.filter(
|
|
452
|
+
c => c.arguments[0] === 'info'
|
|
453
|
+
)
|
|
454
|
+
assert.equal(infoCalls.length, 2)
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
it('should extract error data for warning logs', async () => {
|
|
458
|
+
const dataError = new Error('fail')
|
|
459
|
+
dataError.data = { error: 'specific error message' }
|
|
460
|
+
inst.installPlugin = mock.fn(async () => { throw dataError })
|
|
461
|
+
await inst.installPlugins([['bad', '1.0.0']])
|
|
462
|
+
const warnCalls = inst.log.mock.calls.filter(
|
|
463
|
+
c => c.arguments[0] === 'warn'
|
|
464
|
+
)
|
|
465
|
+
assert.equal(warnCalls.length, 1)
|
|
466
|
+
assert.equal(warnCalls[0].arguments[3], 'specific error message')
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
// -----------------------------------------------------------------------
|
|
471
|
+
// serveSchema
|
|
472
|
+
// -----------------------------------------------------------------------
|
|
473
|
+
describe('serveSchema()', () => {
|
|
474
|
+
it('should return a middleware function', () => {
|
|
475
|
+
const middleware = inst.serveSchema()
|
|
476
|
+
assert.equal(typeof middleware, 'function')
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('should send schema JSON when found', async () => {
|
|
480
|
+
const schemaData = { type: 'object', properties: {} }
|
|
481
|
+
inst.get = mock.fn(async () => ({ schemaName: 'mySchema' }))
|
|
482
|
+
inst.getSchema = mock.fn(async () => schemaData)
|
|
483
|
+
|
|
484
|
+
const req = { apiData: { query: { type: 'myPlugin' } } }
|
|
485
|
+
let sentType = null
|
|
486
|
+
let sentJson = null
|
|
487
|
+
const res = {
|
|
488
|
+
type: mock.fn(function (t) { sentType = t; return this }),
|
|
489
|
+
json: mock.fn((data) => { sentJson = data }),
|
|
490
|
+
sendError: mock.fn()
|
|
491
|
+
}
|
|
492
|
+
const next = mock.fn()
|
|
493
|
+
|
|
494
|
+
const handler = inst.serveSchema()
|
|
495
|
+
await handler(req, res, next)
|
|
496
|
+
|
|
497
|
+
assert.equal(sentType, 'application/schema+json')
|
|
498
|
+
assert.deepEqual(sentJson, schemaData)
|
|
499
|
+
assert.equal(res.sendError.mock.callCount(), 0)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
it('should send NOT_FOUND error when schema is null', async () => {
|
|
503
|
+
inst.get = mock.fn(async () => ({ schemaName: 'missing' }))
|
|
504
|
+
inst.getSchema = mock.fn(async () => null)
|
|
505
|
+
|
|
506
|
+
const req = { apiData: { query: { type: 'myPlugin' } } }
|
|
507
|
+
const res = {
|
|
508
|
+
type: mock.fn(function () { return this }),
|
|
509
|
+
json: mock.fn(),
|
|
510
|
+
sendError: mock.fn()
|
|
511
|
+
}
|
|
512
|
+
const next = mock.fn()
|
|
513
|
+
|
|
514
|
+
const handler = inst.serveSchema()
|
|
515
|
+
await handler(req, res, next)
|
|
516
|
+
|
|
517
|
+
assert.equal(res.sendError.mock.callCount(), 1)
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('should use empty object fallback when get returns null', async () => {
|
|
521
|
+
inst.get = mock.fn(async () => null)
|
|
522
|
+
inst.getSchema = mock.fn(async () => null)
|
|
523
|
+
|
|
524
|
+
const req = { apiData: { query: { type: 'unknown' } } }
|
|
525
|
+
const res = {
|
|
526
|
+
type: mock.fn(function () { return this }),
|
|
527
|
+
json: mock.fn(),
|
|
528
|
+
sendError: mock.fn()
|
|
529
|
+
}
|
|
530
|
+
const next = mock.fn()
|
|
531
|
+
|
|
532
|
+
const handler = inst.serveSchema()
|
|
533
|
+
await handler(req, res, next)
|
|
534
|
+
|
|
535
|
+
// getSchema should be called with undefined (from {}.schemaName)
|
|
536
|
+
assert.equal(inst.getSchema.mock.calls[0].arguments[0], undefined)
|
|
537
|
+
assert.equal(res.sendError.mock.callCount(), 1)
|
|
538
|
+
})
|
|
539
|
+
|
|
540
|
+
it('should call next with the error when an exception occurs', async () => {
|
|
541
|
+
const testError = new Error('unexpected')
|
|
542
|
+
inst.get = mock.fn(async () => { throw testError })
|
|
543
|
+
|
|
544
|
+
const req = { apiData: { query: { type: 'x' } } }
|
|
545
|
+
const res = { sendError: mock.fn() }
|
|
546
|
+
const next = mock.fn()
|
|
547
|
+
|
|
548
|
+
const handler = inst.serveSchema()
|
|
549
|
+
await handler(req, res, next)
|
|
550
|
+
|
|
551
|
+
assert.equal(next.mock.callCount(), 1)
|
|
552
|
+
assert.equal(next.mock.calls[0].arguments[0], testError)
|
|
553
|
+
})
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
// -----------------------------------------------------------------------
|
|
557
|
+
// installHandler
|
|
558
|
+
// -----------------------------------------------------------------------
|
|
559
|
+
describe('installHandler()', () => {
|
|
560
|
+
it('should respond with plugin data on success', async () => {
|
|
561
|
+
const pluginResult = { name: 'myPlugin', version: '1.0.0' }
|
|
562
|
+
inst.installPlugins = mock.fn(async () => [pluginResult])
|
|
563
|
+
|
|
564
|
+
const req = {
|
|
565
|
+
body: { name: 'myPlugin', version: '1.0.0' }
|
|
566
|
+
}
|
|
567
|
+
let sentStatus = null
|
|
568
|
+
let sentData = null
|
|
569
|
+
const res = {
|
|
570
|
+
status: mock.fn(function (s) { sentStatus = s; return this }),
|
|
571
|
+
send: mock.fn((d) => { sentData = d }),
|
|
572
|
+
sendError: mock.fn()
|
|
573
|
+
}
|
|
574
|
+
const next = mock.fn()
|
|
575
|
+
|
|
576
|
+
await inst.installHandler(req, res, next)
|
|
577
|
+
|
|
578
|
+
assert.equal(sentStatus, 201)
|
|
579
|
+
assert.deepEqual(sentData, pluginResult)
|
|
580
|
+
})
|
|
581
|
+
|
|
582
|
+
it('should use file upload path when available', async () => {
|
|
583
|
+
const pluginResult = { name: 'myPlugin', version: '1.0.0' }
|
|
584
|
+
inst.installPlugins = mock.fn(async (plugins) => {
|
|
585
|
+
assert.equal(plugins[0][1], '/tmp/upload/plugin.zip')
|
|
586
|
+
return [pluginResult]
|
|
587
|
+
})
|
|
588
|
+
|
|
589
|
+
const req = {
|
|
590
|
+
body: { name: 'myPlugin', version: '1.0.0' },
|
|
591
|
+
fileUpload: { files: { file: [{ filepath: '/tmp/upload/plugin.zip' }] } }
|
|
592
|
+
}
|
|
593
|
+
const res = {
|
|
594
|
+
status: mock.fn(function () { return this }),
|
|
595
|
+
send: mock.fn(),
|
|
596
|
+
sendError: mock.fn()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
await inst.installHandler(req, res, mock.fn())
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it('should fall back to body version when no file upload', async () => {
|
|
603
|
+
inst.installPlugins = mock.fn(async (plugins) => {
|
|
604
|
+
assert.equal(plugins[0][1], '2.0.0')
|
|
605
|
+
return [{ name: 'p', version: '2.0.0' }]
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
const req = { body: { name: 'p', version: '2.0.0' } }
|
|
609
|
+
const res = {
|
|
610
|
+
status: mock.fn(function () { return this }),
|
|
611
|
+
send: mock.fn(),
|
|
612
|
+
sendError: mock.fn()
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
await inst.installHandler(req, res, mock.fn())
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
it('should pass force=true when body.force is string "true"', async () => {
|
|
619
|
+
inst.installPlugins = mock.fn(async (plugins, opts) => {
|
|
620
|
+
assert.equal(opts.force, true)
|
|
621
|
+
return [{ name: 'p', version: '1.0.0' }]
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
const req = { body: { name: 'p', version: '1.0.0', force: 'true' } }
|
|
625
|
+
const res = {
|
|
626
|
+
status: mock.fn(function () { return this }),
|
|
627
|
+
send: mock.fn(),
|
|
628
|
+
sendError: mock.fn()
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
await inst.installHandler(req, res, mock.fn())
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('should pass force=true when body.force is boolean true', async () => {
|
|
635
|
+
inst.installPlugins = mock.fn(async (plugins, opts) => {
|
|
636
|
+
assert.equal(opts.force, true)
|
|
637
|
+
return [{ name: 'p', version: '1.0.0' }]
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
const req = { body: { name: 'p', version: '1.0.0', force: true } }
|
|
641
|
+
const res = {
|
|
642
|
+
status: mock.fn(function () { return this }),
|
|
643
|
+
send: mock.fn(),
|
|
644
|
+
sendError: mock.fn()
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
await inst.installHandler(req, res, mock.fn())
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it('should pass force=false for other values', async () => {
|
|
651
|
+
inst.installPlugins = mock.fn(async (plugins, opts) => {
|
|
652
|
+
assert.equal(opts.force, false)
|
|
653
|
+
return [{ name: 'p', version: '1.0.0' }]
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
const req = { body: { name: 'p', version: '1.0.0', force: 'false' } }
|
|
657
|
+
const res = {
|
|
658
|
+
status: mock.fn(function () { return this }),
|
|
659
|
+
send: mock.fn(),
|
|
660
|
+
sendError: mock.fn()
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
await inst.installHandler(req, res, mock.fn())
|
|
664
|
+
})
|
|
665
|
+
|
|
666
|
+
it('should sendError on failure', async () => {
|
|
667
|
+
const testError = new Error('install failed')
|
|
668
|
+
testError.code = 'SOME_OTHER_ERROR'
|
|
669
|
+
inst.installPlugins = mock.fn(async () => { throw testError })
|
|
670
|
+
|
|
671
|
+
const req = { body: { name: 'p', version: '1.0.0' } }
|
|
672
|
+
const res = {
|
|
673
|
+
status: mock.fn(function () { return this }),
|
|
674
|
+
send: mock.fn(),
|
|
675
|
+
sendError: mock.fn()
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
await inst.installHandler(req, res, mock.fn())
|
|
679
|
+
|
|
680
|
+
assert.equal(res.sendError.mock.callCount(), 1)
|
|
681
|
+
assert.equal(res.sendError.mock.calls[0].arguments[0], testError)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
it('should translate errors when code matches CONTENTPLUGIN_INSTALL_FAILED', async () => {
|
|
685
|
+
const installError = Object.assign(new Error('CONTENTPLUGIN_INSTALL_FAILED'), {
|
|
686
|
+
code: 'CONTENTPLUGIN_INSTALL_FAILED',
|
|
687
|
+
data: { errors: ['err1', 'err2'] }
|
|
688
|
+
})
|
|
689
|
+
inst.installPlugins = mock.fn(async () => { throw installError })
|
|
690
|
+
|
|
691
|
+
const req = {
|
|
692
|
+
body: { name: 'p', version: '1.0.0' },
|
|
693
|
+
translate: mock.fn((e) => `translated:${e}`)
|
|
694
|
+
}
|
|
695
|
+
const res = {
|
|
696
|
+
status: mock.fn(function () { return this }),
|
|
697
|
+
send: mock.fn(),
|
|
698
|
+
sendError: mock.fn()
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
await inst.installHandler(req, res, mock.fn())
|
|
702
|
+
|
|
703
|
+
assert.equal(req.translate.mock.callCount(), 2)
|
|
704
|
+
assert.deepEqual(installError.data.errors, ['translated:err1', 'translated:err2'])
|
|
705
|
+
})
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
// -----------------------------------------------------------------------
|
|
709
|
+
// updateHandler
|
|
710
|
+
// -----------------------------------------------------------------------
|
|
711
|
+
describe('updateHandler()', () => {
|
|
712
|
+
it('should respond with plugin data on success', async () => {
|
|
713
|
+
const pluginData = { name: 'p', version: '2.0.0' }
|
|
714
|
+
inst.updatePlugin = mock.fn(async () => pluginData)
|
|
715
|
+
|
|
716
|
+
const req = { params: { _id: 'id123' } }
|
|
717
|
+
let sentStatus = null
|
|
718
|
+
let sentData = null
|
|
719
|
+
const res = {
|
|
720
|
+
status: mock.fn(function (s) { sentStatus = s; return this }),
|
|
721
|
+
send: mock.fn((d) => { sentData = d })
|
|
722
|
+
}
|
|
723
|
+
const next = mock.fn()
|
|
724
|
+
|
|
725
|
+
await inst.updateHandler(req, res, next)
|
|
726
|
+
|
|
727
|
+
assert.equal(sentStatus, 200)
|
|
728
|
+
assert.deepEqual(sentData, pluginData)
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('should call next with error on failure', async () => {
|
|
732
|
+
const testError = new Error('update failed')
|
|
733
|
+
inst.updatePlugin = mock.fn(async () => { throw testError })
|
|
734
|
+
|
|
735
|
+
const req = { params: { _id: 'id123' } }
|
|
736
|
+
const res = {
|
|
737
|
+
status: mock.fn(function () { return this }),
|
|
738
|
+
send: mock.fn()
|
|
739
|
+
}
|
|
740
|
+
const next = mock.fn()
|
|
741
|
+
|
|
742
|
+
await inst.updateHandler(req, res, next)
|
|
743
|
+
|
|
744
|
+
assert.equal(next.mock.callCount(), 1)
|
|
745
|
+
assert.equal(next.mock.calls[0].arguments[0], testError)
|
|
746
|
+
})
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
// -----------------------------------------------------------------------
|
|
750
|
+
// usesHandler
|
|
751
|
+
// -----------------------------------------------------------------------
|
|
752
|
+
describe('usesHandler()', () => {
|
|
753
|
+
it('should respond with uses data on success', async () => {
|
|
754
|
+
const usesData = [{ title: 'Course A' }, { title: 'Course B' }]
|
|
755
|
+
inst.getPluginUses = mock.fn(async () => usesData)
|
|
756
|
+
|
|
757
|
+
const req = { params: { _id: 'pid1' } }
|
|
758
|
+
let sentStatus = null
|
|
759
|
+
let sentData = null
|
|
760
|
+
const res = {
|
|
761
|
+
status: mock.fn(function (s) { sentStatus = s; return this }),
|
|
762
|
+
send: mock.fn((d) => { sentData = d })
|
|
763
|
+
}
|
|
764
|
+
const next = mock.fn()
|
|
765
|
+
|
|
766
|
+
await inst.usesHandler(req, res, next)
|
|
767
|
+
|
|
768
|
+
assert.equal(sentStatus, 200)
|
|
769
|
+
assert.deepEqual(sentData, usesData)
|
|
770
|
+
})
|
|
771
|
+
|
|
772
|
+
it('should respond with empty array when no uses', async () => {
|
|
773
|
+
inst.getPluginUses = mock.fn(async () => [])
|
|
774
|
+
|
|
775
|
+
const req = { params: { _id: 'pid1' } }
|
|
776
|
+
let sentData = null
|
|
777
|
+
const res = {
|
|
778
|
+
status: mock.fn(function () { return this }),
|
|
779
|
+
send: mock.fn((d) => { sentData = d })
|
|
780
|
+
}
|
|
781
|
+
const next = mock.fn()
|
|
782
|
+
|
|
783
|
+
await inst.usesHandler(req, res, next)
|
|
784
|
+
|
|
785
|
+
assert.deepEqual(sentData, [])
|
|
786
|
+
})
|
|
787
|
+
|
|
788
|
+
it('should call next with error on failure', async () => {
|
|
789
|
+
const testError = new Error('uses failed')
|
|
790
|
+
inst.getPluginUses = mock.fn(async () => { throw testError })
|
|
791
|
+
|
|
792
|
+
const req = { params: { _id: 'pid1' } }
|
|
793
|
+
const res = {
|
|
794
|
+
status: mock.fn(function () { return this }),
|
|
795
|
+
send: mock.fn()
|
|
796
|
+
}
|
|
797
|
+
const next = mock.fn()
|
|
798
|
+
|
|
799
|
+
await inst.usesHandler(req, res, next)
|
|
800
|
+
|
|
801
|
+
assert.equal(next.mock.callCount(), 1)
|
|
802
|
+
assert.equal(next.mock.calls[0].arguments[0], testError)
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
|
|
806
|
+
// -----------------------------------------------------------------------
|
|
807
|
+
// Bug documentation: isPluginSchema returns undefined instead of false
|
|
808
|
+
// -----------------------------------------------------------------------
|
|
809
|
+
describe('isPluginSchema() - TODO: potential bug', () => {
|
|
810
|
+
it('TODO: isPluginSchema returns undefined instead of false when not found', () => {
|
|
811
|
+
inst.pluginSchemas = { p: ['a'] }
|
|
812
|
+
const result = inst.isPluginSchema('nonexistent')
|
|
813
|
+
// The method does not have an explicit return statement for the
|
|
814
|
+
// false case, so it returns undefined instead of false.
|
|
815
|
+
assert.equal(result, undefined)
|
|
816
|
+
// If this were fixed, the assertion would be:
|
|
817
|
+
// assert.equal(result, false)
|
|
818
|
+
})
|
|
819
|
+
})
|
|
820
|
+
})
|