adapt-authoring-content 3.2.3 → 3.3.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 +11 -8
- package/lib/utils/treeEtag.js +16 -0
- package/lib/utils.js +1 -0
- package/package.json +1 -1
- package/tests/ContentModule.spec.js +40 -8
- package/tests/utils-treeEtag.spec.js +38 -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 } 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,12 +679,6 @@ 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 lastModified = new Date(course.updatedAt)
|
|
683
|
-
lastModified.setMilliseconds(0) // HTTP dates are second-precision; must match before comparing
|
|
684
|
-
const ifModifiedSince = req.headers['if-modified-since'] && new Date(req.headers['if-modified-since'])
|
|
685
|
-
if (ifModifiedSince && lastModified <= ifModifiedSince) {
|
|
686
|
-
return res.status(304).end()
|
|
687
|
-
}
|
|
688
682
|
const treeFields = [
|
|
689
683
|
'_id',
|
|
690
684
|
'_parentId',
|
|
@@ -700,16 +694,25 @@ class ContentModule extends AbstractApiModule {
|
|
|
700
694
|
'_theme',
|
|
701
695
|
'_enabledPlugins',
|
|
702
696
|
'_colorLabel',
|
|
697
|
+
'_language',
|
|
703
698
|
'heroImage',
|
|
704
699
|
'updatedAt'
|
|
705
700
|
]
|
|
701
|
+
// ETag (not Last-Modified): folds the projected field list into the
|
|
702
|
+
// validator so the cache busts when the response shape changes, not just
|
|
703
|
+
// when the course data does — otherwise an added field stays missing for
|
|
704
|
+
// unedited courses whose updatedAt never moves.
|
|
705
|
+
const etag = treeEtag(course.updatedAt, treeFields)
|
|
706
|
+
if (req.headers['if-none-match'] === etag) {
|
|
707
|
+
return res.status(304).end()
|
|
708
|
+
}
|
|
706
709
|
const items = await this.find(
|
|
707
710
|
{ _courseId },
|
|
708
711
|
{ validate: false },
|
|
709
712
|
{ projection: fieldsToProjection(treeFields) }
|
|
710
713
|
)
|
|
711
714
|
const tree = new ContentTree(items)
|
|
712
|
-
res.set('
|
|
715
|
+
res.set('ETag', etag)
|
|
713
716
|
res.json(items.map(item => ({
|
|
714
717
|
...item,
|
|
715
718
|
_children: tree.getChildren(item._id).map(c => c._id)
|
|
@@ -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
|
@@ -6,3 +6,4 @@ export { default as contentTypeToSchemaName } from './utils/contentTypeToSchemaN
|
|
|
6
6
|
export { default as fieldsToProjection } from './utils/fieldsToProjection.js'
|
|
7
7
|
export { default as formatFriendlyId } from './utils/formatFriendlyId.js'
|
|
8
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,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
|
+
})
|
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
|
-
})
|