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.1.0",
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
+ })