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.0.3",
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.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,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
+ })