adapt-authoring-spoortracking 1.0.3 → 1.1.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.
@@ -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
package/package.json CHANGED
@@ -1,17 +1,20 @@
1
1
  {
2
2
  "name": "adapt-authoring-spoortracking",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
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.5",
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,273 @@
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
+ // TODO: BUG - insertTrackingId crashes when content.find returns an empty array
131
+ // Destructuring `[{ _trackingId }]` from an empty array gives `undefined`,
132
+ // causing a TypeError. This happens when a block is the first content item
133
+ // inserted for a course.
134
+ it('should handle empty find result gracefully', async () => {
135
+ instance._contentMock.find.mock.mockImplementation(async () => [])
136
+ const data = { _type: 'block', _courseId: 'emptyCourse' }
137
+ await assert.rejects(
138
+ () => instance.insertTrackingId(data),
139
+ TypeError
140
+ )
141
+ })
142
+ })
143
+
144
+ describe('resetCourseTrackingIds', () => {
145
+ let instance
146
+
147
+ beforeEach(() => {
148
+ instance = createMockInstance()
149
+ })
150
+
151
+ it('should reassign sequential _trackingId values starting from 1', async () => {
152
+ const blocks = [
153
+ { _id: 'b1', _trackingId: 5 },
154
+ { _id: 'b2', _trackingId: 12 },
155
+ { _id: 'b3', _trackingId: 20 }
156
+ ]
157
+ instance._contentMock.find.mock.mockImplementation(async () => blocks)
158
+ await instance.resetCourseTrackingIds('course1')
159
+
160
+ const updateCalls = instance._contentMock.update.mock.calls
161
+ assert.equal(updateCalls.length, 3)
162
+ assert.deepEqual(updateCalls[0].arguments, [{ _id: 'b1' }, { _trackingId: 1 }, { schemaName: 'block' }])
163
+ assert.deepEqual(updateCalls[1].arguments, [{ _id: 'b2' }, { _trackingId: 2 }, { schemaName: 'block' }])
164
+ assert.deepEqual(updateCalls[2].arguments, [{ _id: 'b3' }, { _trackingId: 3 }, { schemaName: 'block' }])
165
+ })
166
+
167
+ it('should query for blocks sorted by _trackingId ascending', async () => {
168
+ instance._contentMock.find.mock.mockImplementation(async () => [])
169
+ await instance.resetCourseTrackingIds('courseXYZ')
170
+
171
+ const call = instance._contentMock.find.mock.calls[0]
172
+ assert.deepEqual(call.arguments[0], { _type: 'block', _courseId: 'courseXYZ' })
173
+ assert.deepEqual(call.arguments[1], {})
174
+ assert.deepEqual(call.arguments[2], { sort: [['_trackingId', 1]] })
175
+ })
176
+
177
+ it('should do nothing when no blocks are found', async () => {
178
+ instance._contentMock.find.mock.mockImplementation(async () => [])
179
+ await instance.resetCourseTrackingIds('emptyCourse')
180
+ assert.equal(instance._contentMock.update.mock.callCount(), 0)
181
+ })
182
+
183
+ it('should log a debug message after resetting', async () => {
184
+ instance._contentMock.find.mock.mockImplementation(async () => [])
185
+ await instance.resetCourseTrackingIds('course1')
186
+ assert.equal(instance.log.mock.callCount(), 1)
187
+ assert.deepEqual(instance.log.mock.calls[0].arguments, ['debug', 'RESET', 'course1'])
188
+ })
189
+
190
+ it('should handle a single block', async () => {
191
+ const blocks = [{ _id: 'b1', _trackingId: 99 }]
192
+ instance._contentMock.find.mock.mockImplementation(async () => blocks)
193
+ await instance.resetCourseTrackingIds('course1')
194
+
195
+ const updateCalls = instance._contentMock.update.mock.calls
196
+ assert.equal(updateCalls.length, 1)
197
+ assert.deepEqual(updateCalls[0].arguments, [{ _id: 'b1' }, { _trackingId: 1 }, { schemaName: 'block' }])
198
+ })
199
+
200
+ it('should propagate errors from content.find', async () => {
201
+ instance._contentMock.find.mock.mockImplementation(async () => { throw new Error('db error') })
202
+ await assert.rejects(
203
+ () => instance.resetCourseTrackingIds('course1'),
204
+ { message: 'db error' }
205
+ )
206
+ })
207
+
208
+ it('should propagate errors from content.update', async () => {
209
+ instance._contentMock.find.mock.mockImplementation(async () => [{ _id: 'b1', _trackingId: 1 }])
210
+ instance._contentMock.update.mock.mockImplementation(async () => { throw new Error('update failed') })
211
+ await assert.rejects(
212
+ () => instance.resetCourseTrackingIds('course1'),
213
+ { message: 'update failed' }
214
+ )
215
+ })
216
+ })
217
+
218
+ describe('resetTrackingHandler', () => {
219
+ let instance
220
+
221
+ beforeEach(() => {
222
+ instance = createMockInstance()
223
+ })
224
+
225
+ it('should call resetCourseTrackingIds with the courseId from params', async () => {
226
+ instance._contentMock.find.mock.mockImplementation(async () => [])
227
+ const req = { params: { _courseId: 'course123' } }
228
+ const res = { sendStatus: mock.fn() }
229
+ const next = mock.fn()
230
+
231
+ await instance.resetTrackingHandler(req, res, next)
232
+
233
+ const findCall = instance._contentMock.find.mock.calls[0]
234
+ assert.deepEqual(findCall.arguments[0], { _type: 'block', _courseId: 'course123' })
235
+ })
236
+
237
+ it('should send 204 on success', async () => {
238
+ instance._contentMock.find.mock.mockImplementation(async () => [])
239
+ const req = { params: { _courseId: 'course1' } }
240
+ const res = { sendStatus: mock.fn() }
241
+ const next = mock.fn()
242
+
243
+ await instance.resetTrackingHandler(req, res, next)
244
+
245
+ assert.equal(res.sendStatus.mock.callCount(), 1)
246
+ assert.deepEqual(res.sendStatus.mock.calls[0].arguments, [204])
247
+ assert.equal(next.mock.callCount(), 0)
248
+ })
249
+
250
+ it('should call next with error on failure', async () => {
251
+ instance._contentMock.find.mock.mockImplementation(async () => { throw new Error('fail') })
252
+ const req = { params: { _courseId: 'course1' } }
253
+ const res = { sendStatus: mock.fn() }
254
+ const next = mock.fn()
255
+
256
+ await instance.resetTrackingHandler(req, res, next)
257
+
258
+ assert.equal(next.mock.callCount(), 1)
259
+ assert.equal(next.mock.calls[0].arguments[0].message, 'fail')
260
+ assert.equal(res.sendStatus.mock.callCount(), 0)
261
+ })
262
+ })
263
+
264
+ describe('class structure', () => {
265
+ it('should export a class', () => {
266
+ assert.equal(typeof SpoorTrackingModule, 'function')
267
+ assert.equal(typeof SpoorTrackingModule.prototype.init, 'function')
268
+ assert.equal(typeof SpoorTrackingModule.prototype.insertTrackingId, 'function')
269
+ assert.equal(typeof SpoorTrackingModule.prototype.resetCourseTrackingIds, 'function')
270
+ assert.equal(typeof SpoorTrackingModule.prototype.resetTrackingHandler, 'function')
271
+ })
272
+ })
273
+ })