adapt-authoring-server 1.3.0 → 1.4.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@master
10
+ - uses: actions/setup-node@master
11
+ with:
12
+ node-version: 'lts/*'
13
+ cache: 'npm'
14
+ - run: npm ci
15
+ - run: npm test
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "adapt-authoring-server",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Provides an Express application routing and more",
5
5
  "homepage": "https://github.com/adapt-security/adapt-authoring-server",
6
6
  "license": "GPL-3.0",
7
7
  "type": "module",
8
8
  "main": "index.js",
9
9
  "repository": "github:adapt-security/adapt-authoring-server",
10
+ "scripts": {
11
+ "test": "node --test tests/**/*.spec.js"
12
+ },
10
13
  "dependencies": {
11
14
  "adapt-authoring-core": "^1.7.0",
12
15
  "express": "^5.1.0",
@@ -0,0 +1,552 @@
1
+ import { describe, it, before } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import express from 'express'
4
+ import { App } from 'adapt-authoring-core'
5
+
6
+ describe('Router', () => {
7
+ let Router
8
+ let mockApp
9
+
10
+ before(async () => {
11
+ const app = App.instance
12
+ app.logger = { log: () => {}, name: 'adapt-authoring-logger' }
13
+ app.dependencyloader.instances = app.dependencyloader.instances || {}
14
+ app.dependencyloader.instances['adapt-authoring-server'] = { url: 'http://localhost:5000' }
15
+ // Wait for App singleton's async init/setReady to settle and
16
+ // reset exitCode set by App.init() failure (expected in test context)
17
+ await app.onReady().catch(() => {})
18
+ process.exitCode = 0
19
+
20
+ const routerModule = await import('../lib/Router.js')
21
+ Router = routerModule.default
22
+ mockApp = express()
23
+ })
24
+
25
+ describe('constructor', () => {
26
+ it('should create a router with default values', () => {
27
+ const router = new Router('/test', mockApp)
28
+
29
+ assert.equal(router.root, '/test')
30
+ assert.equal(typeof router.expressRouter, 'function')
31
+ assert.ok(Array.isArray(router.routes))
32
+ assert.equal(router.routes.length, 0)
33
+ assert.ok(Array.isArray(router.childRouters))
34
+ assert.equal(router.childRouters.length, 0)
35
+ assert.ok(Array.isArray(router.routerMiddleware))
36
+ assert.ok(Array.isArray(router.handlerMiddleware))
37
+ assert.equal(router._initialised, false)
38
+ })
39
+
40
+ it('should store the parent router', () => {
41
+ const router = new Router('/test', mockApp)
42
+
43
+ assert.equal(router.parentRouter, mockApp)
44
+ })
45
+
46
+ it('should filter out non-function middleware', () => {
47
+ const fn1 = () => {}
48
+ const fn2 = () => {}
49
+ const middleware = [fn1, 'not a function', fn2, 42, null]
50
+ const router = new Router('/test', mockApp, [], middleware)
51
+
52
+ assert.ok(router.routerMiddleware.includes(fn1))
53
+ assert.ok(router.routerMiddleware.includes(fn2))
54
+ assert.ok(!router.routerMiddleware.includes('not a function'))
55
+ })
56
+
57
+ it('should filter out non-function handler middleware', () => {
58
+ const fn = () => {}
59
+ const handlerMiddleware = [fn, 'bad', undefined]
60
+ const router = new Router('/test', mockApp, [], [], handlerMiddleware)
61
+
62
+ assert.ok(router.handlerMiddleware.includes(fn))
63
+ assert.ok(!router.handlerMiddleware.includes('bad'))
64
+ })
65
+
66
+ it('should filter invalid routes passed to constructor', () => {
67
+ const validRoute = { route: '/users', handlers: { get: () => {} } }
68
+ const invalidRoute = { route: 123, handlers: { get: () => {} } }
69
+ const router = new Router('/test', mockApp, [validRoute, invalidRoute])
70
+
71
+ assert.equal(router.routes.length, 1)
72
+ assert.equal(router.routes[0].route, '/users')
73
+ })
74
+
75
+ it('should include default router middleware (addErrorHandler)', () => {
76
+ const router = new Router('/test', mockApp)
77
+
78
+ assert.ok(router.routerMiddleware.length >= 1)
79
+ })
80
+
81
+ it('should include default handler middleware (addExistenceProps, handleInternalRoutes)', () => {
82
+ const router = new Router('/test', mockApp)
83
+
84
+ assert.ok(router.handlerMiddleware.length >= 2)
85
+ })
86
+ })
87
+
88
+ describe('#path', () => {
89
+ it('should generate path from parent router', () => {
90
+ const parentRouter = new Router('/api', mockApp)
91
+ const childRouter = new Router('users', parentRouter)
92
+
93
+ assert.equal(childRouter.path, '/api/users')
94
+ })
95
+
96
+ it('should handle trailing slashes correctly', () => {
97
+ const parentRouter = new Router('/api/', mockApp)
98
+ const childRouter = new Router('/users', parentRouter)
99
+
100
+ // Documents existing behavior: double slashes are allowed
101
+ assert.equal(childRouter.path, '/api//users')
102
+ })
103
+
104
+ it('should handle root path without parent path property', () => {
105
+ const router = new Router('/test', mockApp)
106
+
107
+ assert.equal(router.path, '/test')
108
+ })
109
+
110
+ it('should build multi-level nested paths', () => {
111
+ const root = new Router('/api', mockApp)
112
+ const child = new Router('v1', root)
113
+ const grandchild = new Router('users', child)
114
+
115
+ assert.equal(grandchild.path, '/api/v1/users')
116
+ })
117
+ })
118
+
119
+ describe('#url', () => {
120
+ it('should return server url + path', () => {
121
+ const router = new Router('/api', mockApp)
122
+
123
+ assert.equal(router.url, 'http://localhost:5000/api')
124
+ })
125
+
126
+ it('should include full hierarchy path', () => {
127
+ const parent = new Router('/api', mockApp)
128
+ const child = new Router('users', parent)
129
+
130
+ assert.equal(child.url, 'http://localhost:5000/api/users')
131
+ })
132
+
133
+ it('should return empty string when server instance is not available', () => {
134
+ const origInstances = App.instance.dependencyloader.instances
135
+ App.instance.dependencyloader.instances = {}
136
+ const router = new Router('/api', mockApp)
137
+
138
+ assert.equal(router.url, '')
139
+
140
+ App.instance.dependencyloader.instances = origInstances
141
+ })
142
+ })
143
+
144
+ describe('#map', () => {
145
+ it('should return a router map object', () => {
146
+ const api = new Router('api', mockApp)
147
+ api.createChildRouter('test', [
148
+ { route: '/test', handlers: { get: () => {} }, post: () => {} },
149
+ { route: '/test2', handlers: { patch: () => {} } }
150
+ ])
151
+
152
+ const map = api.map
153
+
154
+ assert.equal(typeof map, 'object')
155
+ assert.equal(map.test_endpoints.length, 2)
156
+ })
157
+
158
+ it('should return empty object for router with no routes', () => {
159
+ const router = new Router('api', mockApp)
160
+
161
+ assert.deepEqual(router.map, {})
162
+ })
163
+ })
164
+
165
+ describe('#addMiddleware()', () => {
166
+ it('should add middleware to router stack', () => {
167
+ const router = new Router('/test', mockApp)
168
+ const middleware = () => {}
169
+ const initialLength = router.routerMiddleware.length
170
+
171
+ router.addMiddleware(middleware)
172
+
173
+ assert.equal(router.routerMiddleware.length, initialLength + 1)
174
+ assert.ok(router.routerMiddleware.includes(middleware))
175
+ })
176
+
177
+ it('should return router instance for chaining', () => {
178
+ const router = new Router('/test', mockApp)
179
+ const result = router.addMiddleware(() => {})
180
+
181
+ assert.equal(result, router)
182
+ })
183
+
184
+ it('should not add duplicate middleware', () => {
185
+ const router = new Router('/test', mockApp)
186
+ const middleware = () => {}
187
+
188
+ router.addMiddleware(middleware)
189
+ const lengthAfterFirst = router.routerMiddleware.length
190
+ router.addMiddleware(middleware)
191
+
192
+ assert.equal(router.routerMiddleware.length, lengthAfterFirst)
193
+ })
194
+
195
+ it('should add multiple middleware in one call', () => {
196
+ const router = new Router('/test', mockApp)
197
+ const fn1 = () => {}
198
+ const fn2 = () => {}
199
+ const initialLength = router.routerMiddleware.length
200
+
201
+ router.addMiddleware(fn1, fn2)
202
+
203
+ assert.equal(router.routerMiddleware.length, initialLength + 2)
204
+ })
205
+
206
+ it('should not add non-function values', () => {
207
+ const router = new Router('/test', mockApp)
208
+ const initialLength = router.routerMiddleware.length
209
+
210
+ router.addMiddleware('not a function')
211
+
212
+ assert.equal(router.routerMiddleware.length, initialLength)
213
+ })
214
+
215
+ it('should return router when called with no arguments', () => {
216
+ const router = new Router('/test', mockApp)
217
+ const result = router.addMiddleware()
218
+
219
+ assert.equal(result, router)
220
+ })
221
+ })
222
+
223
+ describe('#addHandlerMiddleware()', () => {
224
+ it('should add handler middleware to stack', () => {
225
+ const router = new Router('/test', mockApp)
226
+ const middleware = () => {}
227
+ const initialLength = router.handlerMiddleware.length
228
+
229
+ router.addHandlerMiddleware(middleware)
230
+
231
+ assert.equal(router.handlerMiddleware.length, initialLength + 1)
232
+ assert.ok(router.handlerMiddleware.includes(middleware))
233
+ })
234
+
235
+ it('should return router instance for chaining', () => {
236
+ const router = new Router('/test', mockApp)
237
+ const result = router.addHandlerMiddleware(() => {})
238
+
239
+ assert.equal(result, router)
240
+ })
241
+
242
+ it('should not add duplicate handler middleware', () => {
243
+ const router = new Router('/test', mockApp)
244
+ const middleware = () => {}
245
+
246
+ router.addHandlerMiddleware(middleware)
247
+ const lengthAfterFirst = router.handlerMiddleware.length
248
+ router.addHandlerMiddleware(middleware)
249
+
250
+ assert.equal(router.handlerMiddleware.length, lengthAfterFirst)
251
+ })
252
+ })
253
+
254
+ describe('#getHandlerMiddleware()', () => {
255
+ it('should get middleware from router hierarchy', () => {
256
+ const parentRouter = new Router('/api', mockApp)
257
+ const childRouter = new Router('users', parentRouter)
258
+ const middleware = () => {}
259
+
260
+ parentRouter.addHandlerMiddleware(middleware)
261
+ const result = childRouter.getHandlerMiddleware()
262
+
263
+ assert.ok(Array.isArray(result))
264
+ assert.ok(result.includes(middleware))
265
+ })
266
+
267
+ it('should stop recursing when parent is not a Router', () => {
268
+ const router = new Router('/test', mockApp)
269
+ const result = router.getHandlerMiddleware()
270
+
271
+ assert.ok(Array.isArray(result))
272
+ assert.ok(result.length > 0)
273
+ })
274
+
275
+ it('should return unique middleware (deduplicated)', () => {
276
+ const parentRouter = new Router('/api', mockApp)
277
+ const childRouter = new Router('users', parentRouter)
278
+
279
+ const result = childRouter.getHandlerMiddleware()
280
+ const unique = [...new Set(result)]
281
+
282
+ assert.equal(result.length, unique.length)
283
+ })
284
+ })
285
+
286
+ describe('#addRoute()', () => {
287
+ it('should add valid route', () => {
288
+ const router = new Router('/test', mockApp)
289
+ const route = { route: '/users', handlers: { get: () => {} } }
290
+
291
+ router.addRoute(route)
292
+
293
+ assert.equal(router.routes.length, 1)
294
+ assert.equal(router.routes[0].route, '/users')
295
+ })
296
+
297
+ it('should return router instance for chaining', () => {
298
+ const router = new Router('/test', mockApp)
299
+ const route = { route: '/users', handlers: { get: () => {} } }
300
+ const result = router.addRoute(route)
301
+
302
+ assert.equal(result, router)
303
+ })
304
+
305
+ it('should add multiple routes in one call', () => {
306
+ const router = new Router('/test', mockApp)
307
+ const route1 = { route: '/users', handlers: { get: () => {} } }
308
+ const route2 = { route: '/posts', handlers: { post: () => {} } }
309
+
310
+ router.addRoute(route1, route2)
311
+
312
+ assert.equal(router.routes.length, 2)
313
+ })
314
+
315
+ it('should filter out invalid routes', () => {
316
+ const router = new Router('/test', mockApp)
317
+ const valid = { route: '/users', handlers: { get: () => {} } }
318
+ const invalid = { route: 123, handlers: {} }
319
+
320
+ router.addRoute(valid, invalid)
321
+
322
+ assert.equal(router.routes.length, 1)
323
+ })
324
+
325
+ it('should not add routes after initialisation', () => {
326
+ const router = new Router('/test', mockApp)
327
+ router.init()
328
+
329
+ router.addRoute({ route: '/late', handlers: { get: () => {} } })
330
+
331
+ assert.equal(router.routes.length, 0)
332
+ })
333
+ })
334
+
335
+ describe('#validateRoute()', () => {
336
+ it('should return true for valid route', () => {
337
+ const router = new Router('/test', mockApp)
338
+ const route = { route: '/users', handlers: { get: () => {} } }
339
+
340
+ assert.equal(router.validateRoute(route), true)
341
+ })
342
+
343
+ it('should return false if route is not a string', () => {
344
+ const router = new Router('/test', mockApp)
345
+
346
+ assert.equal(router.validateRoute({ route: 123, handlers: { get: () => {} } }), false)
347
+ })
348
+
349
+ it('should return false if handlers is missing', () => {
350
+ const router = new Router('/test', mockApp)
351
+
352
+ assert.equal(router.validateRoute({ route: '/test' }), false)
353
+ })
354
+
355
+ it('should return false if handler method is not a valid Express method', () => {
356
+ const router = new Router('/test', mockApp)
357
+
358
+ assert.equal(router.validateRoute({ route: '/test', handlers: { badmethod: () => {} } }), false)
359
+ })
360
+
361
+ it('should return false if handler value is not a function or array of functions', () => {
362
+ const router = new Router('/test', mockApp)
363
+
364
+ assert.equal(router.validateRoute({ route: '/test', handlers: { get: 'not a function' } }), false)
365
+ })
366
+
367
+ it('should accept array of functions as handler', () => {
368
+ const router = new Router('/test', mockApp)
369
+ const route = { route: '/users', handlers: { get: [() => {}, () => {}] } }
370
+
371
+ assert.equal(router.validateRoute(route), true)
372
+ })
373
+
374
+ it('should return false if handler array contains non-functions', () => {
375
+ const router = new Router('/test', mockApp)
376
+ const route = { route: '/users', handlers: { get: [() => {}, 'not a function'] } }
377
+
378
+ assert.equal(router.validateRoute(route), false)
379
+ })
380
+
381
+ it('should accept multiple HTTP methods', () => {
382
+ const router = new Router('/test', mockApp)
383
+ const route = { route: '/users', handlers: { get: () => {}, post: () => {}, put: () => {}, delete: () => {} } }
384
+
385
+ assert.equal(router.validateRoute(route), true)
386
+ })
387
+ })
388
+
389
+ describe('#createChildRouter()', () => {
390
+ it('should create and return a child router', () => {
391
+ const router = new Router('/api', mockApp)
392
+ const child = router.createChildRouter('users')
393
+
394
+ assert.ok(child instanceof Router)
395
+ assert.equal(child.root, 'users')
396
+ assert.equal(child.parentRouter, router)
397
+ })
398
+
399
+ it('should add child to childRouters array', () => {
400
+ const router = new Router('/api', mockApp)
401
+ router.createChildRouter('users')
402
+
403
+ assert.equal(router.childRouters.length, 1)
404
+ })
405
+
406
+ it('should pass routes to child router', () => {
407
+ const router = new Router('/api', mockApp)
408
+ const routes = [{ route: '/list', handlers: { get: () => {} } }]
409
+ const child = router.createChildRouter('users', routes)
410
+
411
+ assert.equal(child.routes.length, 1)
412
+ })
413
+
414
+ it('should pass middleware to child router', () => {
415
+ const router = new Router('/api', mockApp)
416
+ const mw = () => {}
417
+ const child = router.createChildRouter('users', [], [mw])
418
+
419
+ assert.ok(child.routerMiddleware.includes(mw))
420
+ })
421
+
422
+ it('should pass handler middleware to child router', () => {
423
+ const router = new Router('/api', mockApp)
424
+ const mw = () => {}
425
+ const child = router.createChildRouter('users', [], [], [mw])
426
+
427
+ assert.ok(child.handlerMiddleware.includes(mw))
428
+ })
429
+
430
+ it('should not create child after initialisation', () => {
431
+ const router = new Router('/api', mockApp)
432
+ router.init()
433
+
434
+ const result = router.createChildRouter('late')
435
+
436
+ assert.equal(result, router)
437
+ assert.equal(router.childRouters.length, 0)
438
+ })
439
+ })
440
+
441
+ describe('#warnOnInited()', () => {
442
+ it('should return false if not initialised', () => {
443
+ const router = new Router('/test', mockApp)
444
+
445
+ assert.equal(router.warnOnInited('test message'), false)
446
+ })
447
+
448
+ it('should return true if initialised', () => {
449
+ const router = new Router('/test', mockApp)
450
+ router.init()
451
+
452
+ assert.equal(router.warnOnInited('test message'), true)
453
+ })
454
+ })
455
+
456
+ describe('#init()', () => {
457
+ it('should set _initialised to true', () => {
458
+ const router = new Router('/test', mockApp)
459
+
460
+ router.init()
461
+
462
+ assert.equal(router._initialised, true)
463
+ })
464
+
465
+ it('should apply router middleware to express router', () => {
466
+ const mw = (req, res, next) => next()
467
+ const router = new Router('/test', mockApp, [], [mw])
468
+
469
+ router.init()
470
+
471
+ assert.ok(router.routerMiddleware.includes(mw))
472
+ })
473
+
474
+ it('should initialise child routers', () => {
475
+ const router = new Router('/api', mockApp)
476
+ const child = router.createChildRouter('users')
477
+
478
+ router.init()
479
+
480
+ assert.equal(child._initialised, true)
481
+ })
482
+
483
+ it('should register routes on the express router', () => {
484
+ const handler = (req, res) => res.json({})
485
+ const routes = [{ route: '/items', handlers: { get: handler } }]
486
+ const router = new Router('/test', mockApp, routes)
487
+
488
+ router.init()
489
+
490
+ assert.equal(router._initialised, true)
491
+ })
492
+
493
+ it('should not re-initialise if already initialised', () => {
494
+ const router = new Router('/test', mockApp)
495
+ router.init()
496
+
497
+ // Second call should be a no-op
498
+ router.init()
499
+
500
+ assert.equal(router._initialised, true)
501
+ })
502
+
503
+ it('should mount to parent Router via expressRouter.use', () => {
504
+ const parentRouter = new Router('/api', mockApp)
505
+ const childRouter = new Router('users', parentRouter)
506
+
507
+ childRouter.init()
508
+
509
+ assert.equal(childRouter._initialised, true)
510
+ })
511
+ })
512
+
513
+ describe('#flattenRouters()', () => {
514
+ it('should return array containing the router itself', () => {
515
+ const router = new Router('/api', mockApp)
516
+ const result = router.flattenRouters()
517
+
518
+ assert.ok(Array.isArray(result))
519
+ assert.ok(result.includes(router))
520
+ })
521
+
522
+ it('should include child routers', () => {
523
+ const router = new Router('/api', mockApp)
524
+ const child = router.createChildRouter('users')
525
+
526
+ const result = router.flattenRouters()
527
+
528
+ assert.ok(result.includes(router))
529
+ assert.ok(result.includes(child))
530
+ })
531
+
532
+ it('should include nested grandchild routers', () => {
533
+ const router = new Router('/api', mockApp)
534
+ const child = router.createChildRouter('v1')
535
+ const grandchild = child.createChildRouter('users')
536
+
537
+ const result = router.flattenRouters()
538
+
539
+ assert.ok(result.includes(router))
540
+ assert.ok(result.includes(child))
541
+ assert.ok(result.includes(grandchild))
542
+ })
543
+ })
544
+
545
+ describe('#log()', () => {
546
+ it('should not throw when called', () => {
547
+ const router = new Router('/test', mockApp)
548
+
549
+ assert.doesNotThrow(() => router.log('debug', 'test message'))
550
+ })
551
+ })
552
+ })
@@ -0,0 +1,163 @@
1
+ import { describe, it, before } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { Hook } from 'adapt-authoring-core'
4
+
5
+ describe('ServerModule', () => {
6
+ let ServerModule
7
+
8
+ before(async () => {
9
+ ServerModule = (await import('../lib/ServerModule.js')).default
10
+ })
11
+
12
+ function createMockApp () {
13
+ return {
14
+ onReady: () => new Promise(() => {}),
15
+ logger: { log: () => {}, name: 'adapt-authoring-logger' },
16
+ dependencyloader: {
17
+ instances: { 'adapt-authoring-server': { url: 'http://localhost:5000' } },
18
+ moduleLoadedHook: new Hook()
19
+ },
20
+ config: {
21
+ get: (key) => {
22
+ const values = {
23
+ 'ServerModule.host': 'localhost',
24
+ 'ServerModule.port': 5000,
25
+ 'ServerModule.trustProxy': true,
26
+ 'ServerModule.url': null,
27
+ 'ServerModule.verboseErrorLogging': false
28
+ }
29
+ return values[key]
30
+ }
31
+ },
32
+ errors: {
33
+ SERVER_START: { setData: (data) => ({ ...data, statusCode: 500 }) }
34
+ }
35
+ }
36
+ }
37
+
38
+ describe('constructor', () => {
39
+ it('should be defined', () => {
40
+ assert.ok(ServerModule)
41
+ assert.equal(typeof ServerModule, 'function')
42
+ })
43
+ })
44
+
45
+ describe('#static()', () => {
46
+ it('should return express.static middleware', () => {
47
+ const instance = new ServerModule(createMockApp())
48
+ const middleware = instance.static('/public')
49
+
50
+ assert.equal(typeof middleware, 'function')
51
+ })
52
+
53
+ it('should accept options parameter', () => {
54
+ const instance = new ServerModule(createMockApp())
55
+ const middleware = instance.static('/public', { maxAge: 3600 })
56
+
57
+ assert.equal(typeof middleware, 'function')
58
+ })
59
+ })
60
+
61
+ describe('properties', () => {
62
+ it('should have host property from config', () => {
63
+ const instance = new ServerModule(createMockApp())
64
+
65
+ assert.equal(instance.host, 'localhost')
66
+ })
67
+
68
+ it('should have port property from config', () => {
69
+ const instance = new ServerModule(createMockApp())
70
+
71
+ assert.equal(instance.port, 5000)
72
+ })
73
+
74
+ it('should return url from config when set', () => {
75
+ const app = createMockApp()
76
+ const origGet = app.config.get
77
+ app.config.get = (key) => {
78
+ if (key === 'ServerModule.url') return 'http://example.com'
79
+ return origGet(key)
80
+ }
81
+ const instance = new ServerModule(app)
82
+
83
+ assert.equal(instance.url, 'http://example.com')
84
+ })
85
+
86
+ it('should generate url from host and port when url config is not set', () => {
87
+ const instance = new ServerModule(createMockApp())
88
+
89
+ assert.equal(instance.url, 'localhost:5000')
90
+ })
91
+
92
+ it('should have isListening set to false initially', () => {
93
+ const instance = new ServerModule(createMockApp())
94
+
95
+ assert.equal(instance.isListening, false)
96
+ })
97
+
98
+ it('should have a listeningHook', () => {
99
+ const instance = new ServerModule(createMockApp())
100
+
101
+ assert.ok(instance.listeningHook instanceof Hook)
102
+ })
103
+
104
+ it('should have a requestHook', () => {
105
+ const instance = new ServerModule(createMockApp())
106
+
107
+ assert.ok(instance.requestHook instanceof Hook)
108
+ })
109
+
110
+ it('should have an expressApp', () => {
111
+ const instance = new ServerModule(createMockApp())
112
+
113
+ assert.equal(typeof instance.expressApp, 'function')
114
+ })
115
+
116
+ it('should have an api Router', () => {
117
+ const instance = new ServerModule(createMockApp())
118
+
119
+ assert.ok(instance.api)
120
+ assert.equal(instance.api.root, '/api')
121
+ })
122
+
123
+ it('should have a root Router', () => {
124
+ const instance = new ServerModule(createMockApp())
125
+
126
+ assert.ok(instance.root)
127
+ assert.equal(instance.root.root, '/')
128
+ })
129
+
130
+ it('should set httpServer to undefined initially', () => {
131
+ const instance = new ServerModule(createMockApp())
132
+
133
+ assert.equal(instance.httpServer, undefined)
134
+ })
135
+
136
+ it('should set isListening to false initially', () => {
137
+ const instance = new ServerModule(createMockApp())
138
+
139
+ assert.equal(instance.isListening, false)
140
+ })
141
+
142
+ it('should set trust proxy on express app', () => {
143
+ const instance = new ServerModule(createMockApp())
144
+
145
+ assert.equal(instance.expressApp.get('trust proxy'), true)
146
+ })
147
+
148
+ it('should set view engine to hbs', () => {
149
+ const instance = new ServerModule(createMockApp())
150
+
151
+ assert.equal(instance.expressApp.get('view engine'), 'hbs')
152
+ })
153
+ })
154
+
155
+ describe('#close()', () => {
156
+ it('should resolve if httpServer is not set', async () => {
157
+ const instance = new ServerModule(createMockApp())
158
+ instance.httpServer = undefined
159
+
160
+ await assert.doesNotReject(() => instance.close())
161
+ })
162
+ })
163
+ })
@@ -0,0 +1,555 @@
1
+ import { describe, it, before } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { App } from 'adapt-authoring-core'
4
+
5
+ describe('ServerUtils', () => {
6
+ let ServerUtils
7
+
8
+ before(async () => {
9
+ const app = App.instance
10
+ app.logger = { log: () => {}, name: 'adapt-authoring-logger' }
11
+ app.dependencyloader.instances = app.dependencyloader.instances || {}
12
+ app.dependencyloader.instances['adapt-authoring-server'] = { url: 'http://localhost:5000' }
13
+ await app.onReady().catch(() => {})
14
+ process.exitCode = 0
15
+
16
+ ServerUtils = (await import('../lib/ServerUtils.js')).default
17
+ })
18
+
19
+ describe('#addExistenceProps()', () => {
20
+ it('should set hasBody, hasParams, hasQuery to false for empty objects', () => {
21
+ const req = { method: 'POST', body: {}, params: {}, query: {} }
22
+ const res = {}
23
+ let nextCalled = false
24
+ const next = () => { nextCalled = true }
25
+
26
+ ServerUtils.addExistenceProps(req, res, next)
27
+
28
+ assert.equal(req.hasBody, false)
29
+ assert.equal(req.hasParams, false)
30
+ assert.equal(req.hasQuery, false)
31
+ assert.equal(nextCalled, true)
32
+ })
33
+
34
+ it('should set hasBody, hasParams, hasQuery to true for populated objects', () => {
35
+ const req = { method: 'POST', body: { foo: 'bar' }, params: { id: '123' }, query: { search: 'test' } }
36
+ const res = {}
37
+ let nextCalled = false
38
+ const next = () => { nextCalled = true }
39
+
40
+ ServerUtils.addExistenceProps(req, res, next)
41
+
42
+ assert.equal(req.hasBody, true)
43
+ assert.equal(req.hasParams, true)
44
+ assert.equal(req.hasQuery, true)
45
+ assert.equal(nextCalled, true)
46
+ })
47
+
48
+ it('should remove undefined and null values from request objects', () => {
49
+ const req = { method: 'POST', body: { foo: 'bar', baz: undefined, qux: null }, params: {}, query: {} }
50
+ const res = {}
51
+ const next = () => {}
52
+
53
+ ServerUtils.addExistenceProps(req, res, next)
54
+
55
+ assert.equal(req.body.foo, 'bar')
56
+ assert.equal(req.body.baz, undefined)
57
+ assert.equal(req.body.qux, undefined)
58
+ assert.equal(req.hasBody, true)
59
+ })
60
+
61
+ it('should clear body for GET requests', () => {
62
+ const req = { method: 'GET', body: { foo: 'bar' }, params: {}, query: {} }
63
+ const res = {}
64
+ const next = () => {}
65
+
66
+ ServerUtils.addExistenceProps(req, res, next)
67
+
68
+ assert.deepEqual(req.body, {})
69
+ assert.equal(req.hasBody, false)
70
+ })
71
+
72
+ it('should handle falsy attr values (missing body/params/query)', () => {
73
+ const req = { method: 'POST', body: null, params: undefined, query: false }
74
+ const res = {}
75
+ const next = () => {}
76
+
77
+ ServerUtils.addExistenceProps(req, res, next)
78
+
79
+ assert.equal(req.hasBody, true)
80
+ assert.equal(req.hasParams, true)
81
+ assert.equal(req.hasQuery, true)
82
+ })
83
+
84
+ it('should mark has* false when all entries are null or undefined', () => {
85
+ const req = { method: 'POST', body: { a: null, b: undefined }, params: {}, query: {} }
86
+ const res = {}
87
+ const next = () => {}
88
+
89
+ ServerUtils.addExistenceProps(req, res, next)
90
+
91
+ assert.equal(req.hasBody, false)
92
+ })
93
+ })
94
+
95
+ describe('#cacheRouteConfig()', () => {
96
+ it('should cache route config on request object', () => {
97
+ const routeConfig = { route: '/test', handlers: {}, internal: false }
98
+ const middleware = ServerUtils.cacheRouteConfig(routeConfig)
99
+ const req = {}
100
+ const res = {}
101
+ let nextCalled = false
102
+ const next = () => { nextCalled = true }
103
+
104
+ middleware(req, res, next)
105
+
106
+ assert.equal(req.routeConfig, routeConfig)
107
+ assert.equal(nextCalled, true)
108
+ })
109
+
110
+ it('should return a middleware function', () => {
111
+ const middleware = ServerUtils.cacheRouteConfig({})
112
+
113
+ assert.equal(typeof middleware, 'function')
114
+ })
115
+ })
116
+
117
+ describe('#addErrorHandler()', () => {
118
+ it('should add sendError method to response object', () => {
119
+ const req = {}
120
+ const res = {}
121
+ let nextCalled = false
122
+ const next = () => { nextCalled = true }
123
+
124
+ ServerUtils.addErrorHandler(req, res, next)
125
+
126
+ assert.equal(typeof res.sendError, 'function')
127
+ assert.equal(nextCalled, true)
128
+ })
129
+
130
+ it('sendError should send AdaptError as JSON with status code', () => {
131
+ const req = { translate: (error) => error.message }
132
+ const res = {}
133
+ const next = () => {}
134
+
135
+ ServerUtils.addErrorHandler(req, res, next)
136
+
137
+ let statusCode
138
+ let jsonData
139
+ res.status = (code) => { statusCode = code; return res }
140
+ res.json = (data) => { jsonData = data }
141
+
142
+ const adaptError = {
143
+ constructor: { name: 'AdaptError' },
144
+ statusCode: 404,
145
+ code: 'NOT_FOUND',
146
+ message: 'Not found'
147
+ }
148
+ res.sendError(adaptError)
149
+
150
+ assert.equal(statusCode, 404)
151
+ assert.equal(jsonData.code, 'NOT_FOUND')
152
+ assert.equal(jsonData.message, 'Not found')
153
+ })
154
+
155
+ it('sendError should fall back to SERVER_ERROR for unknown errors', () => {
156
+ App.instance.errors = App.instance.errors || {}
157
+ App.instance.errors.SERVER_ERROR = {
158
+ constructor: { name: 'AdaptError' },
159
+ statusCode: 500,
160
+ code: 'SERVER_ERROR',
161
+ message: 'Internal server error'
162
+ }
163
+
164
+ const req = { translate: (error) => error.message }
165
+ const res = {}
166
+ const next = () => {}
167
+
168
+ ServerUtils.addErrorHandler(req, res, next)
169
+
170
+ let statusCode
171
+ let jsonData
172
+ res.status = (code) => { statusCode = code; return res }
173
+ res.json = (data) => { jsonData = data }
174
+
175
+ res.sendError(new Error('something broke'))
176
+
177
+ assert.equal(statusCode, 500)
178
+ assert.equal(jsonData.code, 'SERVER_ERROR')
179
+ })
180
+
181
+ it('sendError should look up known error codes on non-AdaptError', () => {
182
+ App.instance.errors = App.instance.errors || {}
183
+ App.instance.errors.CUSTOM_CODE = {
184
+ statusCode: 422,
185
+ code: 'CUSTOM_CODE',
186
+ message: 'Custom error'
187
+ }
188
+
189
+ const req = { translate: (error) => error.message }
190
+ const res = {}
191
+ const next = () => {}
192
+
193
+ ServerUtils.addErrorHandler(req, res, next)
194
+
195
+ let statusCode
196
+ let jsonData
197
+ res.status = (code) => { statusCode = code; return res }
198
+ res.json = (data) => { jsonData = data }
199
+
200
+ const error = new Error('details')
201
+ error.code = 'CUSTOM_CODE'
202
+ res.sendError(error)
203
+
204
+ assert.equal(statusCode, 422)
205
+ assert.equal(jsonData.code, 'CUSTOM_CODE')
206
+ })
207
+
208
+ it('sendError should include data field in response', () => {
209
+ const req = { translate: (error) => error.message }
210
+ const res = {}
211
+ const next = () => {}
212
+
213
+ ServerUtils.addErrorHandler(req, res, next)
214
+
215
+ let jsonData
216
+ res.status = () => res
217
+ res.json = (data) => { jsonData = data }
218
+
219
+ const adaptError = {
220
+ constructor: { name: 'AdaptError' },
221
+ statusCode: 400,
222
+ code: 'BAD_REQUEST',
223
+ message: 'Bad request',
224
+ data: { field: 'email' }
225
+ }
226
+ res.sendError(adaptError)
227
+
228
+ assert.deepEqual(jsonData.data, { field: 'email' })
229
+ })
230
+
231
+ it('sendError should use error.message when req.translate is not available', () => {
232
+ const req = {}
233
+ const res = {}
234
+ const next = () => {}
235
+
236
+ ServerUtils.addErrorHandler(req, res, next)
237
+
238
+ let jsonData
239
+ res.status = () => res
240
+ res.json = (data) => { jsonData = data }
241
+
242
+ const adaptError = {
243
+ constructor: { name: 'AdaptError' },
244
+ statusCode: 500,
245
+ code: 'ERR',
246
+ message: 'fallback message'
247
+ }
248
+ res.sendError(adaptError)
249
+
250
+ assert.equal(jsonData.message, 'fallback message')
251
+ })
252
+ })
253
+
254
+ describe('#mapHandler()', () => {
255
+ it('should return a function', () => {
256
+ const mockRouter = { map: { test: 'data' } }
257
+ const handler = ServerUtils.mapHandler(mockRouter)
258
+
259
+ assert.equal(typeof handler, 'function')
260
+ })
261
+
262
+ it('should respond with router map', () => {
263
+ const mockRouter = { map: { test: 'data' } }
264
+ const handler = ServerUtils.mapHandler(mockRouter)
265
+ const req = {}
266
+ let responseData = null
267
+ const res = {
268
+ json: (data) => { responseData = data }
269
+ }
270
+
271
+ handler(req, res)
272
+
273
+ assert.deepEqual(responseData, { test: 'data' })
274
+ })
275
+ })
276
+
277
+ describe('#getAllRoutes()', () => {
278
+ it('should collect routes from router hierarchy', () => {
279
+ const mockRouter = {
280
+ path: '/api',
281
+ routes: [
282
+ { route: '/users', handlers: { get: () => {}, post: () => {} } },
283
+ { route: '/posts', handlers: { get: () => {} } }
284
+ ],
285
+ flattenRouters: () => [mockRouter]
286
+ }
287
+
288
+ const routeMap = ServerUtils.getAllRoutes(mockRouter)
289
+
290
+ assert.ok(routeMap instanceof Map)
291
+ assert.equal(routeMap.size, 2)
292
+ assert.ok(routeMap.has('/api/users'))
293
+ assert.ok(routeMap.has('/api/posts'))
294
+ assert.ok(routeMap.get('/api/users').has('GET'))
295
+ assert.ok(routeMap.get('/api/users').has('POST'))
296
+ assert.ok(routeMap.get('/api/posts').has('GET'))
297
+ })
298
+
299
+ it('should collect routes from multiple routers in hierarchy', () => {
300
+ const childRouter = {
301
+ path: '/api/v1',
302
+ routes: [
303
+ { route: '/items', handlers: { get: () => {} } }
304
+ ]
305
+ }
306
+ const parentRouter = {
307
+ path: '/api',
308
+ routes: [
309
+ { route: '/health', handlers: { get: () => {} } }
310
+ ],
311
+ flattenRouters: () => [parentRouter, childRouter]
312
+ }
313
+
314
+ const routeMap = ServerUtils.getAllRoutes(parentRouter)
315
+
316
+ assert.equal(routeMap.size, 2)
317
+ assert.ok(routeMap.has('/api/health'))
318
+ assert.ok(routeMap.has('/api/v1/items'))
319
+ })
320
+
321
+ it('should handle root path "/" by omitting it from the prefix', () => {
322
+ const mockRouter = {
323
+ path: '/',
324
+ routes: [
325
+ { route: '/status', handlers: { get: () => {} } }
326
+ ],
327
+ flattenRouters: () => [mockRouter]
328
+ }
329
+
330
+ const routeMap = ServerUtils.getAllRoutes(mockRouter)
331
+
332
+ assert.ok(routeMap.has('/status'))
333
+ })
334
+
335
+ it('should return empty map for router with no routes', () => {
336
+ const mockRouter = {
337
+ path: '/api',
338
+ routes: [],
339
+ flattenRouters: () => [mockRouter]
340
+ }
341
+
342
+ const routeMap = ServerUtils.getAllRoutes(mockRouter)
343
+
344
+ assert.equal(routeMap.size, 0)
345
+ })
346
+ })
347
+
348
+ describe('#methodNotAllowedHandler()', () => {
349
+ it('should return a middleware function', () => {
350
+ const mockRouter = {
351
+ path: '/api',
352
+ routes: [],
353
+ flattenRouters: () => [mockRouter]
354
+ }
355
+ const handler = ServerUtils.methodNotAllowedHandler(mockRouter)
356
+
357
+ assert.equal(typeof handler, 'function')
358
+ })
359
+
360
+ it('should call next() when route is not found', () => {
361
+ const mockRouter = {
362
+ path: '/api',
363
+ routes: [
364
+ { route: '/users', handlers: { get: () => {} } }
365
+ ],
366
+ flattenRouters: () => [mockRouter]
367
+ }
368
+ const handler = ServerUtils.methodNotAllowedHandler(mockRouter)
369
+ let nextCalled = false
370
+
371
+ handler({ method: 'GET', path: '/unknown', originalUrl: '/api/unknown' }, {}, () => { nextCalled = true })
372
+
373
+ assert.equal(nextCalled, true)
374
+ })
375
+
376
+ it('should call next() when method matches', () => {
377
+ const mockRouter = {
378
+ path: '/api',
379
+ routes: [
380
+ { route: '/users', handlers: { get: () => {} } }
381
+ ],
382
+ flattenRouters: () => [mockRouter]
383
+ }
384
+ const handler = ServerUtils.methodNotAllowedHandler(mockRouter)
385
+ let nextCalled = false
386
+ let nextError = null
387
+
388
+ handler(
389
+ { method: 'GET', path: '/api/users', originalUrl: '/api/users' },
390
+ {},
391
+ (err) => { nextCalled = true; nextError = err }
392
+ )
393
+
394
+ assert.equal(nextCalled, true)
395
+ assert.equal(nextError, undefined)
396
+ })
397
+
398
+ it('should call next with METHOD_NOT_ALLOWED when path exists but method does not match', () => {
399
+ const mockError = { code: 'METHOD_NOT_ALLOWED' }
400
+ App.instance.errors = App.instance.errors || {}
401
+ App.instance.errors.METHOD_NOT_ALLOWED = { setData: () => mockError }
402
+
403
+ const mockRouter = {
404
+ path: '/api',
405
+ routes: [
406
+ { route: '/users', handlers: { get: () => {}, post: () => {} } }
407
+ ],
408
+ flattenRouters: () => [mockRouter]
409
+ }
410
+ const handler = ServerUtils.methodNotAllowedHandler(mockRouter)
411
+ let nextError = null
412
+
413
+ handler(
414
+ { method: 'DELETE', path: '/api/users', originalUrl: '/api/users' },
415
+ {},
416
+ (err) => { nextError = err }
417
+ )
418
+
419
+ assert.equal(nextError.code, 'METHOD_NOT_ALLOWED')
420
+ })
421
+ })
422
+
423
+ describe('#generateRouterMap()', () => {
424
+ it('should generate a router map', () => {
425
+ const mockRouter = {
426
+ root: 'api',
427
+ path: '/api',
428
+ url: 'http://localhost:5000/api',
429
+ routes: [
430
+ { route: '/test', handlers: { get: () => {} } }
431
+ ],
432
+ childRouters: [],
433
+ flattenRouters: function () {
434
+ return [this]
435
+ }
436
+ }
437
+
438
+ const map = ServerUtils.generateRouterMap(mockRouter)
439
+
440
+ assert.equal(typeof map, 'object')
441
+ })
442
+
443
+ it('should return empty object for router with no routes', () => {
444
+ const mockRouter = {
445
+ root: 'api',
446
+ path: '/api',
447
+ url: 'http://localhost:5000/api',
448
+ routes: [],
449
+ childRouters: [],
450
+ flattenRouters: function () {
451
+ return [this]
452
+ }
453
+ }
454
+
455
+ const map = ServerUtils.generateRouterMap(mockRouter)
456
+
457
+ assert.deepEqual(map, {})
458
+ })
459
+
460
+ it('should include endpoint URLs and accepted methods', () => {
461
+ const mockRouter = {
462
+ root: 'api',
463
+ route: '/api',
464
+ path: '/api',
465
+ url: 'http://localhost:5000/api',
466
+ routes: [
467
+ { route: '/users', handlers: { get: () => {}, post: () => {} }, meta: { get: { description: 'list users' } } }
468
+ ],
469
+ childRouters: [],
470
+ parentRouter: null,
471
+ flattenRouters: function () {
472
+ return [this]
473
+ }
474
+ }
475
+
476
+ const map = ServerUtils.generateRouterMap(mockRouter)
477
+ const keys = Object.keys(map)
478
+
479
+ assert.ok(keys.length > 0)
480
+ const endpoints = map[keys[0]]
481
+ assert.ok(Array.isArray(endpoints))
482
+ assert.equal(endpoints[0].url, 'http://localhost:5000/api/users')
483
+ assert.ok('get' in endpoints[0].accepted_methods)
484
+ assert.ok('post' in endpoints[0].accepted_methods)
485
+ assert.deepEqual(endpoints[0].accepted_methods.get, { description: 'list users' })
486
+ assert.deepEqual(endpoints[0].accepted_methods.post, {})
487
+ })
488
+
489
+ it('should use relative route keys for child routers', () => {
490
+ const childRouter = {
491
+ root: 'users',
492
+ path: '/api/users',
493
+ url: 'http://localhost:5000/api/users',
494
+ routes: [
495
+ { route: '/:id', handlers: { get: () => {} } }
496
+ ],
497
+ childRouters: [],
498
+ parentRouter: null
499
+ }
500
+ const mockRouter = {
501
+ root: 'api',
502
+ route: '/api',
503
+ path: '/api',
504
+ url: 'http://localhost:5000/api',
505
+ routes: [],
506
+ childRouters: [childRouter],
507
+ flattenRouters: function () {
508
+ return [this, childRouter]
509
+ }
510
+ }
511
+ childRouter.parentRouter = mockRouter
512
+
513
+ const map = ServerUtils.generateRouterMap(mockRouter)
514
+ const keys = Object.keys(map)
515
+
516
+ assert.ok(keys.some(k => k.includes('users')))
517
+ })
518
+ })
519
+
520
+ describe('#rootNotFoundHandler()', () => {
521
+ it('should respond with NOT_FOUND status code', () => {
522
+ App.instance.errors = App.instance.errors || {}
523
+ App.instance.errors.NOT_FOUND = { statusCode: 404 }
524
+
525
+ let statusCode
526
+ let endCalled = false
527
+ const res = {
528
+ status: (code) => { statusCode = code; return res },
529
+ end: () => { endCalled = true }
530
+ }
531
+
532
+ ServerUtils.rootNotFoundHandler({}, res)
533
+
534
+ assert.equal(statusCode, 404)
535
+ assert.equal(endCalled, true)
536
+ })
537
+ })
538
+
539
+ describe('#apiNotFoundHandler()', () => {
540
+ it('should call next with ENDPOINT_NOT_FOUND error', () => {
541
+ const mockError = { code: 'ENDPOINT_NOT_FOUND' }
542
+ App.instance.errors = App.instance.errors || {}
543
+ App.instance.errors.ENDPOINT_NOT_FOUND = { setData: () => mockError }
544
+
545
+ let nextArg
546
+ ServerUtils.apiNotFoundHandler(
547
+ { originalUrl: '/api/missing', method: 'GET' },
548
+ {},
549
+ (err) => { nextArg = err }
550
+ )
551
+
552
+ assert.equal(nextArg.code, 'ENDPOINT_NOT_FOUND')
553
+ })
554
+ })
555
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Mock AbstractModule for testing
3
+ */
4
+ class AbstractModuleMock {
5
+ constructor () {
6
+ this.app = {
7
+ onReady: () => new Promise(() => {}),
8
+ errors: {}
9
+ }
10
+ }
11
+
12
+ async init () {}
13
+
14
+ getConfig (key) {
15
+ const config = {
16
+ host: 'localhost',
17
+ port: 5000,
18
+ trustProxy: true,
19
+ url: null
20
+ }
21
+ return config[key]
22
+ }
23
+
24
+ log () {}
25
+ }
26
+
27
+ export default AbstractModuleMock
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Mock App for testing
3
+ */
4
+ export const App = {
5
+ instance: {
6
+ logger: {
7
+ log: () => {}
8
+ },
9
+ dependencyloader: {
10
+ instances: {
11
+ 'adapt-authoring-server': {
12
+ url: 'http://localhost:5000'
13
+ }
14
+ }
15
+ }
16
+ }
17
+ }