adapt-authoring-contentplugin 1.0.9 → 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
|
|
@@ -187,9 +187,32 @@ class ContentPluginModule extends AbstractApiModule {
|
|
|
187
187
|
return (await this.framework.getManifestPlugins()).map(([name, version]) => `${name}@${version}`)
|
|
188
188
|
}
|
|
189
189
|
const fwPlugins = await this.framework.getInstalledPlugins()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
const missingPlugins = dbPlugins.filter(dbP => !fwPlugins.find(fwP => dbP.name === fwP.name))
|
|
191
|
+
// For local installs, check if backup exists if main plugin directory doesn't
|
|
192
|
+
const pluginsWithPaths = await Promise.all(missingPlugins.map(async (p) => {
|
|
193
|
+
if (!p.isLocalInstall) {
|
|
194
|
+
return `${p.name}@${p.version}`
|
|
195
|
+
}
|
|
196
|
+
const pluginDir = this.getConfig('pluginDir')
|
|
197
|
+
const pluginPath = path.join(pluginDir, p.name)
|
|
198
|
+
// Check if the main plugin directory exists
|
|
199
|
+
try {
|
|
200
|
+
await fs.access(pluginPath)
|
|
201
|
+
return `${p.name}@${pluginPath}`
|
|
202
|
+
} catch (e) {
|
|
203
|
+
// Check for backups
|
|
204
|
+
if (e.code && e.code !== 'ENOENT' && e.code !== 'ENOTDIR') {
|
|
205
|
+
this.log('warn', `Unexpected error accessing ${pluginPath}: ${e.code}`)
|
|
206
|
+
}
|
|
207
|
+
const mostRecentBackup = await this.getMostRecentBackup(pluginDir, p.name)
|
|
208
|
+
if (mostRecentBackup) {
|
|
209
|
+
return `${p.name}@${mostRecentBackup}`
|
|
210
|
+
}
|
|
211
|
+
// No backup found, return the standard path (will likely fail, but consistent with original behavior)
|
|
212
|
+
return `${p.name}@${pluginPath}`
|
|
213
|
+
}
|
|
214
|
+
}))
|
|
215
|
+
return pluginsWithPaths
|
|
193
216
|
}
|
|
194
217
|
|
|
195
218
|
/**
|
|
@@ -330,6 +353,134 @@ class ContentPluginModule extends AbstractApiModule {
|
|
|
330
353
|
return info
|
|
331
354
|
}
|
|
332
355
|
|
|
356
|
+
/**
|
|
357
|
+
* Creates a backup of an existing plugin directory with version information
|
|
358
|
+
* @param {String} pluginPath Path to the plugin directory
|
|
359
|
+
* @param {String} pluginName Name of the plugin
|
|
360
|
+
* @returns {Promise<String|null>} Path to the backup directory, or null if no backup was created
|
|
361
|
+
*/
|
|
362
|
+
async backupPluginVersion (pluginPath, pluginName) {
|
|
363
|
+
try {
|
|
364
|
+
await fs.access(pluginPath)
|
|
365
|
+
} catch (e) { // No plugin, no backup needed
|
|
366
|
+
return null
|
|
367
|
+
}
|
|
368
|
+
let existingVersion
|
|
369
|
+
try {
|
|
370
|
+
const pkgPath = path.join(pluginPath, 'package.json')
|
|
371
|
+
const pkg = await this.readJson(pkgPath)
|
|
372
|
+
existingVersion = pkg.version
|
|
373
|
+
} catch (e) {
|
|
374
|
+
try {
|
|
375
|
+
const bowerPath = path.join(pluginPath, 'bower.json')
|
|
376
|
+
const bower = await this.readJson(bowerPath)
|
|
377
|
+
existingVersion = bower.version
|
|
378
|
+
} catch (e2) {
|
|
379
|
+
this.log('warn', `Could not read version for backup of ${pluginName}`)
|
|
380
|
+
existingVersion = `unknown-${Date.now()}`
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const backupDir = `${pluginPath}-v${existingVersion}`
|
|
384
|
+
await fs.rename(pluginPath, backupDir)
|
|
385
|
+
this.log('info', `Backed up ${pluginName}@${existingVersion}`)
|
|
386
|
+
return backupDir
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Gets the most recent backup for a plugin based on version sorting
|
|
391
|
+
* @param {String} pluginDir Base directory containing plugins
|
|
392
|
+
* @param {String} pluginName Name of the plugin
|
|
393
|
+
* @returns {Promise<String|null>} Path to the most recent backup, or null if none found
|
|
394
|
+
*/
|
|
395
|
+
async getMostRecentBackup (pluginDir, pluginName) {
|
|
396
|
+
const pattern = `${pluginName}-v*`
|
|
397
|
+
const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
|
|
398
|
+
|
|
399
|
+
if (backups.length === 0) {
|
|
400
|
+
return null
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Sort by version (newest first)
|
|
404
|
+
// Extract version from backup path (format: pluginName-vX.Y.Z)
|
|
405
|
+
backups.sort((a, b) => {
|
|
406
|
+
const versionA = path.basename(a).replace(`${pluginName}-v`, '')
|
|
407
|
+
const versionB = path.basename(b).replace(`${pluginName}-v`, '')
|
|
408
|
+
|
|
409
|
+
// Try to compare as semver versions
|
|
410
|
+
if (semver.valid(versionA) && semver.valid(versionB)) {
|
|
411
|
+
return semver.rcompare(versionA, versionB) // descending order
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Fall back to alphabetical comparison for non-semver versions (e.g., unknown-timestamp)
|
|
415
|
+
return b.localeCompare(a)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
return backups[0]
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Cleans up old plugin version backups, keeping only the most recent one
|
|
423
|
+
* @param {String} pluginDir Base directory containing plugins
|
|
424
|
+
* @param {String} pluginName Name of the plugin
|
|
425
|
+
* @returns {Promise<void>}
|
|
426
|
+
*/
|
|
427
|
+
async cleanupOldPluginBackups (pluginDir, pluginName) {
|
|
428
|
+
const pattern = `${pluginName}-v*`
|
|
429
|
+
const backups = await glob(pattern, { cwd: pluginDir, absolute: true })
|
|
430
|
+
|
|
431
|
+
if (backups.length <= 1) {
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Get the most recent backup using the helper
|
|
436
|
+
const mostRecent = await this.getMostRecentBackup(pluginDir, pluginName)
|
|
437
|
+
|
|
438
|
+
// Remove all backups except the most recent
|
|
439
|
+
const backupsToRemove = backups.filter(backup => backup !== mostRecent)
|
|
440
|
+
for (const backup of backupsToRemove) {
|
|
441
|
+
await fs.rm(backup, { recursive: true })
|
|
442
|
+
this.log('info', `Removed old backup: ${backup}`)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Restores a plugin from the most recent backup
|
|
448
|
+
* @param {String} pluginName Name of the plugin to restore
|
|
449
|
+
* @returns {Promise<Object>} Resolves with restored plugin info
|
|
450
|
+
*/
|
|
451
|
+
async restorePluginFromBackup (pluginName) {
|
|
452
|
+
const pluginDir = this.getConfig('pluginDir')
|
|
453
|
+
const pluginPath = path.join(pluginDir, pluginName)
|
|
454
|
+
const mostRecentBackup = await this.getMostRecentBackup(pluginDir, pluginName)
|
|
455
|
+
|
|
456
|
+
if (!mostRecentBackup) {
|
|
457
|
+
throw this.app.errors.NOT_FOUND
|
|
458
|
+
.setData({ type: 'backup', id: pluginName })
|
|
459
|
+
}
|
|
460
|
+
// Remove current version if it exists
|
|
461
|
+
try {
|
|
462
|
+
await fs.access(pluginPath)
|
|
463
|
+
await fs.rm(pluginPath, { recursive: true })
|
|
464
|
+
} catch (e) {
|
|
465
|
+
// Current version doesn't exist, that's fine
|
|
466
|
+
}
|
|
467
|
+
// Restore the backup
|
|
468
|
+
await fs.rename(mostRecentBackup, pluginPath)
|
|
469
|
+
this.log('info', `Restored ${pluginName} from backup`)
|
|
470
|
+
let pkg
|
|
471
|
+
try {
|
|
472
|
+
pkg = await this.readJson(path.join(pluginPath, 'package.json'))
|
|
473
|
+
} catch (e) {
|
|
474
|
+
try {
|
|
475
|
+
pkg = await this.readJson(path.join(pluginPath, 'bower.json'))
|
|
476
|
+
} catch (e2) {
|
|
477
|
+
throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
|
|
478
|
+
.setData({ pluginName, message: 'Could not read package.json or bower.json from backup' })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return pkg
|
|
482
|
+
}
|
|
483
|
+
|
|
333
484
|
/**
|
|
334
485
|
* Ensures local plugin source files are stored in the correct location and structured in an expected way
|
|
335
486
|
* @param {Object} pluginData Plugin metadata
|
|
@@ -357,6 +508,15 @@ class ContentPluginModule extends AbstractApiModule {
|
|
|
357
508
|
} catch (e) {
|
|
358
509
|
throw this.app.errors.CONTENTPLUGIN_INVALID_ZIP
|
|
359
510
|
}
|
|
511
|
+
|
|
512
|
+
const pluginDir = this.getConfig('pluginDir')
|
|
513
|
+
|
|
514
|
+
// Back up the existing version if it exists
|
|
515
|
+
await this.backupPluginVersion(pkg.sourcePath, pkg.name)
|
|
516
|
+
|
|
517
|
+
// Clean up old backups (keep only 1 previous version)
|
|
518
|
+
await this.cleanupOldPluginBackups(pluginDir, pkg.name)
|
|
519
|
+
|
|
360
520
|
// move the files into the persistent location
|
|
361
521
|
await fs.cp(sourcePath, pkg.sourcePath, { recursive: true })
|
|
362
522
|
await fs.rm(sourcePath, { recursive: true })
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-contentplugin",
|
|
3
|
-
"version": "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
|
+
})
|