adapt-authoring-adaptframework 2.0.8 → 2.0.10

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.
@@ -431,6 +431,7 @@ class AdaptFrameworkImport {
431
431
  if (pluginData[type] !== undefined) return type
432
432
  }
433
433
  }
434
+ this.configEnabledPlugins = this.contentJson.config?._enabledPlugins ?? []
434
435
  await Promise.all(usedPluginPaths.map(async p => {
435
436
  const bowerJson = await readJson(`${p}/bower.json`)
436
437
  const { name, version, targetAttribute } = bowerJson
@@ -613,6 +614,17 @@ class AdaptFrameworkImport {
613
614
  */
614
615
  async importCoursePlugins () {
615
616
  this.installedPlugins = (await this.contentplugin.find({})).reduce((m, p) => Object.assign(m, { [p.name]: p }), {})
617
+ const missingFromBoth = this.configEnabledPlugins.filter(p =>
618
+ !this.usedContentPlugins[p] && !this.installedPlugins[p]
619
+ )
620
+ if (missingFromBoth.length) {
621
+ if (this.settings.isDryRun) {
622
+ this.statusReport.error.push({ code: 'MISSING_PLUGINS', data: missingFromBoth })
623
+ return
624
+ }
625
+ throw App.instance.errors.FW_IMPORT_MISSING_PLUGINS
626
+ .setData({ plugins: missingFromBoth.join(', ') })
627
+ }
616
628
  const pluginsToInstall = []
617
629
  const pluginsToUpdate = []
618
630
 
@@ -858,38 +870,67 @@ class AdaptFrameworkImport {
858
870
  * @return {Promise}
859
871
  */
860
872
  async cleanUp (error) {
861
- if (!this.settings.removeSource) {
862
- return
873
+ if (error) {
874
+ await this.rollback()
863
875
  }
864
- try {
865
- const tasks = [fs.rm(this.path, { recursive: true })]
866
- if (error) {
867
- // Uninstall newly installed plugins
868
- tasks.push(Promise.all(Object.values(this.newContentPlugins).map(p => this.contentplugin.uninstallPlugin(p._id))))
869
- // Restore updated plugins to their original versions
870
- if (Object.keys(this.updatedContentPlugins).length > 0) {
871
- tasks.push(this.restoreUpdatedPlugins())
872
- }
873
- // Delete imported assets
874
- tasks.push(Promise.all(Object.values(this.assetMap).map(a => this.assets.delete({ _id: a }))))
875
- // Delete newly created tags
876
- if (this.newTagIds.length > 0) {
877
- const tags = await App.instance.waitForModule('tags')
878
- tasks.push(Promise.all(this.newTagIds.map(id => tags.delete({ _id: id }))))
879
- }
880
- let _courseId
881
- try {
882
- _courseId = parseObjectId(this.idMap[this.contentJson.course._id])
883
- } catch (e) {}
884
- if (_courseId) {
885
- tasks.push(
886
- this.content.deleteMany({ _courseId }),
887
- this.courseassets.deleteMany({ courseId: _courseId })
888
- )
889
- }
876
+ if (this.settings.removeSource) {
877
+ try {
878
+ await fs.rm(this.path, { recursive: true })
879
+ } catch (e) {} // ignore source removal errors
880
+ }
881
+ }
882
+
883
+ /**
884
+ * Rolls back all changes made during a failed import
885
+ * @return {Promise}
886
+ */
887
+ async rollback () {
888
+ log('info', 'rolling back failed import')
889
+ const tasks = []
890
+ // Uninstall newly installed plugins
891
+ if (this.contentplugin) {
892
+ tasks.push(...Object.values(this.newContentPlugins).map(p =>
893
+ this.contentplugin.uninstallPlugin(p._id)
894
+ .catch(e => log('warn', `failed to uninstall plugin '${p.name}'`, e))
895
+ ))
896
+ }
897
+ // Restore updated plugins to their original versions
898
+ if (Object.keys(this.updatedContentPlugins).length) {
899
+ tasks.push(this.restoreUpdatedPlugins())
900
+ }
901
+ // Delete imported assets
902
+ if (this.assets) {
903
+ tasks.push(...Object.values(this.assetMap).map(id =>
904
+ this.assets.delete({ _id: id })
905
+ .catch(e => log('warn', `failed to delete asset '${id}'`, e))
906
+ ))
907
+ }
908
+ // Delete newly created tags
909
+ if (this.newTagIds.length) {
910
+ try {
911
+ const tags = await App.instance.waitForModule('tags')
912
+ tasks.push(...this.newTagIds.map(id =>
913
+ tags.delete({ _id: id })
914
+ .catch(e => log('warn', `failed to delete tag '${id}'`, e))
915
+ ))
916
+ } catch (e) {
917
+ log('warn', 'failed to load tags module for rollback', e)
890
918
  }
891
- await Promise.allSettled(tasks)
892
- } catch (e) {} // ignore any thrown errors
919
+ }
920
+ // Delete course content and course assets
921
+ if (this.content) {
922
+ try {
923
+ const _courseId = parseObjectId(this.idMap[this.contentJson.course._id])
924
+ tasks.push(
925
+ this.content.deleteMany({ _courseId })
926
+ .catch(e => log('warn', 'failed to delete course content', e)),
927
+ this.courseassets.deleteMany({ courseId: _courseId })
928
+ .catch(e => log('warn', 'failed to delete course assets', e))
929
+ )
930
+ } catch (e) {} // courseId not available, no content to roll back
931
+ }
932
+ await Promise.allSettled(tasks)
933
+ log('info', 'rollback complete')
893
934
  }
894
935
 
895
936
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-adaptframework",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
4
  "description": "Adapt framework integration for the Adapt authoring tool",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-adaptframework",
6
6
  "license": "GPL-3.0",
@@ -8,7 +8,7 @@
8
8
  "main": "index.js",
9
9
  "repository": "github:adapt-security/adapt-authoring-adaptframework",
10
10
  "scripts": {
11
- "test": "node --test 'tests/**/*.spec.js'"
11
+ "test": "node --test --test-force-exit --experimental-test-module-mocks 'tests/**/*.spec.js'"
12
12
  },
13
13
  "dependencies": {
14
14
  "adapt-authoring-browserslist": "^1.3.4",
@@ -1,6 +1,16 @@
1
- import { describe, it } from 'node:test'
1
+ import { describe, it, mock } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import AdaptFrameworkImport from '../lib/AdaptFrameworkImport.js'
3
+
4
+ // Prevent log() from triggering App.instance boot during tests
5
+ mock.module('../lib/utils/log.js', {
6
+ namedExports: {
7
+ log: async () => {},
8
+ logDir: () => {},
9
+ logMemory: () => {}
10
+ }
11
+ })
12
+
13
+ const { default: AdaptFrameworkImport } = await import('../lib/AdaptFrameworkImport.js')
4
14
 
5
15
  describe('AdaptFrameworkImport', () => {
6
16
  describe('.typeToSchema()', () => {
@@ -324,4 +334,258 @@ describe('AdaptFrameworkImport', () => {
324
334
  assert.equal(ctx.contentJson.contentObjects.art1._type, 'article')
325
335
  })
326
336
  })
337
+
338
+ describe('#cleanUp()', () => {
339
+ const cleanUp = AdaptFrameworkImport.prototype.cleanUp
340
+ const rollback = AdaptFrameworkImport.prototype.rollback
341
+
342
+ it('should call rollback when an error is passed', async () => {
343
+ let rollbackCalled = false
344
+ const ctx = {
345
+ settings: { removeSource: false },
346
+ rollback: async () => { rollbackCalled = true }
347
+ }
348
+ await cleanUp.call(ctx, new Error('test'))
349
+ assert.equal(rollbackCalled, true)
350
+ })
351
+
352
+ it('should not call rollback when no error is passed', async () => {
353
+ let rollbackCalled = false
354
+ const ctx = {
355
+ settings: { removeSource: false },
356
+ rollback: async () => { rollbackCalled = true }
357
+ }
358
+ await cleanUp.call(ctx, undefined)
359
+ assert.equal(rollbackCalled, false)
360
+ })
361
+
362
+ it('should run rollback even when removeSource is false', async () => {
363
+ let rollbackCalled = false
364
+ const ctx = {
365
+ settings: { removeSource: false },
366
+ rollback: async () => { rollbackCalled = true }
367
+ }
368
+ await cleanUp.call(ctx, new Error('test'))
369
+ assert.equal(rollbackCalled, true)
370
+ })
371
+ })
372
+
373
+ describe('#rollback()', () => {
374
+ const rollback = AdaptFrameworkImport.prototype.rollback
375
+
376
+ function makeRollbackCtx (overrides = {}) {
377
+ return {
378
+ newContentPlugins: {},
379
+ updatedContentPlugins: {},
380
+ assetMap: {},
381
+ newTagIds: [],
382
+ contentJson: { course: {} },
383
+ idMap: {},
384
+ contentplugin: null,
385
+ assets: null,
386
+ content: null,
387
+ courseassets: null,
388
+ ...overrides
389
+ }
390
+ }
391
+
392
+ it('should uninstall newly installed plugins', async () => {
393
+ const uninstalled = []
394
+ const ctx = makeRollbackCtx({
395
+ contentplugin: {
396
+ uninstallPlugin: async (id) => uninstalled.push(id)
397
+ },
398
+ newContentPlugins: {
399
+ 'adapt-contrib-text': { _id: 'p1', name: 'adapt-contrib-text' },
400
+ 'adapt-contrib-gmcq': { _id: 'p2', name: 'adapt-contrib-gmcq' }
401
+ }
402
+ })
403
+ await rollback.call(ctx)
404
+ assert.deepEqual(uninstalled.sort(), ['p1', 'p2'])
405
+ })
406
+
407
+ it('should delete imported assets', async () => {
408
+ const deleted = []
409
+ const ctx = makeRollbackCtx({
410
+ assets: {
411
+ delete: async ({ _id }) => deleted.push(_id)
412
+ },
413
+ assetMap: {
414
+ 'course/en/assets/logo.png': 'a1',
415
+ 'course/en/assets/bg.jpg': 'a2'
416
+ }
417
+ })
418
+ await rollback.call(ctx)
419
+ assert.deepEqual(deleted.sort(), ['a1', 'a2'])
420
+ })
421
+
422
+ it('should delete course content and course assets', async () => {
423
+ const contentDeleted = []
424
+ const courseAssetsDeleted = []
425
+ const ctx = makeRollbackCtx({
426
+ content: {
427
+ deleteMany: async (query) => contentDeleted.push(query)
428
+ },
429
+ courseassets: {
430
+ deleteMany: async (query) => courseAssetsDeleted.push(query)
431
+ },
432
+ contentJson: { course: { _id: 'oldCourseId' } },
433
+ idMap: { oldCourseId: '507f1f77bcf86cd799439011' }
434
+ })
435
+ await rollback.call(ctx)
436
+ assert.equal(contentDeleted.length, 1)
437
+ assert.equal(courseAssetsDeleted.length, 1)
438
+ })
439
+
440
+ it('should skip plugin uninstall when contentplugin is not available', async () => {
441
+ const ctx = makeRollbackCtx({
442
+ contentplugin: null,
443
+ newContentPlugins: { 'adapt-contrib-text': { _id: 'p1', name: 'adapt-contrib-text' } }
444
+ })
445
+ await rollback.call(ctx) // should not throw
446
+ })
447
+
448
+ it('should skip asset deletion when assets module is not available', async () => {
449
+ const ctx = makeRollbackCtx({
450
+ assets: null,
451
+ assetMap: { 'some/path.png': 'a1' }
452
+ })
453
+ await rollback.call(ctx) // should not throw
454
+ })
455
+
456
+ it('should skip content deletion when course ID is not in idMap', async () => {
457
+ const deleted = []
458
+ const ctx = makeRollbackCtx({
459
+ content: {
460
+ deleteMany: async (query) => deleted.push(query)
461
+ },
462
+ courseassets: {
463
+ deleteMany: async (query) => deleted.push(query)
464
+ },
465
+ contentJson: { course: { _id: 'oldCourseId' } },
466
+ idMap: {} // no mapping exists
467
+ })
468
+ await rollback.call(ctx)
469
+ assert.equal(deleted.length, 0)
470
+ })
471
+
472
+ it('should continue cleaning up when an individual asset deletion fails', async () => {
473
+ const deleted = []
474
+ const ctx = makeRollbackCtx({
475
+ assets: {
476
+ delete: async ({ _id }) => {
477
+ if (_id === 'a1') throw new Error('delete failed')
478
+ deleted.push(_id)
479
+ }
480
+ },
481
+ assetMap: {
482
+ 'path/a.png': 'a1',
483
+ 'path/b.png': 'a2',
484
+ 'path/c.png': 'a3'
485
+ }
486
+ })
487
+ await rollback.call(ctx)
488
+ assert.deepEqual(deleted.sort(), ['a2', 'a3'])
489
+ })
490
+
491
+ it('should continue cleaning up when an individual plugin uninstall fails', async () => {
492
+ const uninstalled = []
493
+ const ctx = makeRollbackCtx({
494
+ contentplugin: {
495
+ uninstallPlugin: async (id) => {
496
+ if (id === 'p1') throw new Error('uninstall failed')
497
+ uninstalled.push(id)
498
+ }
499
+ },
500
+ newContentPlugins: {
501
+ 'plugin-a': { _id: 'p1', name: 'plugin-a' },
502
+ 'plugin-b': { _id: 'p2', name: 'plugin-b' }
503
+ }
504
+ })
505
+ await rollback.call(ctx)
506
+ assert.deepEqual(uninstalled, ['p2'])
507
+ })
508
+ })
509
+
510
+ describe('#importCoursePlugins() - early missing plugin detection', () => {
511
+ const importCoursePlugins = AdaptFrameworkImport.prototype.importCoursePlugins
512
+
513
+ function makePluginCtx (overrides = {}) {
514
+ return {
515
+ configEnabledPlugins: [],
516
+ usedContentPlugins: {},
517
+ installedPlugins: {},
518
+ newContentPlugins: {},
519
+ updatedContentPlugins: {},
520
+ pluginsToMigrate: ['core'],
521
+ componentNameMap: {},
522
+ settings: { isDryRun: false, importPlugins: true, updatePlugins: false },
523
+ statusReport: { info: [], warn: [], error: [] },
524
+ contentplugin: {
525
+ find: async () => []
526
+ },
527
+ ...overrides
528
+ }
529
+ }
530
+
531
+ it('should report missing plugins in statusReport during dry run', async () => {
532
+ const ctx = makePluginCtx({
533
+ configEnabledPlugins: ['adapt-contrib-text', 'adapt-contrib-missing'],
534
+ usedContentPlugins: { 'adapt-contrib-text': { version: '1.0.0' } },
535
+ settings: { isDryRun: true, importPlugins: true, updatePlugins: false },
536
+ contentplugin: { find: async () => [] }
537
+ })
538
+ await importCoursePlugins.call(ctx)
539
+ assert.equal(ctx.statusReport.error.length, 1)
540
+ assert.equal(ctx.statusReport.error[0].code, 'MISSING_PLUGINS')
541
+ assert.deepEqual(ctx.statusReport.error[0].data, ['adapt-contrib-missing'])
542
+ })
543
+
544
+ it('should not report error when config plugins exist in the import package', async () => {
545
+ const ctx = makePluginCtx({
546
+ configEnabledPlugins: ['adapt-contrib-text'],
547
+ usedContentPlugins: { 'adapt-contrib-text': { name: 'adapt-contrib-text', version: '1.0.0', type: 'component' } },
548
+ contentplugin: {
549
+ find: async () => [{ name: 'adapt-contrib-text', version: '1.0.0', targetAttribute: '_text', isLocalInstall: true }]
550
+ }
551
+ })
552
+ await importCoursePlugins.call(ctx)
553
+ assert.equal(ctx.statusReport.error.length, 0)
554
+ })
555
+
556
+ it('should not report error when config plugins are installed on the server', async () => {
557
+ const ctx = makePluginCtx({
558
+ configEnabledPlugins: ['adapt-contrib-text'],
559
+ usedContentPlugins: {},
560
+ contentplugin: {
561
+ find: async () => [{ name: 'adapt-contrib-text', version: '1.0.0', targetAttribute: '_text' }]
562
+ }
563
+ })
564
+ await importCoursePlugins.call(ctx)
565
+ assert.equal(ctx.statusReport.error.length, 0)
566
+ })
567
+
568
+ it('should not report error when configEnabledPlugins is empty', async () => {
569
+ const ctx = makePluginCtx({
570
+ configEnabledPlugins: [],
571
+ contentplugin: { find: async () => [] }
572
+ })
573
+ await importCoursePlugins.call(ctx)
574
+ assert.equal(ctx.statusReport.error.length, 0)
575
+ })
576
+
577
+ it('should only flag plugins missing from both package and server', async () => {
578
+ const ctx = makePluginCtx({
579
+ configEnabledPlugins: ['adapt-contrib-text', 'adapt-contrib-gmcq', 'adapt-contrib-missing'],
580
+ usedContentPlugins: { 'adapt-contrib-text': { version: '1.0.0' } },
581
+ settings: { isDryRun: true, importPlugins: true, updatePlugins: false },
582
+ contentplugin: {
583
+ find: async () => [{ name: 'adapt-contrib-gmcq', version: '2.0.0', targetAttribute: '_gmcq' }]
584
+ }
585
+ })
586
+ await importCoursePlugins.call(ctx)
587
+ assert.equal(ctx.statusReport.error.length, 1)
588
+ assert.deepEqual(ctx.statusReport.error[0].data, ['adapt-contrib-missing'])
589
+ })
590
+ })
327
591
  })