adapt-authoring-content 3.2.4 → 3.4.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/lib/ContentModule.js +24 -1
- package/lib/utils/excludeIdsFromQuery.js +22 -0
- package/lib/utils.js +1 -0
- package/package.json +1 -1
- package/tests/utils-excludeIdsFromQuery.spec.js +40 -0
- package/tests/_ht.js +0 -116
package/lib/ContentModule.js
CHANGED
|
@@ -2,7 +2,7 @@ import { AbstractApiModule } from 'adapt-authoring-api'
|
|
|
2
2
|
import { Hook, stringifyValues } from 'adapt-authoring-core'
|
|
3
3
|
import { createObjectId, parseObjectId } from 'adapt-authoring-mongodb'
|
|
4
4
|
import { ObjectId } from 'mongodb'
|
|
5
|
-
import { ContentTree, buildAssetUsagePipeline, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, fieldsToProjection, formatFriendlyId, parseMaxSeq, treeEtag } from './utils.js'
|
|
5
|
+
import { ContentTree, buildAssetUsagePipeline, computeSortOrderOps, contentTypeToSchemaName, excludeIdsFromQuery, extractAssetIds, fieldsToProjection, formatFriendlyId, parseMaxSeq, treeEtag } from './utils.js'
|
|
6
6
|
/**
|
|
7
7
|
* Module which handles course content
|
|
8
8
|
* @memberof content
|
|
@@ -58,6 +58,7 @@ class ContentModule extends AbstractApiModule {
|
|
|
58
58
|
this.postDeleteHook.tap(this.touchCourse.bind(this))
|
|
59
59
|
|
|
60
60
|
assets.preDeleteHook.tap(this.enforceAssetNotInUse.bind(this))
|
|
61
|
+
assets.queryHook.tap(this.onAssetQueryHook, this)
|
|
61
62
|
|
|
62
63
|
// block builds when the course structure has empty containers. adaptframework depends on
|
|
63
64
|
// content (not vice-versa), so tap its hook once available rather than awaiting it here.
|
|
@@ -149,6 +150,27 @@ class ContentModule extends AbstractApiModule {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
/**
|
|
154
|
+
* "Unused assets" filter for the asset manager. The UI sends a `?unused`
|
|
155
|
+
* query-string flag (see adapt-authoring-ui Assets page); restrict the assets
|
|
156
|
+
* query to those not referenced by any content document. Read from req.query
|
|
157
|
+
* because the flag is not part of the asset schema.
|
|
158
|
+
*
|
|
159
|
+
* Lives here, tapping the assets module's queryHook, so the `_assetIds`
|
|
160
|
+
* mechanism stays owned by content and assets remains usage-agnostic. Uses
|
|
161
|
+
* queryHook (not accessQueryHook) because it's a user-driven filter that must
|
|
162
|
+
* apply to every role.
|
|
163
|
+
* @param {external:ExpressRequest} req
|
|
164
|
+
* @return {Promise}
|
|
165
|
+
*/
|
|
166
|
+
async onAssetQueryHook (req) {
|
|
167
|
+
if (!req.query.unused) return
|
|
168
|
+
// distinct can surface a null when legacy docs carry a null in _assetIds;
|
|
169
|
+
// those would crash the mongodb layer's ObjectId conversion of the $nin array
|
|
170
|
+
const usedIds = (await this.mongodb.getCollection(this.collectionName).distinct('_assetIds')).filter(Boolean)
|
|
171
|
+
excludeIdsFromQuery(req.apiData.query, usedIds)
|
|
172
|
+
}
|
|
173
|
+
|
|
152
174
|
/** @override */
|
|
153
175
|
async getSchemaName (data) {
|
|
154
176
|
const { contentplugin } = this
|
|
@@ -694,6 +716,7 @@ class ContentModule extends AbstractApiModule {
|
|
|
694
716
|
'_theme',
|
|
695
717
|
'_enabledPlugins',
|
|
696
718
|
'_colorLabel',
|
|
719
|
+
'_language',
|
|
697
720
|
'heroImage',
|
|
698
721
|
'updatedAt'
|
|
699
722
|
]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutates a mongo query to exclude the given `_id`s. If the query already
|
|
3
|
+
* has an `_id` constraint, both are preserved via `$and` so existing filters
|
|
4
|
+
* are not silently dropped.
|
|
5
|
+
* @param {Object} query The mongo query object to mutate
|
|
6
|
+
* @param {Array} ids Document `_id`s to exclude. Falsy or empty → no-op
|
|
7
|
+
* @memberof content
|
|
8
|
+
*/
|
|
9
|
+
export function excludeIdsFromQuery (query, ids) {
|
|
10
|
+
if (!ids?.length) return
|
|
11
|
+
const existing = query._id
|
|
12
|
+
if (existing) {
|
|
13
|
+
query.$and = [
|
|
14
|
+
...(query.$and ?? []),
|
|
15
|
+
{ _id: existing },
|
|
16
|
+
{ _id: { $nin: ids } }
|
|
17
|
+
]
|
|
18
|
+
delete query._id
|
|
19
|
+
} else {
|
|
20
|
+
query._id = { $nin: ids }
|
|
21
|
+
}
|
|
22
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { default as ContentTree } from './ContentTree.js'
|
|
2
2
|
export { default as buildAssetUsagePipeline } from './utils/buildAssetUsagePipeline.js'
|
|
3
3
|
export { default as computeSortOrderOps } from './utils/computeSortOrderOps.js'
|
|
4
|
+
export { excludeIdsFromQuery } from './utils/excludeIdsFromQuery.js'
|
|
4
5
|
export { extractAssetIds } from './utils/extractAssetIds.js'
|
|
5
6
|
export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaName.js'
|
|
6
7
|
export { default as fieldsToProjection } from './utils/fieldsToProjection.js'
|
package/package.json
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { excludeIdsFromQuery } from '../lib/utils/excludeIdsFromQuery.js'
|
|
4
|
+
|
|
5
|
+
describe('excludeIdsFromQuery', () => {
|
|
6
|
+
it('is a no-op for empty/falsy ids', () => {
|
|
7
|
+
const query = { type: 'image' }
|
|
8
|
+
excludeIdsFromQuery(query, [])
|
|
9
|
+
excludeIdsFromQuery(query, undefined)
|
|
10
|
+
assert.deepEqual(query, { type: 'image' })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('adds a $nin _id constraint when none exists', () => {
|
|
14
|
+
const query = { type: 'image' }
|
|
15
|
+
excludeIdsFromQuery(query, ['a', 'b'])
|
|
16
|
+
assert.deepEqual(query, { type: 'image', _id: { $nin: ['a', 'b'] } })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('preserves an existing _id constraint via $and', () => {
|
|
20
|
+
const query = { _id: { $in: ['keep'] } }
|
|
21
|
+
excludeIdsFromQuery(query, ['drop'])
|
|
22
|
+
assert.deepEqual(query, {
|
|
23
|
+
$and: [
|
|
24
|
+
{ _id: { $in: ['keep'] } },
|
|
25
|
+
{ _id: { $nin: ['drop'] } }
|
|
26
|
+
]
|
|
27
|
+
})
|
|
28
|
+
assert.ok(!('_id' in query))
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('appends to an existing $and', () => {
|
|
32
|
+
const query = { $and: [{ a: 1 }], _id: 'x' }
|
|
33
|
+
excludeIdsFromQuery(query, ['y'])
|
|
34
|
+
assert.deepEqual(query.$and, [
|
|
35
|
+
{ a: 1 },
|
|
36
|
+
{ _id: 'x' },
|
|
37
|
+
{ _id: { $nin: ['y'] } }
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
})
|
package/tests/_ht.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
})
|