adapt-authoring-content 3.5.0 → 3.5.1

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.
@@ -75,8 +75,13 @@ Key methods:
75
75
  | `getAncestors(itemId)` | parent chain upward, O(depth) |
76
76
  | `getSiblings(itemId)` | siblings excluding self |
77
77
  | `getEmptyContainers()` | childless non-`component`/non-`config` items |
78
+ | `isReachable(itemId)` | whether the `_parentId` chain ends at the course root |
79
+ | `getUnreachableItems()` | orphans — items whose chain never reaches the course |
78
80
  | `getComponentNames()` | unique `_component` values in the course |
79
81
 
82
+ `getUnreachableItems` returns `[]` when the tree has no `course` node (reachability
83
+ is undecidable without a root).
84
+
80
85
  The module builds a `ContentTree` internally for delete, clone, sort-order and
81
86
  plugin-list maintenance — anywhere it needs the whole course in memory.
82
87
 
@@ -114,11 +119,16 @@ doesn't leak existence; supers are exempt.
114
119
  ## CRUD, scaffold, clone, reorder
115
120
 
116
121
  ### insert
117
- On insert (`insert`): a `_friendlyId` is generated if absent, `_assetIds` is
118
- computed if absent, then `super.insert` runs. A new `course` gets its `_courseId`
122
+ On insert (`insert`): `validateParent` runs first a `_parentId` that doesn't
123
+ resolve to an existing item in the same course throws `INVALID_PARENT` (400),
124
+ which stops orphans being created by a delete/insert race or a stale client
125
+ reference. Then a `_friendlyId` is generated if absent, `_assetIds` is
126
+ computed if absent, and `super.insert` runs. A new `course` gets its `_courseId`
119
127
  written back. Otherwise `updateSortOrder` and `updateEnabledPlugins` run unless
120
128
  disabled via the `updateSortOrder: false` / `updateEnabledPlugins: false`
121
129
  options. A duplicate-key error is rethrown as `DUPL_FRIENDLY_ID` (409).
130
+ `update` applies the same `validateParent` check when a write reparents an item
131
+ (`_parentId` present in the update data).
122
132
 
123
133
  ### insertRecursive
124
134
  `POST /api/content/insertrecursive` (`handleInsertRecursive` → `insertRecursive`)
@@ -220,9 +230,20 @@ schemas of enabled plugins; built schemas are cached per
220
230
 
221
231
  When `adaptframework` is available, the module taps its `preBuildHook` with
222
232
  `enforceNoEmptyContainers` (`init`). This builds a `ContentTree` for the course
223
- and calls `getEmptyContainers()`; if any non-`component`, non-`config` item has
224
- no children (an empty page/article/block, or empty menu/course) it throws
225
- `EMPTY_CONTAINERS` (400) listing the offending items, blocking the build.
233
+ (via the same flat `_courseId` query the build itself uses) and:
234
+
235
+ 1. **Prunes orphans.** `getUnreachableItems()` returns items whose `_parentId`
236
+ chain never reaches the course root — left behind by an interrupted delete,
237
+ invisible in the editor (which only renders the reachable tree) yet still
238
+ carried into the build, where they break it. These are deleted and the count
239
+ is recorded on `build.prunedOrphans` so the caller can surface it; a `warn`
240
+ is logged with the pruned `_id`s.
241
+ 2. **Blocks on genuine gaps.** Among the remaining reachable items, any
242
+ non-`component`, non-`config` container with no children (an empty
243
+ page/article/block, or empty menu/course) throws `EMPTY_CONTAINERS` (400).
244
+ The error data lists each offending item's `_id`, `_type`, `title` and
245
+ `_parentId`.
246
+
226
247
  `content` depends on `adaptframework`'s hook (not vice-versa), so the tap is
227
248
  deferred via `waitForModule(...).then(...)` rather than awaited in `init`.
