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.
- package/docs/content-model.md +26 -5
- package/errors/errors.json +1 -1
- package/lib/ContentModule.js +37 -4
- package/lib/ContentTree.js +37 -0
- package/package.json +1 -1
- package/tests/ContentModule.spec.js +121 -0
- package/tests/ContentTree.spec.js +79 -0
package/docs/content-model.md
CHANGED
|
@@ -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`):
|
|
118
|
-
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
`
|
|
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
|
|
package/errors/errors.json
CHANGED
|
@@ -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
|
package/lib/ContentModule.js
CHANGED
|
@@ -112,8 +112,11 @@ class ContentModule extends AbstractApiModule {
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* preBuildHook observer:
|
|
116
|
-
*
|
|
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
|
|
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)
|
package/lib/ContentTree.js
CHANGED
|
@@ -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
|
@@ -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)
|