adapt-authoring-content 3.2.2 → 3.2.4
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 +27 -7
- package/lib/utils/fieldsToProjection.js +10 -0
- package/lib/utils/treeEtag.js +16 -0
- package/lib/utils.js +2 -0
- package/package.json +1 -1
- package/tests/ContentModule.spec.js +40 -8
- package/tests/utils-fieldsToProjection.spec.js +22 -0
- package/tests/utils-treeEtag.spec.js +38 -0
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, formatFriendlyId, parseMaxSeq } from './utils.js'
|
|
5
|
+
import { ContentTree, buildAssetUsagePipeline, computeSortOrderOps, contentTypeToSchemaName, extractAssetIds, fieldsToProjection, formatFriendlyId, parseMaxSeq, treeEtag } from './utils.js'
|
|
6
6
|
/**
|
|
7
7
|
* Module which handles course content
|
|
8
8
|
* @memberof content
|
|
@@ -679,19 +679,39 @@ class ContentModule extends AbstractApiModule {
|
|
|
679
679
|
if (!req.auth.isSuper && this.accessCheckHook.hasObservers && !(await this.accessCheckHook.invoke(req, course)).every(Boolean)) {
|
|
680
680
|
throw this.app.errors.NOT_FOUND.setData({ type: 'content', id: _courseId })
|
|
681
681
|
}
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
682
|
+
const treeFields = [
|
|
683
|
+
'_id',
|
|
684
|
+
'_parentId',
|
|
685
|
+
'_courseId',
|
|
686
|
+
'_type',
|
|
687
|
+
'_sortOrder',
|
|
688
|
+
'title',
|
|
689
|
+
'displayTitle',
|
|
690
|
+
'_friendlyId',
|
|
691
|
+
'_component',
|
|
692
|
+
'_layout',
|
|
693
|
+
'_menu',
|
|
694
|
+
'_theme',
|
|
695
|
+
'_enabledPlugins',
|
|
696
|
+
'_colorLabel',
|
|
697
|
+
'heroImage',
|
|
698
|
+
'updatedAt'
|
|
699
|
+
]
|
|
700
|
+
// ETag (not Last-Modified): folds the projected field list into the
|
|
701
|
+
// validator so the cache busts when the response shape changes, not just
|
|
702
|
+
// when the course data does — otherwise an added field stays missing for
|
|
703
|
+
// unedited courses whose updatedAt never moves.
|
|
704
|
+
const etag = treeEtag(course.updatedAt, treeFields)
|
|
705
|
+
if (req.headers['if-none-match'] === etag) {
|
|
686
706
|
return res.status(304).end()
|
|
687
707
|
}
|
|
688
708
|
const items = await this.find(
|
|
689
709
|
{ _courseId },
|
|
690
710
|
{ validate: false },
|
|
691
|
-
{ projection:
|
|
711
|
+
{ projection: fieldsToProjection(treeFields) }
|
|
692
712
|
)
|
|
693
713
|
const tree = new ContentTree(items)
|
|
694
|
-
res.set('
|
|
714
|
+
res.set('ETag', etag)
|
|
695
715
|
res.json(items.map(item => ({
|
|
696
716
|
...item,
|
|
697
717
|
_children: tree.getChildren(item._id).map(c => c._id)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds a MongoDB inclusion projection from a list of field names, mapping
|
|
3
|
+
* each to `1` (e.g. `['_id', 'title']` -> `{ _id: 1, title: 1 }`). Lets a
|
|
4
|
+
* handler declare its projected fields as a readable one-per-line array.
|
|
5
|
+
* @param {Array<string>} fields Field names to include
|
|
6
|
+
* @return {Object} Inclusion projection
|
|
7
|
+
*/
|
|
8
|
+
export default function fieldsToProjection (fields) {
|
|
9
|
+
return Object.fromEntries((fields ?? []).map(field => [field, 1]))
|
|
10
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'crypto'
|
|
2
|
+
/**
|
|
3
|
+
* Builds a weak ETag for the content tree response. Combines the course's
|
|
4
|
+
* `updatedAt` with a hash of the projected field list, so the cache
|
|
5
|
+
* invalidates both when the course data changes AND when the tree response
|
|
6
|
+
* *shape* changes (e.g. a field is added to the projection). Keying solely on
|
|
7
|
+
* `updatedAt` served stale bodies to unedited courses after a shape change —
|
|
8
|
+
* a newly-projected field was missing until the course happened to be edited.
|
|
9
|
+
* @param {Date|string|number} updatedAt Course updatedAt
|
|
10
|
+
* @param {Array<string>} fields Projected tree field names
|
|
11
|
+
* @return {string} Weak ETag value (quoted, `W/`-prefixed)
|
|
12
|
+
*/
|
|
13
|
+
export default function treeEtag (updatedAt, fields) {
|
|
14
|
+
const shape = createHash('sha1').update([...(fields ?? [])].sort().join(',')).digest('hex').slice(0, 8)
|
|
15
|
+
return `W/"${new Date(updatedAt).getTime()}-${shape}"`
|
|
16
|
+
}
|
package/lib/utils.js
CHANGED
|
@@ -3,5 +3,7 @@ export { default as buildAssetUsagePipeline } from './utils/buildAssetUsagePipel
|
|
|
3
3
|
export { default as computeSortOrderOps } from './utils/computeSortOrderOps.js'
|
|
4
4
|
export { extractAssetIds } from './utils/extractAssetIds.js'
|
|
5
5
|
export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaName.js'
|
|
6
|
+
export { default as fieldsToProjection } from './utils/fieldsToProjection.js'
|
|
6
7
|
export { default as formatFriendlyId } from './utils/formatFriendlyId.js'
|
|
7
8
|
export { default as parseMaxSeq } from './utils/parseMaxSeq.js'
|
|
9
|
+
export { default as treeEtag } from './utils/treeEtag.js'
|
package/package.json
CHANGED
|
@@ -572,17 +572,24 @@ describe('ContentModule', () => {
|
|
|
572
572
|
})
|
|
573
573
|
|
|
574
574
|
describe('handleTree', () => {
|
|
575
|
-
it('should return 304 when content
|
|
576
|
-
const lastModified = new Date('2025-01-01T00:00:00Z')
|
|
575
|
+
it('should return 304 when the ETag matches (content + shape unchanged)', async () => {
|
|
577
576
|
const inst = createInstance({
|
|
578
|
-
findOne: mock.fn(async () => ({ updatedAt:
|
|
577
|
+
findOne: mock.fn(async () => ({ updatedAt: new Date('2025-01-01T00:00:00Z') })),
|
|
578
|
+
find: mock.fn(async () => [])
|
|
579
579
|
})
|
|
580
|
+
// capture the current ETag from a fresh request...
|
|
581
|
+
let etag
|
|
582
|
+
const firstRes = { set: mock.fn((k, v) => { if (k === 'ETag') etag = v }), json: mock.fn() }
|
|
583
|
+
await ContentModule.prototype.handleTree.call(inst, { auth: { isSuper: true }, apiData: { query: { _courseId: COURSE_ID } }, headers: {} }, firstRes, mock.fn())
|
|
584
|
+
assert.match(etag, /^W\/".+"$/)
|
|
585
|
+
|
|
586
|
+
// ...then a conditional request carrying it gets a 304
|
|
580
587
|
let statusCode
|
|
581
588
|
let ended = false
|
|
582
589
|
const req = {
|
|
583
590
|
auth: { isSuper: true },
|
|
584
591
|
apiData: { query: { _courseId: COURSE_ID } },
|
|
585
|
-
headers: { 'if-
|
|
592
|
+
headers: { 'if-none-match': etag }
|
|
586
593
|
}
|
|
587
594
|
const res = {
|
|
588
595
|
status: mock.fn(function (code) { statusCode = code; return this }),
|
|
@@ -595,6 +602,31 @@ describe('ContentModule', () => {
|
|
|
595
602
|
assert.equal(next.mock.callCount(), 0)
|
|
596
603
|
})
|
|
597
604
|
|
|
605
|
+
it('should serve the tree (not 304) when the ETag no longer matches the response shape', async () => {
|
|
606
|
+
// a cached ETag from before a treeFields change must not short-circuit to
|
|
607
|
+
// 304 — otherwise the new field stays missing for unedited courses
|
|
608
|
+
const inst = createInstance({
|
|
609
|
+
findOne: mock.fn(async () => ({ updatedAt: new Date('2025-01-01T00:00:00Z') })),
|
|
610
|
+
find: mock.fn(async () => [{ _id: COURSE_ID, _type: 'course', _courseId: COURSE_ID }])
|
|
611
|
+
})
|
|
612
|
+
let statusCode
|
|
613
|
+
const req = {
|
|
614
|
+
auth: { isSuper: true },
|
|
615
|
+
apiData: { query: { _courseId: COURSE_ID } },
|
|
616
|
+
headers: { 'if-none-match': 'W/"1735689600000-deadbeef"' }
|
|
617
|
+
}
|
|
618
|
+
const res = {
|
|
619
|
+
status: mock.fn(function (code) { statusCode = code; return this }),
|
|
620
|
+
set: mock.fn(),
|
|
621
|
+
json: mock.fn()
|
|
622
|
+
}
|
|
623
|
+
const next = mock.fn()
|
|
624
|
+
await ContentModule.prototype.handleTree.call(inst, req, res, next)
|
|
625
|
+
assert.equal(statusCode, undefined, 'did not 304')
|
|
626
|
+
assert.equal(res.json.mock.callCount(), 1, 'served the tree')
|
|
627
|
+
assert.equal(next.mock.callCount(), 0)
|
|
628
|
+
})
|
|
629
|
+
|
|
598
630
|
it('should return items with _children when content has been modified', async () => {
|
|
599
631
|
const lastModified = new Date('2025-01-15T00:00:00Z')
|
|
600
632
|
const items = [
|
|
@@ -612,9 +644,9 @@ describe('ContentModule', () => {
|
|
|
612
644
|
headers: {}
|
|
613
645
|
}
|
|
614
646
|
let responseData
|
|
615
|
-
let
|
|
647
|
+
let etagHeader
|
|
616
648
|
const res = {
|
|
617
|
-
set: mock.fn((key, val) => { if (key === '
|
|
649
|
+
set: mock.fn((key, val) => { if (key === 'ETag') etagHeader = val }),
|
|
618
650
|
json: mock.fn((data) => { responseData = data })
|
|
619
651
|
}
|
|
620
652
|
const next = mock.fn()
|
|
@@ -631,8 +663,8 @@ describe('ContentModule', () => {
|
|
|
631
663
|
// article should have no children
|
|
632
664
|
const art = responseData.find(i => i._id === 'art1')
|
|
633
665
|
assert.deepEqual(art._children, [])
|
|
634
|
-
//
|
|
635
|
-
assert.
|
|
666
|
+
// ETag header should be set
|
|
667
|
+
assert.match(etagHeader, /^W\/".+"$/)
|
|
636
668
|
})
|
|
637
669
|
|
|
638
670
|
it('should call next on error', async () => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fieldsToProjection from '../lib/utils/fieldsToProjection.js'
|
|
4
|
+
|
|
5
|
+
describe('fieldsToProjection', () => {
|
|
6
|
+
it('maps each field name to 1', () => {
|
|
7
|
+
assert.deepEqual(fieldsToProjection(['_id', 'title']), { _id: 1, title: 1 })
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns an empty projection for an empty array', () => {
|
|
11
|
+
assert.deepEqual(fieldsToProjection([]), {})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('treats a missing argument as empty', () => {
|
|
15
|
+
assert.deepEqual(fieldsToProjection(), {})
|
|
16
|
+
assert.deepEqual(fieldsToProjection(undefined), {})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('collapses duplicate field names to a single key', () => {
|
|
20
|
+
assert.deepEqual(fieldsToProjection(['_id', '_id', 'title']), { _id: 1, title: 1 })
|
|
21
|
+
})
|
|
22
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import treeEtag from '../lib/utils/treeEtag.js'
|
|
4
|
+
|
|
5
|
+
const updatedAt = new Date('2026-06-18T17:34:00.123Z')
|
|
6
|
+
|
|
7
|
+
describe('treeEtag', () => {
|
|
8
|
+
it('produces a quoted weak ETag', () => {
|
|
9
|
+
assert.match(treeEtag(updatedAt, ['_id', 'title']), /^W\/".+"$/)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('is stable for the same inputs', () => {
|
|
13
|
+
assert.equal(treeEtag(updatedAt, ['_id', 'title']), treeEtag(updatedAt, ['_id', 'title']))
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('is independent of field order', () => {
|
|
17
|
+
assert.equal(treeEtag(updatedAt, ['_id', 'title']), treeEtag(updatedAt, ['title', '_id']))
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('changes when the field set changes (shape bust)', () => {
|
|
21
|
+
assert.notEqual(treeEtag(updatedAt, ['_id', 'title']), treeEtag(updatedAt, ['_id', 'title', 'heroImage']))
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('changes when updatedAt changes (data bust)', () => {
|
|
25
|
+
assert.notEqual(treeEtag(updatedAt, ['_id']), treeEtag(new Date('2026-06-19T00:00:00Z'), ['_id']))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('accepts Date, string and numeric timestamps equivalently', () => {
|
|
29
|
+
const fields = ['_id', 'title']
|
|
30
|
+
assert.equal(treeEtag(updatedAt, fields), treeEtag(updatedAt.toISOString(), fields))
|
|
31
|
+
assert.equal(treeEtag(updatedAt, fields), treeEtag(updatedAt.getTime(), fields))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('treats a missing field list as empty', () => {
|
|
35
|
+
assert.match(treeEtag(updatedAt), /^W\/".+"$/)
|
|
36
|
+
assert.equal(treeEtag(updatedAt), treeEtag(updatedAt, []))
|
|
37
|
+
})
|
|
38
|
+
})
|