228
249
 
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "EMPTY_CONTAINERS": {
26
26
  "data": {
27
- "items": "The childless content items blocking the build"
27
+ "items": "The childless content items blocking the build (each with _id, _type, title and _parentId)"
28
28
  },
29
29
  "description": "Course cannot be built: one or more pages, articles or blocks have no content",
30
30
  "statusCode": 400
@@ -112,8 +112,11 @@ class ContentModule extends AbstractApiModule {
112
112
  }
113
113
 
114
114
  /**
115
- * preBuildHook observer: refuses a build when any non-component content item has no children
116
- * (an empty page, article or block). Components are leaf nodes and config is exempt.
115
+ * preBuildHook observer: prunes orphaned items (unreachable from the course root, left by an
116
+ * interrupted delete invisible in the editor but build-breaking), then refuses the build when
117
+ * any reachable non-component container has no children (an empty page, article or block).
118
+ * Components are leaf nodes and config is exempt. Records the pruned count on `build` so the
119
+ * caller can surface it.
117
120
  * @param {AdaptFrameworkBuild} build The build being run
118
121
  * @return {Promise}
119
122
  */
@@ -124,10 +127,18 @@ class ContentModule extends AbstractApiModule {
124
127
  { validate: false },
125
128
  { projection: { _type: 1, _parentId: 1, title: 1, displayTitle: 1 } }
126
129
  )
127
- const empty = new ContentTree(items).getEmptyContainers()
130
+ const tree = new ContentTree(items)
131
+ const orphans = tree.getUnreachableItems()
132
+ if (orphans.length) {
133
+ await this.mongodb.deleteMany(this.collectionName, { _id: { $in: orphans.map(o => o._id) } })
134
+ this.log('warn', `pruned ${orphans.length} orphaned content item(s) from course ${_courseId}: ${orphans.map(o => o._id).join(', ')}`)
135
+ build.prunedOrphans = (build.prunedOrphans ?? 0) + orphans.length
136
+ }
137
+ const orphanIds = new Set(orphans.map(o => o._id.toString()))
138
+ const empty = tree.getEmptyContainers().filter(i => !orphanIds.has(i._id.toString()))
128
139
  if (!empty.length) return
129
140
  throw this.app.errors.EMPTY_CONTAINERS.setData({
130
- items: empty.map(i => ({ _id: i._id.toString(), _type: i._type, title: i.displayTitle || i.title }))
141
+ items: empty.map(i => ({ _id: i._id.toString(), _type: i._type, title: i.displayTitle || i.title, _parentId: i._parentId?.toString() }))
131
142
  })
132
143
  }
133
144
 
@@ -325,8 +336,29 @@ class ContentModule extends AbstractApiModule {
325
336
  return extractAssetIds(schema, doc)
326
337
  }
327
338
 
339
+ /**
340
+ * Throws INVALID_PARENT when a write sets a _parentId that does not resolve to an existing item
341
+ * in the same course. Prevents orphans (items unreachable from the course root) being created by
342
+ * a delete/insert race or a stale client reference. No-op when no _parentId is set (course/config
343
+ * roots, or an update that does not reparent).
344
+ * @param {Object} data The content data being written
345
+ * @return {Promise}
346
+ */
347
+ async validateParent (data) {
348
+ if (!data._parentId) return
349
+ const parent = await this.findOne(
350
+ { _id: data._parentId },
351
+ { validate: false, throwOnMissing: false },
352
+ { projection: { _id: 1, _courseId: 1 } }
353
+ )
354
+ if (!parent || (data._courseId && parent._courseId && parent._courseId.toString() !== data._courseId.toString())) {
355
+ throw this.app.errors.INVALID_PARENT.setData({ parentId: data._parentId.toString() })
356
+ }
357
+ }
358
+
328
359
  /** @override */
