adapt-authoring-spoortracking 1.0.3 → 1.1.1
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.
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
on: push
|
|
3
|
+
jobs:
|
|
4
|
+
default:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-node@v4
|
|
11
|
+
with:
|
|
12
|
+
node-version: 'lts/*'
|
|
13
|
+
cache: 'npm'
|
|
14
|
+
- run: npm ci
|
|
15
|
+
- run: npm test
|
|
@@ -34,7 +34,7 @@ class SpoorTrackingModule extends AbstractModule {
|
|
|
34
34
|
return
|
|
35
35
|
}
|
|
36
36
|
const content = await this.app.waitForModule('content')
|
|
37
|
-
const [{ _trackingId }] = await content.find({ _courseId: data._courseId }, {}, { limit: 1, sort: [['_trackingId', -1]] })
|
|
37
|
+
const [{ _trackingId } = {}] = await content.find({ _courseId: data._courseId }, {}, { limit: 1, sort: [['_trackingId', -1]] })
|
|
38
38
|
data._trackingId = (_trackingId ?? 0) + 1
|
|
39
39
|
}
|
|
40
40
|
|
package/package.json
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-spoortracking",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Module for making course content compatible with spoor",
|
|
5
5
|
"homepage": "https://github.com/adapt-security/adapt-authoring-spoortracking",
|
|
6
6
|
"license": "GPL-3.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "index.js",
|
|
9
9
|
"repository": "github:adapt-security/adapt-authoring-spoortracking",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test 'tests/**/*.spec.js'"
|
|
12
|
+
},
|
|
10
13
|
"dependencies": {
|
|
11
14
|
"adapt-authoring-core": "^1.7.0"
|
|
12
15
|
},
|
|
13
16
|
"peerDependencies": {
|
|
14
|
-
"adapt-authoring-auth": "^1.0.
|
|
17
|
+
"adapt-authoring-auth": "^1.0.7",
|
|
15
18
|
"adapt-authoring-content": "^1.2.3",
|
|
16
19
|
"adapt-authoring-server": "^1.2.1"
|
|
17
20
|
},
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { describe, it, mock, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
|
|
4
|
+
import SpoorTrackingModule from '../lib/SpoorTrackingModule.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Creates a mock SpoorTrackingModule instance with stubbed app dependencies.
|
|
8
|
+
* Since SpoorTrackingModule extends AbstractModule and relies on a running
|
|
9
|
+
* application, we construct a plain object with the module's prototype methods
|
|
10
|
+
* and inject mock collaborators.
|
|
11
|
+
*/
|
|
12
|
+
function createMockInstance (overrides = {}) {
|
|
13
|
+
const contentMock = {
|
|
14
|
+
find: mock.fn(async () => []),
|
|
15
|
+
update: mock.fn(async () => ({})),
|
|
16
|
+
preInsertHook: { tap: mock.fn() }
|
|
17
|
+
}
|
|
18
|
+
const authMock = {
|
|
19
|
+
secureRoute: mock.fn()
|
|
20
|
+
}
|
|
21
|
+
const serverMock = {
|
|
22
|
+
api: {
|
|
23
|
+
createChildRouter: mock.fn(() => ({
|
|
24
|
+
addRoute: mock.fn()
|
|
25
|
+
}))
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const appMock = {
|
|
29
|
+
waitForModule: mock.fn(async (...names) => {
|
|
30
|
+
const map = { auth: authMock, content: contentMock, server: serverMock }
|
|
31
|
+
if (names.length === 1) return map[names[0]]
|
|
32
|
+
return names.map(n => map[n])
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
const logMock = mock.fn()
|
|
36
|
+
|
|
37
|
+
const instance = Object.create(SpoorTrackingModule.prototype)
|
|
38
|
+
instance.app = appMock
|
|
39
|
+
instance.log = logMock
|
|
40
|
+
instance._contentMock = contentMock
|
|
41
|
+
instance._authMock = authMock
|
|
42
|
+
instance._serverMock = serverMock
|
|
43
|
+
instance._appMock = appMock
|
|
44
|
+
|
|
45
|
+
Object.assign(instance, overrides)
|
|
46
|
+
return instance
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('SpoorTrackingModule', () => {
|
|
50
|
+
describe('insertTrackingId', () => {
|
|
51
|
+
let instance
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
instance = createMockInstance()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should skip non-block types', async () => {
|
|
58
|
+
const data = { _type: 'component', _courseId: 'course1' }
|
|
59
|
+
await instance.insertTrackingId(data)
|
|
60
|
+
assert.equal(instance._contentMock.find.mock.callCount(), 0)
|
|
61
|
+
assert.equal(data._trackingId, undefined)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should skip if _trackingId is already an integer', async () => {
|
|
65
|
+
const data = { _type: 'block', _courseId: 'course1', _trackingId: 5 }
|
|
66
|
+
await instance.insertTrackingId(data)
|
|
67
|
+
assert.equal(instance._contentMock.find.mock.callCount(), 0)
|
|
68
|
+
assert.equal(data._trackingId, 5)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should skip if _trackingId is 0 (a valid integer)', async () => {
|
|
72
|
+
const data = { _type: 'block', _courseId: 'course1', _trackingId: 0 }
|
|
73
|
+
await instance.insertTrackingId(data)
|
|
74
|
+
assert.equal(instance._contentMock.find.mock.callCount(), 0)
|
|
75
|
+
assert.equal(data._trackingId, 0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('should assign _trackingId as max + 1 when blocks exist', async () => {
|
|
79
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _trackingId: 10 }])
|
|
80
|
+
const data = { _type: 'block', _courseId: 'course1' }
|
|
81
|
+
await instance.insertTrackingId(data)
|
|
82
|
+
assert.equal(data._trackingId, 11)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should assign _trackingId 1 when existing block has _trackingId 0', async () => {
|
|
86
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _trackingId: 0 }])
|
|
87
|
+
const data = { _type: 'block', _courseId: 'course1' }
|
|
88
|
+
await instance.insertTrackingId(data)
|
|
89
|
+
assert.equal(data._trackingId, 1)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('should call content.find with correct query and options', async () => {
|
|
93
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _trackingId: 3 }])
|
|
94
|
+
const data = { _type: 'block', _courseId: 'courseABC' }
|
|
95
|
+
await instance.insertTrackingId(data)
|
|
96
|
+
const call = instance._contentMock.find.mock.calls[0]
|
|
97
|
+
assert.deepEqual(call.arguments[0], { _courseId: 'courseABC' })
|
|
98
|
+
assert.deepEqual(call.arguments[1], {})
|
|
99
|
+
assert.deepEqual(call.arguments[2], { limit: 1, sort: [['_trackingId', -1]] })
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('should not skip when _trackingId is a non-integer number', async () => {
|
|
103
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _trackingId: 5 }])
|
|
104
|
+
const data = { _type: 'block', _courseId: 'course1', _trackingId: 1.5 }
|
|
105
|
+
await instance.insertTrackingId(data)
|
|
106
|
+
assert.equal(data._trackingId, 6)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should not skip when _trackingId is a string', async () => {
|
|
110
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _trackingId: 2 }])
|
|
111
|
+
const data = { _type: 'block', _courseId: 'course1', _trackingId: '5' }
|
|
112
|
+
await instance.insertTrackingId(data)
|
|
113
|
+
assert.equal(data._trackingId, 3)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('should use nullish coalescing so undefined _trackingId defaults to 1', async () => {
|
|
117
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _trackingId: undefined }])
|
|
118
|
+
const data = { _type: 'block', _courseId: 'course1' }
|
|
119
|
+
await instance.insertTrackingId(data)
|
|
120
|
+
assert.equal(data._trackingId, 1)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should handle null _trackingId in result using nullish coalescing', async () => {
|
|
124
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _trackingId: null }])
|
|
125
|
+
const data = { _type: 'block', _courseId: 'course1' }
|
|
126
|
+
await instance.insertTrackingId(data)
|
|
127
|
+
assert.equal(data._trackingId, 1)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('should handle empty find result gracefully', async () => {
|
|
131
|
+
instance._contentMock.find.mock.mockImplementation(async () => [])
|
|
132
|
+
const data = { _type: 'block', _courseId: 'emptyCourse' }
|
|
133
|
+
await instance.insertTrackingId(data)
|
|
134
|
+
assert.equal(data._trackingId, 1)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
describe('resetCourseTrackingIds', () => {
|
|
139
|
+
let instance
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
instance = createMockInstance()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should reassign sequential _trackingId values starting from 1', async () => {
|
|
146
|
+
const blocks = [
|
|
147
|
+
{ _id: 'b1', _trackingId: 5 },
|
|
148
|
+
{ _id: 'b2', _trackingId: 12 },
|
|
149
|
+
{ _id: 'b3', _trackingId: 20 }
|
|
150
|
+
]
|
|
151
|
+
instance._contentMock.find.mock.mockImplementation(async () => blocks)
|
|
152
|
+
await instance.resetCourseTrackingIds('course1')
|
|
153
|
+
|
|
154
|
+
const updateCalls = instance._contentMock.update.mock.calls
|
|
155
|
+
assert.equal(updateCalls.length, 3)
|
|
156
|
+
assert.deepEqual(updateCalls[0].arguments, [{ _id: 'b1' }, { _trackingId: 1 }, { schemaName: 'block' }])
|
|
157
|
+
assert.deepEqual(updateCalls[1].arguments, [{ _id: 'b2' }, { _trackingId: 2 }, { schemaName: 'block' }])
|
|
158
|
+
assert.deepEqual(updateCalls[2].arguments, [{ _id: 'b3' }, { _trackingId: 3 }, { schemaName: 'block' }])
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('should query for blocks sorted by _trackingId ascending', async () => {
|
|
162
|
+
instance._contentMock.find.mock.mockImplementation(async () => [])
|
|
163
|
+
await instance.resetCourseTrackingIds('courseXYZ')
|
|
164
|
+
|
|
165
|
+
const call = instance._contentMock.find.mock.calls[0]
|
|
166
|
+
assert.deepEqual(call.arguments[0], { _type: 'block', _courseId: 'courseXYZ' })
|
|
167
|
+
assert.deepEqual(call.arguments[1], {})
|
|
168
|
+
assert.deepEqual(call.arguments[2], { sort: [['_trackingId', 1]] })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('should do nothing when no blocks are found', async () => {
|
|
172
|
+
instance._contentMock.find.mock.mockImplementation(async () => [])
|
|
173
|
+
await instance.resetCourseTrackingIds('emptyCourse')
|
|
174
|
+
assert.equal(instance._contentMock.update.mock.callCount(), 0)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('should log a debug message after resetting', async () => {
|
|
178
|
+
instance._contentMock.find.mock.mockImplementation(async () => [])
|
|
179
|
+
await instance.resetCourseTrackingIds('course1')
|
|
180
|
+
assert.equal(instance.log.mock.callCount(), 1)
|
|
181
|
+
assert.deepEqual(instance.log.mock.calls[0].arguments, ['debug', 'RESET', 'course1'])
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should handle a single block', async () => {
|
|
185
|
+
const blocks = [{ _id: 'b1', _trackingId: 99 }]
|
|
186
|
+
instance._contentMock.find.mock.mockImplementation(async () => blocks)
|
|
187
|
+
await instance.resetCourseTrackingIds('course1')
|
|
188
|
+
|
|
189
|
+
const updateCalls = instance._contentMock.update.mock.calls
|
|
190
|
+
assert.equal(updateCalls.length, 1)
|
|
191
|
+
assert.deepEqual(updateCalls[0].arguments, [{ _id: 'b1' }, { _trackingId: 1 }, { schemaName: 'block' }])
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('should propagate errors from content.find', async () => {
|
|
195
|
+
instance._contentMock.find.mock.mockImplementation(async () => { throw new Error('db error') })
|
|
196
|
+
await assert.rejects(
|
|
197
|
+
() => instance.resetCourseTrackingIds('course1'),
|
|
198
|
+
{ message: 'db error' }
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('should propagate errors from content.update', async () => {
|
|
203
|
+
instance._contentMock.find.mock.mockImplementation(async () => [{ _id: 'b1', _trackingId: 1 }])
|
|
204
|
+
instance._contentMock.update.mock.mockImplementation(async () => { throw new Error('update failed') })
|
|
205
|
+
await assert.rejects(
|
|
206
|
+
() => instance.resetCourseTrackingIds('course1'),
|
|
207
|
+
{ message: 'update failed' }
|
|
208
|
+
)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe('resetTrackingHandler', () => {
|
|
213
|
+
let instance
|
|
214
|
+
|
|
215
|
+
beforeEach(() => {
|
|
216
|
+
instance = createMockInstance()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('should call resetCourseTrackingIds with the courseId from params', async () => {
|
|
220
|
+
instance._contentMock.find.mock.mockImplementation(async () => [])
|
|
221
|
+
const req = { params: { _courseId: 'course123' } }
|
|
222
|
+
const res = { sendStatus: mock.fn() }
|
|
223
|
+
const next = mock.fn()
|
|
224
|
+
|
|
225
|
+
await instance.resetTrackingHandler(req, res, next)
|
|
226
|
+
|
|
227
|
+
const findCall = instance._contentMock.find.mock.calls[0]
|
|
228
|
+
assert.deepEqual(findCall.arguments[0], { _type: 'block', _courseId: 'course123' })
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('should send 204 on success', async () => {
|
|
232
|
+
instance._contentMock.find.mock.mockImplementation(async () => [])
|
|
233
|
+
const req = { params: { _courseId: 'course1' } }
|
|
234
|
+
const res = { sendStatus: mock.fn() }
|
|
235
|
+
const next = mock.fn()
|
|
236
|
+
|
|
237
|
+
await instance.resetTrackingHandler(req, res, next)
|
|
238
|
+
|
|
239
|
+
assert.equal(res.sendStatus.mock.callCount(), 1)
|
|
240
|
+
assert.deepEqual(res.sendStatus.mock.calls[0].arguments, [204])
|
|
241
|
+
assert.equal(next.mock.callCount(), 0)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('should call next with error on failure', async () => {
|
|
245
|
+
instance._contentMock.find.mock.mockImplementation(async () => { throw new Error('fail') })
|
|
246
|
+
const req = { params: { _courseId: 'course1' } }
|
|
247
|
+
const res = { sendStatus: mock.fn() }
|
|
248
|
+
const next = mock.fn()
|
|
249
|
+
|
|
250
|
+
await instance.resetTrackingHandler(req, res, next)
|
|
251
|
+
|
|
252
|
+
assert.equal(next.mock.callCount(), 1)
|
|
253
|
+
assert.equal(next.mock.calls[0].arguments[0].message, 'fail')
|
|
254
|
+
assert.equal(res.sendStatus.mock.callCount(), 0)
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('class structure', () => {
|
|
259
|
+
it('should export a class', () => {
|
|
260
|
+
assert.equal(typeof SpoorTrackingModule, 'function')
|
|
261
|
+
assert.equal(typeof SpoorTrackingModule.prototype.init, 'function')
|
|
262
|
+
assert.equal(typeof SpoorTrackingModule.prototype.insertTrackingId, 'function')
|
|
263
|
+
assert.equal(typeof SpoorTrackingModule.prototype.resetCourseTrackingIds, 'function')
|
|
264
|
+
assert.equal(typeof SpoorTrackingModule.prototype.resetTrackingHandler, 'function')
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
})
|