adapt-authoring-content 2.1.8 → 3.0.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.
@@ -1,1810 +1,688 @@
1
- import { describe, it, mock, beforeEach } from 'node:test'
1
+ import { describe, it, mock } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { getDescendants } from '../lib/utils.js'
4
-
5
- /**
6
- * ContentModule extends AbstractApiModule (which extends AbstractModule).
7
- * All methods rely heavily on this.app, this.find(), super.insert(), etc.
8
- * We mock the full dependency chain so each method can be tested in isolation.
9
- */
10
-
11
- // ---------------------------------------------------------------------------
12
- // Helpers: build a mock ContentModule instance with configurable stubs
13
- // ---------------------------------------------------------------------------
14
-
15
- function createMockError (code) {
16
- const err = new Error(code)
17
- err.code = code
18
- err.setData = (d) => {
19
- const copy = new Error(code)
20
- copy.code = code
21
- copy.data = d
22
- copy.setData = err.setData
23
- return copy
24
- }
25
- return err
26
- }
27
3
 
28
- function createMockApp () {
4
+ import ContentModule from '../lib/ContentModule.js'
5
+ import ContentTree from '../lib/ContentTree.js'
6
+
7
+ const COURSE_ID = '507f1f77bcf86cd799439011'
8
+
9
+ function createMockCollection (overrides = {}) {
29
10
  return {
30
- errors: {
31
- NOT_FOUND: createMockError('NOT_FOUND'),
32
- INVALID_PARENT: createMockError('INVALID_PARENT'),
33
- UNKNOWN_SCHEMA_NAME: createMockError('UNKNOWN_SCHEMA_NAME'),
34
- MONGO_DUPL_INDEX: createMockError('MONGO_DUPL_INDEX'),
35
- DUPL_FRIENDLY_ID: createMockError('DUPL_FRIENDLY_ID')
36
- },
37
- waitForModule: mock.fn(async () => ({})),
38
- config: { get: mock.fn(() => 10) }
11
+ findOne: mock.fn(async () => null),
12
+ updateOne: mock.fn(async () => {}),
13
+ findOneAndUpdate: mock.fn(async () => ({ seq: 1 })),
14
+ find: mock.fn(() => ({ toArray: mock.fn(async () => []) })),
15
+ deleteMany: mock.fn(async () => {}),
16
+ ...overrides
39
17
  }
40
18
  }
41
19
 
42
- function createMockHook () {
43
- return {
44
- invoke: mock.fn(async () => {}),
45
- tap: mock.fn(),
46
- hasObservers: false,
47
- _hookObservers: []
48
- }
20
+ function createMockMongodb (collectionOverrides) {
21
+ const col = createMockCollection(collectionOverrides)
22
+ return { getCollection: mock.fn(() => col), collection: col }
49
23
  }
50
24
 
51
- /**
52
- * Builds a plain object that behaves like a ContentModule instance
53
- * with all inherited methods stubbed. Individual tests can override
54
- * specific stubs before exercising the method under test.
55
- */
56
25
  function createInstance (overrides = {}) {
57
- const app = createMockApp()
58
- const instance = {
59
- app,
60
- root: 'content',
61
- collectionName: 'content',
26
+ return {
62
27
  schemaName: 'content',
63
- routes: [],
64
-
28
+ collectionName: 'content',
29
+ counterCollectionName: 'contentcounters',
30
+ idInterval: 5,
31
+ contentplugin: { findOne: mock.fn(async () => null) },
32
+ jsonschema: { extendSchema: mock.fn() },
33
+ authored: { schemaName: 'authored' },
34
+ tags: { schemaExtensionName: 'tags' },
35
+ mongodb: createMockMongodb(),
65
36
  find: mock.fn(async () => []),
66
- findOne: mock.fn(async () => ({})),
67
- insert: mock.fn(async (data) => ({ ...data, _id: 'new-id' })),
68
- update: mock.fn(async (q, d) => ({ ...q, ...d })),
69
- delete: mock.fn(async () => ({})),
70
-
71
- setDefaultOptions: mock.fn((opts) => opts),
72
- checkAccess: mock.fn(async (req, data) => data),
73
- log: mock.fn(),
74
-
75
- requestHook: createMockHook(),
76
- preCloneHook: createMockHook(),
77
- postCloneHook: createMockHook(),
78
-
79
- handleInsertRecursive: mock.fn(),
80
- handleClone: mock.fn(),
81
-
37
+ findOne: mock.fn(async () => null),
82
38
  ...overrides
83
39
  }
84
- return instance
85
40
  }
86
41
 
87
- // Import the actual class to pull method bodies from the prototype
88
- const { default: ContentModule } = await import('../lib/ContentModule.js')
89
-
90
- // ---------------------------------------------------------------------------
91
- // Tests
92
- // ---------------------------------------------------------------------------
93
-
94
42
  describe('ContentModule', () => {
95
- // -----------------------------------------------------------------------
96
- // setValues
97
- // -----------------------------------------------------------------------
98
- describe('setValues', () => {
99
- it('should set collectionName and schemaName to "content"', async () => {
100
- const inst = createInstance()
101
- Object.getPrototypeOf(ContentModule.prototype).setValues = mock.fn(async function () {})
102
- await ContentModule.prototype.setValues.call(inst)
103
-
104
- assert.equal(inst.collectionName, 'content')
105
- assert.equal(inst.schemaName, 'content')
106
- })
107
- })
108
-
109
- // -----------------------------------------------------------------------
110
- // getSchemaName
111
- // -----------------------------------------------------------------------
112
43
  describe('getSchemaName', () => {
113
- let inst
44
+ const bind = (overrides) => ContentModule.prototype.getSchemaName.bind(createInstance(overrides))
114
45
 
115
- beforeEach(() => {
116
- inst = createInstance()
117
- inst.app.waitForModule = mock.fn(async () => ({
118
- find: mock.fn(async () => [])
119
- }))
46
+ it('should return default schema name when no _type or _component', async () => {
47
+ const result = await bind()({})
48
+ assert.equal(result, 'content')
120
49
  })
121
50
 
122
- it('should return the default schema name when no _type or _component and no _id', async () => {
123
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
124
- ...inst,
125
- app: {
126
- ...inst.app,
127
- waitForModule: mock.fn(async () => ({
128
- find: mock.fn(async () => [])
129
- }))
130
- },
131
- find: mock.fn(async () => [])
132
- })
133
-
134
- const result = await getSchemaName({})
135
- assert.equal(typeof result, 'string')
51
+ it('should return _type directly for article', async () => {
52
+ assert.equal(await bind()({ _type: 'article' }), 'article')
136
53
  })
137
54
 
138
- it('should return _type directly for non-component types (e.g. article)', async () => {
139
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
140
- ...inst,
141
- app: {
142
- ...inst.app,
143
- waitForModule: mock.fn(async () => ({
144
- find: mock.fn(async () => [])
145
- }))
146
- },
147
- find: mock.fn(async () => [])
148
- })
149
-
150
- const result = await getSchemaName({ _type: 'article' })
151
- assert.equal(result, 'article')
55
+ it('should return _type directly for block', async () => {
56
+ assert.equal(await bind()({ _type: 'block' }), 'block')
152
57
  })
153
58
 
154
- it('should return "contentobject" for _type "page"', async () => {
155
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
156
- ...inst,
157
- app: {
158
- ...inst.app,
159
- waitForModule: mock.fn(async () => ({
160
- find: mock.fn(async () => [])
161
- }))
162
- },
163
- find: mock.fn(async () => [])
164
- })
165
-
166
- const result = await getSchemaName({ _type: 'page' })
167
- assert.equal(result, 'contentobject')
59
+ it('should return _type directly for course', async () => {
60
+ assert.equal(await bind()({ _type: 'course' }), 'course')
168
61
  })
169
62
 
170
- it('should return "contentobject" for _type "menu"', async () => {
171
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
172
- ...inst,
173
- app: {
174
- ...inst.app,
175
- waitForModule: mock.fn(async () => ({
176
- find: mock.fn(async () => [])
177
- }))
178
- },
179
- find: mock.fn(async () => [])
180
- })
181
-
182
- const result = await getSchemaName({ _type: 'menu' })
183
- assert.equal(result, 'contentobject')
63
+ it('should return _type directly for config', async () => {
64
+ assert.equal(await bind()({ _type: 'config' }), 'config')
184
65
  })
185
66
 
186
- it('should return block type directly for _type "block"', async () => {
187
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
188
- ...inst,
189
- app: {
190
- ...inst.app,
191
- waitForModule: mock.fn(async () => ({
192
- find: mock.fn(async () => [])
193
- }))
194
- },
195
- find: mock.fn(async () => [])
196
- })
197
-
198
- const result = await getSchemaName({ _type: 'block' })
199
- assert.equal(result, 'block')
67
+ it('should return "contentobject" for page', async () => {
68
+ assert.equal(await bind()({ _type: 'page' }), 'contentobject')
200
69
  })
201
70
 
202
- it('should look up a component plugin schema for _type "component"', async () => {
203
- const contentplugin = {
204
- find: mock.fn(async () => [{ targetAttribute: '_myPlugin' }]),
205
- findOne: mock.fn(async () => ({ targetAttribute: '_myPlugin' }))
206
- }
207
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
208
- ...inst,
209
- app: {
210
- ...inst.app,
211
- waitForModule: mock.fn(async () => contentplugin)
212
- },
213
- find: mock.fn(async () => [])
214
- })
215
-
216
- const result = await getSchemaName({
217
- _type: 'component',
218
- _component: 'adapt-contrib-text'
219
- })
220
- assert.equal(result, 'myPlugin-component')
71
+ it('should return "contentobject" for menu', async () => {
72
+ assert.equal(await bind()({ _type: 'menu' }), 'contentobject')
221
73
  })
222
74
 
223
- it('should fall back to default if component plugin is not found', async () => {
224
- const contentplugin = {
225
- find: mock.fn(async () => []),
226
- findOne: mock.fn(async () => undefined)
227
- }
228
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
229
- ...inst,
230
- schemaName: 'content',
231
- app: {
232
- ...inst.app,
233
- waitForModule: mock.fn(async () => contentplugin)
234
- },
235
- find: mock.fn(async () => [])
75
+ it('should look up component plugin schema', async () => {
76
+ const fn = bind({
77
+ contentplugin: { findOne: mock.fn(async () => ({ targetAttribute: '_myPlugin' })) }
236
78
  })
237
-
238
- const result = await getSchemaName({
239
- _type: 'component',
240
- _component: 'unknown-component'
241
- })
242
- assert.equal(result, 'content')
79
+ assert.equal(await fn({ _type: 'component', _component: 'adapt-contrib-text' }), 'myPlugin-component')
243
80
  })
244
81
 
245
- it('should look up _type from the DB when _id is present but _type is missing', async () => {
246
- const contentplugin = {
247
- find: mock.fn(async () => [])
248
- }
249
- const findMock = mock.fn(async () => [{ _type: 'article', _component: undefined }])
250
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
251
- ...inst,
252
- schemaName: 'content',
253
- app: {
254
- ...inst.app,
255
- waitForModule: mock.fn(async () => contentplugin)
256
- },
257
- find: findMock
258
- })
259
-
260
- const result = await getSchemaName({ _id: 'some-id' })
261
- assert.equal(result, 'article')
262
- assert.equal(findMock.mock.callCount(), 1)
82
+ it('should fall back to default when component plugin not found', async () => {
83
+ assert.equal(
84
+ await bind()({ _type: 'component', _component: 'unknown' }),
85
+ 'content'
86
+ )
263
87
  })
264
88
 
265
- it('should return "course" for _type "course"', async () => {
266
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
267
- ...inst,
268
- app: {
269
- ...inst.app,
270
- waitForModule: mock.fn(async () => ({
271
- find: mock.fn(async () => [])
272
- }))
273
- },
274
- find: mock.fn(async () => [])
275
- })
276
-
277
- const result = await getSchemaName({ _type: 'course' })
278
- assert.equal(result, 'course')
89
+ it('should look up _type from DB when _id present but _type missing', async () => {
90
+ const findOneMock = mock.fn(async () => ({ _type: 'article' }))
91
+ assert.equal(await bind({ findOne: findOneMock })({ _id: 'some-id' }), 'article')
92
+ assert.equal(findOneMock.mock.callCount(), 1)
279
93
  })
280
94
 
281
- it('should return "config" for _type "config"', async () => {
282
- const getSchemaName = ContentModule.prototype.getSchemaName.bind({
283
- ...inst,
284
- app: {
285
- ...inst.app,
286
- waitForModule: mock.fn(async () => ({
287
- find: mock.fn(async () => [])
288
- }))
289
- },
290
- find: mock.fn(async () => [])
291
- })
292
-
293
- const result = await getSchemaName({ _type: 'config' })
294
- assert.equal(result, 'config')
95
+ it('should populate data._courseId from DB when missing', async () => {
96
+ const findOneMock = mock.fn(async () => ({ _type: 'article', _courseId: 'c123' }))
97
+ const data = { _id: 'some-id' }
98
+ await bind({ findOne: findOneMock })(data)
99
+ assert.equal(data._courseId, 'c123')
295
100
  })
296
- })
297
-
298
- // -----------------------------------------------------------------------
299
- // getDescendants
300
- // -----------------------------------------------------------------------
301
- describe('getDescendants', () => {
302
- it('should return empty array when root item has no children', async () => {
303
- const inst = createInstance({
304
- find: mock.fn(async () => [
305
- { _id: 'root', _courseId: 'c1', _type: 'page' }
306
- ])
307
- })
308
101
 
309
- const result = await getDescendants(q => inst.find(q), {
310
- _id: 'root',
311
- _courseId: 'c1',
312
- _type: 'page'
313
- })
314
-
315
- assert.deepEqual(result, [])
102
+ it('should not overwrite existing data._courseId', async () => {
103
+ const findOneMock = mock.fn(async () => ({ _type: 'article', _courseId: 'c999' }))
104
+ const data = { _id: 'some-id', _courseId: 'c123' }
105
+ await bind({ findOne: findOneMock })(data)
106
+ assert.equal(data._courseId, 'c123')
316
107
  })
317
108
 
318
- it('should return direct children', async () => {
319
- const child1 = { _id: 'child1', _parentId: 'root', _courseId: 'c1', _type: 'article' }
320
- const inst = createInstance({
321
- find: mock.fn(async () => [
322
- { _id: 'root', _courseId: 'c1', _type: 'page' },
323
- child1
324
- ])
325
- })
326
-
327
- const result = await getDescendants(q => inst.find(q), {
328
- _id: 'root',
329
- _courseId: 'c1',
330
- _type: 'page'
331
- })
332
-
333
- assert.equal(result.length, 1)
334
- assert.equal(result[0]._id, 'child1')
109
+ it('should return default when DB lookup returns null', async () => {
110
+ const findOneMock = mock.fn(async () => null)
111
+ assert.equal(await bind({ findOne: findOneMock })({ _id: 'missing' }), 'content')
335
112
  })
336
113
 
337
- it('should return nested descendants (depth > 1)', async () => {
338
- const child1 = { _id: 'child1', _parentId: 'root', _courseId: 'c1', _type: 'article' }
339
- const child2 = { _id: 'child2', _parentId: 'child1', _courseId: 'c1', _type: 'block' }
340
- const child3 = { _id: 'child3', _parentId: 'child2', _courseId: 'c1', _type: 'component' }
341
- const inst = createInstance({
342
- find: mock.fn(async () => [
343
- { _id: 'root', _courseId: 'c1', _type: 'page' },
344
- child1,
345
- child2,
346
- child3
347
- ])
114
+ it('should not query DB when both _type and _component are present', async () => {
115
+ const findOneMock = mock.fn(async () => null)
116
+ const fn = bind({
117
+ findOne: findOneMock,
118
+ contentplugin: { findOne: mock.fn(async () => ({ targetAttribute: '_text' })) }
348
119
  })
349
-
350
- const result = await getDescendants(q => inst.find(q), {
351
- _id: 'root',
352
- _courseId: 'c1',
353
- _type: 'page'
354
- })
355
-
356
- assert.equal(result.length, 3)
357
- const ids = result.map(r => r._id)
358
- assert.ok(ids.includes('child1'))
359
- assert.ok(ids.includes('child2'))
360
- assert.ok(ids.includes('child3'))
120
+ await fn({ _id: 'some-id', _type: 'component', _component: 'adapt-contrib-text' })
121
+ assert.equal(findOneMock.mock.callCount(), 0)
361
122
  })
123
+ })
362
124
 
363
- it('should include config item for course type roots', async () => {
364
- const config = { _id: 'config1', _courseId: 'c1', _type: 'config' }
365
- const inst = createInstance({
366
- find: mock.fn(async () => [
367
- { _id: 'c1', _courseId: 'c1', _type: 'course' },
368
- config
369
- ])
370
- })
371
-
372
- const result = await getDescendants(q => inst.find(q), {
373
- _id: 'c1',
374
- _courseId: 'c1',
375
- _type: 'course'
376
- })
377
-
378
- assert.ok(result.some(r => r._type === 'config'))
379
- })
380
-
381
- it('should NOT include config for non-course roots', async () => {
382
- const config = { _id: 'config1', _courseId: 'c1', _type: 'config' }
383
- const inst = createInstance({
384
- find: mock.fn(async () => [
385
- { _id: 'page1', _courseId: 'c1', _type: 'page' },
386
- config
387
- ])
388
- })
389
-
390
- const result = await getDescendants(q => inst.find(q), {
391
- _id: 'page1',
392
- _courseId: 'c1',
393
- _type: 'page'
394
- })
125
+ describe('updateSortOrder', () => {
126
+ const call = (item, updateData) =>
127
+ ContentModule.prototype.updateSortOrder.call(createInstance(), item, updateData)
395
128
 
396
- assert.ok(!result.some(r => r._type === 'config'))
129
+ it('should return early for config type', async () => {
130
+ assert.equal(await call({ _type: 'config', _parentId: 'p', _id: 'x' }, {}), undefined)
397
131
  })
398
132
 
399
- it('should handle _parentId comparison with toString()', async () => {
400
- const parent = {
401
- _id: 'root',
402
- _courseId: 'c1',
403
- _type: 'page',
404
- toString () { return 'root' }
405
- }
406
- const child = {
407
- _id: 'child1',
408
- _parentId: { toString () { return 'root' } },
409
- _courseId: 'c1',
410
- _type: 'article'
411
- }
412
- const inst = createInstance({
413
- find: mock.fn(async () => [parent, child])
414
- })
415
-
416
- const result = await getDescendants(q => inst.find(q), parent)
417
-
418
- assert.equal(result.length, 1)
419
- assert.equal(result[0]._id, 'child1')
133
+ it('should return early for course type', async () => {
134
+ assert.equal(await call({ _type: 'course', _parentId: 'p', _id: 'x' }, {}), undefined)
420
135
  })
421
136
 
422
- it('should handle multiple children at the same level', async () => {
423
- const child1 = { _id: 'c1', _parentId: 'root', _courseId: 'x', _type: 'article' }
424
- const child2 = { _id: 'c2', _parentId: 'root', _courseId: 'x', _type: 'article' }
425
- const inst = createInstance({
426
- find: mock.fn(async () => [
427
- { _id: 'root', _courseId: 'x', _type: 'page' },
428
- child1,
429
- child2
430
- ])
431
- })
432
-
433
- const result = await getDescendants(q => inst.find(q), {
434
- _id: 'root',
435
- _courseId: 'x',
436
- _type: 'page'
437
- })
438
-
439
- assert.equal(result.length, 2)
137
+ it('should return early when _parentId is falsy', async () => {
138
+ assert.equal(await call({ _type: 'article', _id: 'x' }, {}), undefined)
440
139
  })
441
140
  })
442
141
 
443
- // -----------------------------------------------------------------------
444
- // insert (logic tests using simulated method body)
445
- // -----------------------------------------------------------------------
446
- describe('insert', () => {
447
- it('should call super.insert and return result for non-course types', async () => {
448
- const superInsert = mock.fn(async (data) => ({
449
- ...data,
450
- _id: 'new-id'
451
- }))
452
- const updateSortOrder = mock.fn(async () => {})
453
- const updateEnabledPlugins = mock.fn(async () => {})
454
-
455
- const insertFn = async (data, options = {}) => {
456
- const doc = await superInsert(data, options)
457
- if (doc._type === 'course') {
458
- return doc
459
- }
460
- await Promise.all([
461
- options.updateSortOrder !== false && updateSortOrder(doc, data),
462
- options.updateEnabledPlugins !== false && updateEnabledPlugins(doc)
463
- ])
464
- return doc
142
+ describe('insertRecursive', () => {
143
+ function createReq ({ rootId, body } = {}) {
144
+ return {
145
+ apiData: { query: { rootId }, data: {} },
146
+ auth: { user: { _id: 'user1' } },
147
+ body,
148
+ translate: key => key
149
+ }
150
+ }
151
+
152
+ function createRecursiveInstance () {
153
+ const insertCalls = []
154
+ let id = 0
155
+ const nextId = () => `id${++id}`
156
+ const insert = mock.fn(async data => {
157
+ insertCalls.push(data)
158
+ return { ...data, _id: nextId(), _courseId: data._courseId ?? nextId() }
159
+ })
160
+ return {
161
+ instance: {
162
+ insert,
163
+ findOne: mock.fn(async () => null),
164
+ updateSortOrder: mock.fn(async () => {}),
165
+ updateEnabledPlugins: mock.fn(async () => {})
166
+ },
167
+ insertCalls
465
168
  }
169
+ }
466
170
 
467
- const result = await insertFn({ _type: 'article', title: 'Test' })
468
- assert.equal(result._type, 'article')
469
- assert.equal(superInsert.mock.callCount(), 1)
470
- assert.equal(updateSortOrder.mock.callCount(), 1)
471
- assert.equal(updateEnabledPlugins.mock.callCount(), 1)
472
- })
473
-
474
- it('should update _courseId after inserting a course', async () => {
475
- const updateFn = mock.fn(async (q, d) => ({ ...q, ...d }))
476
- const superInsert = mock.fn(async (data) => ({
477
- ...data,
478
- _id: 'course-1'
479
- }))
480
-
481
- const insertFn = async (data) => {
482
- const doc = await superInsert(data)
483
- if (doc._type === 'course') {
484
- return updateFn({ _id: doc._id }, { _courseId: doc._id.toString() })
485
- }
486
- return doc
171
+ it('should assign _sortOrder to every non-config/course child when creating a new course', async () => {
172
+ const { instance, insertCalls } = createRecursiveInstance()
173
+ await ContentModule.prototype.insertRecursive.call(instance, createReq())
174
+ // payloads: course, config, page, article, block, component
175
+ const needSortOrder = insertCalls.filter(d => d._type !== 'course' && d._type !== 'config')
176
+ assert.ok(needSortOrder.length > 0, 'expected at least one child insert')
177
+ for (const d of needSortOrder) {
178
+ assert.equal(typeof d._sortOrder, 'number', `${d._type} inserted without numeric _sortOrder`)
487
179
  }
488
-
489
- await insertFn({ _type: 'course', title: 'My Course' })
490
- assert.equal(updateFn.mock.callCount(), 1)
491
- assert.equal(
492
- updateFn.mock.calls[0].arguments[1]._courseId,
493
- 'course-1'
494
- )
495
180
  })
181
+ })
496
182
 
497
- it('should skip updateSortOrder when options.updateSortOrder is false', async () => {
183
+ describe('update guards', () => {
184
+ // Exercises the guard logic from update() — whether updateSortOrder/updateEnabledPlugins
185
+ // are called based on which fields are in the update data.
186
+ // We replicate the guard block directly because super.update cannot be mocked on a plain object.
187
+ async function callUpdate (data) {
498
188
  const updateSortOrder = mock.fn(async () => {})
499
189
  const updateEnabledPlugins = mock.fn(async () => {})
500
- const superInsert = mock.fn(async (data) => ({ ...data, _id: 'id' }))
501
-
502
- const insertFn = async (data, options = {}) => {
503
- const doc = await superInsert(data)
504
- if (doc._type === 'course') return doc
505
- await Promise.all([
506
- options.updateSortOrder !== false && updateSortOrder(doc, data),
507
- options.updateEnabledPlugins !== false && updateEnabledPlugins(doc)
508
- ])
509
- return doc
510
- }
511
-
512
- await insertFn({ _type: 'block' }, { updateSortOrder: false })
190
+ const doc = { _id: 'x', _courseId: 'c', ...data }
191
+ const sortChanged = '_sortOrder' in data || '_parentId' in data
192
+ const pluginsChanged = '_component' in data || '_menu' in data || '_theme' in data || '_enabledPlugins' in data
193
+ await Promise.all([
194
+ sortChanged && updateSortOrder(doc, data),
195
+ pluginsChanged && updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {})
196
+ ])
197
+ return { updateSortOrder, updateEnabledPlugins }
198
+ }
199
+
200
+ it('should skip both when updating unrelated fields', async () => {
201
+ const { updateSortOrder, updateEnabledPlugins } = await callUpdate({ title: 'new' })
513
202
  assert.equal(updateSortOrder.mock.callCount(), 0)
514
- assert.equal(updateEnabledPlugins.mock.callCount(), 1)
515
- })
516
-
517
- it('should skip updateEnabledPlugins when options.updateEnabledPlugins is false', async () => {
518
- const updateSortOrder = mock.fn(async () => {})
519
- const updateEnabledPlugins = mock.fn(async () => {})
520
- const superInsert = mock.fn(async (data) => ({ ...data, _id: 'id' }))
521
-
522
- const insertFn = async (data, options = {}) => {
523
- const doc = await superInsert(data)
524
- if (doc._type === 'course') return doc
525
- await Promise.all([
526
- options.updateSortOrder !== false && updateSortOrder(doc, data),
527
- options.updateEnabledPlugins !== false && updateEnabledPlugins(doc)
528
- ])
529
- return doc
530
- }
531
-
532
- await insertFn({ _type: 'block' }, { updateEnabledPlugins: false })
533
- assert.equal(updateSortOrder.mock.callCount(), 1)
534
203
  assert.equal(updateEnabledPlugins.mock.callCount(), 0)
535
204
  })
536
- })
537
-
538
- // -----------------------------------------------------------------------
539
- // update (logic tests using simulated method body)
540
- // -----------------------------------------------------------------------
541
- describe('update', () => {
542
- it('should call super.update then updateSortOrder and updateEnabledPlugins', async () => {
543
- const superUpdate = mock.fn(async () => ({
544
- _id: 'id1',
545
- _type: 'article',
546
- _parentId: 'p1',
547
- _courseId: 'c1'
548
- }))
549
- const updateSortOrder = mock.fn(async () => {})
550
- const updateEnabledPlugins = mock.fn(async () => {})
551
205
 
552
- const updateFn = async (query, data) => {
553
- const doc = await superUpdate(query, data)
554
- await Promise.all([
555
- updateSortOrder(doc, data),
556
- updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {})
557
- ])
558
- return doc
559
- }
560
-
561
- const result = await updateFn({ _id: 'id1' }, { title: 'Updated' })
562
- assert.equal(result._id, 'id1')
563
- assert.equal(superUpdate.mock.callCount(), 1)
206
+ it('should call updateSortOrder when _sortOrder changes', async () => {
207
+ const { updateSortOrder } = await callUpdate({ _sortOrder: 2 })
564
208
  assert.equal(updateSortOrder.mock.callCount(), 1)
565
- assert.equal(updateEnabledPlugins.mock.callCount(), 1)
566
209
  })
567
210
 
568
- it('should pass forceUpdate when _enabledPlugins is present in data', async () => {
569
- const superUpdate = mock.fn(async () => ({ _id: 'id', _courseId: 'c1' }))
570
- const updateEnabledPlugins = mock.fn(async () => {})
571
-
572
- const updateFn = async (query, data) => {
573
- const doc = await superUpdate(query, data)
574
- await updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {})
575
- return doc
576
- }
577
-
578
- await updateFn({ _id: 'id' }, { _enabledPlugins: ['plugin-a'] })
579
- const args = updateEnabledPlugins.mock.calls[0].arguments
580
- assert.deepEqual(args[1], { forceUpdate: true })
581
- })
582
-
583
- it('should pass empty options when _enabledPlugins is not in data', async () => {
584
- const superUpdate = mock.fn(async () => ({ _id: 'id', _courseId: 'c1' }))
585
- const updateEnabledPlugins = mock.fn(async () => {})
586
-
587
- const updateFn = async (query, data) => {
588
- const doc = await superUpdate(query, data)
589
- await updateEnabledPlugins(doc, data._enabledPlugins ? { forceUpdate: true } : {})
590
- return doc
591
- }
592
-
593
- await updateFn({ _id: 'id' }, { title: 'Updated' })
594
- const args = updateEnabledPlugins.mock.calls[0].arguments
595
- assert.deepEqual(args[1], {})
211
+ it('should call updateSortOrder when _parentId changes', async () => {
212
+ const { updateSortOrder } = await callUpdate({ _parentId: 'p2' })
213
+ assert.equal(updateSortOrder.mock.callCount(), 1)
596
214
  })
597
- })
598
-
599
- // -----------------------------------------------------------------------
600
- // delete (logic tests using simulated method body)
601
- // -----------------------------------------------------------------------
602
- describe('delete', () => {
603
- it('should throw when target document is not found', async () => {
604
- const findFn = mock.fn(async () => [])
605
- const errors = { NOT_FOUND: createMockError('NOT_FOUND') }
606
-
607
- const deleteFn = async (query, options = {}) => {
608
- const [targetDoc] = await findFn(query)
609
- if (!targetDoc) {
610
- throw errors.NOT_FOUND.setData({ type: options.schemaName, id: JSON.stringify(query) })
611
- }
612
- return targetDoc
613
- }
614
215
 
615
- await assert.rejects(
616
- () => deleteFn({ _id: 'missing' }),
617
- (err) => {
618
- assert.equal(err.code, 'NOT_FOUND')
619
- return true
620
- }
621
- )
216
+ it('should call updateEnabledPlugins when _component changes', async () => {
217
+ const { updateEnabledPlugins } = await callUpdate({ _component: 'new-comp' })
218
+ assert.equal(updateEnabledPlugins.mock.callCount(), 1)
622
219
  })
623
220
 
624
- it('should delete target and all descendants', async () => {
625
- const targetDoc = { _id: 'target', _courseId: 'c1', _type: 'page', _parentId: 'c1' }
626
- const desc1 = { _id: 'desc1', _courseId: 'c1', _type: 'article', _parentId: 'target' }
627
- const desc2 = { _id: 'desc2', _courseId: 'c1', _type: 'block', _parentId: 'desc1' }
628
-
629
- const superDelete = mock.fn(async () => {})
630
- const getDescendants = mock.fn(async () => [desc1, desc2])
631
- const findFn = mock.fn(async () => [targetDoc])
632
- const updateSortOrder = mock.fn(async () => {})
633
- const updateEnabledPlugins = mock.fn(async () => {})
634
-
635
- const deleteFn = async (query) => {
636
- const [target] = await findFn(query)
637
- if (!target) throw new Error('NOT_FOUND')
638
- const descendants = await getDescendants(target)
639
- await Promise.all([...descendants, target].map(d => superDelete({ _id: d._id })))
640
- await Promise.all([
641
- updateEnabledPlugins(target),
642
- updateSortOrder(target)
643
- ])
644
- return [target, ...descendants]
645
- }
646
-
647
- const result = await deleteFn({ _id: 'target' })
648
- assert.equal(result.length, 3)
649
- assert.equal(result[0]._id, 'target')
650
- assert.equal(superDelete.mock.callCount(), 3)
221
+ it('should call updateEnabledPlugins when _enabledPlugins changes', async () => {
222
+ const { updateEnabledPlugins } = await callUpdate({ _enabledPlugins: [] })
223
+ assert.equal(updateEnabledPlugins.mock.callCount(), 1)
651
224
  })
652
225
 
653
- it('should return target as first element followed by descendants', async () => {
654
- const target = { _id: 't1', _courseId: 'c1', _type: 'article', _parentId: 'p1' }
655
- const child = { _id: 'c1child', _courseId: 'c1', _type: 'block', _parentId: 't1' }
656
-
657
- const deleteFn = async (query) => {
658
- const [targetDoc] = [target]
659
- const descendants = [child]
660
- return [targetDoc, ...descendants]
661
- }
662
-
663
- const result = await deleteFn({ _id: 't1' })
664
- assert.equal(result[0]._id, 't1')
665
- assert.equal(result[1]._id, 'c1child')
226
+ it('should call updateEnabledPlugins when _menu changes', async () => {
227
+ const { updateEnabledPlugins } = await callUpdate({ _menu: 'new-menu' })
228
+ assert.equal(updateEnabledPlugins.mock.callCount(), 1)
666
229
  })
667
- })
668
230
 
669
- // -----------------------------------------------------------------------
670
- // updateSortOrder
671
- // -----------------------------------------------------------------------
672
- describe('updateSortOrder', () => {
673
- it('should return early for config type', async () => {
674
- const inst = createInstance()
675
- const result = await ContentModule.prototype.updateSortOrder.call(
676
- inst,
677
- { _type: 'config', _parentId: 'p1', _id: 'x' },
678
- {}
679
- )
680
- assert.equal(result, undefined)
681
- assert.equal(inst.find.mock.callCount(), 0)
231
+ it('should call updateEnabledPlugins when _theme changes', async () => {
232
+ const { updateEnabledPlugins } = await callUpdate({ _theme: 'new-theme' })
233
+ assert.equal(updateEnabledPlugins.mock.callCount(), 1)
682
234
  })
683
235
 
684
- it('should return early for course type', async () => {
685
- const inst = createInstance()
686
- const result = await ContentModule.prototype.updateSortOrder.call(
687
- inst,
688
- { _type: 'course', _parentId: 'p1', _id: 'x' },
689
- {}
690
- )
691
- assert.equal(result, undefined)
692
- assert.equal(inst.find.mock.callCount(), 0)
236
+ it('should pass forceUpdate when _enabledPlugins is in data', async () => {
237
+ const { updateEnabledPlugins } = await callUpdate({ _enabledPlugins: ['p1'] })
238
+ assert.deepEqual(updateEnabledPlugins.mock.calls[0].arguments[1], { forceUpdate: true })
693
239
  })
694
240
 
695
- it('should return early when _parentId is falsy', async () => {
696
- const inst = createInstance()
697
- const result = await ContentModule.prototype.updateSortOrder.call(
698
- inst,
699
- { _type: 'article', _id: 'x' },
700
- {}
701
- )
702
- assert.equal(result, undefined)
703
- assert.equal(inst.find.mock.callCount(), 0)
241
+ it('should not pass forceUpdate for other plugin fields', async () => {
242
+ const { updateEnabledPlugins } = await callUpdate({ _component: 'x' })
243
+ assert.deepEqual(updateEnabledPlugins.mock.calls[0].arguments[1], {})
704
244
  })
245
+ })
705
246
 
706
- it('should query siblings excluding current item', async () => {
707
- // item._sortOrder = 2, so newSO = 2-1 = 1 which is > -1,
708
- // so item is spliced at position 1.
709
- // After splice: [s1, item, s2] => expected _sortOrder: [1, 2, 3]
710
- // s1 needs _sortOrder=1 (match), item needs _sortOrder=2 (match),
711
- // s2 needs _sortOrder=3 to avoid super.update calls
712
- const siblingsAlreadyOrdered = [
713
- { _id: 's1', _sortOrder: 1 },
714
- { _id: 's2', _sortOrder: 3 }
715
- ]
716
-
247
+ describe('registerConfigSchemas', () => {
248
+ it('should extend config schema with authored and tags', () => {
249
+ const extendSchema = mock.fn()
717
250
  const inst = createInstance({
718
- find: mock.fn(async () => [...siblingsAlreadyOrdered])
251
+ jsonschema: { extendSchema },
252
+ authored: { schemaName: 'authored' },
253
+ tags: { schemaExtensionName: 'tags-ext' }
719
254
  })
720
-
721
- const item = { _type: 'article', _parentId: 'p1', _id: 'new-item', _sortOrder: 2 }
722
-
723
- await ContentModule.prototype.updateSortOrder.call(
724
- inst,
725
- item,
726
- { title: 'New' }
727
- )
728
-
729
- assert.equal(inst.find.mock.callCount(), 1)
730
- const findArgs = inst.find.mock.calls[0].arguments
731
- assert.equal(findArgs[0]._parentId, 'p1')
732
- assert.deepEqual(findArgs[0]._id, { $ne: 'new-item' })
733
- })
734
-
735
- it('should not splice item into siblings when updateData is falsy', async () => {
736
- // Use siblings where _sortOrder already matches expected re-indexed values
737
- // so super.update is not triggered
738
- const siblings = [
739
- { _id: 's1', _sortOrder: 1 },
740
- { _id: 's2', _sortOrder: 2 }
741
- ]
742
- const findMock = mock.fn(async () => [...siblings])
743
-
744
- const inst = createInstance({ find: findMock })
745
-
746
- await ContentModule.prototype.updateSortOrder.call(
747
- inst,
748
- { _type: 'article', _parentId: 'p1', _id: 'deleted-item', _sortOrder: 2 },
749
- undefined
750
- )
751
-
752
- // With falsy updateData, the item should NOT be spliced into siblings
753
- assert.equal(findMock.mock.callCount(), 1)
255
+ ContentModule.prototype.registerConfigSchemas.call(inst)
256
+ assert.equal(extendSchema.mock.callCount(), 2)
257
+ assert.deepEqual(extendSchema.mock.calls[0].arguments, ['config', 'authored'])
258
+ assert.deepEqual(extendSchema.mock.calls[1].arguments, ['config', 'tags-ext'])
754
259
  })
755
260
  })
756
261
 
757
- // -----------------------------------------------------------------------
758
- // handleInsertRecursive
759
- // -----------------------------------------------------------------------
760
- describe('handleInsertRecursive', () => {
761
- it('should respond with 201 and JSON data on success', async () => {
762
- const expectedResult = { _id: 'new-course', _type: 'course' }
763
- const inst = createInstance()
764
- inst.insertRecursive = mock.fn(async () => expectedResult)
262
+ describe('generateFriendlyIds', () => {
263
+ const bind = (overrides) => {
264
+ const inst = createInstance(overrides)
265
+ inst.findMaxSeq = ContentModule.prototype.findMaxSeq.bind(inst)
266
+ return ContentModule.prototype.generateFriendlyIds.bind(inst)
267
+ }
765
268
 
766
- let statusCode, jsonData
767
- const res = {
768
- status: (code) => {
769
- statusCode = code
770
- return { json: (data) => { jsonData = data } }
771
- }
772
- }
773
- const next = mock.fn()
774
-
775
- await ContentModule.prototype.handleInsertRecursive.call(inst, {}, res, next)
776
-
777
- assert.equal(statusCode, 201)
778
- assert.deepEqual(jsonData, expectedResult)
779
- assert.equal(next.mock.callCount(), 0)
269
+ it('should return ["config"] for config type', async () => {
270
+ assert.deepEqual(await bind()('config', null, 1), ['config'])
780
271
  })
781
272
 
782
- it('should call next with error when insertRecursive throws', async () => {
783
- const inst = createInstance()
784
- const error = new Error('insert failed')
785
- inst.insertRecursive = mock.fn(async () => { throw error })
786
-
787
- const next = mock.fn()
788
- const res = {
789
- status: () => ({ json: () => {} })
790
- }
791
-
792
- await ContentModule.prototype.handleInsertRecursive.call(inst, {}, res, next)
793
-
794
- assert.equal(next.mock.callCount(), 1)
795
- assert.equal(next.mock.calls[0].arguments[0], error)
273
+ it('should generate a course ID without language', async () => {
274
+ const result = await bind()('course', null, 1)
275
+ assert.deepEqual(result, ['course-1'])
796
276
  })
797
- })
798
277
 
799
- // -----------------------------------------------------------------------
800
- // handleClone
801
- // -----------------------------------------------------------------------
802
- describe('handleClone', () => {
803
- it('should respond with 201 and cloned data on success', async () => {
804
- const clonedData = { _id: 'cloned-id', _type: 'article' }
805
- const inst = createInstance()
806
- inst.clone = mock.fn(async () => clonedData)
807
- inst.findOne = mock.fn(async () => ({ _id: 'orig', _type: 'article' }))
808
- inst.checkAccess = mock.fn(async () => {})
809
-
810
- let statusCode, jsonData
811
- const req = {
812
- body: { _id: 'orig', _parentId: 'parent-1', title: 'Cloned Title' },
813
- auth: { user: { _id: 'user-1' } }
814
- }
815
- const res = {
816
- status: (code) => {
817
- statusCode = code
818
- return { json: (data) => { jsonData = data } }
819
- }
820
- }
821
- const next = mock.fn()
822
-
823
- await ContentModule.prototype.handleClone.call(inst, req, res, next)
824
-
825
- assert.equal(statusCode, 201)
826
- assert.deepEqual(jsonData, clonedData)
827
- assert.equal(next.mock.callCount(), 0)
828
- })
829
-
830
- it('should strip _id and _parentId from customData before passing to clone', async () => {
831
- const inst = createInstance()
832
- inst.clone = mock.fn(async () => ({ _id: 'new' }))
833
- inst.findOne = mock.fn(async () => ({ _id: 'orig' }))
834
- inst.checkAccess = mock.fn(async () => {})
835
-
836
- const req = {
837
- body: { _id: 'orig', _parentId: 'p1', title: 'Cloned' },
838
- auth: { user: { _id: 'user-1' } }
839
- }
840
- const res = {
841
- status: () => ({ json: () => {} })
842
- }
843
-
844
- await ContentModule.prototype.handleClone.call(inst, req, res, mock.fn())
845
-
846
- const cloneArgs = inst.clone.mock.calls[0].arguments
847
- assert.equal(cloneArgs[3]._id, undefined)
848
- assert.equal(cloneArgs[3]._parentId, undefined)
849
- assert.equal(cloneArgs[3].title, 'Cloned')
278
+ it('should generate a course ID with language', async () => {
279
+ const result = await bind()('course', null, 1, 'en')
280
+ assert.deepEqual(result, ['course-1-en'])
850
281
  })
851
282
 
852
- it('should call next with error when clone throws', async () => {
853
- const inst = createInstance()
854
- const error = new Error('clone failed')
855
- inst.clone = mock.fn(async () => { throw error })
856
- inst.findOne = mock.fn(async () => ({ _id: 'orig' }))
857
- inst.checkAccess = mock.fn(async () => {})
858
-
859
- const req = {
860
- body: { _id: 'orig', _parentId: 'p1' },
861
- auth: { user: { _id: 'user-1' } }
862
- }
863
- const res = {
864
- status: () => ({ json: () => {} })
865
- }
866
- const next = mock.fn()
867
-
868
- await ContentModule.prototype.handleClone.call(inst, req, res, next)
869
-
870
- assert.equal(next.mock.callCount(), 1)
871
- assert.equal(next.mock.calls[0].arguments[0], error)
283
+ it('should generate a non-course ID using type prefix', async () => {
284
+ const result = await bind()('block', COURSE_ID, 1)
285
+ assert.deepEqual(result, ['b-1'])
872
286
  })
873
287
 
874
- it('should call requestHook, checkAccess, then clone in order', async () => {
875
- const callOrder = []
876
- const inst = createInstance()
877
- inst.requestHook = { invoke: mock.fn(async () => { callOrder.push('requestHook') }) }
878
- inst.findOne = mock.fn(async () => {
879
- callOrder.push('findOne')
880
- return { _id: 'orig' }
288
+ it('should seed counter from existing content on first use', async () => {
289
+ const docs = [{ _friendlyId: 'b-10' }, { _friendlyId: 'b-15' }]
290
+ const mongodb = createMockMongodb({
291
+ findOneAndUpdate: mock.fn(async () => ({ seq: 4 })),
292
+ find: mock.fn(() => ({ toArray: mock.fn(async () => docs) }))
881
293
  })
882
- inst.checkAccess = mock.fn(async () => {
883
- callOrder.push('checkAccess')
884
- })
885
- inst.clone = mock.fn(async () => {
886
- callOrder.push('clone')
887
- return { _id: 'new' }
888
- })
889
-
890
- const req = {
891
- body: { _id: 'orig', _parentId: 'p1' },
892
- auth: { user: { _id: 'user-1' } }
893
- }
894
- const res = {
895
- status: () => ({ json: () => {} })
896
- }
897
-
898
- await ContentModule.prototype.handleClone.call(inst, req, res, mock.fn())
899
-
900
- assert.deepEqual(callOrder, ['requestHook', 'findOne', 'checkAccess', 'clone'])
294
+ await bind({ mongodb })('block', COURSE_ID, 1)
295
+ assert.equal(mongodb.collection.updateOne.mock.callCount(), 1)
296
+ assert.deepEqual(mongodb.collection.updateOne.mock.calls[0].arguments[1], { $setOnInsert: { seq: 15 } })
901
297
  })
902
- })
903
298
 
904
- // -----------------------------------------------------------------------
905
- // clone
906
- // -----------------------------------------------------------------------
907
- describe('clone', () => {
908
- it('should throw NOT_FOUND when original document is not found', async () => {
909
- const inst = createInstance({
910
- find: mock.fn(async () => [])
299
+ it('should skip seeding when counter already exists', async () => {
300
+ const mongodb = createMockMongodb({
301
+ findOne: mock.fn(async () => ({ seq: 5 })),
302
+ findOneAndUpdate: mock.fn(async () => ({ seq: 6 }))
911
303
  })
912
-
913
- await assert.rejects(
914
- () => ContentModule.prototype.clone.call(inst, 'user1', 'missing-id', 'parent1'),
915
- (err) => {
916
- assert.equal(err.code, 'NOT_FOUND')
917
- return true
918
- }
919
- )
304
+ await bind({ mongodb })('block', COURSE_ID, 1)
305
+ assert.equal(mongodb.collection.updateOne.mock.callCount(), 0)
920
306
  })
921
307
 
922
- it('should throw INVALID_PARENT when parent not found and type is not course/config', async () => {
923
- let callCount = 0
924
- const inst = createInstance({
925
- find: mock.fn(async () => {
926
- callCount++
927
- if (callCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1' }]
928
- return [] // parent not found
929
- })
308
+ it('should atomically increment the counter', async () => {
309
+ const mongodb = createMockMongodb({
310
+ findOne: mock.fn(async () => ({ seq: 6 })),
311
+ findOneAndUpdate: mock.fn(async () => ({ seq: 7 }))
930
312
  })
931
- inst.preCloneHook = createMockHook()
932
-
933
- await assert.rejects(
934
- () => ContentModule.prototype.clone.call(inst, 'user1', 'orig', 'missing-parent'),
935
- (err) => {
936
- assert.equal(err.code, 'INVALID_PARENT')
937
- return true
938
- }
939
- )
313
+ const result = await bind({ mongodb })('article', COURSE_ID, 1)
314
+ assert.deepEqual(result, ['a-7'])
315
+ assert.equal(mongodb.collection.findOneAndUpdate.mock.callCount(), 1)
316
+ assert.deepEqual(mongodb.collection.findOneAndUpdate.mock.calls[0].arguments[1], { $inc: { seq: 1 } })
940
317
  })
318
+ })
941
319
 
942
- it('should invoke preCloneHook when invokePreHook option is not false', async () => {
943
- const preCloneHook = createMockHook()
944
- let findCallCount = 0
945
- const inst = createInstance({
946
- find: mock.fn(async () => {
947
- findCallCount++
948
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1' }]
949
- if (findCallCount === 2) return [{ _id: 'parent', _type: 'page', _courseId: 'c1' }]
950
- return []
951
- }),
952
- insert: mock.fn(async (data) => ({ ...data, _id: 'new-id' })),
953
- preCloneHook,
954
- postCloneHook: createMockHook()
320
+ describe('findMaxSeq', () => {
321
+ const bind = (docs) => ContentModule.prototype.findMaxSeq.bind(createInstance({
322
+ mongodb: createMockMongodb({
323
+ find: mock.fn(() => ({ toArray: mock.fn(async () => docs) }))
955
324
  })
325
+ }))
956
326
 
957
- await ContentModule.prototype.clone.call(inst, 'user1', 'orig', 'parent')
958
-
959
- assert.equal(preCloneHook.invoke.mock.callCount(), 1)
327
+ it('should return 0 when no documents exist', async () => {
328
+ assert.equal(await bind([])('block', COURSE_ID), 0)
960
329
  })
961
330
 
962
- it('should skip preCloneHook when invokePreHook option is false', async () => {
963
- const preCloneHook = createMockHook()
964
- let findCallCount = 0
965
- const inst = createInstance({
966
- find: mock.fn(async () => {
967
- findCallCount++
968
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1' }]
969
- if (findCallCount === 2) return [{ _id: 'parent', _type: 'page', _courseId: 'c1' }]
970
- return []
971
- }),
972
- insert: mock.fn(async (data) => ({ ...data, _id: 'new-id' })),
973
- preCloneHook,
974
- postCloneHook: createMockHook()
975
- })
976
-
977
- await ContentModule.prototype.clone.call(
978
- inst, 'user1', 'orig', 'parent', {}, { invokePreHook: false }
979
- )
980
-
981
- assert.equal(preCloneHook.invoke.mock.callCount(), 0)
331
+ it('should return max number for non-course types', async () => {
332
+ const docs = [{ _friendlyId: 'b-10' }, { _friendlyId: 'b-25' }, { _friendlyId: 'b-5' }]
333
+ assert.equal(await bind(docs)('block', COURSE_ID), 25)
982
334
  })
983
335
 
984
- it('should skip postCloneHook when invokePostHook option is false', async () => {
985
- const postCloneHook = createMockHook()
986
- let findCallCount = 0
987
- const inst = createInstance({
988
- find: mock.fn(async () => {
989
- findCallCount++
990
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1' }]
991
- if (findCallCount === 2) return [{ _id: 'parent', _type: 'page', _courseId: 'c1' }]
992
- return []
993
- }),
994
- insert: mock.fn(async (data) => ({ ...data, _id: 'new-id' })),
995
- preCloneHook: createMockHook(),
996
- postCloneHook
997
- })
998
-
999
- await ContentModule.prototype.clone.call(
1000
- inst, 'user1', 'orig', 'parent', {}, { invokePostHook: false }
1001
- )
1002
-
1003
- assert.equal(postCloneHook.invoke.mock.callCount(), 0)
336
+ it('should return raw max number for course type', async () => {
337
+ const docs = [{ _friendlyId: 'course-3-en' }, { _friendlyId: 'course-7-fr' }]
338
+ assert.equal(await bind(docs)('course'), 7)
1004
339
  })
1005
340
 
1006
- it('should use "contentobject" schema for page types', async () => {
1007
- let findCallCount = 0
1008
- const insertFn = mock.fn(async (data, opts) => ({
1009
- ...data,
1010
- _id: 'new-id'
1011
- }))
1012
- const inst = createInstance({
1013
- find: mock.fn(async () => {
1014
- findCallCount++
1015
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'page', _courseId: 'c1' }]
1016
- if (findCallCount === 2) return [{ _id: 'c1', _type: 'course', _courseId: 'c1' }]
1017
- return []
1018
- }),
1019
- insert: insertFn,
1020
- preCloneHook: createMockHook(),
1021
- postCloneHook: createMockHook()
1022
- })
1023
-
1024
- await ContentModule.prototype.clone.call(inst, 'user1', 'orig', 'c1')
1025
-
1026
- const insertCall = insertFn.mock.calls[0].arguments
1027
- assert.equal(insertCall[1].schemaName, 'contentobject')
341
+ it('should skip documents without numeric IDs', async () => {
342
+ const docs = [{ _friendlyId: 'config' }, { _friendlyId: 'b-15' }]
343
+ assert.equal(await bind(docs)('block', COURSE_ID), 15)
1028
344
  })
345
+ })
1029
346
 
1030
- it('should use "contentobject" schema for menu types', async () => {
1031
- let findCallCount = 0
1032
- const insertFn = mock.fn(async (data, opts) => ({
1033
- ...data,
1034
- _id: 'new-id'
1035
- }))
1036
- const inst = createInstance({
1037
- find: mock.fn(async () => {
1038
- findCallCount++
1039
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'menu', _courseId: 'c1' }]
1040
- if (findCallCount === 2) return [{ _id: 'parent', _type: 'course', _courseId: 'c1' }]
1041
- return []
1042
- }),
1043
- insert: insertFn,
1044
- preCloneHook: createMockHook(),
1045
- postCloneHook: createMockHook()
1046
- })
1047
-
1048
- await ContentModule.prototype.clone.call(inst, 'user1', 'orig', 'parent')
1049
-
1050
- const insertCall = insertFn.mock.calls[0].arguments
1051
- assert.equal(insertCall[1].schemaName, 'contentobject')
347
+ describe('deleteCounters', () => {
348
+ it('should call deleteMany with parsed ObjectIds', async () => {
349
+ const mongodb = createMockMongodb()
350
+ await ContentModule.prototype.deleteCounters.call(
351
+ createInstance({ mongodb }),
352
+ ['507f1f77bcf86cd799439011']
353
+ )
354
+ assert.equal(mongodb.collection.deleteMany.mock.callCount(), 1)
355
+ const query = mongodb.collection.deleteMany.mock.calls[0].arguments[0]
356
+ assert.ok(query._courseId.$in)
357
+ assert.equal(query._courseId.$in.length, 1)
1052
358
  })
359
+ })
1053
360
 
1054
- it('should set createdBy to the userId argument', async () => {
1055
- let findCallCount = 0
1056
- const insertFn = mock.fn(async (data, opts) => ({
1057
- ...data,
1058
- _id: 'new-id'
1059
- }))
1060
- const inst = createInstance({
1061
- find: mock.fn(async () => {
1062
- findCallCount++
1063
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1' }]
1064
- if (findCallCount === 2) return [{ _id: 'p', _type: 'page', _courseId: 'c1' }]
1065
- return []
361
+ describe('clone', () => {
362
+ const COURSE_OID = '507f1f77bcf86cd799439011'
363
+ const PAGE_OID = '507f1f77bcf86cd799439022'
364
+ const ART_OID = '507f1f77bcf86cd799439033'
365
+ const BLOCK_OID = '507f1f77bcf86cd799439044'
366
+ const COMP_OID = '507f1f77bcf86cd799439055'
367
+ const CONFIG_OID = '507f1f77bcf86cd799439066'
368
+ const PARENT_OID = '507f1f77bcf86cd799439077'
369
+ const USER_OID = '507f1f77bcf86cd799439088'
370
+
371
+ function createCloneInstance (collectionOverrides = {}) {
372
+ const insertedDocs = []
373
+ const mongodb = createMockMongodb({
374
+ insertMany: mock.fn(async (docs) => { insertedDocs.push(...docs) }),
375
+ deleteMany: mock.fn(async () => {}),
376
+ ...collectionOverrides
377
+ })
378
+ const inst = createInstance({
379
+ mongodb,
380
+ app: { errors: { NOT_FOUND: makeError('NOT_FOUND'), INVALID_PARENT: makeError('INVALID_PARENT') } },
381
+ generateFriendlyIds: mock.fn(async (_type, _courseId, count) => {
382
+ return Array.from({ length: count }, (_, i) => `${_type[0]}-${i + 1}`)
1066
383
  }),
1067
- insert: insertFn,
1068
- preCloneHook: createMockHook(),
1069
- postCloneHook: createMockHook()
384
+ getSchema: mock.fn(async () => ({})),
385
+ updateEnabledPlugins: mock.fn(async () => {}),
386
+ preCloneHook: { invoke: mock.fn(async () => {}) },
387
+ preInsertHook: { invoke: mock.fn(async () => {}) },
388
+ postInsertHook: { invoke: mock.fn(async () => {}) },
389
+ postCloneHook: { invoke: mock.fn(async () => {}) }
1070
390
  })
391
+ return { inst, mongodb, insertedDocs }
392
+ }
1071
393
 
1072
- await ContentModule.prototype.clone.call(inst, 'user-42', 'orig', 'p')
1073
-
1074
- const payload = insertFn.mock.calls[0].arguments[0]
1075
- assert.equal(payload.createdBy, 'user-42')
1076
- })
1077
-
1078
- it('should clear _id and _trackingId from cloned payload', async () => {
1079
- let findCallCount = 0
1080
- const insertFn = mock.fn(async (data, opts) => ({
1081
- ...data,
1082
- _id: 'new-id'
1083
- }))
1084
- const inst = createInstance({
1085
- find: mock.fn(async () => {
1086
- findCallCount++
1087
- if (findCallCount === 1) {
1088
- return [{
1089
- _id: 'orig',
1090
- _type: 'article',
1091
- _courseId: 'c1',
1092
- _trackingId: 'track-1'
1093
- }]
1094
- }
1095
- if (findCallCount === 2) return [{ _id: 'p', _type: 'page', _courseId: 'c1' }]
1096
- return []
1097
- }),
1098
- insert: insertFn,
1099
- preCloneHook: createMockHook(),
1100
- postCloneHook: createMockHook()
1101
- })
394
+ function makeError (code) {
395
+ return { code, setData: (d) => Object.assign(new Error(code), { code, data: d }) }
396
+ }
1102
397
 
1103
- await ContentModule.prototype.clone.call(inst, 'user1', 'orig', 'p')
398
+ it('should throw NOT_FOUND when original doc is missing', async () => {
399
+ const { inst } = createCloneInstance()
1104
400
 
1105
- const payload = insertFn.mock.calls[0].arguments[0]
1106
- assert.equal(payload._id, undefined)
1107
- assert.equal(payload._trackingId, undefined)
401
+ const tree = new ContentTree([])
402
+ await assert.rejects(
403
+ () => ContentModule.prototype.clone.call(inst, USER_OID, 'missing-id', PARENT_OID, {}, { tree }),
404
+ (err) => err.code === 'NOT_FOUND'
405
+ )
1108
406
  })
1109
407
 
1110
- it('should recursively clone children', async () => {
1111
- let findCallCount = 0
1112
- const insertFn = mock.fn(async (data, opts) => ({
1113
- ...data,
1114
- _id: `new-${data._type}`
1115
- }))
1116
- const inst = createInstance({
1117
- find: mock.fn(async () => {
1118
- findCallCount++
1119
- // 1: find original (article)
1120
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1' }]
1121
- // 2: find parent
1122
- if (findCallCount === 2) return [{ _id: 'p', _type: 'page', _courseId: 'c1' }]
1123
- // 3: find children of orig
1124
- if (findCallCount === 3) return [{ _id: 'child1', _type: 'block', _courseId: 'c1' }]
1125
- // 4: find orig child (block) for recursive clone
1126
- if (findCallCount === 4) return [{ _id: 'child1', _type: 'block', _courseId: 'c1' }]
1127
- // 5: find parent of child clone (new-article)
1128
- if (findCallCount === 5) return [{ _id: 'new-article', _type: 'article', _courseId: 'c1' }]
1129
- // 6+: children of child1
1130
- return []
1131
- }),
1132
- insert: insertFn,
1133
- preCloneHook: createMockHook(),
1134
- postCloneHook: createMockHook()
1135
- })
408
+ it('should throw INVALID_PARENT when non-course has no parent', async () => {
409
+ const { inst } = createCloneInstance()
1136
410
 
1137
- // Bind the real clone method to inst so recursive this.clone() calls work
1138
- inst.clone = ContentModule.prototype.clone.bind(inst)
1139
-
1140
- await inst.clone('user1', 'orig', 'p')
1141
-
1142
- // Should have inserted the article clone and the block clone
1143
- assert.ok(insertFn.mock.callCount() >= 2)
411
+ const tree = new ContentTree([
412
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID }
413
+ ])
414
+ inst.findOne = mock.fn(async () => null) // parent lookup returns null
415
+ await assert.rejects(
416
+ () => ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, 'bad-parent', {}, { tree }),
417
+ (err) => err.code === 'INVALID_PARENT'
418
+ )
1144
419
  })
1145
- })
1146
420
 
1147
- // -----------------------------------------------------------------------
1148
- // insertRecursive
1149
- // -----------------------------------------------------------------------
1150
- describe('insertRecursive', () => {
1151
- it('should create a new course with children when rootId is undefined', async () => {
1152
- const insertedItems = []
1153
- const inst = createInstance({
1154
- insert: mock.fn(async (data, opts) => {
1155
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'id-course' }
1156
- insertedItems.push(item)
1157
- return item
1158
- })
1159
- })
421
+ it('should clone a page and its descendants via insertMany', async () => {
422
+ const { inst, mongodb } = createCloneInstance()
1160
423
 
1161
- const req = {
1162
- apiData: { query: {}, data: { title: 'New Course' } },
1163
- auth: { user: { _id: 'user-1' } },
1164
- translate: mock.fn((key) => `Translated: ${key}`),
1165
- body: {}
1166
- }
1167
-
1168
- const result = await ContentModule.prototype.insertRecursive.call(inst, req)
424
+ const items = [
425
+ { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID },
426
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID },
427
+ { _id: ART_OID, _type: 'article', _parentId: PAGE_OID, _courseId: COURSE_OID },
428
+ { _id: BLOCK_OID, _type: 'block', _parentId: ART_OID, _courseId: COURSE_OID },
429
+ { _id: COMP_OID, _type: 'component', _parentId: BLOCK_OID, _courseId: COURSE_OID, _component: 'adapt-contrib-text' }
430
+ ]
431
+ const tree = new ContentTree(items)
432
+ const parent = { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID }
433
+ const result = await ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, COURSE_OID, { title: 'Cloned' }, { tree, parent })
434
+
435
+ // insertMany should have been called once
436
+ assert.equal(mongodb.collection.insertMany.mock.callCount(), 1)
437
+ const inserted = mongodb.collection.insertMany.mock.calls[0].arguments[0]
438
+ // should clone page + article + block + component = 4 items
439
+ assert.equal(inserted.length, 4)
440
+ // root payload should have customData applied
441
+ assert.equal(result.title, 'Cloned')
442
+ assert.equal(result.createdBy.toString(), USER_OID)
443
+ })
444
+
445
+ it('should clone a course with config', async () => {
446
+ const { inst, mongodb } = createCloneInstance()
447
+
448
+ const items = [
449
+ { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID, _friendlyId: 'course-1' },
450
+ { _id: CONFIG_OID, _type: 'config', _courseId: COURSE_OID },
451
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID }
452
+ ]
453
+ const tree = new ContentTree(items)
454
+ const result = await ContentModule.prototype.clone.call(inst, USER_OID, COURSE_OID, undefined, {}, { tree })
1169
455
 
456
+ const inserted = mongodb.collection.insertMany.mock.calls[0].arguments[0]
457
+ // course + config + page = 3
458
+ assert.equal(inserted.length, 3)
1170
459
  assert.equal(result._type, 'course')
1171
- // course + config + page + article + block + component = 6
1172
- assert.equal(insertedItems.length, 6)
1173
460
  })
1174
461
 
1175
- it('should create child types starting from the rootId type', async () => {
1176
- const insertedItems = []
1177
- let findCalled = false
462
+ it('should remap parent IDs correctly', async () => {
463
+ const { inst, mongodb } = createCloneInstance()
1178
464
 
1179
- const inst = createInstance({
1180
- find: mock.fn(async () => {
1181
- if (!findCalled) {
1182
- findCalled = true
1183
- return [{ _id: 'page1', _type: 'page', _courseId: 'c1' }]
1184
- }
1185
- return []
1186
- }),
1187
- insert: mock.fn(async (data) => {
1188
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'c1' }
1189
- insertedItems.push(item)
1190
- return item
1191
- })
1192
- })
1193
-
1194
- const req = {
1195
- apiData: { query: { rootId: 'page1' }, data: {} },
1196
- auth: { user: { _id: 'user-1' } },
1197
- translate: mock.fn((key) => `Translated: ${key}`),
1198
- body: {}
1199
- }
1200
-
1201
- await ContentModule.prototype.insertRecursive.call(inst, req)
465
+ const items = [
466
+ { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID },
467
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID },
468
+ { _id: ART_OID, _type: 'article', _parentId: PAGE_OID, _courseId: COURSE_OID }
469
+ ]
470
+ const tree = new ContentTree(items)
471
+ const parent = { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID }
472
+ await ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, COURSE_OID, {}, { tree, parent })
1202
473
 
1203
- const types = insertedItems.map(i => i._type)
1204
- assert.ok(types.includes('article'))
1205
- assert.ok(types.includes('block'))
1206
- assert.ok(types.includes('component'))
1207
- assert.ok(!types.includes('course'))
1208
- assert.ok(!types.includes('page'))
474
+ const inserted = mongodb.collection.insertMany.mock.calls[0].arguments[0]
475
+ const clonedPage = inserted.find(d => d._type === 'page')
476
+ const clonedArticle = inserted.find(d => d._type === 'article')
477
+ // article's parent should be the cloned page's new ID, not the original
478
+ assert.equal(clonedArticle._parentId.toString(), clonedPage._id.toString())
1209
479
  })
1210
480
 
1211
- it('should handle menu type specially by replacing course with menu', async () => {
1212
- const insertedItems = []
1213
- const inst = createInstance({
1214
- find: mock.fn(async () => [{ _id: 'c1', _type: 'course', _courseId: 'c1' }]),
1215
- insert: mock.fn(async (data) => {
1216
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'c1' }
1217
- insertedItems.push(item)
1218
- return item
1219
- })
481
+ it('should roll back on insertMany failure', async () => {
482
+ const deleteManyMock = mock.fn(async () => {})
483
+ const { inst, mongodb } = createCloneInstance({
484
+ insertMany: mock.fn(async () => { throw new Error('insert failed') }),
485
+ deleteMany: deleteManyMock
1220
486
  })
1221
487
 
1222
- const req = {
1223
- apiData: { query: { rootId: 'c1' }, data: {} },
1224
- auth: { user: { _id: 'user-1' } },
1225
- translate: mock.fn((key) => `Translated: ${key}`),
1226
- body: { _type: 'menu' }
1227
- }
1228
-
1229
- await ContentModule.prototype.insertRecursive.call(inst, req)
1230
-
1231
- const types = insertedItems.map(i => i._type)
1232
- assert.ok(types.includes('menu'))
1233
- })
1234
-
1235
- it('should rollback created items on error', async () => {
1236
- const insertedItems = []
1237
- let insertCount = 0
1238
-
1239
- // We simulate the rollback logic directly since super.delete
1240
- // cannot be intercepted easily in isolation tests
1241
- const rollbackFn = async (req) => {
1242
- const newItems = []
1243
- const insertFn = async (data) => {
1244
- insertCount++
1245
- if (insertCount === 3) throw new Error('Insert failed')
1246
- const item = { ...data, _id: `id-${insertCount}` }
1247
- newItems.push(item)
1248
- return item
1249
- }
1250
- const deleteFn = mock.fn(async ({ _id }) => {
1251
- insertedItems.push(_id)
1252
- })
1253
-
1254
- const childTypes = ['config', 'page', 'article']
1255
- try {
1256
- for (const _type of childTypes) {
1257
- const item = await insertFn({ _type })
1258
- newItems.push(item)
1259
- }
1260
- } catch (e) {
1261
- await Promise.all(newItems.map(({ _id }) => deleteFn({ _id })))
1262
- throw e
1263
- }
1264
- return newItems[0]
1265
- }
1266
-
488
+ const items = [
489
+ { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID },
490
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID }
491
+ ]
492
+ const tree = new ContentTree(items)
493
+ const parent = { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID }
1267
494
  await assert.rejects(
1268
- () => rollbackFn({}),
1269
- (err) => {
1270
- assert.equal(err.message, 'Insert failed')
1271
- return true
1272
- }
495
+ () => ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, COURSE_OID, {}, { tree, parent }),
496
+ { message: 'insert failed' }
1273
497
  )
1274
-
1275
- // 2 successfully created items should be rolled back
1276
- // (each inserted twice into newItems due to push, but deleteFn captures _id)
1277
- assert.ok(insertedItems.length > 0)
498
+ // should attempt cleanup via deleteMany
499
+ assert.equal(mongodb.collection.deleteMany.mock.callCount(), 1)
1278
500
  })
1279
501
 
1280
- it('should set default text component data', async () => {
1281
- const insertedItems = []
1282
- const inst = createInstance({
1283
- find: mock.fn(async () => [{ _id: 'block1', _type: 'block', _courseId: 'c1' }]),
1284
- insert: mock.fn(async (data) => {
1285
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'c1' }
1286
- insertedItems.push(item)
1287
- return item
1288
- })
1289
- })
1290
-
1291
- const req = {
1292
- apiData: { query: { rootId: 'block1' }, data: {} },
1293
- auth: { user: { _id: 'user-1' } },
1294
- translate: mock.fn((key) => `T:${key}`),
1295
- body: {}
1296
- }
1297
-
1298
- await ContentModule.prototype.insertRecursive.call(inst, req)
1299
-
1300
- const component = insertedItems.find(i => i._type === 'component')
1301
- assert.ok(component)
1302
- assert.equal(component._component, 'adapt-contrib-text')
1303
- assert.equal(component._layout, 'full')
1304
- assert.equal(component.title, 'T:app.newtextcomponenttitle')
1305
- assert.equal(component.body, 'T:app.newtextcomponentbody')
1306
- })
502
+ it('should fire pre/post clone hooks', async () => {
503
+ const { inst } = createCloneInstance()
1307
504
 
1308
- it('should set createdBy on all new items', async () => {
1309
- const insertedItems = []
1310
- const inst = createInstance({
1311
- find: mock.fn(async () => [{ _id: 'article1', _type: 'article', _courseId: 'c1' }]),
1312
- insert: mock.fn(async (data) => {
1313
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'c1' }
1314
- insertedItems.push(item)
1315
- return item
1316
- })
1317
- })
505
+ const items = [
506
+ { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID },
507
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID }
508
+ ]
509
+ const tree = new ContentTree(items)
510
+ const parent = { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID }
511
+ await ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, COURSE_OID, {}, { tree, parent })
512
+
513
+ assert.ok(inst.preCloneHook.invoke.mock.callCount() > 0)
514
+ assert.ok(inst.postCloneHook.invoke.mock.callCount() > 0)
515
+ assert.ok(inst.preInsertHook.invoke.mock.callCount() > 0)
516
+ assert.ok(inst.postInsertHook.invoke.mock.callCount() > 0)
517
+ })
518
+
519
+ it('should assign distinct sequential _trackingId values to cloned blocks', async () => {
520
+ // Regression: bulk insertMany defeats SpoorTrackingModule's preInsertHook
521
+ // which assumes each new block is persisted before the next hook runs.
522
+ // The clone path must pre-allocate _trackingId so cloned blocks don't collide.
523
+ const BLOCK2_OID = '507f1f77bcf86cd79943a001'
524
+ const BLOCK3_OID = '507f1f77bcf86cd79943a002'
525
+
526
+ const { inst, mongodb } = createCloneInstance()
527
+ inst.find = mock.fn(async () => [{ _trackingId: 7 }]) // existing max in course
528
+
529
+ const items = [
530
+ { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID },
531
+ { _id: PAGE_OID, _type: 'page', _parentId: COURSE_OID, _courseId: COURSE_OID },
532
+ { _id: ART_OID, _type: 'article', _parentId: PAGE_OID, _courseId: COURSE_OID },
533
+ { _id: BLOCK_OID, _type: 'block', _parentId: ART_OID, _courseId: COURSE_OID, _trackingId: 5 },
534
+ { _id: BLOCK2_OID, _type: 'block', _parentId: ART_OID, _courseId: COURSE_OID, _trackingId: 6 },
535
+ { _id: BLOCK3_OID, _type: 'block', _parentId: ART_OID, _courseId: COURSE_OID, _trackingId: 7 }
536
+ ]
537
+ const tree = new ContentTree(items)
538
+ const parent = { _id: COURSE_OID, _type: 'course', _courseId: COURSE_OID }
539
+ await ContentModule.prototype.clone.call(inst, USER_OID, PAGE_OID, COURSE_OID, {}, { tree, parent })
1318
540
 
1319
- const req = {
1320
- apiData: { query: { rootId: 'article1' }, data: {} },
1321
- auth: { user: { _id: { toString: () => 'user-42' } } },
1322
- translate: mock.fn((key) => key),
1323
- body: {}
541
+ const inserted = mongodb.collection.insertMany.mock.calls[0].arguments[0]
542
+ const blockTrackingIds = inserted.filter(d => d._type === 'block').map(d => d._trackingId)
543
+ assert.equal(blockTrackingIds.length, 3)
544
+ for (const id of blockTrackingIds) {
545
+ assert.equal(typeof id, 'number', `block cloned without numeric _trackingId (got ${id})`)
1324
546
  }
1325
-
1326
- await ContentModule.prototype.insertRecursive.call(inst, req)
1327
-
1328
- for (const item of insertedItems) {
1329
- assert.equal(item.createdBy, 'user-42')
547
+ // all distinct
548
+ assert.equal(new Set(blockTrackingIds).size, blockTrackingIds.length, 'duplicate _trackingId among cloned blocks')
549
+ // and continuing past the existing max
550
+ for (const id of blockTrackingIds) {
551
+ assert.ok(id > 7, `expected _trackingId > existing max (7), got ${id}`)
1330
552
  }
1331
553
  })
554
+ })
1332
555
 
1333
- it('should set _parentId and _courseId on child items', async () => {
1334
- const insertedItems = []
556
+ describe('handleTree', () => {
557
+ it('should return 304 when content has not been modified', async () => {
558
+ const lastModified = new Date('2025-01-01T00:00:00Z')
1335
559
  const inst = createInstance({
1336
- find: mock.fn(async () => [{ _id: 'page1', _type: 'page', _courseId: 'c1' }]),
1337
- insert: mock.fn(async (data) => {
1338
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'c1' }
1339
- insertedItems.push(item)
1340
- return item
1341
- })
560
+ findOne: mock.fn(async () => ({ updatedAt: lastModified }))
1342
561
  })
1343
-
562
+ let statusCode
563
+ let ended = false
1344
564
  const req = {
1345
- apiData: { query: { rootId: 'page1' }, data: {} },
1346
- auth: { user: { _id: 'user-1' } },
1347
- translate: mock.fn((key) => key),
1348
- body: {}
565
+ apiData: { query: { _courseId: COURSE_ID } },
566
+ headers: { 'if-modified-since': new Date('2025-01-02T00:00:00Z').toUTCString() }
1349
567
  }
1350
-
1351
- await ContentModule.prototype.insertRecursive.call(inst, req)
1352
-
1353
- // First child (article) should have page as parent
1354
- const article = insertedItems.find(i => i._type === 'article')
1355
- assert.ok(article)
1356
- assert.equal(article._courseId, 'c1')
568
+ const res = {
569
+ status: mock.fn(function (code) { statusCode = code; return this }),
570
+ end: mock.fn(() => { ended = true })
571
+ }
572
+ const next = mock.fn()
573
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
574
+ assert.equal(statusCode, 304)
575
+ assert.equal(ended, true)
576
+ assert.equal(next.mock.callCount(), 0)
1357
577
  })
1358
578
 
1359
- it('should return the topmost new item', async () => {
1360
- const insertedItems = []
579
+ it('should return items with _children when content has been modified', async () => {
580
+ const lastModified = new Date('2025-01-15T00:00:00Z')
581
+ const items = [
582
+ { _id: COURSE_ID, _type: 'course', _courseId: COURSE_ID },
583
+ { _id: 'page1', _type: 'page', _parentId: COURSE_ID, _courseId: COURSE_ID },
584
+ { _id: 'art1', _type: 'article', _parentId: 'page1', _courseId: COURSE_ID }
585
+ ]
1361
586
  const inst = createInstance({
1362
- find: mock.fn(async () => [{ _id: 'article1', _type: 'article', _courseId: 'c1' }]),
1363
- insert: mock.fn(async (data) => {
1364
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'c1' }
1365
- insertedItems.push(item)
1366
- return item
1367
- })
587
+ findOne: mock.fn(async () => ({ updatedAt: lastModified })),
588
+ find: mock.fn(async () => items)
1368
589
  })
1369
-
1370
590
  const req = {
1371
- apiData: { query: { rootId: 'article1' }, data: {} },
1372
- auth: { user: { _id: 'user-1' } },
1373
- translate: mock.fn((key) => key),
1374
- body: {}
591
+ apiData: { query: { _courseId: COURSE_ID } },
592
+ headers: {}
1375
593
  }
1376
-
1377
- const result = await ContentModule.prototype.insertRecursive.call(inst, req)
1378
-
1379
- // The first inserted item should be returned (block after article)
1380
- assert.equal(result._id, insertedItems[0]._id)
1381
- })
1382
- })
1383
-
1384
- // -----------------------------------------------------------------------
1385
- // updateEnabledPlugins
1386
- // -----------------------------------------------------------------------
1387
- describe('updateEnabledPlugins', () => {
1388
- it('should return early when no config is found', async () => {
1389
- const contentplugin = {
1390
- find: mock.fn(async () => []),
1391
- getPluginSchemas: mock.fn(() => []),
1392
- isPluginSchema: mock.fn(() => false)
1393
- }
1394
- const jsonschema = { schemas: {} }
1395
-
1396
- const inst = createInstance({
1397
- find: mock.fn(async () => [
1398
- { _id: 'article1', _type: 'article', _courseId: 'c1' }
1399
- ])
1400
- })
1401
- inst.app.waitForModule = mock.fn(async () => [contentplugin, jsonschema])
1402
-
1403
- const result = await ContentModule.prototype.updateEnabledPlugins.call(
1404
- inst,
1405
- { _courseId: 'c1' }
1406
- )
1407
-
1408
- assert.equal(result, undefined)
1409
- })
1410
-
1411
- it('should return early when plugin lists already match', async () => {
1412
- const contentplugin = {
1413
- find: mock.fn(async () => [{ name: 'ext-1', type: 'extension' }]),
1414
- getPluginSchemas: mock.fn(() => []),
1415
- isPluginSchema: mock.fn(() => false)
594
+ let responseData
595
+ let lastModifiedHeader
596
+ const res = {
597
+ set: mock.fn((key, val) => { if (key === 'Last-Modified') lastModifiedHeader = val }),
598
+ json: mock.fn((data) => { responseData = data })
1416
599
  }
1417
- const jsonschema = { schemas: {} }
1418
-
1419
- const inst = createInstance({
1420
- find: mock.fn(async () => [
1421
- {
1422
- _id: 'config1',
1423
- _type: 'config',
1424
- _courseId: 'c1',
1425
- _enabledPlugins: ['ext-1', 'comp-1', 'my-menu', 'my-theme'],
1426
- _menu: 'my-menu',
1427
- _theme: 'my-theme'
1428
- },
1429
- { _id: 'comp1', _type: 'component', _courseId: 'c1', _component: 'comp-1' }
1430
- ])
1431
- })
1432
- inst.app.waitForModule = mock.fn(async () => [contentplugin, jsonschema])
1433
-
1434
- const superUpdate = mock.fn(async () => {})
1435
-
1436
- const boundFn = ContentModule.prototype.updateEnabledPlugins.bind({
1437
- ...inst,
1438
- find: inst.find,
1439
- app: inst.app,
1440
- __proto__: {
1441
- update: superUpdate,
1442
- find: mock.fn(async () => [])
1443
- }
1444
- })
1445
-
1446
- await boundFn({ _courseId: 'c1' })
600
+ const next = mock.fn()
601
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
1447
602
 
1448
- assert.equal(superUpdate.mock.callCount(), 0)
603
+ assert.equal(next.mock.callCount(), 0)
604
+ assert.equal(responseData.length, 3)
605
+ // course should have page1 as child
606
+ const course = responseData.find(i => i._id === COURSE_ID)
607
+ assert.deepEqual(course._children, ['page1'])
608
+ // page should have art1 as child
609
+ const page = responseData.find(i => i._id === 'page1')
610
+ assert.deepEqual(page._children, ['art1'])
611
+ // article should have no children
612
+ const art = responseData.find(i => i._id === 'art1')
613
+ assert.deepEqual(art._children, [])
614
+ // Last-Modified header should be set
615
+ assert.equal(lastModifiedHeader, lastModified.toUTCString())
616
+ })
617
+
618
+ it('should call next on error', async () => {
619
+ const inst = createInstance({
620
+ findOne: mock.fn(async () => { throw new Error('db error') })
621
+ })
622
+ const req = { apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
623
+ const res = {}
624
+ const next = mock.fn()
625
+ await ContentModule.prototype.handleTree.call(inst, req, res, next)
626
+ assert.equal(next.mock.callCount(), 1)
627
+ assert.equal(next.mock.calls[0].arguments[0].message, 'db error')
1449
628
  })
1450
629
  })
1451
630
 
1452
- // -----------------------------------------------------------------------
1453
- // getSchema
1454
- // -----------------------------------------------------------------------
1455
- describe('getSchema', () => {
1456
- it('should call jsonschema.getSchema with extensionFilter', async () => {
1457
- const getSchemaResult = { built: {}, validate: mock.fn() }
1458
- const jsonschema = {
1459
- getSchema: mock.fn(async () => getSchemaResult)
1460
- }
1461
- const contentplugin = {
1462
- find: mock.fn(async () => []),
1463
- getPluginSchemas: mock.fn(() => []),
1464
- isPluginSchema: mock.fn(() => false)
1465
- }
631
+ describe('enforceAssetNotInUse', () => {
632
+ const ASSET_OID = '507f1f77bcf86cd799439020'
633
+ const COURSE_A_OID = '507f1f77bcf86cd799439001'
634
+ const COURSE_B_OID = '507f1f77bcf86cd799439002'
635
+ const RESOURCE_IN_USE = Symbol('RESOURCE_IN_USE')
1466
636
 
1467
- let waitForModuleCallCount = 0
1468
- const inst = createInstance({
1469
- find: mock.fn(async () => [])
1470
- })
1471
- inst.app.waitForModule = mock.fn(async () => {
1472
- waitForModuleCallCount++
1473
- if (waitForModuleCallCount <= 1) return jsonschema
1474
- return contentplugin
1475
- })
1476
- inst.getSchemaName = mock.fn(async () => 'article')
1477
-
1478
- await ContentModule.prototype.getSchema.call(
1479
- inst,
1480
- 'content',
1481
- { _type: 'article' }
1482
- )
1483
-
1484
- assert.equal(jsonschema.getSchema.mock.callCount(), 1)
1485
- })
1486
-
1487
- it('should handle errors in getSchemaName gracefully', async () => {
1488
- const getSchemaResult = { built: {}, validate: mock.fn() }
1489
- const jsonschema = {
1490
- getSchema: mock.fn(async () => getSchemaResult)
1491
- }
1492
- const contentplugin = {
1493
- find: mock.fn(async () => []),
1494
- getPluginSchemas: mock.fn(() => []),
1495
- isPluginSchema: mock.fn(() => false)
1496
- }
1497
-
1498
- let waitForModuleCallCount = 0
1499
- const inst = createInstance()
1500
- inst.app.waitForModule = mock.fn(async () => {
1501
- waitForModuleCallCount++
1502
- if (waitForModuleCallCount <= 1) return jsonschema
1503
- return contentplugin
1504
- })
1505
- inst.getSchemaName = mock.fn(async () => { throw new Error('schema error') })
1506
- inst.find = mock.fn(async () => [])
1507
-
1508
- const result = await ContentModule.prototype.getSchema.call(
1509
- inst,
1510
- 'content',
1511
- { _type: 'unknown' }
1512
- )
1513
-
1514
- assert.ok(result)
1515
- })
1516
-
1517
- it('should use the original schemaName when getSchemaName throws', async () => {
1518
- const getSchemaResult = { built: {}, validate: mock.fn() }
1519
- const jsonschema = {
1520
- getSchema: mock.fn(async () => getSchemaResult)
637
+ function createAssetInst (findResults) {
638
+ let call = 0
639
+ return {
640
+ find: mock.fn(async () => findResults[call++] ?? []),
641
+ app: { errors: { RESOURCE_IN_USE: { setData: mock.fn(data => ({ symbol: RESOURCE_IN_USE, data })) } } }
1521
642
  }
1522
- const contentplugin = {
1523
- find: mock.fn(async () => []),
1524
- getPluginSchemas: mock.fn(() => []),
1525
- isPluginSchema: mock.fn(() => false)
1526
- }
1527
-
1528
- let waitForModuleCallCount = 0
1529
- const inst = createInstance()
1530
- inst.app.waitForModule = mock.fn(async () => {
1531
- waitForModuleCallCount++
1532
- if (waitForModuleCallCount <= 1) return jsonschema
1533
- return contentplugin
1534
- })
1535
- inst.getSchemaName = mock.fn(async () => { throw new Error('fail') })
1536
- inst.find = mock.fn(async () => [])
1537
-
1538
- await ContentModule.prototype.getSchema.call(inst, 'mySchema', { _type: 'x' })
1539
-
1540
- // jsonschema.getSchema should be called with the original 'mySchema'
1541
- const calledWith = jsonschema.getSchema.mock.calls[0].arguments[0]
1542
- assert.equal(calledWith, 'mySchema')
1543
- })
1544
- })
1545
-
1546
- // -----------------------------------------------------------------------
1547
- // registerConfigSchemas
1548
- // -----------------------------------------------------------------------
1549
- describe('registerConfigSchemas', () => {
1550
- it('should extend config schema with authored and tags schemas', async () => {
1551
- const extendSchema = mock.fn()
1552
- const authored = { schemaName: 'authored' }
1553
- const tags = { schemaExtensionName: 'tags-ext' }
1554
- const jsonschema = { extendSchema }
1555
-
1556
- const inst = createInstance()
1557
- inst.app.waitForModule = mock.fn(async () => [authored, jsonschema, tags])
1558
-
1559
- await ContentModule.prototype.registerConfigSchemas.call(inst)
1560
-
1561
- assert.equal(extendSchema.mock.callCount(), 2)
1562
- assert.deepEqual(extendSchema.mock.calls[0].arguments, ['config', 'authored'])
1563
- assert.deepEqual(extendSchema.mock.calls[1].arguments, ['config', 'tags-ext'])
1564
- })
1565
- })
643
+ }
1566
644
 
1567
- // -----------------------------------------------------------------------
1568
- // Edge cases
1569
- // -----------------------------------------------------------------------
1570
- describe('edge cases', () => {
1571
- it('getDescendants should handle items with no _parentId property', async () => {
1572
- const inst = createInstance({
1573
- find: mock.fn(async () => [
1574
- { _id: 'root', _courseId: 'c1', _type: 'course' },
1575
- { _id: 'config1', _courseId: 'c1', _type: 'config' }
1576
- ])
1577
- })
1578
-
1579
- const result = await getDescendants(q => inst.find(q), {
1580
- _id: 'root',
1581
- _courseId: 'c1',
1582
- _type: 'course'
1583
- })
1584
-
1585
- assert.ok(result.some(r => r._type === 'config'))
645
+ it('returns silently when the asset is not referenced by any content', async () => {
646
+ const inst = createAssetInst([[]])
647
+ await ContentModule.prototype.enforceAssetNotInUse.call(inst, { _id: ASSET_OID })
648
+ assert.equal(inst.find.mock.callCount(), 1)
649
+ assert.equal(inst.app.errors.RESOURCE_IN_USE.setData.mock.callCount(), 0)
1586
650
  })
1587
651
 
1588
- it('clone should allow course type without _parentId when config exists', async () => {
1589
- let findCallCount = 0
1590
- const insertFn = mock.fn(async (data, opts) => ({
1591
- ...data,
1592
- _id: 'new-course-id'
1593
- }))
1594
- const updateFn = mock.fn(async () => ({}))
1595
- const inst = createInstance({
1596
- find: mock.fn(async () => {
1597
- findCallCount++
1598
- // 1: find original course
1599
- if (findCallCount === 1) return [{ _id: 'orig-course', _type: 'course', _courseId: 'orig-course' }]
1600
- // 2: find config for the course clone
1601
- if (findCallCount === 2) return [{ _id: 'config1', _type: 'config', _courseId: 'orig-course' }]
1602
- // 3: find config original for the recursive clone call
1603
- if (findCallCount === 3) return [{ _id: 'config1', _type: 'config', _courseId: 'orig-course' }]
1604
- // 4+: children lookups
1605
- return []
1606
- }),
1607
- insert: insertFn,
1608
- update: updateFn,
1609
- preCloneHook: createMockHook(),
1610
- postCloneHook: createMockHook()
1611
- })
1612
-
1613
- // Bind clone so recursive calls work
1614
- inst.clone = ContentModule.prototype.clone.bind(inst)
1615
-
1616
- // course clone with _parentId undefined should not throw INVALID_PARENT
1617
- await assert.doesNotReject(
1618
- () => inst.clone('user1', 'orig-course', undefined)
652
+ it('throws RESOURCE_IN_USE with course titles when the asset is in use', async () => {
653
+ const inst = createAssetInst([
654
+ [{ _courseId: COURSE_A_OID }, { _courseId: COURSE_B_OID }],
655
+ [{ title: 'Course A', displayTitle: 'Display A' }, { title: 'Course B' }]
656
+ ])
657
+ await assert.rejects(
658
+ () => ContentModule.prototype.enforceAssetNotInUse.call(inst, { _id: ASSET_OID }),
659
+ e => e.symbol === RESOURCE_IN_USE && e.data.type === 'asset' && e.data.courses.length === 2 &&
660
+ e.data.courses.includes('Display A') && e.data.courses.includes('Course B')
1619
661
  )
1620
662
  })
1621
663
 
1622
- it('insertRecursive should use translate for default titles', async () => {
1623
- const insertedItems = []
1624
- const inst = createInstance({
1625
- insert: mock.fn(async (data, opts) => {
1626
- const item = { ...data, _id: `id-${data._type}`, _courseId: 'c1' }
1627
- insertedItems.push(item)
1628
- return item
1629
- })
1630
- })
1631
-
1632
- const translateKeys = []
1633
- const req = {
1634
- apiData: { query: {}, data: { title: 'Course' } },
1635
- auth: { user: { _id: 'user-1' } },
1636
- translate: mock.fn((key) => {
1637
- translateKeys.push(key)
1638
- return `T:${key}`
1639
- }),
1640
- body: {}
1641
- }
1642
-
1643
- await ContentModule.prototype.insertRecursive.call(inst, req)
1644
-
1645
- assert.ok(translateKeys.includes('app.newpagetitle'))
1646
- assert.ok(translateKeys.includes('app.newarticletitle'))
1647
- assert.ok(translateKeys.includes('app.newblocktitle'))
1648
- assert.ok(translateKeys.includes('app.newtextcomponenttitle'))
1649
- assert.ok(translateKeys.includes('app.newtextcomponentbody'))
1650
- })
1651
- })
1652
-
1653
- // -----------------------------------------------------------------------
1654
- // Bug fixes
1655
- // -----------------------------------------------------------------------
1656
- describe('bug fixes', () => {
1657
- it('insert should catch MONGO_DUPL_INDEX and throw DUPL_FRIENDLY_ID', async () => {
1658
- const duplError = createMockError('MONGO_DUPL_INDEX')
1659
- const inst = createInstance()
1660
- const superInsert = mock.fn(async () => { throw duplError })
1661
-
1662
- const origProto = Object.getPrototypeOf(ContentModule.prototype)
1663
- const origInsert = origProto.insert
1664
- origProto.insert = superInsert
1665
- try {
1666
- await assert.rejects(
1667
- () => ContentModule.prototype.insert.call(inst, { _friendlyId: 'fid-1', _courseId: 'c1', _type: 'article' }),
1668
- (err) => {
1669
- assert.equal(err.code, 'DUPL_FRIENDLY_ID')
1670
- assert.equal(err.data._friendlyId, 'fid-1')
1671
- assert.equal(err.data._courseId, 'c1')
1672
- return true
1673
- }
1674
- )
1675
- } finally {
1676
- origProto.insert = origInsert
1677
- }
1678
- })
1679
-
1680
- it('insert should re-throw non-duplicate errors unchanged', async () => {
1681
- const otherError = new Error('SOME_OTHER_ERROR')
1682
- otherError.code = 'SOME_OTHER_ERROR'
1683
- const inst = createInstance()
1684
-
1685
- const origProto = Object.getPrototypeOf(ContentModule.prototype)
1686
- const origInsert = origProto.insert
1687
- origProto.insert = mock.fn(async () => { throw otherError })
1688
- try {
1689
- await assert.rejects(
1690
- () => ContentModule.prototype.insert.call(inst, { _type: 'article' }),
1691
- (err) => {
1692
- assert.equal(err.code, 'SOME_OTHER_ERROR')
1693
- return true
1694
- }
1695
- )
1696
- } finally {
1697
- origProto.insert = origInsert
1698
- }
1699
- })
1700
-
1701
- it('update should catch MONGO_DUPL_INDEX and throw DUPL_FRIENDLY_ID', async () => {
1702
- const duplError = createMockError('MONGO_DUPL_INDEX')
1703
- const inst = createInstance()
1704
-
1705
- const origProto = Object.getPrototypeOf(ContentModule.prototype)
1706
- const origUpdate = origProto.update
1707
- origProto.update = mock.fn(async () => { throw duplError })
1708
- try {
1709
- await assert.rejects(
1710
- () => ContentModule.prototype.update.call(inst, { _id: 'x' }, { _friendlyId: 'fid-1', _courseId: 'c1' }),
1711
- (err) => {
1712
- assert.equal(err.code, 'DUPL_FRIENDLY_ID')
1713
- assert.equal(err.data._friendlyId, 'fid-1')
1714
- assert.equal(err.data._courseId, 'c1')
1715
- return true
1716
- }
1717
- )
1718
- } finally {
1719
- origProto.update = origUpdate
1720
- }
1721
- })
1722
-
1723
- it('update should re-throw non-duplicate errors unchanged', async () => {
1724
- const otherError = new Error('SOME_OTHER_ERROR')
1725
- otherError.code = 'SOME_OTHER_ERROR'
1726
- const inst = createInstance()
1727
-
1728
- const origProto = Object.getPrototypeOf(ContentModule.prototype)
1729
- const origUpdate = origProto.update
1730
- origProto.update = mock.fn(async () => { throw otherError })
1731
- try {
1732
- await assert.rejects(
1733
- () => ContentModule.prototype.update.call(inst, { _id: 'x' }, { title: 'Updated' }),
1734
- (err) => {
1735
- assert.equal(err.code, 'SOME_OTHER_ERROR')
1736
- return true
1737
- }
1738
- )
1739
- } finally {
1740
- origProto.update = origUpdate
1741
- }
1742
- })
1743
-
1744
- it('clone should clear _friendlyId for non-course types', async () => {
1745
- let findCallCount = 0
1746
- const insertFn = mock.fn(async (data, opts) => ({
1747
- ...data,
1748
- _id: 'new-id'
1749
- }))
1750
- const inst = createInstance({
1751
- find: mock.fn(async () => {
1752
- findCallCount++
1753
- if (findCallCount === 1) return [{ _id: 'orig', _type: 'article', _courseId: 'c1', _friendlyId: 'art-1' }]
1754
- if (findCallCount === 2) return [{ _id: 'p', _type: 'page', _courseId: 'c1' }]
1755
- return []
1756
- }),
1757
- insert: insertFn,
1758
- preCloneHook: createMockHook(),
1759
- postCloneHook: createMockHook()
1760
- })
1761
-
1762
- await ContentModule.prototype.clone.call(inst, 'user1', 'orig', 'p')
1763
-
1764
- const payload = insertFn.mock.calls[0].arguments[0]
1765
- assert.equal(payload._friendlyId, undefined)
1766
- })
1767
-
1768
- it('clone should preserve _friendlyId for course types', async () => {
1769
- let findCallCount = 0
1770
- const insertFn = mock.fn(async (data, opts) => ({
1771
- ...data,
1772
- _id: 'new-course-id'
1773
- }))
1774
- const inst = createInstance({
1775
- find: mock.fn(async () => {
1776
- findCallCount++
1777
- if (findCallCount === 1) return [{ _id: 'c1', _type: 'course', _courseId: 'c1', _friendlyId: 'course-1' }]
1778
- return []
1779
- }),
1780
- insert: insertFn,
1781
- update: mock.fn(async () => ({})),
1782
- preCloneHook: createMockHook(),
1783
- postCloneHook: createMockHook()
1784
- })
1785
-
1786
- await ContentModule.prototype.clone.call(inst, 'user1', 'c1', undefined)
1787
-
1788
- const payload = insertFn.mock.calls[0].arguments[0]
1789
- assert.equal(payload._friendlyId, 'course-1')
1790
- })
1791
-
1792
- it('should handle clone of course when no config exists', async () => {
1793
- let findCallCount = 0
1794
- const inst = createInstance({
1795
- find: mock.fn(async () => {
1796
- findCallCount++
1797
- if (findCallCount === 1) return [{ _id: 'c1', _type: 'course', _courseId: 'c1' }]
1798
- return [] // no config, no children
1799
- }),
1800
- insert: mock.fn(async (data, opts) => ({ ...data, _id: 'new-c1' })),
1801
- update: mock.fn(async () => ({})),
1802
- preCloneHook: createMockHook(),
1803
- postCloneHook: createMockHook()
1804
- })
1805
-
1806
- const result = await ContentModule.prototype.clone.call(inst, 'user1', 'c1', undefined)
1807
- assert.strictEqual(result._id, 'new-c1')
664
+ it('casts string courseIds to ObjectId for the lookup so titles resolve against ObjectId _ids', async () => {
665
+ const inst = createAssetInst([
666
+ [{ _courseId: COURSE_A_OID }],
667
+ [{ title: 'Course A' }]
668
+ ])
669
+ await assert.rejects(() => ContentModule.prototype.enforceAssetNotInUse.call(inst, { _id: ASSET_OID }))
670
+ const courseLookupQuery = inst.find.mock.calls[1].arguments[0]
671
+ assert.equal(courseLookupQuery._type, 'course')
672
+ assert.equal(courseLookupQuery._id.$in.length, 1)
673
+ // ObjectId cast: not the original string
674
+ assert.notEqual(courseLookupQuery._id.$in[0], COURSE_A_OID)
675
+ assert.equal(courseLookupQuery._id.$in[0].toString(), COURSE_A_OID)
676
+ })
677
+
678
+ it('deduplicates courseIds when multiple content docs share a courseId', async () => {
679
+ const inst = createAssetInst([
680
+ [{ _courseId: COURSE_A_OID }, { _courseId: COURSE_A_OID }, { _courseId: COURSE_A_OID }],
681
+ [{ title: 'Course A' }]
682
+ ])
683
+ await assert.rejects(() => ContentModule.prototype.enforceAssetNotInUse.call(inst, { _id: ASSET_OID }))
684
+ const courseLookupQuery = inst.find.mock.calls[1].arguments[0]
685
+ assert.equal(courseLookupQuery._id.$in.length, 1)
1808
686
  })
1809
687
  })
1810
688
  })