329
360
  async insert (data, options = {}, mongoOptions = {}) {
361
+ await this.validateParent(data)
330
362
  if (!data._friendlyId) {
331
363
  const [id] = await this.generateFriendlyIds(data._type, data._courseId, 1, data._language)
332
364
  data._friendlyId = id
@@ -356,6 +388,7 @@ class ContentModule extends AbstractApiModule {
356
388
 
357
389
  /** @override */
358
390
  async update (query, data, options, mongoOptions) {
391
+ if ('_parentId' in data && data._parentId) await this.validateParent(data)
359
392
  let doc
360
393
  try {
361
394
  doc = await super.update(query, data, options, mongoOptions)
@@ -128,6 +128,43 @@ class ContentTree {
128
128
  )
129
129
  }
130
130
 
131
+ /**
132
+ * Whether an item's _parentId chain terminates at the course root. Returns false
133
+ * when a link in the chain points at an item missing from the tree (e.g. a block
134
+ * whose parent article was deleted) or forms a cycle.
135
+ * @param {string|Object} itemId
136
+ * @returns {boolean}
137
+ */
138
+ isReachable (itemId) {
139
+ let current = this.byId.get(itemId.toString())
140
+ if (!current) return false
141
+ const seen = new Set()
142
+ while (current?._parentId) {
143
+ const id = current._id.toString()
144
+ if (seen.has(id)) return false
145
+ seen.add(id)
146
+ current = this.byId.get(current._parentId.toString())
147
+ if (!current) return false
148
+ }
149
+ return current === this.course
150
+ }
151
+
152
+ /**
153
+ * Items whose _parentId chain does not reach the course root (orphans), excluding
154
+ * the course and config (childless roots). Orphans are invisible in the editor yet
155
+ * still returned by flat _courseId queries, so they break a build. Returns [] when
156
+ * the tree has no course node, since reachability cannot be determined.
157
+ * @returns {Array<Object>} Orphaned items
158
+ */
159
+ getUnreachableItems () {
160
+ if (!this.course) return []
161
+ return this.items.filter(i =>
162
+ i._type !== 'course' &&
163
+ i._type !== 'config' &&
164
+ !this.isReachable(i._id)
165
+ )
166
+ }
167
+
131
168
  /**
132
169
  * O(1) — unique component names across the course
133
170
  * @returns {Array<string>}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-content",
3
- "version": "3.5.0",
3
+ "version": "3.5.1",
4
4
  "description": "Module for managing Adapt content",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-content",
6
6
  "license": "GPL-3.0",
@@ -790,6 +790,127 @@ describe('ContentModule', () => {
790
790
  })
791
791
  })
792
792
 
793
+ describe('validateParent', () => {
794
+ const PARENT = '507f1f77bcf86cd799439011'
795
+ const COURSE = '507f1f77bcf86cd799439022'
796
+ const INVALID = Symbol('INVALID_PARENT')
797
+
798
+ function createInst (parentDoc) {
799
+ return {
800
+ findOne: mock.fn(async () => parentDoc),
801
+ app: { errors: { INVALID_PARENT: { setData: mock.fn(data => Object.assign(new Error('INVALID_PARENT'), { symbol: INVALID, data })) } } }
802
+ }
803
+ }
804
+ const run = (inst, data) => ContentModule.prototype.validateParent.call(inst, data)
805
+
806
+ it('is a no-op when no _parentId is set', async () => {
807
+ const inst = createInst(null)
808
+ await run(inst, { _type: 'course' })
809
+ assert.equal(inst.findOne.mock.callCount(), 0)
810
+ })
811
+
812
+ it('passes when the parent exists in the same course', async () => {
813
+ const inst = createInst({ _id: PARENT, _courseId: COURSE })
814
+ await run(inst, { _parentId: PARENT, _courseId: COURSE })
815
+ assert.equal(inst.app.errors.INVALID_PARENT.setData.mock.callCount(), 0)
816
+ })
817
+
818
+ it('throws INVALID_PARENT when the parent does not exist', async () => {
819
+ const inst = createInst(null)
820
+ await assert.rejects(() => run(inst, { _parentId: 'gone', _courseId: COURSE }),
821
+ e => e.symbol === INVALID && e.data.parentId === 'gone')
822
+ })
823
+
824
+ it('throws INVALID_PARENT when the parent belongs to another course', async () => {
825
+ const inst = createInst({ _id: PARENT, _courseId: 'othercourse' })
826
+ await assert.rejects(() => run(inst, { _parentId: PARENT, _courseId: COURSE }),
827
+ e => e.symbol === INVALID)
828
+ })
829
+
830
+ it('passes existence-only when data carries no _courseId to cross-check', async () => {
831
+ const inst = createInst({ _id: PARENT, _courseId: COURSE })
832
+ await run(inst, { _parentId: PARENT })
833
+ assert.equal(inst.app.errors.INVALID_PARENT.setData.mock.callCount(), 0)
834
+ })
835
+ })
836
+
837
+ describe('enforceNoEmptyContainers', () => {
838
+ const COURSE = '507f1f77bcf86cd799439011'
839
+ const EMPTY = Symbol('EMPTY_CONTAINERS')
840
+
841
+ function createInst (items) {
842
+ const deleteMany = mock.fn(async () => {})
843
+ const inst = {
844
+ collectionName: 'content',
845
+ find: mock.fn(async () => items),
846
+ mongodb: { deleteMany },
847
+ log: mock.fn(),
848
+ app: { errors: { EMPTY_CONTAINERS: { setData: mock.fn(data => Object.assign(new Error('EMPTY'), { symbol: EMPTY, data })) } } }
849
+ }
850
+ return { inst, deleteMany }
851
+ }
852
+
853
+ const run = (inst, build = { courseId: COURSE }) =>
854
+ ContentModule.prototype.enforceNoEmptyContainers.call(inst, build)
855
+
856
+ it('passes silently for a fully connected, populated course', async () => {
857
+ const { inst, deleteMany } = createInst([
858
+ { _id: 'c', _type: 'course' },
859
+ { _id: 'p', _type: 'page', _parentId: 'c' },
860
+ { _id: 'a', _type: 'article', _parentId: 'p' },
861
+ { _id: 'b', _type: 'block', _parentId: 'a' },
862
+ { _id: 'cmp', _type: 'component', _parentId: 'b', _component: 'adapt-contrib-text' }
863
+ ])
864
+ await run(inst)
865
+ assert.equal(deleteMany.mock.callCount(), 0)
866
+ assert.equal(inst.app.errors.EMPTY_CONTAINERS.setData.mock.callCount(), 0)
867
+ })
868
+
869
+ it('prunes orphans and does not block when no reachable container is empty', async () => {
870
+ const { inst, deleteMany } = createInst([
871
+ { _id: 'c', _type: 'course' },
872
+ { _id: 'p', _type: 'page', _parentId: 'c' },
873
+ { _id: 'a', _type: 'article', _parentId: 'p' },
874
+ { _id: 'b', _type: 'block', _parentId: 'a' },
875
+ { _id: 'cmp', _type: 'component', _parentId: 'b', _component: 'adapt-contrib-text' },
876
+ { _id: 'orphan', _type: 'block', _parentId: 'gone', title: 'Block title' }
877
+ ])
878
+ const build = { courseId: COURSE }
879
+ await run(inst, build)
880
+ assert.equal(deleteMany.mock.callCount(), 1)
881
+ assert.deepEqual(deleteMany.mock.calls[0].arguments, ['content', { _id: { $in: ['orphan'] } }])
882
+ assert.equal(build.prunedOrphans, 1)
883
+ assert.equal(inst.app.errors.EMPTY_CONTAINERS.setData.mock.callCount(), 0)
884
+ })
885
+
886
+ it('blocks on a reachable empty container, surfacing _parentId', async () => {
887
+ const { inst, deleteMany } = createInst([
888
+ { _id: 'c', _type: 'course' },
889
+ { _id: 'p', _type: 'page', _parentId: 'c' },
890
+ { _id: 'a', _type: 'article', _parentId: 'p', title: 'Empty article' }
891
+ ])
892
+ await assert.rejects(() => run(inst), e =>
893
+ e.symbol === EMPTY &&
894
+ e.data.items.length === 1 &&
895
+ e.data.items[0]._id === 'a' &&
896
+ e.data.items[0]._parentId === 'p')
897
+ assert.equal(deleteMany.mock.callCount(), 0)
898
+ })
899
+
900
+ it('prunes orphans and still blocks on a separate reachable empty', async () => {
901
+ const { inst, deleteMany } = createInst([
902
+ { _id: 'c', _type: 'course' },
903
+ { _id: 'p', _type: 'page', _parentId: 'c' },
904
+ { _id: 'a', _type: 'article', _parentId: 'p', title: 'Empty article' },
905
+ { _id: 'orphan', _type: 'block', _parentId: 'gone' }
906
+ ])
907
+ await assert.rejects(() => run(inst), e =>
908
+ e.data.items.length === 1 && e.data.items[0]._id === 'a')
909
+ assert.equal(deleteMany.mock.callCount(), 1)
910
+ assert.deepEqual(deleteMany.mock.calls[0].arguments[1], { _id: { $in: ['orphan'] } })
911
+ })
912
+ })
913
+
793
914
  describe('delete', () => {
794
915
  const COURSE_OID = '507f1f77bcf86cd799439011'
795
916
  const TARGET_OID = '507f1f77bcf86cd799439012'
@@ -201,6 +201,85 @@ describe('ContentTree', () => {
201
201
  })
202
202
  })
203
203
 
204
+ describe('isReachable', () => {
205
+ it('should return true for items whose chain reaches the course', () => {
206
+ const tree = new ContentTree(items)
207
+ assert.equal(tree.isReachable('id9'), true)
208
+ assert.equal(tree.isReachable('id5'), true)
209
+ assert.equal(tree.isReachable('id1'), true)
210
+ })
211
+
212
+ it('should return false when a parent is missing from the tree', () => {
213
+ const tree = new ContentTree([
214
+ { _id: makeId(1), _type: 'course' },
215
+ { _id: makeId(2), _type: 'block', _parentId: makeId(99) }
216
+ ])
217
+ assert.equal(tree.isReachable('id2'), false)
218
+ })
219
+
220
+ it('should return false for non-existent ids', () => {
221
+ const tree = new ContentTree(items)
222
+ assert.equal(tree.isReachable('missing'), false)
223
+ })
224
+
225
+ it('should return false when there is no course root', () => {
226
+ const tree = new ContentTree([
227
+ { _id: makeId(1), _type: 'page', _parentId: makeId(2) },
228
+ { _id: makeId(2), _type: 'menu' }
229
+ ])
230
+ assert.equal(tree.isReachable('id1'), false)
231
+ })
232
+
233
+ it('should not loop forever on a cycle', () => {
234
+ const tree = new ContentTree([
235
+ { _id: makeId(1), _type: 'course' },
236
+ { _id: makeId(2), _type: 'page', _parentId: makeId(3) },
237
+ { _id: makeId(3), _type: 'article', _parentId: makeId(2) }
238
+ ])
239
+ assert.equal(tree.isReachable('id2'), false)
240
+ })
241
+ })
242
+
243
+ describe('getUnreachableItems', () => {
244
+ it('should return [] for a fully connected course', () => {
245
+ assert.deepEqual(new ContentTree(items).getUnreachableItems(), [])
246
+ })
247
+
248
+ it('should return an orphaned block whose parent article was deleted', () => {
249
+ const tree = new ContentTree([
250
+ { _id: makeId(1), _type: 'course' },
251
+ { _id: makeId(2), _type: 'page', _parentId: makeId(1) },
252
+ { _id: makeId(3), _type: 'block', _parentId: makeId(99) }
253
+ ])
254
+ const orphans = tree.getUnreachableItems()
255
+ assert.deepEqual(orphans.map(i => i._id.toString()), ['id3'])
256
+ })
257
+
258
+ it('should return the whole orphaned subtree, not just childless items', () => {
259
+ const tree = new ContentTree([
260
+ { _id: makeId(1), _type: 'course' },
261
+ { _id: makeId(2), _type: 'article', _parentId: makeId(99) },
262
+ { _id: makeId(3), _type: 'block', _parentId: makeId(2) }
263
+ ])
264
+ assert.deepEqual(tree.getUnreachableItems().map(i => i._id.toString()).sort(), ['id2', 'id3'])
265
+ })
266
+
267
+ it('should never flag course or config', () => {
268
+ const tree = new ContentTree([
269
+ { _id: makeId(1), _type: 'course' },
270
+ { _id: makeId(2), _type: 'config', _courseId: 'c1' }
271
+ ])
272
+ assert.deepEqual(tree.getUnreachableItems(), [])
273
+ })
274
+
275
+ it('should return [] when the tree has no course node', () => {
276
+ const tree = new ContentTree([
277
+ { _id: makeId(1), _type: 'block', _parentId: makeId(99) }
278
+ ])
279
+ assert.deepEqual(tree.getUnreachableItems(), [])
280
+ })
281
+ })
282
+
204
283
  describe('getComponentNames', () => {
205
284
  it('should return unique component names', () => {
206
285
  const tree = new ContentTree(items)