adapt-authoring-content 2.1.7 → 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.
- package/errors/errors.json +7 -8
- package/index.js +1 -0
- package/lib/ContentModule.js +432 -110
- package/lib/ContentTree.js +128 -0
- package/lib/utils/computeSortOrderOps.js +21 -0
- package/lib/utils/contentTypeToSchemaName.js +9 -0
- package/lib/utils/extractAssetIds.js +18 -0
- package/lib/utils/formatFriendlyId.js +12 -0
- package/lib/utils/parseMaxSeq.js +16 -0
- package/lib/utils.js +6 -1
- package/migrations/3.0.0.js +123 -0
- package/package.json +4 -3
- package/routes.json +51 -0
- package/schema/contentassets.schema.json +18 -0
- package/tests/ContentModule.spec.js +512 -1634
- package/tests/ContentTree.spec.js +230 -0
- package/tests/_ht.js +116 -0
- package/tests/utils-computeSortOrderOps.spec.js +94 -0
- package/tests/utils-contentTypeToSchemaName.spec.js +21 -0
- package/tests/utils-extractAssetIds.spec.js +118 -0
- package/tests/utils-formatFriendlyId.spec.js +40 -0
- package/tests/utils-parseMaxSeq.spec.js +49 -0
- package/lib/utils/getDescendants.js +0 -22
- package/tests/utils-getDescendants.spec.js +0 -117
|
@@ -1,1810 +1,688 @@
|
|
|
1
|
-
import { describe, it, mock
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
58
|
-
const instance = {
|
|
59
|
-
app,
|
|
60
|
-
root: 'content',
|
|
61
|
-
collectionName: 'content',
|
|
26
|
+
return {
|
|
62
27
|
schemaName: 'content',
|
|
63
|
-
|
|
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
|
-
|
|
44
|
+
const bind = (overrides) => ContentModule.prototype.getSchemaName.bind(createInstance(overrides))
|
|
114
45
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
123
|
-
|
|
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
|
|
139
|
-
|
|
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
|
|
155
|
-
|
|
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
|
|
171
|
-
|
|
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
|
|
187
|
-
|
|
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
|
|
203
|
-
|
|
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
|
|
224
|
-
const
|
|
225
|
-
|
|
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
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
319
|
-
const
|
|
320
|
-
|
|
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
|
|
338
|
-
const
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
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
|
|
423
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
553
|
-
|
|
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
|
|
569
|
-
const
|
|
570
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
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
|
|
625
|
-
const
|
|
626
|
-
|
|
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
|
|
654
|
-
const
|
|
655
|
-
|
|
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
|
-
|
|
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
|
|
685
|
-
const
|
|
686
|
-
|
|
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
|
|
696
|
-
const
|
|
697
|
-
|
|
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
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
|
|
251
|
+
jsonschema: { extendSchema },
|
|
252
|
+
authored: { schemaName: 'authored' },
|
|
253
|
+
tags: { schemaExtensionName: 'tags-ext' }
|
|
719
254
|
})
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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
|
-
|
|
767
|
-
|
|
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
|
|
783
|
-
const
|
|
784
|
-
|
|
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
|
-
|
|
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
|
|
853
|
-
const
|
|
854
|
-
|
|
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
|
|
875
|
-
const
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
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
|
-
|
|
883
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
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
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
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
|
-
|
|
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
|
|
963
|
-
const
|
|
964
|
-
|
|
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
|
|
985
|
-
const
|
|
986
|
-
|
|
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
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
398
|
+
it('should throw NOT_FOUND when original doc is missing', async () => {
|
|
399
|
+
const { inst } = createCloneInstance()
|
|
1104
400
|
|
|
1105
|
-
const
|
|
1106
|
-
assert.
|
|
1107
|
-
|
|
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
|
|
1111
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
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
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const
|
|
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
|
|
1176
|
-
const
|
|
1177
|
-
let findCalled = false
|
|
462
|
+
it('should remap parent IDs correctly', async () => {
|
|
463
|
+
const { inst, mongodb } = createCloneInstance()
|
|
1178
464
|
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
assert.
|
|
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
|
|
1212
|
-
const
|
|
1213
|
-
const inst =
|
|
1214
|
-
|
|
1215
|
-
|
|
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
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
-
() =>
|
|
1269
|
-
|
|
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
|
-
|
|
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
|
|
1281
|
-
const
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
1328
|
-
for (const
|
|
1329
|
-
assert.
|
|
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
|
-
|
|
1334
|
-
|
|
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
|
-
|
|
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: {
|
|
1346
|
-
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1356
|
-
assert.equal(
|
|
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
|
|
1360
|
-
const
|
|
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
|
-
|
|
1363
|
-
|
|
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: {
|
|
1372
|
-
|
|
1373
|
-
translate: mock.fn((key) => key),
|
|
1374
|
-
body: {}
|
|
591
|
+
apiData: { query: { _courseId: COURSE_ID } },
|
|
592
|
+
headers: {}
|
|
1375
593
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
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('
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
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('
|
|
1623
|
-
const
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
})
|