adapt-authoring-content 3.4.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.
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "documentation": {
3
- "enable": true
3
+ "enable": true,
4
+ "manualPages": {
5
+ "content-model.md": "concepts"
6
+ }
4
7
  }
5
8
  }
@@ -0,0 +1,265 @@
1
+ # Content model
2
+
3
+ `adapt-authoring-content` stores every piece of authored course content as a flat
4
+ collection of documents (`collectionName = 'content'`). It extends
5
+ `AbstractApiModule`, so it inherits standard REST CRUD plus the access/hook
6
+ machinery, and layers on the tree structure, friendly IDs, asset indexing and
7
+ build-time validation described here.
8
+
9
+ Source: `lib/ContentModule.js`, `lib/ContentTree.js`, `lib/utils/*`.
10
+
11
+ ## Hierarchy
12
+
13
+ Content forms a tree whose nodes are distinguished by `_type`:
14
+
15
+ ```
16
+ course
17
+ ├── config (one per course, a childless sibling — not a tree child)
18
+ ├── menu (optional; a menu can nest pages/menus)
19
+ └── page
20
+ └── article
21
+ └── block
22
+ └── component (leaf node)
23
+ ```
24
+
25
+ The recursive scaffold order is hard-coded in `insertRecursive`
26
+ (`ContentModule.js`):
27
+
28
+ ```js
29
+ ['course', 'page', 'article', 'block', 'component']
30
+ ```
31
+
32
+ `menu` is inserted as a special case (when `req.body._type === 'menu'`); `config`
33
+ replaces `course` in the chain when a new course is created.
34
+
35
+ Every `_type` maps to a JSON schema via `contentTypeToSchemaName`
36
+ (`lib/utils/contentTypeToSchemaName.js`): `page` and `menu` both resolve to
37
+ `contentobject`; all other types map to a schema of the same name. A `component`
38
+ is special — its schema is derived from its plugin's `targetAttribute`
39
+ (see `getSchemaName`), e.g. `adapt-contrib-text` → `text-component`.
40
+
41
+ ## Linking fields
42
+
43
+ Items are joined by ID references, not nesting:
44
+
45
+ - `_parentId` — the immediate parent item. `config` has none; `course` has none.
46
+ - `_courseId` — the owning course. Set on every descendant. A `course` document
47
+ gets its own `_id` written back as `_courseId` after insert (see `insert`),
48
+ so a single `{ _courseId }` query returns the whole course incl. the course doc.
49
+
50
+ Indexes built in `init` reflect the common access paths:
51
+
52
+ ```js
53
+ { _courseId: 1, _parentId: 1, _type: 1 }
54
+ { _parentId: 1 }
55
+ { _type: 1, _courseId: 1 }
56
+ { _assetIds: 1 }
57
+ { _courseId: 1, _friendlyId: 1 } // unique, partial (string & non-empty)
58
+ ```
59
+
60
+ ## ContentTree
61
+
62
+ `lib/ContentTree.js` is a pure, DB-free structure over a flat array of items
63
+ (one course's worth). It builds O(1) lookup maps on construction (`byId`,
64
+ `byParent`, `byType`) and exposes `course`/`config` directly. It runs on both
65
+ server and client.
66
+
67
+ Key methods:
68
+
69
+ | Method | Returns |
70
+ | --- | --- |
71
+ | `getById(id)` | item, O(1) |
72
+ | `getChildren(parentId)` | direct children, O(1) |
73
+ | `getByType(type)` | all items of a type, O(1) |
74
+ | `getDescendants(rootId)` | all descendants (BFS), O(n) |
75
+ | `getAncestors(itemId)` | parent chain upward, O(depth) |
76
+ | `getSiblings(itemId)` | siblings excluding self |
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 |
80
+ | `getComponentNames()` | unique `_component` values in the course |
81
+
82
+ `getUnreachableItems` returns `[]` when the tree has no `course` node (reachability
83
+ is undecidable without a root).
84
+
85
+ The module builds a `ContentTree` internally for delete, clone, sort-order and
86
+ plugin-list maintenance — anywhere it needs the whole course in memory.
87
+
88
+ ## Tree endpoint
89
+
90
+ `GET /api/content/tree/:_courseId` (`handleTree`, `routes.json`) returns a
91
+ lightweight projection of every item in a course for rendering tree/list views
92
+ without fetching full documents. Permission: `read:content`.
93
+
94
+ Projected fields (`treeFields` in `handleTree`):
95
+
96
+ ```
97
+ _id, _parentId, _courseId, _type, _sortOrder, title, displayTitle,
98
+ _friendlyId, _component, _layout, _menu, _theme, _enabledPlugins,
99
+ _colorLabel, _language, heroImage, updatedAt
100
+ ```
101
+
102
+ Each returned item also carries a `_children` array of child `_id`s, computed
103
+ from the in-memory tree.
104
+
105
+ Caching is via a **weak ETag**, not `Last-Modified` (despite the `routes.json`
106
+ OpenAPI `meta` still describing `If-Modified-Since`). `treeEtag`
107
+ (`lib/utils/treeEtag.js`) folds the course `updatedAt` and a hash of the
108
+ projected field list together, so the cache busts both when the course changes
109
+ *and* when the response shape changes — a field added to the projection won't
110
+ stay missing for unedited courses. If `If-None-Match` matches, the handler
111
+ returns `304`.
112
+
113
+ The course `updatedAt` is bumped on any descendant change via `touchCourse`,
114
+ tapped into `postInsertHook`/`postUpdateHook`/`postDeleteHook` (`init`). The
115
+ handler also enforces course-level access itself (owner / `_isShared` /
116
+ `_shareWithUsers` / shared group), returning `404` rather than `403` so it
117
+ doesn't leak existence; supers are exempt.
118
+
119
+ ## CRUD, scaffold, clone, reorder
120
+
121
+ ### insert
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`
127
+ written back. Otherwise `updateSortOrder` and `updateEnabledPlugins` run unless
128
+ disabled via the `updateSortOrder: false` / `updateEnabledPlugins: false`
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).
132
+
133
+ ### insertRecursive
134
+ `POST /api/content/insertrecursive` (`handleInsertRecursive` → `insertRecursive`)
135
+ bootstraps a parent plus all required children in one call. With no `rootId` it
136
+ creates a course (+ config + a default page/article/block/text-component);
137
+ with a `rootId` it fills in the missing descendant types below that parent.
138
+ Defaults (titles, the default `adapt-contrib-text` component body) are pulled
139
+ from langpack strings via `req.translate('app.…')`. On any failure all
140
+ just-created items are rolled back. Sort-order/plugin side effects run once for
141
+ the topmost new item.
142
+
143
+ ### clone
144
+ `POST /api/content/clone` (`handleClone` → `clone`) duplicates an item and all
145
+ descendants in a single bulk `insertMany`. It pre-generates new `ObjectId`s
146
+ (old→new map), remaps `_parentId`/`_courseId`, bulk-allocates friendly IDs per
147
+ type, then fires `preInsertHook`/`postInsertHook` per payload and
148
+ `preCloneHook`/`postCloneHook` per item. Cloning a `course` also clones its
149
+ `config`. `clone` accepts `_parentId` for re-homing; an invalid parent throws
150
+ `INVALID_PARENT`.
151
+
152
+ Clone-specific hooks (created in `init`):
153
+
154
+ - `preCloneHook` — mutable; invoked per source item before cloning.
155
+ - `postCloneHook` — invoked per item with `(originalItem, newDoc)`.
156
+
157
+ ### delete
158
+ `delete` cascades: it builds the course tree, collects `getDescendants`, bulk-
159
+ deletes them via raw mongodb (avoiding per-item hook storms), deletes the target
160
+ via `super.delete` (to trigger delete middleware), then invokes `postDeleteHook`
161
+ once with the full descendants list. Deleting a `course` also removes its
162
+ `config` and its friendly-ID counters (`deleteCounters`). Sort-order and the
163
+ course plugin list are recalculated afterwards.
164
+
165
+ ### Reordering — `_sortOrder`
166
+ Siblings under one parent are ordered by an integer `_sortOrder` starting at 1.
167
+ `updateSortOrder` re-fetches siblings sorted by `_sortOrder` and delegates to
168
+ `computeSortOrderOps` (`lib/utils/computeSortOrderOps.js`), which splices the
169
+ moved/inserted item to its target index and emits the minimal set of
170
+ `updateOne` ops to renumber. `course` and `config` (and any parentless item)
171
+ are exempt. On `update`, sort recalculation only runs when `_sortOrder` or
172
+ `_parentId` is in the update data.
173
+
174
+ ## `_friendlyId`
175
+
176
+ A human-readable per-course identifier (`formatFriendlyId`,
177
+ `lib/utils/formatFriendlyId.js`):
178
+
179
+ - `course` → `course-<n>` (with `-<language>` suffix when `_language` is given)
180
+ - `config` → `config`
181
+ - everything else → `<first-letter-of-type>-<n>`, e.g. `p-3`, `b-12`, `c-40`
182
+
183
+ IDs are unique per course (enforced by the partial unique index above).
184
+ Sequence numbers come from an atomic counter collection
185
+ (`contentcounters`, keyed `{ _type, _courseId }`). `generateFriendlyIds`
186
+ reserves a range in one `$inc`, seeding the counter from existing content
187
+ (`findMaxSeq` → `parseMaxSeq`, which extracts the numeric part of existing IDs)
188
+ on first use.
189
+
190
+ ## `_assetIds`
191
+
192
+ Each content document carries `_assetIds`: the unique IDs of assets it
193
+ references. The field is added by extending the `content` schema with
194
+ `contentassets` (`schema/contentassets.schema.json`; `editorOnly`, hidden in
195
+ the UI), wired in `init` via `jsonschema.extendSchema('content', 'contentassets')`.
196
+
197
+ `computeAssetIds` → `extractAssetIds` (`lib/utils/extractAssetIds.js`) walks the
198
+ item's built schema for `_backboneForms` `Asset`-type fields and collects their
199
+ non-URL values. `_assetIds` is computed on insert and **recomputed on every
200
+ update** from the full merged document (stored as `ObjectId`s to match query
201
+ coercion).
202
+
203
+ Two consumers:
204
+
205
+ - `assets.preDeleteHook` → `enforceAssetNotInUse`: refuses to delete an asset
206
+ still referenced by content, throwing `RESOURCE_IN_USE` with the affected
207
+ course titles.
208
+ - `POST /api/content/assetusage` (`handleAssetUsage`): returns a map of asset
209
+ `_id` → distinct-course count, via `buildAssetUsagePipeline`
210
+ (`lib/utils/buildAssetUsagePipeline.js`). An optional `assetIds` body array
211
+ scopes the counts; assets with no usage are omitted. Counting distinct
212
+ `_courseId` (`$addToSet`) means many references within one course count once.
213
+
214
+ ## `_enabledPlugins`
215
+
216
+ The course `config` document holds `_enabledPlugins` — the list of content
217
+ plugins (components, the menu, the theme, extensions) in use. `updateEnabledPlugins`
218
+ recomputes it from the tree's component names plus `config._menu`/`config._theme`,
219
+ preserving existing extensions. When new plugins are added it re-validates the
220
+ affected content types (`menu`/`page` for `contentobject`-targeted schemas) to
221
+ apply their schema defaults. It runs on insert/clone and on updates that touch
222
+ `_component`, `_menu`, `_theme` or `_enabledPlugins`.
223
+
224
+ `getSchema` reads `config._enabledPlugins` for the course so it only merges the
225
+ schemas of enabled plugins; built schemas are cached per
226
+ `schemaName + enabledPlugins` key (`_schemaCache`), cleared on
227
+ `jsonschema.registerSchemasHook`.
228
+
229
+ ## Build-time validation
230
+
231
+ When `adaptframework` is available, the module taps its `preBuildHook` with
232
+ `enforceNoEmptyContainers` (`init`). This builds a `ContentTree` for the course
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
+
247
+ `content` depends on `adaptframework`'s hook (not vice-versa), so the tap is
248
+ deferred via `waitForModule(...).then(...)` rather than awaited in `init`.
249
+
250
+ ## Configuration
251
+
252
+ `conf/config.schema.json` exposes only pagination options (inherited API
253
+ behaviour); there are no content-domain config keys:
254
+
255
+ ```json
256
+ {
257
+ "defaultPageSize": { "type": "number", "default": 500 },
258
+ "maxPageSize": { "type": "number", "default": 500 }
259
+ }
260
+ ```
261
+
262
+ ## Errors
263
+
264
+ `errors/errors.json`: `INVALID_PARENT` (400), `DUPL_FRIENDLY_ID` (409),
265
+ `RESOURCE_IN_USE` (400), `EMPTY_CONTAINERS` (400).
@@ -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
 
@@ -150,6 +161,32 @@ class ContentModule extends AbstractApiModule {
150
161
  }
151
162
  }
152
163
 
164
+ /**
165
+ * Returns the courses that reference a given asset as `{ _id, title }` rows, for the asset sheet's "used in courses" list.
166
+ * @param {external:ExpressRequest} req
167
+ * @param {external:ExpressResponse} res
168
+ * @param {function} next
169
+ * @return {Promise}
170
+ */
171
+ async handleAssetCourses (req, res, next) {
172
+ try {
173
+ const usedBy = await this.find(
174
+ { _assetIds: req.apiData.query._id },
175
+ { validate: false },
176
+ { projection: { _courseId: 1 } }
177
+ )
178
+ const courseIds = [...new Set(usedBy.map(d => d._courseId?.toString()).filter(Boolean))].map(id => parseObjectId(id))
179
+ const courses = await this.find(
180
+ { _type: 'course', _id: { $in: courseIds } },
181
+ { validate: false },
182
+ { projection: { title: 1, displayTitle: 1 } }
183
+ )
184
+ res.json(courses.map(c => ({ _id: c._id.toString(), title: c.displayTitle || c.title })))
185
+ } catch (e) {
186
+ next(e)
187
+ }
188
+ }
189
+
153
190
  /**
154
191
  * "Unused assets" filter for the asset manager. The UI sends a `?unused`
155
192
  * query-string flag (see adapt-authoring-ui Assets page); restrict the assets
@@ -299,8 +336,29 @@ class ContentModule extends AbstractApiModule {
299
336
  return extractAssetIds(schema, doc)
300
337
  }
301
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
+
302
359
  /** @override */
303
360
  async insert (data, options = {}, mongoOptions = {}) {
361
+ await this.validateParent(data)
304
362
  if (!data._friendlyId) {
305
363
  const [id] = await this.generateFriendlyIds(data._type, data._courseId, 1, data._language)
306
364
  data._friendlyId = id
@@ -330,6 +388,7 @@ class ContentModule extends AbstractApiModule {
330
388
 
331
389
  /** @override */
332
390
  async update (query, data, options, mongoOptions) {
391
+ if ('_parentId' in data && data._parentId) await this.validateParent(data)
333
392
  let doc
334
393
  try {
335
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.4.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",
package/routes.json CHANGED
@@ -133,6 +133,18 @@
133
133
  "responses": { "200": { "description": "Object mapping asset _id to the number of distinct courses referencing it; assets with no usage are omitted" } }
134
134
  }
135
135
  }
136
+ },
137
+ {
138
+ "route": "/assetusage/:_id",
139
+ "handlers": { "get": "handleAssetCourses" },
140
+ "permissions": { "get": ["read:${scope}"] },
141
+ "meta": {
142
+ "get": {
143
+ "summary": "List the courses that reference an asset",
144
+ "parameters": [{ "name": "_id", "in": "path", "description": "The asset _id", "required": true }],
145
+ "responses": { "200": { "description": "Array of { _id, title } for each course referencing the asset" } }
146
+ }
147
+ }
136
148
  }
137
149
  ]
138
150
  }
@@ -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)