adapt-authoring-content 2.1.8 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/errors/errors.json +7 -8
- package/index.js +1 -0
- package/lib/ContentModule.js +423 -108
- 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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import ContentTree from '../lib/ContentTree.js'
|
|
4
|
+
|
|
5
|
+
describe('ContentTree', () => {
|
|
6
|
+
const makeId = (n) => ({ toString: () => `id${n}` })
|
|
7
|
+
|
|
8
|
+
const items = [
|
|
9
|
+
{ _id: makeId(1), _type: 'course', _courseId: 'c1' },
|
|
10
|
+
{ _id: makeId(2), _type: 'config', _courseId: 'c1' },
|
|
11
|
+
{ _id: makeId(3), _type: 'page', _courseId: 'c1', _parentId: makeId(1) },
|
|
12
|
+
{ _id: makeId(4), _type: 'menu', _courseId: 'c1', _parentId: makeId(1) },
|
|
13
|
+
{ _id: makeId(5), _type: 'article', _courseId: 'c1', _parentId: makeId(3) },
|
|
14
|
+
{ _id: makeId(6), _type: 'article', _courseId: 'c1', _parentId: makeId(3) },
|
|
15
|
+
{ _id: makeId(7), _type: 'block', _courseId: 'c1', _parentId: makeId(5) },
|
|
16
|
+
{ _id: makeId(8), _type: 'block', _courseId: 'c1', _parentId: makeId(5) },
|
|
17
|
+
{ _id: makeId(9), _type: 'component', _courseId: 'c1', _parentId: makeId(7), _component: 'adapt-contrib-text' },
|
|
18
|
+
{ _id: makeId(10), _type: 'component', _courseId: 'c1', _parentId: makeId(8), _component: 'adapt-contrib-media' }
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
describe('constructor', () => {
|
|
22
|
+
it('should index all items by id', () => {
|
|
23
|
+
const tree = new ContentTree(items)
|
|
24
|
+
assert.equal(tree.byId.size, 10)
|
|
25
|
+
assert.strictEqual(tree.byId.get('id1'), items[0])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should set course and config references', () => {
|
|
29
|
+
const tree = new ContentTree(items)
|
|
30
|
+
assert.strictEqual(tree.course, items[0])
|
|
31
|
+
assert.strictEqual(tree.config, items[1])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should handle items with no course or config', () => {
|
|
35
|
+
const tree = new ContentTree([
|
|
36
|
+
{ _id: makeId(1), _type: 'page', _parentId: makeId(99) }
|
|
37
|
+
])
|
|
38
|
+
assert.equal(tree.course, null)
|
|
39
|
+
assert.equal(tree.config, null)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should store items array', () => {
|
|
43
|
+
const tree = new ContentTree(items)
|
|
44
|
+
assert.strictEqual(tree.items, items)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should handle empty items array', () => {
|
|
48
|
+
const tree = new ContentTree([])
|
|
49
|
+
assert.equal(tree.byId.size, 0)
|
|
50
|
+
assert.equal(tree.course, null)
|
|
51
|
+
assert.equal(tree.config, null)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('getById', () => {
|
|
56
|
+
it('should return item by string id', () => {
|
|
57
|
+
const tree = new ContentTree(items)
|
|
58
|
+
assert.strictEqual(tree.getById('id3'), items[2])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should return item by object with toString', () => {
|
|
62
|
+
const tree = new ContentTree(items)
|
|
63
|
+
assert.strictEqual(tree.getById(makeId(3)), items[2])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('should return undefined for non-existent id', () => {
|
|
67
|
+
const tree = new ContentTree(items)
|
|
68
|
+
assert.equal(tree.getById('missing'), undefined)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('getChildren', () => {
|
|
73
|
+
it('should return children of a parent', () => {
|
|
74
|
+
const tree = new ContentTree(items)
|
|
75
|
+
const children = tree.getChildren('id1')
|
|
76
|
+
assert.equal(children.length, 2)
|
|
77
|
+
assert.ok(children.some(c => c._type === 'page'))
|
|
78
|
+
assert.ok(children.some(c => c._type === 'menu'))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should return empty array for leaf nodes', () => {
|
|
82
|
+
const tree = new ContentTree(items)
|
|
83
|
+
assert.deepEqual(tree.getChildren('id9'), [])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should return empty array for non-existent parent', () => {
|
|
87
|
+
const tree = new ContentTree(items)
|
|
88
|
+
assert.deepEqual(tree.getChildren('missing'), [])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should accept object with toString', () => {
|
|
92
|
+
const tree = new ContentTree(items)
|
|
93
|
+
const children = tree.getChildren(makeId(1))
|
|
94
|
+
assert.equal(children.length, 2)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('getByType', () => {
|
|
99
|
+
it('should return all items of a given type', () => {
|
|
100
|
+
const tree = new ContentTree(items)
|
|
101
|
+
assert.equal(tree.getByType('article').length, 2)
|
|
102
|
+
assert.equal(tree.getByType('component').length, 2)
|
|
103
|
+
assert.equal(tree.getByType('course').length, 1)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('should return empty array for non-existent type', () => {
|
|
107
|
+
const tree = new ContentTree(items)
|
|
108
|
+
assert.deepEqual(tree.getByType('unknown'), [])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('getDescendants', () => {
|
|
113
|
+
it('should return all descendants of the course root', () => {
|
|
114
|
+
const tree = new ContentTree(items)
|
|
115
|
+
const desc = tree.getDescendants('id1')
|
|
116
|
+
// page, menu, 2 articles, 2 blocks, 2 components = 8 (config excluded since it has no _parentId chain to course)
|
|
117
|
+
assert.equal(desc.length, 8)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('should return all descendants of a page', () => {
|
|
121
|
+
const tree = new ContentTree(items)
|
|
122
|
+
const desc = tree.getDescendants('id3')
|
|
123
|
+
// 2 articles, 2 blocks, 2 components = 6
|
|
124
|
+
assert.equal(desc.length, 6)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should return all descendants of an article', () => {
|
|
128
|
+
const tree = new ContentTree(items)
|
|
129
|
+
const desc = tree.getDescendants('id5')
|
|
130
|
+
// 2 blocks, 2 components = 4
|
|
131
|
+
assert.equal(desc.length, 4)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('should return empty array for leaf nodes', () => {
|
|
135
|
+
const tree = new ContentTree(items)
|
|
136
|
+
assert.deepEqual(tree.getDescendants('id9'), [])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('should return empty array for non-existent id', () => {
|
|
140
|
+
const tree = new ContentTree(items)
|
|
141
|
+
assert.deepEqual(tree.getDescendants('missing'), [])
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('should not include the root item itself', () => {
|
|
145
|
+
const tree = new ContentTree(items)
|
|
146
|
+
const desc = tree.getDescendants('id3')
|
|
147
|
+
assert.ok(!desc.some(d => d._id.toString() === 'id3'))
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('getAncestors', () => {
|
|
152
|
+
it('should return ancestors from leaf to root', () => {
|
|
153
|
+
const tree = new ContentTree(items)
|
|
154
|
+
const ancestors = tree.getAncestors('id9')
|
|
155
|
+
// block -> article -> page -> course
|
|
156
|
+
assert.equal(ancestors.length, 4)
|
|
157
|
+
assert.equal(ancestors[0]._type, 'block')
|
|
158
|
+
assert.equal(ancestors[1]._type, 'article')
|
|
159
|
+
assert.equal(ancestors[2]._type, 'page')
|
|
160
|
+
assert.equal(ancestors[3]._type, 'course')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('should return empty array for root items', () => {
|
|
164
|
+
const tree = new ContentTree(items)
|
|
165
|
+
assert.deepEqual(tree.getAncestors('id1'), [])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('should return empty array for config (no _parentId)', () => {
|
|
169
|
+
const tree = new ContentTree(items)
|
|
170
|
+
assert.deepEqual(tree.getAncestors('id2'), [])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('should return empty array for non-existent id', () => {
|
|
174
|
+
const tree = new ContentTree(items)
|
|
175
|
+
assert.deepEqual(tree.getAncestors('missing'), [])
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('getSiblings', () => {
|
|
180
|
+
it('should return siblings excluding the item itself', () => {
|
|
181
|
+
const tree = new ContentTree(items)
|
|
182
|
+
const siblings = tree.getSiblings('id5')
|
|
183
|
+
assert.equal(siblings.length, 1)
|
|
184
|
+
assert.equal(siblings[0]._id.toString(), 'id6')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('should return empty array for items with no _parentId', () => {
|
|
188
|
+
const tree = new ContentTree(items)
|
|
189
|
+
assert.deepEqual(tree.getSiblings('id1'), [])
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('should return empty array for only children', () => {
|
|
193
|
+
const tree = new ContentTree(items)
|
|
194
|
+
const siblings = tree.getSiblings('id9')
|
|
195
|
+
assert.equal(siblings.length, 0)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('should return empty array for non-existent id', () => {
|
|
199
|
+
const tree = new ContentTree(items)
|
|
200
|
+
assert.deepEqual(tree.getSiblings('missing'), [])
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('getComponentNames', () => {
|
|
205
|
+
it('should return unique component names', () => {
|
|
206
|
+
const tree = new ContentTree(items)
|
|
207
|
+
const names = tree.getComponentNames()
|
|
208
|
+
assert.equal(names.length, 2)
|
|
209
|
+
assert.ok(names.includes('adapt-contrib-text'))
|
|
210
|
+
assert.ok(names.includes('adapt-contrib-media'))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should deduplicate component names', () => {
|
|
214
|
+
const dupeItems = [
|
|
215
|
+
{ _id: makeId(1), _type: 'component', _component: 'adapt-contrib-text' },
|
|
216
|
+
{ _id: makeId(2), _type: 'component', _component: 'adapt-contrib-text' },
|
|
217
|
+
{ _id: makeId(3), _type: 'component', _component: 'adapt-contrib-media' }
|
|
218
|
+
]
|
|
219
|
+
const tree = new ContentTree(dupeItems)
|
|
220
|
+
assert.equal(tree.getComponentNames().length, 2)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('should return empty array when no components exist', () => {
|
|
224
|
+
const tree = new ContentTree([
|
|
225
|
+
{ _id: makeId(1), _type: 'course' }
|
|
226
|
+
])
|
|
227
|
+
assert.deepEqual(tree.getComponentNames(), [])
|
|
228
|
+
})
|
|
229
|
+
})
|
|
230
|
+
})
|
package/tests/_ht.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, mock } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import ContentModule from '../lib/ContentModule.js'
|
|
5
|
+
|
|
6
|
+
const COURSE_ID = '507f1f77bcf86cd799439011'
|
|
7
|
+
|
|
8
|
+
function createMockCollection (overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
findOne: mock.fn(async () => null),
|
|
11
|
+
updateOne: mock.fn(async () => {}),
|
|
12
|
+
findOneAndUpdate: mock.fn(async () => ({ seq: 1 })),
|
|
13
|
+
find: mock.fn(() => ({ toArray: mock.fn(async () => []) })),
|
|
14
|
+
deleteMany: mock.fn(async () => {}),
|
|
15
|
+
...overrides
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createMockMongodb (collectionOverrides) {
|
|
20
|
+
const col = createMockCollection(collectionOverrides)
|
|
21
|
+
return { getCollection: mock.fn(() => col), collection: col }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createInstance (overrides = {}) {
|
|
25
|
+
return {
|
|
26
|
+
schemaName: 'content',
|
|
27
|
+
collectionName: 'content',
|
|
28
|
+
counterCollectionName: 'contentcounters',
|
|
29
|
+
idInterval: 5,
|
|
30
|
+
contentplugin: { findOne: mock.fn(async () => null) },
|
|
31
|
+
jsonschema: { extendSchema: mock.fn() },
|
|
32
|
+
authored: { schemaName: 'authored' },
|
|
33
|
+
tags: { schemaExtensionName: 'tags' },
|
|
34
|
+
mongodb: createMockMongodb(),
|
|
35
|
+
find: mock.fn(async () => []),
|
|
36
|
+
findOne: mock.fn(async () => null),
|
|
37
|
+
...overrides
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('ContentModule', () => {
|
|
42
|
+
describe('handleTree', () => {
|
|
43
|
+
it('should return 304 when content has not been modified', async () => {
|
|
44
|
+
const lastModified = new Date('2025-01-01T00:00:00Z')
|
|
45
|
+
const inst = createInstance({
|
|
46
|
+
findOne: mock.fn(async () => ({ updatedAt: lastModified }))
|
|
47
|
+
})
|
|
48
|
+
let statusCode
|
|
49
|
+
let ended = false
|
|
50
|
+
const req = {
|
|
51
|
+
apiData: { query: { _courseId: COURSE_ID } },
|
|
52
|
+
headers: { 'if-modified-since': new Date('2025-01-02T00:00:00Z').toUTCString() }
|
|
53
|
+
}
|
|
54
|
+
const res = {
|
|
55
|
+
status: mock.fn(function (code) { statusCode = code; return this }),
|
|
56
|
+
end: mock.fn(() => { ended = true })
|
|
57
|
+
}
|
|
58
|
+
const next = mock.fn()
|
|
59
|
+
await ContentModule.prototype.handleTree.call(inst, req, res, next)
|
|
60
|
+
assert.equal(statusCode, 304)
|
|
61
|
+
assert.equal(ended, true)
|
|
62
|
+
assert.equal(next.mock.callCount(), 0)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should return items with _children when content has been modified', async () => {
|
|
66
|
+
const lastModified = new Date('2025-01-15T00:00:00Z')
|
|
67
|
+
const items = [
|
|
68
|
+
{ _id: COURSE_ID, _type: 'course', _courseId: COURSE_ID },
|
|
69
|
+
{ _id: 'page1', _type: 'page', _parentId: COURSE_ID, _courseId: COURSE_ID },
|
|
70
|
+
{ _id: 'art1', _type: 'article', _parentId: 'page1', _courseId: COURSE_ID }
|
|
71
|
+
]
|
|
72
|
+
const inst = createInstance({
|
|
73
|
+
findOne: mock.fn(async () => ({ updatedAt: lastModified })),
|
|
74
|
+
find: mock.fn(async () => items)
|
|
75
|
+
})
|
|
76
|
+
const req = {
|
|
77
|
+
apiData: { query: { _courseId: COURSE_ID } },
|
|
78
|
+
headers: {}
|
|
79
|
+
}
|
|
80
|
+
let responseData
|
|
81
|
+
let lastModifiedHeader
|
|
82
|
+
const res = {
|
|
83
|
+
set: mock.fn((key, val) => { if (key === 'Last-Modified') lastModifiedHeader = val }),
|
|
84
|
+
json: mock.fn((data) => { responseData = data })
|
|
85
|
+
}
|
|
86
|
+
const next = mock.fn()
|
|
87
|
+
await ContentModule.prototype.handleTree.call(inst, req, res, next)
|
|
88
|
+
|
|
89
|
+
assert.equal(next.mock.callCount(), 0)
|
|
90
|
+
assert.equal(responseData.length, 3)
|
|
91
|
+
// course should have page1 as child
|
|
92
|
+
const course = responseData.find(i => i._id === COURSE_ID)
|
|
93
|
+
assert.deepEqual(course._children, ['page1'])
|
|
94
|
+
// page should have art1 as child
|
|
95
|
+
const page = responseData.find(i => i._id === 'page1')
|
|
96
|
+
assert.deepEqual(page._children, ['art1'])
|
|
97
|
+
// article should have no children
|
|
98
|
+
const art = responseData.find(i => i._id === 'art1')
|
|
99
|
+
assert.deepEqual(art._children, [])
|
|
100
|
+
// Last-Modified header should be set
|
|
101
|
+
assert.equal(lastModifiedHeader, lastModified.toUTCString())
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('should call next on error', async () => {
|
|
105
|
+
const inst = createInstance({
|
|
106
|
+
findOne: mock.fn(async () => { throw new Error('db error') })
|
|
107
|
+
})
|
|
108
|
+
const req = { apiData: { query: { _courseId: COURSE_ID } }, headers: {} }
|
|
109
|
+
const res = {}
|
|
110
|
+
const next = mock.fn()
|
|
111
|
+
await ContentModule.prototype.handleTree.call(inst, req, res, next)
|
|
112
|
+
assert.equal(next.mock.callCount(), 1)
|
|
113
|
+
assert.equal(next.mock.calls[0].arguments[0].message, 'db error')
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import computeSortOrderOps from '../lib/utils/computeSortOrderOps.js'
|
|
4
|
+
|
|
5
|
+
describe('computeSortOrderOps', () => {
|
|
6
|
+
it('returns empty array when all siblings already have correct _sortOrder', () => {
|
|
7
|
+
const siblings = [
|
|
8
|
+
{ _id: 'a', _sortOrder: 1 },
|
|
9
|
+
{ _id: 'b', _sortOrder: 2 }
|
|
10
|
+
]
|
|
11
|
+
assert.deepEqual(computeSortOrderOps(siblings), [])
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns ops to fix incorrect _sortOrder values', () => {
|
|
15
|
+
const siblings = [
|
|
16
|
+
{ _id: 'a', _sortOrder: 3 },
|
|
17
|
+
{ _id: 'b', _sortOrder: 5 }
|
|
18
|
+
]
|
|
19
|
+
const ops = computeSortOrderOps(siblings)
|
|
20
|
+
assert.equal(ops.length, 2)
|
|
21
|
+
assert.deepEqual(ops[0], { updateOne: { filter: { _id: 'a' }, update: { $set: { _sortOrder: 1 } } } })
|
|
22
|
+
assert.deepEqual(ops[1], { updateOne: { filter: { _id: 'b' }, update: { $set: { _sortOrder: 2 } } } })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('returns empty array for empty siblings', () => {
|
|
26
|
+
assert.deepEqual(computeSortOrderOps([]), [])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('with item insertion', () => {
|
|
30
|
+
it('appends item to end when _sortOrder is null', () => {
|
|
31
|
+
const siblings = [
|
|
32
|
+
{ _id: 'a', _sortOrder: 1 }
|
|
33
|
+
]
|
|
34
|
+
const item = { _id: 'new', _sortOrder: null }
|
|
35
|
+
const ops = computeSortOrderOps(siblings, item)
|
|
36
|
+
assert.equal(ops.length, 1)
|
|
37
|
+
assert.deepEqual(ops[0], { updateOne: { filter: { _id: 'new' }, update: { $set: { _sortOrder: 2 } } } })
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('appends item to end when _sortOrder is undefined', () => {
|
|
41
|
+
const siblings = [
|
|
42
|
+
{ _id: 'a', _sortOrder: 1 }
|
|
43
|
+
]
|
|
44
|
+
const item = { _id: 'new' }
|
|
45
|
+
const ops = computeSortOrderOps(siblings, item)
|
|
46
|
+
assert.equal(ops.length, 1)
|
|
47
|
+
assert.deepEqual(ops[0], { updateOne: { filter: { _id: 'new' }, update: { $set: { _sortOrder: 2 } } } })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('inserts item at position based on _sortOrder', () => {
|
|
51
|
+
const siblings = [
|
|
52
|
+
{ _id: 'a', _sortOrder: 1 },
|
|
53
|
+
{ _id: 'b', _sortOrder: 2 }
|
|
54
|
+
]
|
|
55
|
+
const item = { _id: 'new', _sortOrder: 2 }
|
|
56
|
+
const ops = computeSortOrderOps(siblings, item)
|
|
57
|
+
// item spliced at index 1 (_sortOrder - 1 = 1)
|
|
58
|
+
// result: [a, new, b] -> sortOrders [1, 2, 3]
|
|
59
|
+
// a already has _sortOrder 1, new already has 2, only b needs updating 2→3
|
|
60
|
+
assert.equal(ops.length, 1)
|
|
61
|
+
assert.deepEqual(ops[0], { updateOne: { filter: { _id: 'b' }, update: { $set: { _sortOrder: 3 } } } })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('appends to end when _sortOrder is 0', () => {
|
|
65
|
+
const siblings = [
|
|
66
|
+
{ _id: 'a', _sortOrder: 1 },
|
|
67
|
+
{ _id: 'b', _sortOrder: 2 }
|
|
68
|
+
]
|
|
69
|
+
const item = { _id: 'new', _sortOrder: 0 }
|
|
70
|
+
const ops = computeSortOrderOps(siblings, item)
|
|
71
|
+
// _sortOrder - 1 = -1, which is not > -1, so appends to end
|
|
72
|
+
assert.equal(ops.length, 1)
|
|
73
|
+
assert.deepEqual(ops[0], { updateOne: { filter: { _id: 'new' }, update: { $set: { _sortOrder: 3 } } } })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('inserts into empty siblings list', () => {
|
|
77
|
+
const item = { _id: 'new', _sortOrder: null }
|
|
78
|
+
const ops = computeSortOrderOps([], item)
|
|
79
|
+
assert.equal(ops.length, 1)
|
|
80
|
+
assert.deepEqual(ops[0], { updateOne: { filter: { _id: 'new' }, update: { $set: { _sortOrder: 1 } } } })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('only returns ops for items that need updating', () => {
|
|
84
|
+
const siblings = [
|
|
85
|
+
{ _id: 'a', _sortOrder: 1 },
|
|
86
|
+
{ _id: 'b', _sortOrder: 3 }
|
|
87
|
+
]
|
|
88
|
+
const item = { _id: 'new', _sortOrder: 2 }
|
|
89
|
+
const ops = computeSortOrderOps(siblings, item)
|
|
90
|
+
// result: [a, new, b] -> [1, 2, 3] — a=1 ok, new=2 ok, b=3 ok — no ops needed
|
|
91
|
+
assert.equal(ops.length, 0)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import contentTypeToSchemaName from '../lib/utils/contentTypeToSchemaName.js'
|
|
4
|
+
|
|
5
|
+
describe('contentTypeToSchemaName', () => {
|
|
6
|
+
const cases = [
|
|
7
|
+
{ _type: 'page', expected: 'contentobject' },
|
|
8
|
+
{ _type: 'menu', expected: 'contentobject' },
|
|
9
|
+
{ _type: 'article', expected: 'article' },
|
|
10
|
+
{ _type: 'block', expected: 'block' },
|
|
11
|
+
{ _type: 'component', expected: 'component' },
|
|
12
|
+
{ _type: 'course', expected: 'course' },
|
|
13
|
+
{ _type: 'config', expected: 'config' }
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
for (const { _type, expected } of cases) {
|
|
17
|
+
it(`maps "${_type}" to "${expected}"`, () => {
|
|
18
|
+
assert.equal(contentTypeToSchemaName(_type), expected)
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { extractAssetIds } from '../lib/utils/extractAssetIds.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Creates a minimal schema-like object with a walk method that
|
|
7
|
+
* mirrors Schema.walk behaviour for the given properties
|
|
8
|
+
*/
|
|
9
|
+
function mockSchema (properties) {
|
|
10
|
+
return {
|
|
11
|
+
walk (data, predicate, schema, parentPath = '') {
|
|
12
|
+
schema = schema ?? properties
|
|
13
|
+
const matches = []
|
|
14
|
+
for (const [key, val] of Object.entries(schema)) {
|
|
15
|
+
if (data[key] === undefined) continue
|
|
16
|
+
const currentPath = parentPath ? `${parentPath}/${key}` : key
|
|
17
|
+
if (val.properties) {
|
|
18
|
+
matches.push(...this.walk(data[key], predicate, val.properties, currentPath))
|
|
19
|
+
} else if (val?.items?.properties) {
|
|
20
|
+
data[key].forEach((item, i) => {
|
|
21
|
+
matches.push(...this.walk(item, predicate, val.items.properties, `${currentPath}/${i}`))
|
|
22
|
+
})
|
|
23
|
+
} else if (predicate(val)) {
|
|
24
|
+
matches.push({ path: currentPath, key, data, value: data[key] })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return matches
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('extractAssetIds()', () => {
|
|
33
|
+
it('should return empty array when no asset fields exist', () => {
|
|
34
|
+
const schema = mockSchema({ title: { type: 'string' } })
|
|
35
|
+
assert.deepEqual(extractAssetIds(schema, { title: 'test' }), [])
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('should extract asset ID from _backboneForms Asset string type', () => {
|
|
39
|
+
const schema = mockSchema({ image: { _backboneForms: 'Asset' } })
|
|
40
|
+
assert.deepEqual(extractAssetIds(schema, { image: 'abc123' }), ['abc123'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should extract asset ID from _backboneForms.type Asset', () => {
|
|
44
|
+
const schema = mockSchema({ image: { _backboneForms: { type: 'Asset' } } })
|
|
45
|
+
assert.deepEqual(extractAssetIds(schema, { image: 'abc123' }), ['abc123'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('should skip keys not present in data', () => {
|
|
49
|
+
const schema = mockSchema({ image: { _backboneForms: 'Asset' } })
|
|
50
|
+
assert.deepEqual(extractAssetIds(schema, {}), [])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('should skip falsy asset values', () => {
|
|
54
|
+
const schema = mockSchema({ image: { _backboneForms: 'Asset' } })
|
|
55
|
+
assert.deepEqual(extractAssetIds(schema, { image: '' }), [])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('should skip HTTP URLs', () => {
|
|
59
|
+
const schema = mockSchema({ image: { _backboneForms: 'Asset' } })
|
|
60
|
+
assert.deepEqual(extractAssetIds(schema, { image: 'http://example.com/image.png' }), [])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should skip HTTPS URLs', () => {
|
|
64
|
+
const schema = mockSchema({ image: { _backboneForms: 'Asset' } })
|
|
65
|
+
assert.deepEqual(extractAssetIds(schema, { image: 'https://example.com/image.png' }), [])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should recurse into nested schema properties', () => {
|
|
69
|
+
const schema = mockSchema({
|
|
70
|
+
_graphic: {
|
|
71
|
+
properties: {
|
|
72
|
+
src: { _backboneForms: 'Asset' }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
assert.deepEqual(extractAssetIds(schema, { _graphic: { src: 'img123' } }), ['img123'])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should recurse into array items with properties', () => {
|
|
80
|
+
const schema = mockSchema({
|
|
81
|
+
_items: {
|
|
82
|
+
items: {
|
|
83
|
+
properties: {
|
|
84
|
+
src: { _backboneForms: 'Asset' }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
assert.deepEqual(extractAssetIds(schema, { _items: [{ src: 'a1' }, { src: 'a2' }] }), ['a1', 'a2'])
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should deduplicate asset IDs', () => {
|
|
93
|
+
const schema = mockSchema({
|
|
94
|
+
_items: {
|
|
95
|
+
items: {
|
|
96
|
+
properties: {
|
|
97
|
+
src: { _backboneForms: 'Asset' }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
assert.deepEqual(extractAssetIds(schema, { _items: [{ src: 'same' }, { src: 'same' }] }), ['same'])
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should handle toString on non-string asset values', () => {
|
|
106
|
+
const schema = mockSchema({ image: { _backboneForms: 'Asset' } })
|
|
107
|
+
assert.deepEqual(extractAssetIds(schema, { image: { toString: () => 'obj123' } }), ['obj123'])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should handle multiple asset fields at the same level', () => {
|
|
111
|
+
const schema = mockSchema({
|
|
112
|
+
image1: { _backboneForms: 'Asset' },
|
|
113
|
+
image2: { _backboneForms: { type: 'Asset' } },
|
|
114
|
+
title: { type: 'string' }
|
|
115
|
+
})
|
|
116
|
+
assert.deepEqual(extractAssetIds(schema, { image1: 'a1', image2: 'a2', title: 'test' }), ['a1', 'a2'])
|
|
117
|
+
})
|
|
118
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import formatFriendlyId from '../lib/utils/formatFriendlyId.js'
|
|
4
|
+
|
|
5
|
+
describe('formatFriendlyId', () => {
|
|
6
|
+
describe('course type', () => {
|
|
7
|
+
it('formats without language', () => {
|
|
8
|
+
assert.equal(formatFriendlyId('course', 1), 'course-1')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('formats with language', () => {
|
|
12
|
+
assert.equal(formatFriendlyId('course', 3, 'en'), 'course-3-en')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('omits language suffix when _language is empty string', () => {
|
|
16
|
+
assert.equal(formatFriendlyId('course', 2, ''), 'course-2')
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('config type', () => {
|
|
21
|
+
it('always returns "config"', () => {
|
|
22
|
+
assert.equal(formatFriendlyId('config'), 'config')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('other types', () => {
|
|
27
|
+
const cases = [
|
|
28
|
+
{ _type: 'page', count: 1, expected: 'p-1' },
|
|
29
|
+
{ _type: 'article', count: 2, expected: 'a-2' },
|
|
30
|
+
{ _type: 'block', count: 3, expected: 'b-3' },
|
|
31
|
+
{ _type: 'component', count: 4, expected: 'c-4' }
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
for (const { _type, count, expected } of cases) {
|
|
35
|
+
it(`formats ${_type} with count ${count} as "${expected}"`, () => {
|
|
36
|
+
assert.equal(formatFriendlyId(_type, count), expected)
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import parseMaxSeq from '../lib/utils/parseMaxSeq.js'
|
|
4
|
+
|
|
5
|
+
describe('parseMaxSeq', () => {
|
|
6
|
+
it('returns 0 for empty docs array', () => {
|
|
7
|
+
assert.equal(parseMaxSeq([]), 0)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns max number for course type', () => {
|
|
11
|
+
const docs = [
|
|
12
|
+
{ _friendlyId: 'course-3' },
|
|
13
|
+
{ _friendlyId: 'course-7' },
|
|
14
|
+
{ _friendlyId: 'course-2' }
|
|
15
|
+
]
|
|
16
|
+
assert.equal(parseMaxSeq(docs), 7)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('returns raw max number for non-course types', () => {
|
|
20
|
+
const docs = [
|
|
21
|
+
{ _friendlyId: 'b-10' },
|
|
22
|
+
{ _friendlyId: 'b-25' },
|
|
23
|
+
{ _friendlyId: 'b-5' }
|
|
24
|
+
]
|
|
25
|
+
assert.equal(parseMaxSeq(docs), 25)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns raw number without flooring', () => {
|
|
29
|
+
const docs = [{ _friendlyId: 'a-13' }]
|
|
30
|
+
assert.equal(parseMaxSeq(docs), 13)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('skips docs with no _friendlyId', () => {
|
|
34
|
+
const docs = [
|
|
35
|
+
{ _friendlyId: 'p-10' },
|
|
36
|
+
{},
|
|
37
|
+
{ _friendlyId: undefined }
|
|
38
|
+
]
|
|
39
|
+
assert.equal(parseMaxSeq(docs), 10)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('skips docs with no numeric portion', () => {
|
|
43
|
+
const docs = [
|
|
44
|
+
{ _friendlyId: 'config' },
|
|
45
|
+
{ _friendlyId: 'p-15' }
|
|
46
|
+
]
|
|
47
|
+
assert.equal(parseMaxSeq(docs), 15)
|
|
48
|
+
})
|
|
49
|
+
})
|