adapt-authoring-contentplugin 1.5.2 → 1.5.4

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