diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 00000000..dc511611 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,13 @@ +module.exports = { + "extends": "airbnb-base", + "env": { + "mocha": true, + "jasmine": true + }, + "rules": { + "comma-dangle": 0, + "arrow-body-style": 0, + "no-param-reassign": [ 2, { props: false } ], + "linebreak-style": [ "error", process.platform === 'win32' ? 'windows' : 'unix' ] + } +} diff --git a/.eslintrc.yml b/.eslintrc.yml deleted file mode 100644 index c0b019b5..00000000 --- a/.eslintrc.yml +++ /dev/null @@ -1,4 +0,0 @@ -extends: airbnb-base -env: - mocha: true - jasmine: true diff --git a/.yarnrc.yml b/.yarnrc.yml index 8d7a3278..5253927f 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,6 +2,8 @@ compressionLevel: mixed enableGlobalCache: false +enableStrictSsl: false + nodeLinker: node-modules yarnPath: .yarn/releases/yarn-4.9.4.cjs diff --git a/README.md b/README.md index 587b0189..66c92f87 100644 --- a/README.md +++ b/README.md @@ -94,3 +94,4 @@ $ yarn start ccd-case-activity-api:server Listening on port 3000 +19ms ccd-case-activity-api:redis-client connected to Redis +7ms ``` + diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md new file mode 100644 index 00000000..5b590aca --- /dev/null +++ b/RELEASE-NOTES.md @@ -0,0 +1,4 @@ +## RELEASE NOTES + +### Version 0.1.0-socket-alpha +**EUI-2976** Socket-based Activity Tracking. diff --git a/app.js b/app.js index 3d2182bf..3350ce4d 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,6 @@ const healthcheck = require('@hmcts/nodejs-healthcheck'); const express = require('express'); const logger = require('morgan'); -const bodyParser = require('body-parser'); const config = require('config'); const debug = require('debug')('ccd-case-activity-api:app'); const enableAppInsights = require('./app/app-insights/app-insights'); @@ -41,17 +40,22 @@ if (config.util.getEnv('NODE_ENV') === 'test') { } debug(`starting application with environment: ${config.util.getEnv('NODE_ENV')}`); +console.log(`starting application with environment: ${config.util.getEnv('NODE_ENV')}`); app.use(corsHandler); -app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ extended: false })); -app.use(bodyParser.text()); +app.use(express.json()); +app.use(express.urlencoded({ extended: false })); +app.use(express.text()); + +console.log('Applying auth checker user only filter'); app.use(authCheckerUserOnlyFilter); +console.log('Mounting activity route at /'); app.use('/', activity); // catch 404 and forward to error handler app.use((req, res, next) => { + console.log(`404 Not Found for request: ${req.method} ${req.originalUrl}`); const err = new Error('Not Found'); err.status = 404; next(err); @@ -62,15 +66,17 @@ app.use((req, res, next) => { /* eslint-disable no-unused-vars */ app.use((err, req, res, next) => { debug(`Error processing request: ${err}`); + console.log(`Error processing request: ${err}`); // set locals, only providing error in development res.locals.message = err.message; res.locals.error = req.app.get('env') === 'development' ? err : {}; + console.log(`Returning error response: ${err.status || 500} - ${err.message}`); + res.status(err.status || 500); res.json({ message: err.message, }); }); - module.exports = app; diff --git a/app/health.js b/app/health.js index 1eeb39d3..4bc2ede6 100644 --- a/app/health.js +++ b/app/health.js @@ -14,5 +14,4 @@ const activityHealth = healthcheck.configure({ .catch(() => healthcheck.down())), }, }); - module.exports = activityHealth; diff --git a/app/job/store-cleanup-job.js b/app/job/store-cleanup-job.js index fca51559..279a2bb8 100644 --- a/app/job/store-cleanup-job.js +++ b/app/job/store-cleanup-job.js @@ -1,17 +1,15 @@ const cron = require('node-cron'); const debug = require('debug')('ccd-case-activity-api:store-cleanup-job'); -const moment = require('moment'); const config = require('config'); const redis = require('../redis/redis-client'); const { logPipelineFailures } = redis; -const now = () => moment().valueOf(); const REDIS_ACTIVITY_KEY_PREFIX = config.get('redis.keyPrefix'); -const scanExistingCasesKeys = (f) => { +const scanExistingCasesKeys = (f, prefix) => { const stream = redis.scanStream({ // only returns keys following the pattern - match: `${REDIS_ACTIVITY_KEY_PREFIX}case:*`, + match: `${REDIS_ACTIVITY_KEY_PREFIX}${prefix}:*`, // returns approximately 100 elements per call count: 100, }); @@ -28,9 +26,9 @@ const scanExistingCasesKeys = (f) => { }); }; -const getCasesWithActivities = (f) => scanExistingCasesKeys(f); +const getCasesWithActivities = (f, prefix) => scanExistingCasesKeys(f, prefix); -const cleanupActivitiesCommand = (key) => ['zremrangebyscore', key, '-inf', now()]; +const cleanupActivitiesCommand = (key) => ['zremrangebyscore', key, '-inf', Date.now()]; const pipeline = (cases) => { const commands = cases.map((caseKey) => cleanupActivitiesCommand(caseKey)); @@ -38,8 +36,7 @@ const pipeline = (cases) => { return redis.pipeline(commands); }; -const storeCleanup = () => { - debug('store cleanup starting...'); +const cleanCasesWithPrefix = (prefix) => { getCasesWithActivities((cases) => { // scan returns the prefixed keys. Remove them since the redis client will add it back const casesWithoutPrefix = cases.map((k) => k.replace(REDIS_ACTIVITY_KEY_PREFIX, '')); @@ -50,7 +47,13 @@ const storeCleanup = () => { .catch((err) => { debug('Error in getCasesWithActivities', err.message); }); - }); + }, prefix); +}; + +const storeCleanup = () => { + debug('store cleanup starting...'); + cleanCasesWithPrefix('case'); // Cases via RESTful interface. + cleanCasesWithPrefix('c'); // Cases via socket interface. }; exports.start = (crontab) => { diff --git a/app/redis/instantiator.js b/app/redis/instantiator.js new file mode 100644 index 00000000..c8b45947 --- /dev/null +++ b/app/redis/instantiator.js @@ -0,0 +1,46 @@ +const config = require('config'); +const Redis = require('ioredis'); + +const ERROR = 0; +const RESULT = 1; +const ENV = config.util.getEnv('NODE_ENV'); + +module.exports = (debug) => { + const redis = new Redis({ + port: config.get('redis.port'), + host: config.get('redis.host'), + password: config.get('secrets.ccd.activity-redis-password'), + tls: config.get('redis.ssl'), + keyPrefix: config.get('redis.keyPrefix'), + // log unhandled redis errors + showFriendlyErrorStack: ENV === 'test' || ENV === 'dev', + }); + + /* redis pipeline returns a reply of the form [[op1error, op1result], [op2error, op2result], ...]. + error is null in case of success */ + redis.logPipelineFailures = (plOutcome, message) => { + if (Array.isArray(plOutcome)) { + const operationsFailureOutcome = plOutcome.map((operationOutcome) => operationOutcome[ERROR]); + const failures = operationsFailureOutcome.filter((element) => element !== null); + failures.forEach((f) => debug(`${message}: ${f}`)); + } + return plOutcome; + }; + + redis.extractPipelineResults = (pipelineOutcome) => { + const results = pipelineOutcome.map((operationOutcome) => operationOutcome[RESULT]); + debug(`pipeline results: ${results}`); + return results; + }; + + redis + .on('error', (err) => { + // eslint-disable-next-line no-console + debug(`Redis error: ${err.message}`); + }).on('connect', () => { + // eslint-disable-next-line no-console + debug('connected to Redis'); + }); + + return redis; +}; diff --git a/app/redis/redis-client.js b/app/redis/redis-client.js index d88faeeb..a14d64b0 100644 --- a/app/redis/redis-client.js +++ b/app/redis/redis-client.js @@ -1,47 +1,3 @@ -const config = require('config'); const debug = require('debug')('ccd-case-activity-api:redis-client'); -const Redis = require('ioredis'); -const ERROR = 0; -const RESULT = 1; -const ENV = config.util.getEnv('NODE_ENV'); - -const redis = new Redis({ - port: config.get('redis.port'), - host: config.get('redis.host'), - password: config.get('secrets.ccd.activity-redis-password'), - tls: config.get('redis.ssl'), - keyPrefix: config.get('redis.keyPrefix'), - // log unhandled redis errors - showFriendlyErrorStack: ENV === 'test' || ENV === 'dev', -}); - -/* redis pipeline returns a reply of the form [[op1error, op1result], [op2error, op2result], ...]. - error is null in case of success */ -redis.logPipelineFailures = (plOutcome, message) => { - if (Array.isArray(plOutcome)) { - const operationsFailureOutcome = plOutcome.map((operationOutcome) => operationOutcome[ERROR]); - const failures = operationsFailureOutcome.filter((element) => element !== null); - failures.forEach((f) => debug(`${message}: ${f}`)); - } else { - debug(`${plOutcome} is not an Array...`); - } - return plOutcome; -}; - -redis.extractPipelineResults = (pipelineOutcome) => { - const results = pipelineOutcome.map((operationOutcome) => operationOutcome[RESULT]); - debug(`pipeline results: ${results}`); - return results; -}; - -redis - .on('error', (err) => { - // eslint-disable-next-line no-console - console.log(`Redis error: ${err.message}`); - }).on('connect', () => { - // eslint-disable-next-line no-console - console.log('connected to Redis'); - }); - -module.exports = redis; +module.exports = require('./instantiator')(debug); diff --git a/app/routes/activity-route.js b/app/routes/activity-route.js index 07bf05f4..634f068e 100644 --- a/app/routes/activity-route.js +++ b/app/routes/activity-route.js @@ -6,6 +6,8 @@ const validateRequest = require('./validate-request'); const router = express.Router(); module.exports = (activityService, config) => { + console.log(`Initializing activity route at ${new Date().toISOString()}`); + const addActivity = require('./add-activity')(activityService); // eslint-disable-line global-require const getActivities = require('./get-activities')(activityService); // eslint-disable-line global-require @@ -15,6 +17,8 @@ module.exports = (activityService, config) => { router.use(timeout(toMillis(config.get('app.requestTimeoutSec')))); + console.log(`Setting request timeout to ${config.get('app.requestTimeoutSec')} seconds`); + router.post('/cases/:caseid/activity', (req, res, next) => { validateRequest(caseIdSchema, req.params.caseid)(req, res, next); }, @@ -25,5 +29,7 @@ module.exports = (activityService, config) => { }, getActivities); + console.log('Activity route initialized => ready to accept requests ', router); + return router; }; diff --git a/app/routes/get-activities.js b/app/routes/get-activities.js index c195c210..89a90fdf 100644 --- a/app/routes/get-activities.js +++ b/app/routes/get-activities.js @@ -4,9 +4,13 @@ const utils = require('../util/utils'); const { ifNotTimedOut } = utils; const getActivities = (activityService) => (req, res, next) => { + console.log(`GET_ACTIVITIES request received at ${new Date().toISOString()}`); + const caseIds = req.params.caseids.split(','); const { user } = req.authentication; + console.log(`GET_ACTIVITIES request for caseIds: ${caseIds}`); + debug(`GET_ACTIVITIES request for caseIds: ${caseIds}`); activityService.getActivities(caseIds, user) .then((result) => ifNotTimedOut(req, () => { diff --git a/app/routes/validate-request.js b/app/routes/validate-request.js index c8be4759..7f05758a 100644 --- a/app/routes/validate-request.js +++ b/app/routes/validate-request.js @@ -1,3 +1,5 @@ +const debug = require('debug')('ccd-case-activity-api:validate-request'); + const validateRequest = (schema, value) => (req, res, next) => { const { error } = schema.validate(value); const valid = error == null; @@ -6,7 +8,7 @@ const validateRequest = (schema, value) => (req, res, next) => { } else { const { details } = error; const message = details.map((i) => i.message).join(','); - console.log('error', message); + debug(`error ${message}`); res.status(400).json({ error: message }); } }; diff --git a/app/security/cors.js b/app/security/cors.js index e9d3c279..ebb8eb46 100644 --- a/app/security/cors.js +++ b/app/security/cors.js @@ -2,7 +2,8 @@ const config = require('config'); const sanitize = require('../util/sanitize'); const createWhitelistValidator = (val) => { - const whitelist = config.get('security.cors_origin_whitelist').split(','); + const configValue = config.get('security.cors_origin_whitelist') || ''; + const whitelist = configValue.split(','); for (let i = 0; i < whitelist.length; i += 1) { if (val === whitelist[i]) { return true; diff --git a/app/service/activity-service.js b/app/service/activity-service.js index baa40b6d..db191468 100644 --- a/app/service/activity-service.js +++ b/app/service/activity-service.js @@ -1,4 +1,3 @@ -const moment = require('moment'); const debug = require('debug')('ccd-case-activity-api:activity-service'); module.exports = (config, redis, ttlScoreGenerator) => { @@ -31,7 +30,7 @@ module.exports = (config, redis, ttlScoreGenerator) => { const uniqueUserIds = []; let caseViewers = []; let caseEditors = []; - const now = moment.now(); + const now = Date.now(); const getUserDetails = () => redis.pipeline(uniqueUserIds.map((userId) => ['get', `user:${userId}`])).exec(); const extractUniqueUserIds = (result) => { result.forEach((item) => { diff --git a/app/service/ttl-score-generator.js b/app/service/ttl-score-generator.js index 5cb4a4ad..1ddc45d5 100644 --- a/app/service/ttl-score-generator.js +++ b/app/service/ttl-score-generator.js @@ -1,10 +1,10 @@ const config = require('config'); -const moment = require('moment'); const debug = require('debug')('ccd-case-activity-api:score-generator'); exports.getScore = () => { - const now = moment(); - const score = now.add(config.get('redis.activityTtlSec'), 'seconds').valueOf(); - debug(`generated score out of current timestamp '${now.valueOf()}' plus ${config.get('redis.activityTtlSec')} sec`); + const now = Date.now(); + const ttl = parseInt(config.get('redis.activityTtlSec'), 10) || 0; + const score = now + (ttl * 1000); + debug(`generated score out of current timestamp '${now}' plus ${ttl} sec`); return score; }; diff --git a/app/socket/index.js b/app/socket/index.js new file mode 100644 index 00000000..f30fa79d --- /dev/null +++ b/app/socket/index.js @@ -0,0 +1,149 @@ +const config = require('config'); +const IORouter = require('socket.io-router-middleware'); +const SocketIO = require('socket.io'); +// Missing imports — REQUIRED for Redis Adapter +const { createClient } = require('redis'); +const { createAdapter } = require('@socket.io/redis-adapter'); + +const ActivityService = require('./service/activity-service'); +const Handlers = require('./service/handlers'); +const pubSub = require('./redis/pub-sub')(); +const router = require('./router'); + +/** + * Sets up a series of routes for a "socket" endpoint, that + * leverages socket.io and will more than likely use long polling + * instead of websockets as the latter isn't supported by Azure + * Front Door. + * + * The behaviour is the same, though. + * + * TODO: + * * Some sort of auth / get the credentials when the user connects. + */ +module.exports = (server, redis) => { + console.log('Setting up socket server'); + const activityService = ActivityService(config, redis); + + console.log('Creating socket server'); + const socketServer = SocketIO(server, { + allowEIO3: true, + cors: { + origin: '*', + methods: ['GET', 'POST'], + credentials: false + }, + }); + + // const socketServer = SocketIO(server, { + // allowEIO3: true, + // transports: ['websocket', 'polling'], + // cors: { + // origin: [ + // 'https://manage-case-int1.demo.platform.hmcts.net', + // 'http://localhost:3000' + // ], + // methods: ['GET', 'POST'], + // credentials: true + // }, + // }); + + // + // --------------------------------------------------------- + // ENABLE REDIS ADAPTER (Fixes “Session ID unknown”) + // --------------------------------------------------------- + // + async function enableRedisAdapter(io) { + try { + const redisPort = config.get('redis.port'); + const redisHost = config.get('redis.host'); + + // HMCTS secret pattern → password is inside .value + const redisPwdObj = config.get('secrets.ccd.activity-redis-password'); + // const redisPwd = redisPwdObj?.value ?? redisPwdObj; // supports both flat and nested + + const redisPwd = redisPwdObj && redisPwdObj.value + ? redisPwdObj.value + : redisPwdObj; + + if (!redisHost || !redisPort) { + console.warn('[SOCKET.IO] redis.host/redis.port missing — Redis adapter not enabled'); + return; + } + + const redisUrl = redisPwd + ? `redis://:${encodeURIComponent(redisPwd)}@${redisHost}:${redisPort}` + : `redis://${redisHost}:${redisPort}`; + + console.log('[SOCKET.IO] Connecting to Redis at', redisUrl); + const pubClient = createClient({ url: redisUrl }); + const subClient = pubClient.duplicate(); + + const attachErrorHandlers = (client, name) => { + client.on('error', (err) => { + console.log(`[SOCKET.IO][REDIS][${name}] redis client error:`, err && err.message ? err.message : err); + }); + client.on('connect', () => { + console.log(`[SOCKET.IO][REDIS][${name}] connected`); + }); + client.on('end', () => { + console.log(`[SOCKET.IO][REDIS][${name}] connection ended`); + }); + client.on('reconnecting', () => { + console.log(`[SOCKET.IO][REDIS][${name}] reconnecting`); + }); + }; + + attachErrorHandlers(pubClient, 'pub'); + attachErrorHandlers(subClient, 'sub'); + + await pubClient.connect(); + await subClient.connect(); + + io.adapter(createAdapter(pubClient, subClient)); + + console.log('[SOCKET.IO] Redis adapter enabled'); + } catch (err) { + console.log('[SOCKET.IO] Failed to enable Redis adapter:', err); + } + } + + // Call the adapter initialisation (non-blocking) + enableRedisAdapter(socketServer).catch((err) => { + console.log('[SOCKET.IO] Redis adapter init failed:', err); + }); + + // + // --------------------------------------------------------- + // SETUP ROUTER + HANDLERS + PUBSUB + // --------------------------------------------------------- + // + console.log('Setting up socket handlers and router'); + const handlers = Handlers(activityService, socketServer); + + console.log('Initializing router for socket server'); + router.init(socketServer, new IORouter(), handlers); + + console.log('Initializing pubsub for socket server'); + try { + const watcher = redis.duplicate(); + pubSub.init(watcher, handlers.notify); + console.log('PubSub initialized'); + } catch (e) { + console.error('PubSub init failed (sockets still running):', e); + } + + // + // --------------------------------------------------------- + // LOG CONNECTION EVENTS + // --------------------------------------------------------- + // + // log connections and errors + socketServer.on('connection', (s) => { + console.log('Socket connected:', s.id, 'transport:', s.conn.transport.name); + }); + socketServer.on('error', (err) => { + console.log('[SOCKET.IO] server error:', err && err.message ? err.message : err); + }); + return { socketServer, activityService, handlers }; +}; diff --git a/app/socket/redis/keys.js b/app/socket/redis/keys.js new file mode 100644 index 00000000..a73fb18e --- /dev/null +++ b/app/socket/redis/keys.js @@ -0,0 +1,23 @@ +const keys = { + prefixes: { + case: 'c', + socket: 's', + user: 'u' + }, + case: { + view: (caseId) => keys.compile('case', caseId, 'viewers'), + edit: (caseId) => keys.compile('case', caseId, 'editors'), + base: (caseId) => keys.compile('case', caseId), + }, + user: (userId) => keys.compile('user', userId), + socket: (socketId) => keys.compile('socket', socketId), + compile: (prefix, value, suffix) => { + const key = `${keys.prefixes[prefix]}:${value}`; + if (suffix) { + return `${key}:${suffix}`; + } + return key; + } +}; + +module.exports = keys; diff --git a/app/socket/redis/pub-sub.js b/app/socket/redis/pub-sub.js new file mode 100644 index 00000000..271e0e2b --- /dev/null +++ b/app/socket/redis/pub-sub.js @@ -0,0 +1,15 @@ +const keys = require('./keys'); + +module.exports = () => { + return { + init: (watcher, caseNotifier) => { + if (watcher && typeof caseNotifier === 'function') { + watcher.psubscribe(`${keys.prefixes.case}:*`); + watcher.on('pmessage', (_, room) => { + const caseId = room.replace(`${keys.prefixes.case}:`, ''); + caseNotifier(caseId); + }); + } + } + }; +}; diff --git a/app/socket/router/index.js b/app/socket/router/index.js new file mode 100644 index 00000000..0391e955 --- /dev/null +++ b/app/socket/router/index.js @@ -0,0 +1,106 @@ +const { Logger } = require('@hmcts/nodejs-logging'); +const utils = require('../utils'); + +const logger = Logger.getLogger('index-socket-router'); +const users = {}; +const connections = []; +const router = { + addUser: (socketId, user) => { + if (user && !user.name) { + user.name = `${user.forename} ${user.surname}`; + } + users[socketId] = user; + }, + removeUser: (socketId) => { + delete users[socketId]; + }, + getUser: (socketId) => { + return users[socketId]; + }, + addConnection: (socket) => { + connections.push(socket); + }, + removeConnection: (socket) => { + const socketIndex = connections.indexOf(socket); + if (socketIndex > -1) { + connections.splice(socketIndex, 1); + } + }, + getConnections: () => { + return [...connections]; + }, + init: (io, iorouter, handlers) => { + console.log('Initializing socket router'); + // Set up routes for each type of message. + iorouter.on('view', (socket, ctx, next) => { + const user = router.getUser(socket.id); + utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'view'); + handlers.addActivity(socket, ctx.request.caseId, user, 'view'); + next(); + }); + iorouter.on('edit', (socket, ctx, next) => { + const user = router.getUser(socket.id); + utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'edit'); + handlers.addActivity(socket, ctx.request.caseId, user, 'edit'); + next(); + }); + iorouter.on('watch', (socket, ctx, next) => { + const user = router.getUser(socket.id); + utils.log(socket, `${ctx.request.caseIds} (${user.name})`, 'watch'); + handlers.watch(socket, ctx.request.caseIds); + next(); + }); + iorouter.on('stop', (socket, ctx, next) => { + const user = router.getUser(socket.id); + utils.log(socket, `${ctx.request.caseId} (${user.name})`, 'stop'); + handlers.stop(socket, ctx.request.caseId, user, 'stop'); + next(); + }); + + // On client connection, attach the router and track the socket. + io.on('connection', (socket) => { + console.log(`Socket connected: ${socket.id}`); + logger.warn(`Socket connected: ${socket.id}`); + + router.addConnection(socket); + let userObj = null; + if ( + socket + && socket.handshake + && socket.handshake.query + && socket.handshake.query.user + ) { + try { + userObj = JSON.parse(socket.handshake.query.user); + } catch (e) { + utils.log(socket, '', 'Failed to parse user from handshake query', console.error, e); + logger.warn(`Failed to parse user from handshake query: ${e.message}`); + console.log(`Failed to parse user from handshake query: ${e.message}`); + } + } + router.addUser(socket.id, userObj); + utils.log(socket, '', `connected (${router.getConnections().length} total)`); + logger.warn(`Socket connected: ${socket.id} for user ${userObj ? userObj.name : 'unknown'}`); + + // eslint-disable-next-line no-console + utils.log(socket, '', `connected (${router.getConnections().length} total)`, console.log, Date.now()); + socket.use((packet, next) => { + iorouter.attach(socket, packet, next); + }); + // When the socket disconnects, do an appropriate teardown. + socket.on('disconnect', () => { + console.log(`Socket disconnected: ${socket.id}`); + logger.warn(`Socket disconnected: ${socket.id}`); + + utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`); + // eslint-disable-next-line no-console + utils.log(socket, '', `disconnected (${router.getConnections().length - 1} total)`, console.log, Date.now()); + handlers.removeSocketActivity(socket.id); + router.removeUser(socket.id); + router.removeConnection(socket); + }); + }); + } +}; + +module.exports = router; diff --git a/app/socket/service/activity-service.js b/app/socket/service/activity-service.js new file mode 100644 index 00000000..ca032421 --- /dev/null +++ b/app/socket/service/activity-service.js @@ -0,0 +1,189 @@ +const keys = require('../redis/keys'); +const utils = require('../utils'); + +module.exports = (config, redis) => { + const ttl = { + user: config.get('redis.socket.userDetailsTtlSec'), + activity: config.get('redis.socket.activityTtlSec') + }; + + const notifyChange = (caseId) => { + console.log('Notifying change for caseId ', caseId); + if (caseId) { + redis.publish(keys.case.base(caseId), Date.now().toString()); + } + }; + + const getSocketActivity = async (socketId) => { + console.log('Getting socket activity for socketId ', socketId); + if (socketId) { + const key = keys.socket(socketId); + console.log('Socket activity key: ', key); + return JSON.parse(await redis.get(key)); + } + return null; + }; + + const getUserDetails = async (userIds) => { + console.log('Getting user details for userIds ', userIds); + if (Array.isArray(userIds) && userIds.length > 0) { + console.log('Fetching user details from redis'); + // Get hold of the details. + const details = await redis.pipeline(utils.get.users(userIds)).exec(); + // Now turn them into a map. + return details.reduce((obj, item) => { + if (item[1]) { + const user = JSON.parse(item[1]); + obj[user.id] = { id: user.id, forename: user.forename, surname: user.surname }; + } + return obj; + }, {}); + } + return {}; + }; + + // const doRemoveSocketActivity = async (socketId) => { + // // First make sure we actually have some activity to remove. + // const activity = await getSocketActivity(socketId); + // if (activity) { + // await redis.pipeline([ + // utils.remove.userActivity(activity), + // utils.remove.socketEntry(socketId) + // ]).exec(); + // return activity.caseId; + // } + // return null; + // }; + + // const doRemoveUserActivity = async (socketId) => { + // // First make sure we actually have some activity to remove. + // const activity = await getSocketActivity(socketId); + + // if (activity) { + // await redis.pipeline([ + // utils.remove.userActivity(activity), + // ]).exec(); + // return activity.caseId; + // } + // return null; + // }; + + const doRemoveActivity = async (socketId, removeSocketEntry = false) => { + console.log('Removing activity for socketId ', socketId, ' removeSocketEntry=', removeSocketEntry); + // First make sure we actually have some activity to remove. + const activity = await getSocketActivity(socketId); + if (activity) { + const pipeline = [utils.remove.userActivity(activity)]; + if (removeSocketEntry) { + pipeline.push(utils.remove.socketEntry(socketId)); + } + await redis.pipeline(pipeline).exec(); + return activity.caseId; + } + return null; + }; + + // Backwards-compatible wrappers + const doRemoveSocketActivity = async (socketId) => doRemoveActivity(socketId, true); + const doRemoveUserActivity = async (socketId) => doRemoveActivity(socketId, false); + + const removeSocketActivity = async (socketId) => { + const removedCaseId = await doRemoveSocketActivity(socketId); + if (removedCaseId) { + notifyChange(removedCaseId); + } + }; + + const removeUserActivity = async (socketId) => { + const removedCaseId = await doRemoveUserActivity(socketId); + if (removedCaseId) { + notifyChange(removedCaseId); + } + }; + + const doAddActivity = async (caseId, user, socketId, activity) => { + // Now store this activity. + const activityKey = keys.case[activity](caseId); + return redis.pipeline([ + utils.store.userActivity(activityKey, user.uid, utils.score(ttl.activity)), + utils.store.socketActivity(socketId, activityKey, caseId, user.uid, ttl.user), + utils.store.userDetails(user, ttl.user) + ]).exec(); + }; + + const addActivity = async (caseId, user, socketId, activity) => { + console.log(`adding activity for caseId '${caseId}', user`, user, `on socket '${socketId}' with activity '${activity}'`); + if (caseId && user && socketId && activity) { + // First, clear out any existing activity on this socket. + const removedCaseId = await doRemoveSocketActivity(socketId); + + // Now store this activity. + await doAddActivity(caseId, user, socketId, activity); + if (removedCaseId !== caseId) { + notifyChange(removedCaseId); + } + notifyChange(caseId); + } + return null; + }; + + const getActivityForCases = async (caseIds) => { + if (!Array.isArray(caseIds) || caseIds.length === 0) { + return []; + } + let uniqueUserIds = []; + let caseViewers = []; + let caseEditors = []; + const now = Date.now(); + const getPromise = async (activity, failureMessage, cb) => { + const result = await redis.pipeline( + utils.get.caseActivities(caseIds, activity, now) + ).exec(); + + redis.logPipelineFailures(result, failureMessage); + cb(result); + uniqueUserIds = utils.extractUniqueUserIds(result, uniqueUserIds); + }; + + // Set up the promises fore view and edit. + const caseViewersPromise = getPromise('view', 'caseViewersPromise', (result) => { + caseViewers = result; + }); + const caseEditorsPromise = getPromise('edit', 'caseEditorsPromise', (result) => { + caseEditors = result; + }); + + // Now wait until both promises have been completed. + await Promise.all([caseViewersPromise, caseEditorsPromise]); + + // Get all the user details for both viewers and editors. + const userDetails = await getUserDetails(uniqueUserIds); + + // Now produce a response for every case requested. + return caseIds.map((caseId, index) => { + const cv = caseViewers[index][1]; + const ce = caseEditors[index][1]; + const viewers = cv ? cv.map((v) => userDetails[v]) : []; + const editors = ce ? ce.map((e) => userDetails[e]) : []; + return { + caseId, + viewers: viewers.filter((v) => !!v), + unknownViewers: viewers.filter((v) => !v).length, + editors: editors.filter((e) => !!e), + unknownEditors: editors.filter((e) => !e).length + }; + }); + }; + + return { + addActivity, + getActivityForCases, + getSocketActivity, + getUserDetails, + notifyChange, + redis, + removeSocketActivity, + ttl, + removeUserActivity + }; +}; diff --git a/app/socket/service/handlers.js b/app/socket/service/handlers.js new file mode 100644 index 00000000..bcbed8a7 --- /dev/null +++ b/app/socket/service/handlers.js @@ -0,0 +1,82 @@ +const keys = require('../redis/keys'); +const utils = require('../utils'); + +module.exports = (activityService, socketServer) => { + /** + * Handle a user viewing or editing a case on a specific socket. + * @param {*} socket The socket they're connected on. + * @param {*} caseId The id of the case they're viewing or editing. + * @param {*} user The user object. + * @param {*} activity Whether they're viewing or editing. + */ + async function addActivity(socket, caseId, user, activity) { + // Update what's being watched. + utils.watch.update(socket, [caseId]); + + console.log('Adding activity for caseId ', caseId, ' user ', user, ' activity ', activity); + + // Then add this new activity to redis, which will also clear out the old activity. + await activityService.addActivity(caseId, user, socket.id, activity); + } + + /** + * Notify all users in a case room about any change to activity on a case. + * @param {*} caseId The id of the case that has activity and that people should be + * notified about. + */ + async function notify(caseId) { + const cs = await activityService.getActivityForCases([caseId]); + console.log('notifying case activity: ', JSON.stringify(cs, null, 2)); + socketServer.to(keys.case.base(caseId)).emit('activity', cs); + } + + /** + * Remove any activity associated with a socket. This can be called when the + * socket disconnects. + * @param {*} socketId The id of the socket to remove activity for. + */ + async function removeSocketActivity(socketId) { + console.log('Removing socket activity for socketId ', socketId); + await activityService.removeSocketActivity(socketId); + } + + /** + * Handle a user watching a bunch of cases on a specific socket. + * @param {*} socket The socket they're connected on. + * @param {*} caseIds The ids of the cases they're interested in. + */ + async function watch(socket, caseIds) { + // Stop watching the current cases. + utils.watch.stop(socket); + + // Remove the activity for this socket. + await activityService.removeSocketActivity(socket.id); + + // Now watch the specified cases. + utils.watch.cases(socket, caseIds); + + // And immediately dispatch a message about the activity on those cases. + const cs = await activityService.getActivityForCases(caseIds); + socket.emit('activity', cs); + } + + async function stop(socket, caseId) { + // Stop watching the current cases. + console.log('Stop watching cases to ', caseId, ' for socket ', socket.id); + + // Remove the activity for this socket. + await activityService.removeUserActivity(socket.id); + } + + return { + activityService, + addActivity, + notify, + removeSocketActivity, + socketServer, + watch, + stop + }; +}; + +// ws://localhost:3000/socket.io/?user=%7B%22given_name%22%3A%22SSCS%22%2C%22email%22%3A%22sscs.superuserhmc%40justice.gov.uk%22%2C%22family_name%22%3A%22superuser%22%2C%22name%22%3A%22SSCS%20superuser%22%2C%22ssoProvider%22%3A%22testing-support%22%2C%22uid%22%3A%2241033a79-b9c1-4a36-b0ff-113451f736ba%22%2C%22identity%22%3A%22id%3D41033a79-b9c1-4a36-b0ff-113451f736ba%2Cou%3Duser%2Co%3Dhmcts%2Cou%3Dservices%2Cou%3Dam-config%22%2C%22roles%22%3A%5B%22caseworker%22%2C%22caseworker-sscs%22%2C%22caseworker-sscs-superuser%22%2C%22caseworker-sscs-systemupdate%22%2C%22cwd-user%22%2C%22staff-admin%22%2C%22hmcts-legal-operations%22%2C%22hearing-viewer%22%2C%22hearing-manager%22%2C%22tribunal-caseworker%22%2C%22sscs-tribunal-caseworker%22%5D%2C%22sub%22%3A%22sscs.superuserhmc%40justice.gov.uk%22%2C%22subname%22%3A%22sscs.superuserhmc%40justice.gov.uk%22%2C%22iss%22%3A%22https%3A%2F%2Fforgerock-am.service.core-compute-idam-aat2.internal%3A8443%2Fopenam%2Foauth2%2Frealms%2Froot%2Frealms%2Fhmcts%22%2C%22roleCategory%22%3A%22LEGAL_OPERATIONS%22%7D&EIO=3&transport=websocket diff --git a/app/socket/utils/get.js b/app/socket/utils/get.js new file mode 100644 index 00000000..6c52f817 --- /dev/null +++ b/app/socket/utils/get.js @@ -0,0 +1,22 @@ +const keys = require('../redis/keys'); + +const get = { + caseActivities: (caseIds, activity, now) => { + console.log(`getting case activities for activity '${activity}' and caseIds: `, caseIds); + if (Array.isArray(caseIds) && ['view', 'edit'].indexOf(activity) > -1) { + return caseIds.filter((id) => !!id).map((id) => { + return ['zrangebyscore', keys.case[activity](id), now, '+inf']; + }); + } + return []; + }, + users: (userIds) => { + console.log('getting user details for userIds: ', userIds); + if (Array.isArray(userIds)) { + return userIds.filter((id) => !!id).map((id) => ['get', keys.user(id)]); + } + return []; + } +}; + +module.exports = get; diff --git a/app/socket/utils/index.js b/app/socket/utils/index.js new file mode 100644 index 00000000..5f906ecb --- /dev/null +++ b/app/socket/utils/index.js @@ -0,0 +1,13 @@ +const other = require('./other'); +const get = require('./get'); +const remove = require('./remove'); +const store = require('./store'); +const watch = require('./watch'); + +module.exports = { + ...other, + get, + remove, + store, + watch +}; diff --git a/app/socket/utils/other.js b/app/socket/utils/other.js new file mode 100644 index 00000000..8a9bcaa2 --- /dev/null +++ b/app/socket/utils/other.js @@ -0,0 +1,73 @@ +const debug = require('debug')('ccd-case-activity-api:socket-utils'); + +const other = { + extractUniqueUserIds: (result, uniqueUserIds) => { + const userIds = Array.isArray(uniqueUserIds) ? [...uniqueUserIds] : []; + if (Array.isArray(result)) { + result.forEach((item) => { + if (item && item[1]) { + const users = item[1]; + users.forEach((userId) => { + if (!userIds.includes(userId)) { + userIds.push(userId); + } + }); + } + }); + } + return userIds; + }, + log: (socket, payload, group, logTo, ts) => { + const outputTo = logTo || debug; + const now = ts || new Date().toISOString(); + let text = `${now} | ${socket.id} | ${group}`; + if (typeof payload === 'string') { + if (payload) { + text = `${text} => ${payload}`; + } + outputTo(text); + } else { + outputTo(text); + outputTo(payload); + } + }, + score: (ttlStr) => { + const now = Date.now(); + const ttl = parseInt(ttlStr, 10) || 0; + const score = now + (ttl * 1000); + debug(`generated score out of current timestamp '${now}' plus ${ttl} sec`); + return score; + }, + toUser: (obj) => { + // TODO: REMOVE THIS + // This is here purely until we have proper auth coming from a client. + if (!obj) { + return {}; + } + const name = obj.name || `${obj.forename} ${obj.surname}`; + const nameParts = name.split(' '); + const givenName = obj.forename || nameParts.shift(); + const familyName = obj.surname || nameParts.join(' '); + return { + sub: `${givenName}.${nameParts.join('-')}@mailinator.com`, + uid: obj.id, + roles: [ + 'caseworker-employment', + 'caseworker-employment-leeds', + 'caseworker' + ], + name, + given_name: givenName, + family_name: familyName + }; + }, + toUserString: (user) => { + return user ? JSON.stringify({ + id: user.uid, + forename: user.given_name, + surname: user.family_name + }) : '{}'; + } +}; + +module.exports = other; diff --git a/app/socket/utils/remove.js b/app/socket/utils/remove.js new file mode 100644 index 00000000..dad26c6f --- /dev/null +++ b/app/socket/utils/remove.js @@ -0,0 +1,15 @@ +const debug = require('debug')('ccd-case-activity-api:socket-utils-remove'); +const redisActivityKeys = require('../redis/keys'); + +const remove = { + userActivity: (activity) => { + debug(`about to remove activity "${activity.activityKey}" for user "${activity.userId}"`); + return ['zrem', activity.activityKey, activity.userId]; + }, + socketEntry: (socketId) => { + debug(`about to remove activity for socket "${socketId}"`); + return ['del', redisActivityKeys.socket(socketId)]; + } +}; + +module.exports = remove; diff --git a/app/socket/utils/store.js b/app/socket/utils/store.js new file mode 100644 index 00000000..c7300894 --- /dev/null +++ b/app/socket/utils/store.js @@ -0,0 +1,24 @@ +const debug = require('debug')('ccd-case-activity-api:socket-utils-store'); +const redisActivityKeys = require('../redis/keys'); +const { toUserString } = require('./other'); + +const store = { + userActivity: (activityKey, userId, score) => { + debug(`about to store activity "${activityKey}" for user "${userId}"`); + return ['zadd', activityKey, score, userId]; + }, + userDetails: (user, ttl) => { + const key = redisActivityKeys.user(user.uid); + const userString = toUserString(user); + debug(`about to store details "${key}" for user "${user.uid}": ${userString}`); + return ['set', key, userString, 'EX', ttl]; + }, + socketActivity: (socketId, activityKey, caseId, userId, ttl) => { + const key = redisActivityKeys.socket(socketId); + const userString = JSON.stringify({ activityKey, caseId, userId }); + debug(`about to store activity "${key}" for socket "${socketId}": ${userString}`); + return ['set', key, userString, 'EX', ttl]; + } +}; + +module.exports = store; diff --git a/app/socket/utils/watch.js b/app/socket/utils/watch.js new file mode 100644 index 00000000..8820298d --- /dev/null +++ b/app/socket/utils/watch.js @@ -0,0 +1,29 @@ +const keys = require('../redis/keys'); + +const watch = { + case: (socket, caseId) => { + if (socket && caseId) { + socket.join(keys.case.base(caseId)); + } + }, + cases: (socket, caseIds) => { + if (socket && Array.isArray(caseIds)) { + caseIds.forEach((caseId) => { + watch.case(socket, caseId); + }); + } + }, + stop: (socket) => { + if (socket) { + [...socket.rooms] + .filter((r) => r.indexOf(`${keys.prefixes.case}:`) === 0) // Only case rooms. + .forEach((r) => socket.leave(r)); + } + }, + update: (socket, caseIds) => { + watch.stop(socket); + watch.cases(socket, caseIds); + } +}; + +module.exports = watch; diff --git a/app/user/auth-checker-user-only-filter.js b/app/user/auth-checker-user-only-filter.js index 14368c7e..72d7176b 100644 --- a/app/user/auth-checker-user-only-filter.js +++ b/app/user/auth-checker-user-only-filter.js @@ -29,9 +29,14 @@ const mapFetchErrors = (error, next) => { const authCheckerUserOnlyFilter = (req, res, next) => { req.authentication = {}; + console.log('Authenticating user'); + logger.warn('Authenticating user'); + userRequestAuthorizer .authorise(req) .then((user) => { + console.log(`User authenticated: ${JSON.stringify(user)}`); + console.log(`User authenticated uid: ${user.uid}`); req.authentication.user = user; }) .then(() => next()) diff --git a/app/user/roles-based-authorizer.js b/app/user/roles-based-authorizer.js index 9bff1832..311c851d 100644 --- a/app/user/roles-based-authorizer.js +++ b/app/user/roles-based-authorizer.js @@ -10,6 +10,7 @@ const blacklist = config.get('security.auth_blacklist') const isUserAuthorized = (request, user) => { const authorized = authorizer.isUserAuthorized(user.roles, whitelist, blacklist); debug(`user roles authorized: ${authorized}`); + console.log(`user roles authorized: ${authorized}`); return authorized; }; diff --git a/app/util/utils.js b/app/util/utils.js index 28ba42fe..20b3fcc6 100644 --- a/app/util/utils.js +++ b/app/util/utils.js @@ -7,3 +7,61 @@ exports.ifNotTimedOut = (request, f) => { debug('request timed out'); } }; + +exports.normalizePort = (val) => { + const port = parseInt(val, 10); + if (Number.isNaN(port)) { + // named pipe + return val; + } + if (port >= 0) { + // port number + return port; + } + return false; +}; + +/** + * Event listener for HTTP server "error" event. + */ +exports.onServerError = (port, logTo, exitRoute) => { + return (error) => { + if (error.syscall !== 'listen') { + throw error; + } + + console.log(`Server error on port ${port}: ${error.message}`); + + const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`; + + console.log(`Handling server error for ${bind}`); + console.log(`Error code: ${error.code}`); + + // Handle specific listen errors with friendly messages. + switch (error.code) { + case 'EACCES': + logTo(`${bind} requires elevated privileges`); + exitRoute(1); + break; + case 'EADDRINUSE': + logTo(`${bind} is already in use`); + exitRoute(1); + break; + default: + throw error; + } + }; +}; + +/** + * Event listener for HTTP server "listening" event. + */ +exports.onListening = (server, logTo) => { + return () => { + console.log('Server listening event triggered'); + const addr = server.address(); + const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`; + logTo(`Listening on ${bind}`); + console.log(`Listening on ${bind}`); + }; +}; diff --git a/charts/ccd-case-activity-api/Chart.yaml b/charts/ccd-case-activity-api/Chart.yaml index 1a9102da..22d3bd49 100644 --- a/charts/ccd-case-activity-api/Chart.yaml +++ b/charts/ccd-case-activity-api/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 description: Helm chart for the HMCTS CCD Case Activity name: ccd-case-activity-api home: https://github.com/hmcts/ccd-case-activity-api -version: 1.3.14 +version: 1.3.15 maintainers: - name: HMCTS CCD Dev Team email: ccd-devops@HMCTS.NET @@ -11,6 +11,6 @@ dependencies: version: 3.2.0 repository: 'oci://hmctspublic.azurecr.io/helm' - name: redis - version: 20.13.4 + version: 20.11.3 repository: "oci://registry-1.docker.io/bitnamicharts" condition: redis.enabled diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index 487af142..3e2c0def 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -8,6 +8,7 @@ nodejs: REDIS_SSL_ENABLED: "" CORS_ORIGIN_WHITELIST: "*" keyVaults: + replicas: 2 redis: enabled: true @@ -15,3 +16,10 @@ redis: auth: enabled: true password: "fake-password" + image: + registry: hmctspublic.azurecr.io + repository: imported/bitnami/redis + + global: + security: + allowInsecureImages: true diff --git a/charts/ccd-case-activity-api/values.yaml b/charts/ccd-case-activity-api/values.yaml index c3cd72c1..cdea8d71 100644 --- a/charts/ccd-case-activity-api/values.yaml +++ b/charts/ccd-case-activity-api/values.yaml @@ -6,6 +6,13 @@ redis: enabled: false auth: enabled: false + image: + registry: hmctspublic.azurecr.io + repository: imported/bitnami/redis + +global: + security: + allowInsecureImages: true nodejs: image: 'hmctspublic.azurecr.io/ccd/case-activity-api:latest' @@ -30,7 +37,7 @@ nodejs: REDIS_PORT: 6380 REDIS_SSL_ENABLED: true REDIS_KEY_PREFIX: 'activity:' - REDIS_ACTIVITY_TTL: 5 + REDIS_ACTIVITY_TTL: 30 REDIS_USER_DETAILS_TTL: 6000 APP_REQUEST_TIMEOUT: 5 APP_STORE_CLEANUP_CRONTAB: '* * * * *' diff --git a/config/custom-environment-variables.yaml b/config/custom-environment-variables.yaml index cb5c8a00..85f12b83 100644 --- a/config/custom-environment-variables.yaml +++ b/config/custom-environment-variables.yaml @@ -13,6 +13,9 @@ redis: keyPrefix: REDIS_KEY_PREFIX activityTtlSec: REDIS_ACTIVITY_TTL userDetailsTtlSec: REDIS_USER_DETAILS_TTL + socket: + activityTtlSec: REDIS_SOCKET_ACTIVITY_TTL + userDetailsTtlSec: REDIS_SOCKET_USER_DETAILS_TTL cache: user_info_enabled: CACHE_USER_INFO_ENABLED user_info_ttl: CACHE_USER_INFO_TTL diff --git a/config/default.yaml b/config/default.yaml index ae14f459..0f627948 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -12,6 +12,9 @@ redis: keyPrefix: "activity:" activityTtlSec: 5 userDetailsTtlSec: 2 + socket: + activityTtlSec: 3000 + userDetailsTtlSec: 3600 cache: user_info_enabled: true user_info_ttl: 600 diff --git a/package.json b/package.json index 74afcfdd..4aacf2bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ccd-case-activity-api", - "version": "0.0.2", + "version": "0.1.0-socket-alpha", "private": true, "engines": { "node": "^20.11.0" @@ -8,6 +8,7 @@ "scripts": { "setup": "cross-env NODE_PATH=. yarn node --version", "start": "cross-env NODE_PATH=. yarn node server.js", + "start:debug": "DEBUG=ccd-case-activity-api:* yarn start", "test": "NODE_ENV=test mocha --exit --recursive test/spec app/user", "test:end2end": "NODE_ENV=test mocha --exit test/e2e --timeout 15000", "test:smoke": "echo 'TODO - Ignore SMOKE tests'", @@ -38,6 +39,7 @@ "@hmcts/nodejs-healthcheck": "^1.8.0", "@hmcts/nodejs-logging": "^4.0.4", "@hmcts/properties-volume": "^0.0.14", + "@socket.io/redis-adapter": "^8.3.0", "applicationinsights": "2.9.8", "body-parser": "^1.20.1", "config": "^1.26.1", @@ -57,6 +59,9 @@ "node-cron": "^1.2.1", "node-fetch": "^2.6.7", "or": "^0.2.0", + "redis": "^5.10.0", + "socket.io": "^4.8.1", + "socket.io-router-middleware": "^1.1.2", "yarn": "^1.22.22" }, "devDependencies": { @@ -77,7 +82,7 @@ "sinon-chai": "^3.5.0", "sinon-express-mock": "^2.2.1", "sonar-scanner": "^3.1.0", - "supertest": "^3.0.0" + "supertest": "^7.1.4" }, "resolutions": { "async": "^2.6.4", @@ -91,6 +96,7 @@ "ansi-regex": "^5.0.1", "path-parse": "^1.0.7", "glob-parent": "^5.1.2", + "ws": "^7.4.6", "moment": "^2.29.4", "ajv": "6.12.6", "json5": "^2.2.2", diff --git a/server.js b/server.js index 22c822ff..49a35737 100755 --- a/server.js +++ b/server.js @@ -3,94 +3,53 @@ /** * Module dependencies. */ - require('@hmcts/properties-volume').addTo(require('config')); -var app = require('./app'); - -var debug = require('debug')('ccd-case-activity-api:server'); -var http = require('http'); +const { normalizePort, onListening, onServerError } = require('./app/util/utils'); +const debug = require('debug')('ccd-case-activity-api:server'); +const http = require('http'); +const app = require('./app'); /** * Get port from environment and store in Express. */ - -var port = normalizePort(process.env.PORT || '3460'); -console.log('Starting on port ' + port); +const port = normalizePort(process.env.PORT || '3460'); +console.log(`Starting on port ${port}`); app.set('port', port); /** * Create HTTP server. */ - -var server = http.createServer(app); - -/** - * Listen on provided port, on all network interfaces. - */ - -server.listen(port); -server.on('error', onError); -server.on('listening', onListening); +const server = http.createServer(app); /** - * Normalize a port into a number, string, or false. + * Create the socket server. + * + * This runs on the same server, in parallel to the RESTful interface. At the present + * time, interoperability is turned off to keep them isolated but, with a couple of + * tweaks, it can easily be enabled: + * + * * Adjust the prefixes in socket/redis/keys.js to be the same as the RESTful ones. + * * This will immediately allow the RESTful interface to see what people on sockets + * are viewing/editing. + * * Add redis.publish(...) calls in service/activity-service.js. + * * To notify those on sockets when someone is viewing or editing a case. */ - -function normalizePort(val) { - var port = parseInt(val, 10); - - if (isNaN(port)) { - // named pipe - return val; - } - - if (port >= 0) { - // port number - return port; - } - - return false; -} +const redis = require('./app/redis/redis-client'); +require('./app/socket')(server, redis); /** - * Event listener for HTTP server "error" event. + * Listen on provided port, on all network interfaces. */ -function onError(error) { - if (error.syscall !== 'listen') { - throw error; - } - - var bind = typeof port === 'string' - ? 'Pipe ' + port - : 'Port ' + port; - - // handle specific listen errors with friendly messages - switch (error.code) { - case 'EACCES': - console.error(bind + ' requires elevated privileges'); - process.exit(1); - break; - case 'EADDRINUSE': - console.error(bind + ' is already in use'); - process.exit(1); - break; - default: - throw error; - } -} +console.log(`Listening on port ${port}`); +server.listen(port); -/** - * Event listener for HTTP server "listening" event. - */ +console.log(`Server started on port ${port}`); -function onListening() { +console.log('Registering onServerError handler'); - var addr = server.address(); +server.on('error', onServerError(port, console.error, process.exit)); - var bind = typeof addr === 'string' - ? 'pipe ' + addr - : 'port ' + addr.port; +console.log('Registering onListening handler'); - debug('Listening on ' + bind); -} +server.on('listening', onListening(server, debug)); diff --git a/test/e2e/utils/activity-store-commands.js b/test/e2e/utils/activity-store-commands.js index dd14149e..a0e89a44 100644 --- a/test/e2e/utils/activity-store-commands.js +++ b/test/e2e/utils/activity-store-commands.js @@ -1,12 +1,11 @@ var redis = require('../../../app/redis/redis-client') -var moment = require('moment') exports.getAllCaseViewers = (caseId) => redis.zrangebyscore(`case:${caseId}:viewers`, '-inf', '+inf') -exports.getNotExpiredCaseViewers = (caseId) => redis.zrangebyscore(`case:${caseId}:viewers`, moment().valueOf(), '+inf') +exports.getNotExpiredCaseViewers = (caseId) => redis.zrangebyscore(`case:${caseId}:viewers`, Date.now(), '+inf') exports.getAllCaseEditors = (caseId) => redis.zrangebyscore(`case:${caseId}:editors`, '-inf', '+inf') -exports.getNotExpiredCaseEditors = (caseId) => redis.zrangebyscore(`case:${caseId}:editors`, moment().valueOf(), '+inf') +exports.getNotExpiredCaseEditors = (caseId) => redis.zrangebyscore(`case:${caseId}:editors`, Date.now(), '+inf') exports.getUser = (id) => redis.get(`user:${id}`) \ No newline at end of file diff --git a/test/spec/app/service/activity-service.spec.js b/test/spec/app/service/activity-service.spec.js index 6ef91266..d431fd57 100644 --- a/test/spec/app/service/activity-service.spec.js +++ b/test/spec/app/service/activity-service.spec.js @@ -2,7 +2,6 @@ var redis = require('../../../../app/redis/redis-client'); var config = require('config'); var ttlScoreGenerator = require('../../../../app/service/ttl-score-generator'); var activityService = require('../../../../app/service/activity-service')(config, redis, ttlScoreGenerator); -var moment = require('moment'); var chai = require("chai"); var sinon = require("sinon"); var sinonChai = require("sinon-chai"); @@ -54,7 +53,7 @@ describe("activity service", () => { }); it("getActivities should create a redis pipeline with the correct redis commands for getViewers", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); @@ -91,7 +90,7 @@ describe("activity service", () => { }) it("getActivities should return unknown users if users detail are missing", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); @@ -125,7 +124,7 @@ describe("activity service", () => { }) it("getActivities should not return in the list of viewers the requesting user id", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); @@ -159,7 +158,7 @@ describe("activity service", () => { }) it("getActivities should not return the requesting user id in the list of unknown viewers", (done) => { - sandbox.stub(moment, 'now').returns(TIMESTAMP); + sandbox.stub(Date, 'now').returns(TIMESTAMP); sandbox.stub(config, 'get').returns(USER_DETAILS_TTL); sandbox.stub(redis, "pipeline").callsFake(function (arguments) { argStr = JSON.stringify(arguments); diff --git a/test/spec/app/service/ttl-score-generator.spec.js b/test/spec/app/service/ttl-score-generator.spec.js new file mode 100644 index 00000000..5fbff506 --- /dev/null +++ b/test/spec/app/service/ttl-score-generator.spec.js @@ -0,0 +1,40 @@ + +const expect = require('chai').expect; +const config = require('config'); +const sandbox = require("sinon").createSandbox(); +const ttlScoreGenerator = require('../../../../app/service/ttl-score-generator'); + +describe('service.ttl-score-generator', () => { + + afterEach(() => { + sandbox.restore(); + }); + + describe('getScore', () => { + it('should handle an activity TTL', () => { + const TTL = '12'; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + sandbox.stub(config, 'get').returns(TTL); + const score = ttlScoreGenerator.getScore(); + expect(score).to.equal(12055); // (TTL * 1000) + NOW + }); + it('should handle a numeric TTL', () => { + const TTL = 13; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + sandbox.stub(config, 'get').returns(TTL); + const score = ttlScoreGenerator.getScore(); + expect(score).to.equal(13055); // (TTL * 1000) + NOW + }); + it('should handle a null TTL', () => { + const TTL = null; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + sandbox.stub(config, 'get').returns(TTL); + const score = ttlScoreGenerator.getScore(); + expect(score).to.equal(55); // null TTL => 0 + }); + }); + +}); diff --git a/test/spec/app/socket/index.spec.js b/test/spec/app/socket/index.spec.js new file mode 100644 index 00000000..24f07895 --- /dev/null +++ b/test/spec/app/socket/index.spec.js @@ -0,0 +1,32 @@ +const SocketIO = require('socket.io'); +const expect = require('chai').expect; +const Socket = require('../../../../app/socket'); + +describe('socket', () => { + const MOCK_SERVER = {}; + const MOCK_REDIS = { + duplicated: false, + duplicate: () => { + MOCK_REDIS.duplicated = true; + return MOCK_REDIS; + }, + psubscribe: () => {}, + on: () => {} + }; + + afterEach(() => { + MOCK_REDIS.duplicated = false; + }); + + it('should be appropriately initialised', () => { + const socket = Socket(MOCK_SERVER, MOCK_REDIS); + expect(socket).not.to.be.undefined; + expect(socket.socketServer).to.be.instanceOf(SocketIO.Server); + expect(socket.activityService).to.be.an('object'); + expect(socket.activityService.redis).to.equal(MOCK_REDIS); + expect(socket.handlers).to.be.an('object'); + expect(socket.handlers.activityService).to.equal(socket.activityService); + expect(socket.handlers.socketServer).to.equal(socket.socketServer); + expect(MOCK_REDIS.duplicated).to.be.true; + }) +}); diff --git a/test/spec/app/socket/redis/keys.spec.js b/test/spec/app/socket/redis/keys.spec.js new file mode 100644 index 00000000..5711711e --- /dev/null +++ b/test/spec/app/socket/redis/keys.spec.js @@ -0,0 +1,31 @@ +const keys = require('../../../../../app/socket/redis/keys'); +const expect = require('chai').expect; + +describe('socket.redis.keys', () => { + + it('should get the correct key for viewing a case', () => { + const CASE_ID = '12345678'; + expect(keys.case.view(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:viewers`); + }); + + it('should get the correct key for editing a case', () => { + const CASE_ID = '12345678'; + expect(keys.case.edit(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}:editors`); + }); + + it('should get the correct base key for a case', () => { + const CASE_ID = '12345678'; + expect(keys.case.base(CASE_ID)).to.equal(`${keys.prefixes.case}:${CASE_ID}`); + }); + + it('should get the correct key for a user', () => { + const USER_ID = 'abcdef123456'; + expect(keys.user(USER_ID)).to.equal(`${keys.prefixes.user}:${USER_ID}`); + }); + + it('should get the correct key for a socket', () => { + const SOCKET_ID = 'zyxwvu987654'; + expect(keys.socket(SOCKET_ID)).to.equal(`${keys.prefixes.socket}:${SOCKET_ID}`); + }); + +}); diff --git a/test/spec/app/socket/redis/pub-sub.spec.js b/test/spec/app/socket/redis/pub-sub.spec.js new file mode 100644 index 00000000..92cd064f --- /dev/null +++ b/test/spec/app/socket/redis/pub-sub.spec.js @@ -0,0 +1,65 @@ +const expect = require('chai').expect; +const keys = require('../../../../../app/socket/redis/keys'); +const pubSub = require('../../../../../app/socket/redis/pub-sub')(); + +describe('socket.redis.pub-sub', () => { + const MOCK_SUBSCRIBER = { + patterns: [], + events: {}, + psubscribe: (pattern) => { + if (!MOCK_SUBSCRIBER.patterns.includes(pattern)) { + MOCK_SUBSCRIBER.patterns.push(pattern); + } + }, + on: (event, eventHandler) => { + MOCK_SUBSCRIBER.events[event] = eventHandler; + }, + dispatch: (event, channel, message) => { + const handler = MOCK_SUBSCRIBER.events[event]; + if (handler) { + handler(MOCK_SUBSCRIBER.patterns[0], channel, message); + } + } + }; + const MOCK_NOTIFIER = { + messages: [], + notify: (message) => { + MOCK_NOTIFIER.messages.push(message); + } + }; + + afterEach(() => { + MOCK_SUBSCRIBER.patterns.length = 0; + MOCK_SUBSCRIBER.events = {}; + MOCK_NOTIFIER.messages.length = 0; + }); + + describe('init', () => { + it('should handle a null subscription client', () => { + pubSub.init(null, MOCK_NOTIFIER.notify); + expect(MOCK_SUBSCRIBER.patterns).to.have.lengthOf(0); + expect(MOCK_SUBSCRIBER.events).to.deep.equal({}) + }); + it('should handle a null caseNotifier', () => { + pubSub.init(MOCK_SUBSCRIBER, null); + expect(MOCK_SUBSCRIBER.patterns).to.have.lengthOf(0); + expect(MOCK_SUBSCRIBER.events).to.deep.equal({}) + }); + it('should handle appropriate parameters', () => { + pubSub.init(MOCK_SUBSCRIBER, MOCK_NOTIFIER.notify); + expect(MOCK_SUBSCRIBER.patterns).to.have.lengthOf(1) + .and.to.include(`${keys.prefixes.case}:*`); + expect(MOCK_SUBSCRIBER.events.pmessage).to.be.a('function'); + expect(MOCK_NOTIFIER.messages).to.have.lengthOf(0); + }); + it('should call the caseNotifier when the correct event is received', () => { + pubSub.init(MOCK_SUBSCRIBER, MOCK_NOTIFIER.notify); + const CASE_ID = '1234567890'; + expect(MOCK_NOTIFIER.messages).to.have.lengthOf(0); + MOCK_SUBSCRIBER.dispatch('pmessage', `${keys.prefixes.case}:${CASE_ID}`, new Date().toISOString()); + expect(MOCK_NOTIFIER.messages).to.have.lengthOf(1); + expect(MOCK_NOTIFIER.messages[0]).to.equal(CASE_ID); + }); + }); + +}); diff --git a/test/spec/app/socket/router/index.spec.js b/test/spec/app/socket/router/index.spec.js new file mode 100644 index 00000000..e3cd9475 --- /dev/null +++ b/test/spec/app/socket/router/index.spec.js @@ -0,0 +1,212 @@ +const expect = require('chai').expect; +const router = require('../../../../../app/socket/router'); + +describe('socket.router', () => { + const MOCK_SOCKET_SERVER = { + events: {}, + on: (event, eventHandler) => { + MOCK_SOCKET_SERVER.events[event] = eventHandler; + }, + dispatch: (event, socket) => { + const handler = MOCK_SOCKET_SERVER.events[event]; + if (handler) { + handler(socket); + } + } + }; + const MOCK_IO_ROUTER = { + events: {}, + attachments: [], + on: (event, eventHandler) => { + MOCK_IO_ROUTER.events[event] = eventHandler; + }, + attach: (socket, packet, next) => { + MOCK_IO_ROUTER.attachments.push({ socket, packet, next }); + }, + dispatch: (event, socket, ctx, next) => { + const handler = MOCK_IO_ROUTER.events[event]; + if (handler) { + handler(socket, ctx, next); + } + } + }; + const MOCK_HANDLERS = { + calls: [], + addActivity: (socket, caseId, user, activity) => { + const params = { socket, caseId, user, activity }; + MOCK_HANDLERS.calls.push({ method: 'addActivity', params }); + }, + watch: (socket, caseIds) => { + const params = { socket, caseIds }; + MOCK_HANDLERS.calls.push({ method: 'watch', params }); + }, + removeSocketActivity: async (socketId) => { + const params = { socketId }; + MOCK_HANDLERS.calls.push({ method: 'removeSocketActivity', params }); + } + }; + const MOCK_SOCKET = { + id: 'socket-id', + handshake: { + query: { + user: JSON.stringify({ id: 'a', name: 'Bob Smith' }) + } + }, + rooms: ['socket-id'], + events: {}, + messages: [], + using: [], + join: (room) => { + if (!MOCK_SOCKET.rooms.includes(room)) { + MOCK_SOCKET.rooms.push(room); + } + }, + leave: (room) => { + const roomIndex = MOCK_SOCKET.rooms.indexOf(room); + if (roomIndex > -1) { + MOCK_SOCKET.rooms.splice(roomIndex, 1); + } + }, + emit: (event, message) => { + MOCK_SOCKET.messages.push({ event, message }); + }, + use: (fn) => { + MOCK_SOCKET.using.push(fn); + }, + on: (event, eventHandler) => { + MOCK_SOCKET.events[event] = eventHandler; + }, + dispatch: (event) => { + const handler = MOCK_SOCKET.events[event]; + if (handler) { + handler(MOCK_SOCKET); + } + } + }; + + beforeEach(() => { + router.init(MOCK_SOCKET_SERVER, MOCK_IO_ROUTER, MOCK_HANDLERS); + }); + + afterEach(() => { + MOCK_SOCKET_SERVER.events = {}; + MOCK_IO_ROUTER.events = {}; + MOCK_IO_ROUTER.attachments.length = 0; + MOCK_HANDLERS.calls.length = 0; + MOCK_SOCKET.using.length = 0; + router.removeUser(MOCK_SOCKET.id); + router.removeConnection(MOCK_SOCKET); + }); + + describe('init', () => { + it('should have set up the appropriate events on the socket server', () => { + const EXPECTED_EVENTS = ['connection']; + EXPECTED_EVENTS.forEach((event) => { + expect(MOCK_SOCKET_SERVER.events[event]).to.be.a('function'); + }); + }); + it('should have set up the appropriate events on the io router', () => { + const EXPECTED_EVENTS = ['view', 'edit', 'watch']; + EXPECTED_EVENTS.forEach((event) => { + expect(MOCK_IO_ROUTER.events[event]).to.be.a('function'); + }); + }); + }); + + describe('iorouter', () => { + const MOCK_CONTEXT = { + request: { + caseId: '1234567890', + caseIds: ['2345678901', '3456789012', '4567890123'] + } + }; + const MOCK_JSON_USER = JSON.parse(MOCK_SOCKET.handshake.query.user); + beforeEach(() => { + // Dispatch the connection each time. + MOCK_SOCKET_SERVER.dispatch('connection', MOCK_SOCKET); + }); + it('should appropriately handle registering a user', () => { + expect(router.getUser(MOCK_SOCKET.id)).to.deep.equal(MOCK_JSON_USER); + }); + it('should appropriately handle viewing a case', () => { + const ACTIVITY = 'view'; + let nextCalled = false; + MOCK_IO_ROUTER.dispatch(ACTIVITY, MOCK_SOCKET, MOCK_CONTEXT, () => { + // next() should be called last so everything else should have been done already. + nextCalled = true; + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('addActivity'); + expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); + expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); + // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_JSON_USER); + expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); + }); + expect(nextCalled).to.be.true; + }); + it('should appropriately handle editing a case', () => { + const ACTIVITY = 'edit'; + let nextCalled = false; + MOCK_IO_ROUTER.dispatch(ACTIVITY, MOCK_SOCKET, MOCK_CONTEXT, () => { + // next() should be called last so everything else should have been done already. + nextCalled = true; + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('addActivity'); + expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); + expect(MOCK_HANDLERS.calls[0].params.caseId).to.equal(MOCK_CONTEXT.request.caseId); + // Note that the MOCK_CONTEXT doesn't include the user, which means we had to get it from elsewhere. + expect(MOCK_HANDLERS.calls[0].params.user).to.deep.equal(MOCK_JSON_USER); + expect(MOCK_HANDLERS.calls[0].params.activity).to.equal(ACTIVITY); + }); + expect(nextCalled).to.be.true; + }); + it('should appropriately handle watching cases', () => { + const ACTIVITY = 'watch'; + let nextCalled = false; + MOCK_IO_ROUTER.dispatch(ACTIVITY, MOCK_SOCKET, MOCK_CONTEXT, () => { + // next() should be called last so everything else should have been done already. + nextCalled = true; + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('watch'); + expect(MOCK_HANDLERS.calls[0].params.socket).to.equal(MOCK_SOCKET); + expect(MOCK_HANDLERS.calls[0].params.caseIds).to.deep.equal(MOCK_CONTEXT.request.caseIds); + }); + expect(nextCalled).to.be.true; + }); + }); + + describe('io', () => { + beforeEach(() => { + // Dispatch the connection each time. + MOCK_SOCKET_SERVER.dispatch('connection', MOCK_SOCKET); + }); + it('should appropriately handle a new connection', () => { + expect(router.getConnections()).to.have.lengthOf(1) + .and.to.contain(MOCK_SOCKET); + expect(MOCK_SOCKET.using).to.have.lengthOf(1); + expect(MOCK_SOCKET.using[0]).to.be.a('function'); + expect(MOCK_SOCKET.events.disconnect).to.be.a('function'); + }); + it('should handle a socket use', () => { + const useFn = MOCK_SOCKET.using[0]; + const PACKET = 'packet'; + const NEXT_FN = () => {}; + + expect(MOCK_IO_ROUTER.attachments).to.have.lengthOf(0); + useFn(PACKET, NEXT_FN); + expect(MOCK_IO_ROUTER.attachments).to.have.lengthOf(1); + expect(MOCK_IO_ROUTER.attachments[0].socket).to.equal(MOCK_SOCKET); + expect(MOCK_IO_ROUTER.attachments[0].packet).to.equal(PACKET); + expect(MOCK_IO_ROUTER.attachments[0].next).to.equal(NEXT_FN); + }); + it('should handle a socket disconnecting', () => { + MOCK_SOCKET.dispatch('disconnect'); + expect(MOCK_HANDLERS.calls).to.have.lengthOf(1); + expect(MOCK_HANDLERS.calls[0].method).to.equal('removeSocketActivity'); + expect(MOCK_HANDLERS.calls[0].params.socketId).to.equal(MOCK_SOCKET.id); + expect(router.getUser(MOCK_SOCKET.id)).to.be.undefined; + expect(router.getConnections()).to.have.lengthOf(0); + }); + }); + +}); diff --git a/test/spec/app/socket/service/activity-service.spec.js b/test/spec/app/socket/service/activity-service.spec.js new file mode 100644 index 00000000..8e8ef18e --- /dev/null +++ b/test/spec/app/socket/service/activity-service.spec.js @@ -0,0 +1,391 @@ +const keys = require('../../../../../app/socket/redis/keys'); +const ActivityService = require('../../../../../app/socket/service/activity-service'); +const expect = require('chai').expect; +const sandbox = require("sinon").createSandbox(); + +describe('socket.service.activity-service', () => { + // An instance that can be tested. + let activityService; + + const USER_ID = 'a'; + const CASE_ID = '1234567890'; + const TTL_USER = 20; + const TTL_ACTIVITY = 99; + const MOCK_CONFIG = { + getCalls: [], + keys: { + 'redis.socket.activityTtlSec': TTL_ACTIVITY, + 'redis.socket.userDetailsTtlSec': TTL_USER + }, + get: (key) => { + MOCK_CONFIG.getCalls.push(key); + return MOCK_CONFIG.keys[key]; + } + }; + const MOCK_REDIS = { + messages: [], + gets: [], + pipelines: [], + pipelineFailureLogs: [], + pipelineMode: undefined, + publish: (channel, message) => { + MOCK_REDIS.messages.push({ channel, message }); + }, + get: (key) => { + MOCK_REDIS.gets.push(key); + return JSON.stringify({ + activityKey: keys.case.view(CASE_ID), + caseId: CASE_ID, + userId: USER_ID + }); + }, + pipeline: (pipes) => { + MOCK_REDIS.pipelines.push(pipes); + let result = null; + let execResult = null; + switch (MOCK_REDIS.pipelineMode) { + case 'get': + if (MOCK_REDIS.isUserGet(pipes)) { + execResult = MOCK_REDIS.userPipeline(pipes); + } else { + execResult = MOCK_REDIS.casePipeline(pipes); + } + break; + case 'socket': + execResult = CASE_ID; + break; + case 'user': + execResult = MOCK_REDIS.userPipeline(pipes); + break; + } + return { + exec: () => { + return execResult; + } + }; + }, + casePipeline: (pipes) => { + return pipes.map((pipe) => { + // ['zrangebyscore', keys.case[activity](id), now, '+inf']; + const id = pipe[1].replace(`${keys.prefixes.case}:`, ''); + return [null, [USER_ID, 'MISSING']]; + }); + }, + userPipeline: (pipes) => { + return pipes.map((pipe) => { + const id = pipe[1].replace(`${keys.prefixes.user}:`, ''); + if (id === 'MISSING') { + return [null, null]; + } + return [null, JSON.stringify({ id, forename: `Bob ${id.toUpperCase()}`, surname: 'Smith' })]; + }); + }, + logPipelineFailures: (result, message) => { + MOCK_REDIS.pipelineFailureLogs.push({ result, message }); + }, + isUserGet: (pipes) => { + if (pipes.length > 0) { + return pipes[0][0] === 'get'; + } + return false; + } + }; + + beforeEach(() => { + activityService = ActivityService(MOCK_CONFIG, MOCK_REDIS); + }); + + afterEach(async () => { + MOCK_CONFIG.getCalls.length = 0; + MOCK_REDIS.messages.length = 0; + MOCK_REDIS.gets.length = 0; + MOCK_REDIS.pipelines.length = 0; + MOCK_REDIS.pipelineMode = undefined; + MOCK_REDIS.pipelineFailureLogs.length = 0; + }); + + it('should have appropriately initialised from the config', () => { + expect(MOCK_CONFIG.getCalls).to.include('redis.socket.activityTtlSec'); + expect(activityService.ttl.activity).to.equal(TTL_ACTIVITY); + expect(MOCK_CONFIG.getCalls).to.include('redis.socket.userDetailsTtlSec'); + expect(activityService.ttl.user).to.equal(TTL_USER); + }); + + describe('notifyChange', () => { + it('should broadcast via redis that there is a change to a case', () => { + const NOW = Date.now(); + activityService.notifyChange(CASE_ID); + expect(MOCK_REDIS.messages).to.have.lengthOf(1); + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + const messageTS = parseInt(MOCK_REDIS.messages[0].message, 10); + expect(messageTS).to.be.approximately(NOW, 5); // Within 5ms. + }); + it('should handle a null caseId', () => { + activityService.notifyChange(null); + expect(MOCK_REDIS.messages).to.have.lengthOf(0); // Should have been no broadcast. + }); + }); + + describe('getSocketActivity', () => { + it('should appropriately get socket activity', async () => { + const SOCKET_ID = 'abcdef123456'; + const activity = await activityService.getSocketActivity(SOCKET_ID); + expect(MOCK_REDIS.gets).to.have.lengthOf(1); + expect(MOCK_REDIS.gets[0]).to.equal(keys.socket(SOCKET_ID)); + expect(activity).to.be.an('object'); + expect(activity.activityKey).to.equal(keys.case.view(CASE_ID)); // Just our mock response. + }); + it('should handle a null caseId', async () => { + const SOCKET_ID = null; + const activity = await activityService.getSocketActivity(SOCKET_ID); + expect(MOCK_REDIS.messages).to.have.lengthOf(0); // Should have been no broadcast. + expect(activity).to.be.null; + }); + }); + + describe('getUserDetails', () => { + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'user'; + }); + + it('should appropriately get user details', async () => { + const USER_IDS = ['a', 'b']; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); + USER_IDS.forEach((id, index) => { + const user = userDetails[id]; + expect(user).to.be.an('object'); + expect(user.forename).to.be.a('string'); + expect(user.surname).to.be.a('string'); + + expect(pipes[index]).to.be.an('array') + .and.to.have.lengthOf(2) + .and.to.contain('get') + .and.to.contain(keys.user(id)); + }); + }); + it('should handle null userIds', async () => { + const USER_IDS = null; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + expect(userDetails).to.deep.equal({}); + }); + it('should handle empty userIds', async () => { + const USER_IDS = []; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + expect(userDetails).to.deep.equal({}); + }); + it('should handle a missing user', async () => { + const USER_IDS = ['a', 'b', 'MISSING']; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); + USER_IDS.forEach((id, index) => { + if (id === 'MISSING') { + expect(userDetails[id]).to.be.undefined; + } else { + const user = userDetails[id]; + expect(user).to.be.an('object'); + expect(user.forename).to.be.a('string'); + expect(user.surname).to.be.a('string'); + } + expect(pipes[index]).to.be.an('array') + .and.to.have.lengthOf(2) + .and.to.contain('get') + .and.to.contain(keys.user(id)); + }); + }); + it('should handle a null userId', async () => { + const USER_IDS = ['a', 'b', null]; + const userDetails = await activityService.getUserDetails(USER_IDS); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + // Should not have tried to retrieve the null user at all. + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length - 1); + let userIndex = 0; + USER_IDS.forEach((id) => { + if (id) { + const user = userDetails[id]; + expect(user).to.be.an('object'); + expect(user.forename).to.be.a('string'); + expect(user.surname).to.be.a('string'); + + expect(pipes[userIndex]).to.be.an('array') + .and.to.have.lengthOf(2) + .and.to.contain('get') + .and.to.contain(keys.user(id)); + userIndex++; + } + }); + }); + }); + + describe('removeSocketActivity', () => { + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'socket'; + }); + + it('should appropriately remove socket activity', async () => { + const NOW = Date.now(); + const SOCKET_ID = 'abcdef123456'; + await activityService.removeSocketActivity(SOCKET_ID); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(1); + const pipes = MOCK_REDIS.pipelines[0]; + expect(pipes).to.be.an('array').with.a.lengthOf(2); + // First one should be to remove the user activity. + expect(pipes[0]).to.be.an('array').with.a.lengthOf(3) + .and.to.contain('zrem') + .and.to.contain(keys.case.view(CASE_ID)) + .and.to.contain(USER_ID); + // Second one should be to remove the socket entry. + expect(pipes[1]).to.be.an('array').with.a.lengthOf(2) + .and.to.contain('del') + .and.to.contain(keys.socket(SOCKET_ID)); + + // Should have also notified about the change. + expect(MOCK_REDIS.messages).to.have.lengthOf(1); + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + const messageTS = parseInt(MOCK_REDIS.messages[0].message, 10); + expect(messageTS).to.be.approximately(NOW, 5); // Within 5ms. + }); + it('should handle a null socketId', async () => { + await activityService.removeSocketActivity(null); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + }); + + describe('addActivity', () => { + const DATE_NOW = 55; + + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'add'; + sandbox.stub(Date, 'now').returns(DATE_NOW); + }); + + afterEach(() => { + // completely restore all fakes created through the sandbox + sandbox.restore(); + }); + + it('should appropriately add view activity', async () => { + const NOW = Date.now(); + const USER = { uid: USER_ID, given_name: 'Joe', family_name: 'Bloggs' }; + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(CASE_ID, USER, SOCKET_ID, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(2); + const removePipes = MOCK_REDIS.pipelines[0]; + expect(removePipes).to.be.an('array').with.a.lengthOf(2); // Remove + + const pipes = MOCK_REDIS.pipelines[1]; + // First one should be to add the user activity. + expect(pipes[0]).to.be.an('array').with.a.lengthOf(4) + .and.to.contain('zadd') + .and.to.contain(keys.case.view(CASE_ID)) + .and.to.contain(DATE_NOW + TTL_ACTIVITY * 1000) // TTL + NOW + .and.to.contain(USER_ID); + // Second one should be to add the socket entry. + expect(pipes[1]).to.be.an('array').with.a.lengthOf(5) + .and.to.contain('set') + .and.to.contain(keys.socket(SOCKET_ID)) + .and.to.contain(`{"activityKey":"${keys.case.view(CASE_ID)}","caseId":"${CASE_ID}","userId":"${USER_ID}"}`) + .and.to.contain('EX') + .and.to.contain(TTL_USER); + // Third one should be to set the user details. + expect(pipes[2]).to.be.an('array').with.a.lengthOf(5) + .and.to.contain('set') + .and.to.contain(keys.user(USER_ID)) + .and.to.contain(`{"id":"${USER_ID}","forename":"Joe","surname":"Bloggs"}`) + .and.to.contain('EX') + .and.to.contain(TTL_USER); + + // Should have also notified about the change. + expect(MOCK_REDIS.messages).to.have.lengthOf(1); + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + const messageTS = parseInt(MOCK_REDIS.messages[0].message, 10); + expect(messageTS).to.be.approximately(NOW, 5); // Within 5ms. + }); + it('should notifications about both removed and added cases', async () => { + const NOW = Date.now(); + const USER = { uid: USER_ID, given_name: 'Joe', family_name: 'Bloggs' }; + const SOCKET_ID = 'abcdef123456'; + const NEW_CASE_ID = '0987654321'; + await activityService.addActivity(NEW_CASE_ID, USER, SOCKET_ID, 'view'); + + // Should have been two notifictions... + expect(MOCK_REDIS.messages).to.have.lengthOf(2); + // ... firstly about the original case. + expect(MOCK_REDIS.messages[0].channel).to.equal(keys.case.base(CASE_ID)); + // ... and then about the new case. + expect(MOCK_REDIS.messages[1].channel).to.equal(keys.case.base(NEW_CASE_ID)); + }); + it('should handle a null caseId', async () => { + const USER = { uid: USER_ID }; + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(null, USER, SOCKET_ID, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle a null user', async () => { + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(CASE_ID, null, SOCKET_ID, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle a null socketId', async () => { + const USER = { uid: USER_ID }; + await activityService.addActivity(CASE_ID, USER, null, 'view'); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle a null activity', async () => { + const USER = { uid: USER_ID }; + const SOCKET_ID = 'abcdef123456'; + await activityService.addActivity(CASE_ID, USER, SOCKET_ID, null); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + }); + + describe('getActivityForCases', () => { + const DATE_NOW = 55; + + beforeEach(() => { + MOCK_REDIS.pipelineMode = 'get'; + sandbox.stub(Date, 'now').returns(DATE_NOW); + }); + + afterEach(() => { + // completely restore all fakes created through the sandbox + sandbox.restore(); + }); + + it('should appropriately get case activity', async () => { + const CASE_IDS = ['1234567890','0987654321']; + const result = await activityService.getActivityForCases(CASE_IDS); + expect(result).to.be.an('array').with.a.lengthOf(CASE_IDS.length); + CASE_IDS.forEach((id, index) => { + expect(result[index]).to.be.an('object'); + expect(result[index].caseId).to.equal(id); + expect(result[index].viewers).to.be.an('array').with.a.lengthOf(1); + expect(result[index].viewers[0]).to.be.an('object'); + expect(result[index].viewers[0].forename).to.equal(`Bob ${USER_ID.toUpperCase()}`); + expect(result[index].unknownViewers).to.equal(1); // 'MISSING' id. + expect(result[index].editors).to.be.an('array').with.a.lengthOf(1); + expect(result[index].editors[0]).to.be.an('object'); + expect(result[index].unknownEditors).to.equal(1); // 'MISSING' id. + expect(result[index].editors[0].forename).to.equal(`Bob ${USER_ID.toUpperCase()}`); + }); + }); + it('should handle null caseIds', async () => { + const result = await activityService.getActivityForCases(null); + expect(result).to.be.an('array').with.a.lengthOf(0); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + it('should handle empty caseIds', async () => { + const result = await activityService.getActivityForCases([]); + expect(result).to.be.an('array').with.a.lengthOf(0); + expect(MOCK_REDIS.pipelines).to.have.lengthOf(0); // Should have been no calls to redis. + }); + }); + +}); diff --git a/test/spec/app/socket/service/handlers.spec.js b/test/spec/app/socket/service/handlers.spec.js new file mode 100644 index 00000000..8ac3e221 --- /dev/null +++ b/test/spec/app/socket/service/handlers.spec.js @@ -0,0 +1,186 @@ +const keys = require('../../../../../app/socket/redis/keys'); +const Handlers = require('../../../../../app/socket/service/handlers'); +const expect = require('chai').expect; + + +describe('socket.service.handlers', () => { + // An instance that can be tested. + let handlers; + + const MOCK_ACTIVITY_SERVICE = { + calls: [], + addActivity: async (caseId, user, socketId, activity) => { + const params = { caseId, user, socketId, activity }; + MOCK_ACTIVITY_SERVICE.calls.push({ method: 'addActivity', params }); + return null; + }, + getActivityForCases: async (caseIds) => { + const params = { caseIds }; + MOCK_ACTIVITY_SERVICE.calls.push({ method: 'getActivityForCases', params }); + return caseIds.map((caseId) => { + return { + caseId, + viewers: [], + unknownViewers: 0, + editors: [], + unknownEditors: 0 + }; + }); + }, + removeSocketActivity: async (socketId) => { + const params = { socketId }; + MOCK_ACTIVITY_SERVICE.calls.push({ method: 'removeSocketActivity', params }); + return; + } + }; + const MOCK_SOCKET_SERVER = { + messagesTo: [], + to: (room) => { + const messageTo = { room } + MOCK_SOCKET_SERVER.messagesTo.push(messageTo); + return { + emit: (event, message) => { + messageTo.event = event; + messageTo.message = message; + } + }; + } + }; + const MOCK_SOCKET = { + id: 'socket-id', + rooms: ['socket-id'], + messages: [], + join: (room) => { + if (!MOCK_SOCKET.rooms.includes(room)) { + MOCK_SOCKET.rooms.push(room); + } + }, + leave: (room) => { + const roomIndex = MOCK_SOCKET.rooms.indexOf(room); + if (roomIndex > -1) { + MOCK_SOCKET.rooms.splice(roomIndex, 1); + } + }, + emit: (event, message) => { + MOCK_SOCKET.messages.push({ event, message }); + } + }; + + beforeEach(async () => { + handlers = Handlers(MOCK_ACTIVITY_SERVICE, MOCK_SOCKET_SERVER); + }); + + afterEach(async () => { + MOCK_ACTIVITY_SERVICE.calls.length = 0; + MOCK_SOCKET_SERVER.messagesTo.length = 0; + MOCK_SOCKET.rooms.length = 0; + MOCK_SOCKET.rooms.push(MOCK_SOCKET.id); + MOCK_SOCKET.messages.length = 0; + }); + + describe('addActivity', () => { + it('should update what the socket is watching and add activity for the specified case', async () => { + const CASE_ID = '0987654321'; + const USER = { uid: 'a', name: 'John Smith', given_name: 'John', family_name: 'Smith' }; + const ACTIVITY = 'view'; + + // Pretend the socket is watching a bunch of additional rooms. + MOCK_SOCKET.join(keys.case.base('bob')); + MOCK_SOCKET.join(keys.case.base('fred')); + MOCK_SOCKET.join(keys.case.base('xyz')); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(4); + + // Now make the call. + await handlers.addActivity(MOCK_SOCKET, CASE_ID, USER, ACTIVITY); + + // The socket should be watching that case and that case alone... + // ... plus its own room, which is not related to a case, hence lengthOf(2). + expect(MOCK_SOCKET.rooms).to.have.lengthOf(2) + .and.to.include(MOCK_SOCKET.id) + .and.to.include(keys.case.base(CASE_ID)); + + // The activity service should have been called with appropriate parameters + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(1); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('addActivity'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.caseId).to.equal(CASE_ID); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.socketId).to.equal(MOCK_SOCKET.id); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.activity).to.equal(ACTIVITY); + // The user parameter should have been transformed appropriatel. + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.uid).to.equal(USER.uid); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.name).to.equal(USER.name); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.given_name).to.equal('John'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.user.family_name).to.equal('Smith'); + }); + }); + + describe('notify', () => { + it('should get activity for specified case and notify watchers', async () => { + const CASE_ID = '1234567890'; + await handlers.notify(CASE_ID); + + // The activity service should have been called. + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(1); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('getActivityForCases'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.caseIds).to.deep.equal([CASE_ID]); + + // The socket server should also have been called. + expect(MOCK_SOCKET_SERVER.messagesTo).to.have.lengthOf(1); + expect(MOCK_SOCKET_SERVER.messagesTo[0].room).to.equal(keys.case.base(CASE_ID)); + expect(MOCK_SOCKET_SERVER.messagesTo[0].event).to.equal('activity'); + expect(MOCK_SOCKET_SERVER.messagesTo[0].message).to.be.an('array').and.to.have.lengthOf(1); + expect(MOCK_SOCKET_SERVER.messagesTo[0].message[0].caseId).to.equal(CASE_ID); + }); + }); + + describe('removeSocketActivity', () => { + it('should remove activity for specified socket', async () => { + const SOCKET_ID = 'abcdef123456'; + await handlers.removeSocketActivity(SOCKET_ID); + + // The activity service should have been called. + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(1); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('removeSocketActivity'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.socketId).to.equal(SOCKET_ID); + }); + }); + + describe('watch', () => { + it('should update what the socket is watching, remove its activity, and let the user know what state the cases are in', async () => { + const CASE_IDS = ['0987654321', '9876543210', '8765432109']; + + // Pretend the socket is watching a bunch of additional rooms. + MOCK_SOCKET.join(keys.case.base('bob')); + MOCK_SOCKET.join(keys.case.base('fred')); + MOCK_SOCKET.join(keys.case.base('xyz')); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(4); + + // Now make the call. + await handlers.watch(MOCK_SOCKET, CASE_IDS); + + // The socket should be watching just the cases specified... + // ... plus its own room, which is not related to a case, hence lengthOf(2). + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + CASE_IDS.forEach((caseId) => { + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(caseId)); + }); + + // The activity service should have been called twice. + expect(MOCK_ACTIVITY_SERVICE.calls).to.have.lengthOf(2); + expect(MOCK_ACTIVITY_SERVICE.calls[0].method).to.equal('removeSocketActivity'); + expect(MOCK_ACTIVITY_SERVICE.calls[0].params.socketId).to.equal(MOCK_SOCKET.id); + expect(MOCK_ACTIVITY_SERVICE.calls[1].method).to.equal('getActivityForCases'); + expect(MOCK_ACTIVITY_SERVICE.calls[1].params.caseIds).to.deep.equal(CASE_IDS); + + // And the socket should have been told about the case statuses. + expect(MOCK_SOCKET.messages).to.have.lengthOf(1); + expect(MOCK_SOCKET.messages[0].event).to.equal('activity'); + expect(MOCK_SOCKET.messages[0].message).to.be.an('array').and.have.lengthOf(CASE_IDS.length); + CASE_IDS.forEach((caseId, index) => { + expect(MOCK_SOCKET.messages[0].message[index].caseId).to.equal(caseId); + }) + }) + }); + + +}); diff --git a/test/spec/app/socket/utils/get.spec.js b/test/spec/app/socket/utils/get.spec.js new file mode 100644 index 00000000..36bd84a5 --- /dev/null +++ b/test/spec/app/socket/utils/get.spec.js @@ -0,0 +1,130 @@ +const expect = require('chai').expect; +const get = require('../../../../../app/socket/utils/get'); +const keys = require('../../../../../app/socket/redis/keys'); + +describe('socket.utils', () => { + + describe('get', () => { + + describe('caseActivities', () => { + it('should get the correct result for a single case being viewed', () => { + const CASE_IDS = ['1']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[0][0]).to.equal('zrangebyscore'); + expect(pipes[0][1]).to.equal(keys.case.view(CASE_IDS[0])); + expect(pipes[0][2]).to.equal(NOW); + expect(pipes[0][3]).to.equal('+inf'); + }); + it('should get the correct result for a multiple cases being viewed', () => { + const CASE_IDS = ['1', '8', '2345678', 'x']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length); + CASE_IDS.forEach((id, index) => { + expect(pipes[index]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[index][0]).to.equal('zrangebyscore'); + expect(pipes[index][1]).to.equal(keys.case.view(id)); + expect(pipes[index][2]).to.equal(NOW); + expect(pipes[index][3]).to.equal('+inf'); + }); + }); + it('should handle a null case ID for cases being viewed', () => { + const CASE_IDS = ['1', '8', null, 'x']; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); + let pipeIndex = 0; + CASE_IDS.forEach((id) => { + if (id !== null) { + expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); + expect(pipes[pipeIndex][1]).to.equal(keys.case.view(id)); + expect(pipes[pipeIndex][2]).to.equal(NOW); + expect(pipes[pipeIndex][3]).to.equal('+inf'); + pipeIndex++; + } + }); + }); + it('should handle a null case ID for cases being edited', () => { + const CASE_IDS = ['1', '8', null, 'x']; + const ACTIVITY = 'edit'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(CASE_IDS.length - 1); + let pipeIndex = 0; + CASE_IDS.forEach((id) => { + if (id !== null) { + expect(pipes[pipeIndex]).to.be.an('array').and.have.lengthOf(4); + expect(pipes[pipeIndex][0]).to.equal('zrangebyscore'); + expect(pipes[pipeIndex][1]).to.equal(keys.case.edit(id)); + expect(pipes[pipeIndex][2]).to.equal(NOW); + expect(pipes[pipeIndex][3]).to.equal('+inf'); + pipeIndex++; + } + }); + }); + it('should handle a null array of case IDs', () => { + const CASE_IDS = null; + const ACTIVITY = 'view'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + it('should handle an invalid activity type', () => { + const CASE_IDS = ['1', '8', '2345678', 'x']; + const ACTIVITY = 'bob'; + const NOW = 999; + const pipes = get.caseActivities(CASE_IDS, ACTIVITY, NOW); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + }); + + describe('users', () => { + it('should get the correct result for a single user ID', () => { + const USER_IDS = ['1']; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + expect(pipes[0][0]).to.equal('get'); + expect(pipes[0][1]).to.equal(keys.user(USER_IDS[0])); + }); + it('should get the correct result for multiple user IDs', () => { + const USER_IDS = ['1', '8', '2345678', 'x']; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + USER_IDS.forEach((id, index) => { + expect(pipes[index][0]).to.equal('get'); + expect(pipes[index][1]).to.equal(keys.user(id)); + }); + }); + it('should handle a null user ID', () => { + const USER_IDS = ['1', '8', null, 'x']; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(USER_IDS.length - 1); + expect(pipes[0]).to.be.an('array').and.have.lengthOf(2); + let pipeIndex = 0; + USER_IDS.forEach((id) => { + if (id) { + expect(pipes[pipeIndex][0]).to.equal('get'); + expect(pipes[pipeIndex][1]).to.equal(keys.user(id)); + pipeIndex++; + } + }); + }); + it('should handle a null array of user IDs', () => { + const USER_IDS = null; + const pipes = get.users(USER_IDS); + expect(pipes).to.be.an('array').and.have.lengthOf(0); + }); + }); + + }); + +}); diff --git a/test/spec/app/socket/utils/index.spec.js b/test/spec/app/socket/utils/index.spec.js new file mode 100644 index 00000000..40be897a --- /dev/null +++ b/test/spec/app/socket/utils/index.spec.js @@ -0,0 +1,244 @@ +const expect = require('chai').expect; +const sandbox = require("sinon").createSandbox(); +const utils = require('../../../../../app/socket/utils'); + +describe('socket.utils', () => { + + describe('extractUniqueUserIds', () => { + it('should handle a null result', () => { + const RESULT = null; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result of the wrong type', () => { + const RESULT = 'bob'; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result with the wrong structure', () => { + const RESULT = [ + ['bob'], + ['fred'] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(1) + .and.that.includes('a'); + }); + it('should handle a result containing nulls', () => { + const RESULT = [ + ['bob', ['b']], + ['fred', null] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .that.has.lengthOf(2) + .and.that.includes('a') + .and.that.includes('b'); + }); + it('should handle a result with the correct structure', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(4) + .and.that.includes('a') + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should handle a result with the correct structure but a null original array', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = null; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(3) + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should handle a result with the correct structure but an original array of the wrong type', () => { + const RESULT = [ + ['bob', ['b', 'g']], + ['fred', ['f']] + ]; + const UNIQUE = 'a'; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array').that.has.lengthOf(3) + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g'); + }); + it('should strip out duplicates', () => { + const RESULT = [ + ['bob', ['a', 'b', 'g']], + ['fred', ['f', 'b']] + ]; + const UNIQUE = ['a']; + const IDS = utils.extractUniqueUserIds(RESULT, UNIQUE); + expect(IDS).to.be.an('array') + .and.that.includes('a') + .and.that.includes('b') + .and.that.includes('f') + .and.that.includes('g') + .but.that.has.lengthOf(4); // One of each, despite the RESULT containing an extra 'a', and 'b' twice. + }); + }); + + describe('log', () => { + it('should output string payload', () => { + const logs = []; + const logTo = (str) => { + logs.push(str); + }; + const SOCKET = { id: 'Are' }; + const PAYLOAD = 'entertained?'; + const GROUP = 'you not'; + utils.log(SOCKET, PAYLOAD, GROUP, logTo); + expect(logs).to.have.lengthOf(1); + expect(logs[0]).to.include(`| Are | you not => entertained?`); + }); + it('should output object payload', () => { + const logs = []; + const logTo = (str) => { + logs.push(str); + }; + const SOCKET = { id: 'Are' }; + const PAYLOAD = { sufficiently: 'entertained?' }; + const GROUP = 'you not'; + utils.log(SOCKET, PAYLOAD, GROUP, logTo); + expect(logs).to.have.lengthOf(2); + expect(logs[0]).to.include(`| Are | you not`); + expect(logs[1]).to.equal(PAYLOAD); + }); + }); + + describe('score', () => { + it('should handle a string TTL', () => { + const TTL = '12'; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + const score = utils.score(TTL); + expect(score).to.equal(12055); // (TTL * 1000) + NOW + }); + it('should handle a numeric TTL', () => { + const TTL = 13; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + const score = utils.score(TTL); + expect(score).to.equal(13055); // (TTL * 1000) + NOW + }); + it('should handle a null TTL', () => { + const TTL = null; + const NOW = 55; + sandbox.stub(Date, 'now').returns(NOW); + const score = utils.score(TTL); + expect(score).to.equal(55); // null TTL => 0 + }); + + afterEach(() => { + // completely restore all fakes created through the sandbox + sandbox.restore(); + }); + }); + + describe('toUser', () => { + it('should handle a null object', () => { + const OBJ = null; + const user = utils.toUser(OBJ); + expect(user).to.deep.equal({}); + }); + it('should handle a valid object', () => { + const OBJ = { id: 'bob', name: 'Bob Smith' }; + const user = utils.toUser(OBJ); + expect(user.uid).to.equal(OBJ.id); + expect(user.name).to.equal(OBJ.name); + expect(user.given_name).to.equal('Bob'); + expect(user.family_name).to.equal('Smith'); + expect(user.sub).to.equal('Bob.Smith@mailinator.com'); + }); + it('should handle a valid object with a long name', () => { + const OBJ = { id: 'ddl', name: 'Daniel Day Lewis' }; + const user = utils.toUser(OBJ); + expect(user.uid).to.equal(OBJ.id); + expect(user.name).to.equal(OBJ.name); + expect(user.given_name).to.equal('Daniel'); + expect(user.family_name).to.equal('Day Lewis'); + expect(user.sub).to.equal('Daniel.Day-Lewis@mailinator.com'); + }); + }); + + describe('toUserString', () => { + it('should handle a null user', () => { + expect(utils.toUserString(null)).to.equal('{}'); + }); + it('should handle an undefined user', () => { + expect(utils.toUserString(undefined)).to.equal('{}'); + }); + it('should handle an empty user', () => { + expect(utils.toUserString({})).to.equal('{}'); + }); + it('should handle a full user', () => { + const USER = { + uid: '1234567890', + given_name: 'Bob', + family_name: 'Smith' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob","surname":"Smith"}'); + }); + it('should handle a user with a missing family name', () => { + const USER = { + uid: '1234567890', + given_name: 'Bob' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","forename":"Bob"}'); + }); + it('should handle a user with a missing given name', () => { + const USER = { + uid: '1234567890', + family_name: 'Smith' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890","surname":"Smith"}'); + }); + it('should handle a user with a missing name', () => { + const USER = { + uid: '1234567890' + }; + expect(utils.toUserString(USER)).to.equal('{"id":"1234567890"}'); + }); + }); + + describe('get', () => { + it('should be appropriately set up', () => { + expect(utils.get).to.equal(require('../../../../../app/socket/utils/get')); + }); + }); + describe('remove', () => { + it('should be appropriately set up', () => { + expect(utils.remove).to.equal(require('../../../../../app/socket/utils/remove')); + }); + }); + describe('store', () => { + it('should be appropriately set up', () => { + expect(utils.store).to.equal(require('../../../../../app/socket/utils/store')); + }); + }); + describe('watch', () => { + it('should be appropriately set up', () => { + expect(utils.watch).to.equal(require('../../../../../app/socket/utils/watch')); + }); + }); + +}); diff --git a/test/spec/app/socket/utils/remove.spec.js b/test/spec/app/socket/utils/remove.spec.js new file mode 100644 index 00000000..773ca9aa --- /dev/null +++ b/test/spec/app/socket/utils/remove.spec.js @@ -0,0 +1,36 @@ +const expect = require('chai').expect; +const remove = require('../../../../../app/socket/utils/remove'); +const keys = require('../../../../../app/socket/redis/keys'); + +describe('socket.utils', () => { + + describe('remove', () => { + + describe('userActivity', () => { + it('should produce an appopriate pipe', () => { + const CASE_ID = '1234567890'; + const ACTIVITY = { + activityKey: keys.case.view(CASE_ID), + userId: 'a' + }; + const pipe = remove.userActivity(ACTIVITY); + expect(pipe).to.be.an('array').and.have.lengthOf(3); + expect(pipe[0]).to.equal('zrem'); + expect(pipe[1]).to.equal(ACTIVITY.activityKey); + expect(pipe[2]).to.equal(ACTIVITY.userId); + }); + }); + + describe('socketEntry', () => { + it('should produce an appopriate pipe', () => { + const SOCKET_ID = 'abcdef123456'; + const pipe = remove.socketEntry(SOCKET_ID); + expect(pipe).to.be.an('array').and.have.lengthOf(2); + expect(pipe[0]).to.equal('del'); + expect(pipe[1]).to.equal(keys.socket(SOCKET_ID)); + }); + }); + + }); + +}); diff --git a/test/spec/app/socket/utils/store.spec.js b/test/spec/app/socket/utils/store.spec.js new file mode 100644 index 00000000..7134a0b5 --- /dev/null +++ b/test/spec/app/socket/utils/store.spec.js @@ -0,0 +1,57 @@ +const expect = require('chai').expect; +const store = require('../../../../../app/socket/utils/store'); +const keys = require('../../../../../app/socket/redis/keys'); + +describe('socket.utils', () => { + + describe('store', () => { + + describe('userActivity', () => { + it('should produce an appopriate pipe', () => { + const CASE_ID = '1234567890'; + const ACTIVITY_KEY = keys.case.view(CASE_ID); + const USER_ID = 'a'; + const SCORE = 500; + const pipe = store.userActivity(ACTIVITY_KEY, USER_ID, SCORE); + expect(pipe).to.be.an('array').and.have.lengthOf(4); + expect(pipe[0]).to.equal('zadd'); + expect(pipe[1]).to.equal(ACTIVITY_KEY); + expect(pipe[2]).to.equal(SCORE); + expect(pipe[3]).to.equal(USER_ID); + }); + }); + + describe('userDetails', () => { + it('should produce an appopriate pipe', () => { + const USER = { uid: 'a', given_name: 'Bob', family_name: 'Smith' }; + const TTL = 487; + const pipe = store.userDetails(USER, TTL); + expect(pipe).to.be.an('array').and.have.lengthOf(5); + expect(pipe[0]).to.equal('set'); + expect(pipe[1]).to.equal(keys.user(USER.uid)); + expect(pipe[2]).to.equal('{"id":"a","forename":"Bob","surname":"Smith"}'); + expect(pipe[3]).to.equal('EX'); // Expires in... + expect(pipe[4]).to.equal(TTL); // ...487 seconds. + }); + }); + + describe('socketActivity', () => { + it('should produce an appopriate pipe', () => { + const CASE_ID = '1234567890'; + const SOCKET_ID = 'abcdef123456'; + const ACTIVITY_KEY = keys.case.view(CASE_ID); + const USER_ID = 'a'; + const TTL = 487; + const pipe = store.socketActivity(SOCKET_ID, ACTIVITY_KEY, CASE_ID, USER_ID, TTL); + expect(pipe).to.be.an('array').and.have.lengthOf(5); + expect(pipe[0]).to.equal('set'); + expect(pipe[1]).to.equal(keys.socket(SOCKET_ID)); + expect(pipe[2]).to.equal(`{"activityKey":"${ACTIVITY_KEY}","caseId":"${CASE_ID}","userId":"${USER_ID}"}`); + expect(pipe[3]).to.equal('EX'); // Expires in... + expect(pipe[4]).to.equal(TTL); // ...487 seconds. + }); + }); + + }); + +}); diff --git a/test/spec/app/socket/utils/watch.spec.js b/test/spec/app/socket/utils/watch.spec.js new file mode 100644 index 00000000..94651344 --- /dev/null +++ b/test/spec/app/socket/utils/watch.spec.js @@ -0,0 +1,140 @@ +const keys = require('../../../../../app/socket/redis/keys'); +const watch = require('../../../../../app/socket/utils/watch'); +const expect = require('chai').expect; + +describe('socket.utils', () => { + + describe('watch', () => { + const MOCK_SOCKET = { + id: 'socket-id', + rooms: ['socket-id'], + join: (room) => { + if (!MOCK_SOCKET.rooms.includes(room)) { + MOCK_SOCKET.rooms.push(room); + } + }, + leave: (room) => { + const roomIndex = MOCK_SOCKET.rooms.indexOf(room); + if (roomIndex > -1) { + MOCK_SOCKET.rooms.splice(roomIndex, 1); + } + } + }; + + afterEach(() => { + MOCK_SOCKET.rooms.length = 0; + MOCK_SOCKET.rooms.push(MOCK_SOCKET.id) + }); + + describe('case', () => { + it('should join the appropriate room on the socket', () => { + const CASE_ID = '1234567890'; + watch.case(MOCK_SOCKET, CASE_ID); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(2) + .and.to.include(MOCK_SOCKET.id) + .and.to.include(keys.case.base(CASE_ID)); + }); + it('should handle a null room', () => { + const CASE_ID = null; + watch.case(MOCK_SOCKET, CASE_ID); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + it('should handle a null socket', () => { + const CASE_ID = null; + watch.case(null, CASE_ID); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + }); + + describe('cases', () => { + it('should join all appropriate rooms on the socket', () => { + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + CASE_IDS.forEach((id) => { + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(id)); + }); + }); + it('should handle a null room', () => { + const CASE_IDS = ['1234567890', null, 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length) + .and.to.include(MOCK_SOCKET.id); + CASE_IDS.forEach((id) => { + if (id) { + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(id)); + } + }); + }); + it('should handle a null socket', () => { + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(null, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + }); + + describe('stop', () => { + it('should leave all the case rooms', () => { + // First, join a bunch of rooms. + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + + // Now stop watching the rooms. + watch.stop(MOCK_SOCKET); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + it('should handle a null socket', () => { + // First, join a bunch of rooms. + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + + // Now pass a null socket to the stop method. + watch.stop(null); + + // The MOCK_SOCKET's rooms should be untouched. + expect(MOCK_SOCKET.rooms).to.have.lengthOf(CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + }); + it('should handle no case rooms to leave', () => { + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + + // Now stop watching the rooms, which should have no effect. + watch.stop(MOCK_SOCKET); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(1) + .and.to.include(MOCK_SOCKET.id); + }); + }); + + describe('update', () => { + it('should appropriately replace one set of cases with another', () => { + // First, let's watch a bunch of cases. + const CASE_IDS = ['1234567890', '0987654321', 'bob']; + watch.cases(MOCK_SOCKET, CASE_IDS); + + // Now, let's use a whole different bunch. + const REPLACEMENT_CASE_IDS = ['a', 'b', 'c', 'd']; + watch.update(MOCK_SOCKET, REPLACEMENT_CASE_IDS); + expect(MOCK_SOCKET.rooms).to.have.lengthOf(REPLACEMENT_CASE_IDS.length + 1) + .and.to.include(MOCK_SOCKET.id); + REPLACEMENT_CASE_IDS.forEach((id) => { + expect(MOCK_SOCKET.rooms).to.include(keys.case.base(id)); + }); + CASE_IDS.forEach((id) => { + expect(MOCK_SOCKET.rooms).not.to.include(keys.case.base(id)); + }); + }); + }); + + }); + +}); diff --git a/test/spec/app/util/utils.spec.js b/test/spec/app/util/utils.spec.js new file mode 100644 index 00000000..29bd184f --- /dev/null +++ b/test/spec/app/util/utils.spec.js @@ -0,0 +1,186 @@ +const expect = require('chai').expect; +const utils = require('../../../../app/util/utils'); + +describe('util.utils', () => { + + describe('ifNotTimedOut', () => { + it('should call the function if it is not timed out', () => { + const REQUEST = { timedout: false }; + let functionCalled = false; + utils.ifNotTimedOut(REQUEST, () => { + functionCalled = true; + }); + expect(functionCalled).to.be.true; + }); + it('should not the function if it is timed out', () => { + const REQUEST = { timedout: true }; + let functionCalled = false; + utils.ifNotTimedOut(REQUEST, () => { + functionCalled = true; + }); + expect(functionCalled).to.be.false; + }); + }); + + describe('normalizePort', () => { + it('should parse and use a numeric string', () => { + const PORT = '1234'; + const response = utils.normalizePort(PORT); + expect(response).to.be.a('number').and.to.equal(1234); + }); + it('should parse and use a zero string', () => { + const PORT = '0'; + const response = utils.normalizePort(PORT); + expect(response).to.be.a('number').and.to.equal(0); + }); + it('should bounce a null', () => { + const PORT = null; + const response = utils.normalizePort(PORT); + expect(response).to.equal(PORT); + }); + it('should bounce an object', () => { + const PORT = { bob: 'Bob' }; + const response = utils.normalizePort(PORT); + expect(response).to.equal(PORT); + }); + it('should bounce a string that cannot be parsed as a number', () => { + const PORT = 'Bob'; + const response = utils.normalizePort(PORT); + expect(response).to.equal(PORT); + }); + it('should reject an invalid numeric string', () => { + const PORT = '-1234'; + const response = utils.normalizePort(PORT); + expect(response).to.be.false; + }); + }); + + describe('onServerError', () => { + const getSystemError = (code, syscall, message) => { + return { + address: 'http://test.address.net', + code: code, + errno: 1, + message: message || 'An error occurred', + syscall: syscall + }; + }; + let logTo; + let exitRoute; + beforeEach(() => { + logTo = { + logs: [], + output: (str) => { + logTo.logs.push(str); + } + }; + exitRoute = { + calls: [], + exit: (code) => { + exitRoute.calls.push(code); + } + } + }); + + it('should handle an access error on a numeric port', () => { + const PORT = 1234; + const ERROR = getSystemError('EACCES', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Port 1234 requires elevated privileges'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should handle an access error on a string port', () => { + const PORT = 'BOBBINS'; + const ERROR = getSystemError('EACCES', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Pipe BOBBINS requires elevated privileges'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should handle an address in use error on a numeric port', () => { + const PORT = 1234; + const ERROR = getSystemError('EADDRINUSE', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Port 1234 is already in use'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should handle an address in use error on a string port', () => { + const PORT = 'BOBBINS'; + const ERROR = getSystemError('EADDRINUSE', 'listen'); + utils.onServerError(PORT, logTo.output, exitRoute.exit)(ERROR); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain('Pipe BOBBINS is already in use'); + expect(exitRoute.calls).to.have.a.lengthOf(1) + .and.to.contain(1); + }); + it('should throw an error when not a listen syscall', () => { + const PORT = 1234; + const ERROR = getSystemError('EADDRINUSE', 'not listening', `Sorry, what was that? I wasn't listening.`); + const onServerError = utils.onServerError(PORT, logTo.output, exitRoute.exit); + let errorThrown = null; + try { + onServerError(ERROR); + } catch (err) { + errorThrown = err; + } + expect(errorThrown).to.equal(ERROR); + expect(logTo.logs).to.have.a.lengthOf(0); + expect(exitRoute.calls).to.have.a.lengthOf(0); + }); + it('should rethrow an unhandled error', () => { + const PORT = 1234; + const ERROR = getSystemError('PANIC_STATIONS', 'listen'); + const onServerError = utils.onServerError(PORT, logTo.output, exitRoute.exit); + let errorThrown = null; + try { + onServerError(ERROR); + } catch (err) { + errorThrown = err; + } + expect(errorThrown).to.equal(ERROR); + expect(logTo.logs).to.have.a.lengthOf(0); + expect(exitRoute.calls).to.have.a.lengthOf(0); + }); + + }); + + describe('onListening', () => { + let logTo; + beforeEach(() => { + logTo = { + logs: [], + output: (str) => { + logTo.logs.push(str); + } + }; + }); + it('should handle a string address', () => { + const ADDRESS = 'http://test.address'; + const SERVER = { + address: () => { + return ADDRESS; + } + }; + utils.onListening(SERVER, logTo.output)(); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain(`Listening on pipe ${ADDRESS}`); + }); + it('should handle an address with a port', () => { + const PORT = 6251; + const SERVER = { + address: () => { + return { port: PORT }; + } + }; + utils.onListening(SERVER, logTo.output)(); + expect(logTo.logs).to.have.a.lengthOf(1) + .and.to.contain(`Listening on port ${PORT}`); + }); + }); + +}); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4d6f682e..7f1a27fc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,17 +5,7 @@ __metadata: version: 8 cacheKey: 10 -"@ampproject/remapping@npm:^2.2.0": - version: 2.3.0 - resolution: "@ampproject/remapping@npm:2.3.0" - dependencies: - "@jridgewell/gen-mapping": "npm:^0.3.5" - "@jridgewell/trace-mapping": "npm:^0.3.24" - checksum: 10/f3451525379c68a73eb0a1e65247fbf28c0cccd126d93af21c75fceff77773d43c0d4a2d51978fb131aff25b5f2cb41a9fe48cc296e61ae65e679c4f6918b0ab - languageName: node - linkType: hard - -"@azure/abort-controller@npm:^2.0.0": +"@azure/abort-controller@npm:^2.0.0, @azure/abort-controller@npm:^2.1.2": version: 2.1.2 resolution: "@azure/abort-controller@npm:2.1.2" dependencies: @@ -24,7 +14,7 @@ __metadata: languageName: node linkType: hard -"@azure/core-auth@npm:1.7.2, @azure/core-auth@npm:^1.4.0": +"@azure/core-auth@npm:1.7.2": version: 1.7.2 resolution: "@azure/core-auth@npm:1.7.2" dependencies: @@ -35,6 +25,17 @@ __metadata: languageName: node linkType: hard +"@azure/core-auth@npm:^1.4.0": + version: 1.10.1 + resolution: "@azure/core-auth@npm:1.10.1" + dependencies: + "@azure/abort-controller": "npm:^2.1.2" + "@azure/core-util": "npm:^1.13.0" + tslib: "npm:^2.6.2" + checksum: 10/230c1766d4cb3ac7beac45db65bd5e493e1530f6f1d51dc0fd3537f8144e5c9acfed94700fd28c7aee67bab7502e23a1588adc6aa76f918f08fe40b3b007e2a3 + languageName: node + linkType: hard + "@azure/core-rest-pipeline@npm:1.16.3": version: 1.16.3 resolution: "@azure/core-rest-pipeline@npm:1.16.3" @@ -52,46 +53,47 @@ __metadata: linkType: hard "@azure/core-tracing@npm:^1.0.1, @azure/core-tracing@npm:^1.2.0": - version: 1.2.0 - resolution: "@azure/core-tracing@npm:1.2.0" + version: 1.3.1 + resolution: "@azure/core-tracing@npm:1.3.1" dependencies: tslib: "npm:^2.6.2" - checksum: 10/5d63ffc8f6361545b55b108b2898cda2b424db1a533d11a56890d53ba3b385e9be8f50cfd48a21b897351e1f4bbc56ede14d57187ea927d4489637fc93ebe615 + checksum: 10/7ef179e0ceb58c76d99c22bb5c5faade6fceaa62a265dcdaf09456e979be716e0249bb952d8000b9502b2194aeccb01454d60b497d4a18755e933cf7b1df919d languageName: node linkType: hard -"@azure/core-util@npm:^1.1.0, @azure/core-util@npm:^1.9.0": - version: 1.12.0 - resolution: "@azure/core-util@npm:1.12.0" +"@azure/core-util@npm:^1.1.0, @azure/core-util@npm:^1.13.0, @azure/core-util@npm:^1.9.0": + version: 1.13.1 + resolution: "@azure/core-util@npm:1.13.1" dependencies: - "@azure/abort-controller": "npm:^2.0.0" - "@typespec/ts-http-runtime": "npm:^0.2.2" + "@azure/abort-controller": "npm:^2.1.2" + "@typespec/ts-http-runtime": "npm:^0.3.0" tslib: "npm:^2.6.2" - checksum: 10/6a5544451fae579d91f0066807477ee1ffba93b4bf7629e73f53c561560f792770590ebf86d214ff5242ea8a2808c44e48f4188561352d34372734a6affe8e87 + checksum: 10/81ba529bed2fb615836be9425608e012f9bd243881f861c7ac086ea618c5f91129d3088216eee588323ffc3dfc0013706069830c03810f8a0f4591553ef5843b languageName: node linkType: hard "@azure/logger@npm:^1.0.0": - version: 1.2.0 - resolution: "@azure/logger@npm:1.2.0" + version: 1.3.0 + resolution: "@azure/logger@npm:1.3.0" dependencies: - "@typespec/ts-http-runtime": "npm:^0.2.2" + "@typespec/ts-http-runtime": "npm:^0.3.0" tslib: "npm:^2.6.2" - checksum: 10/21347e019c7c66be0707968f824210845ea9aa21c96ae8b1075f7a53b6a23c230e631db53ba63696b0ede577f1ca5665e4fc4a346b61896b376d15f0a01717ee + checksum: 10/7df11bf3b4952207d7355fde3cee223df2e4a64eaafff05a1fcbcb5c870350f1ef726866b771a7520b0e2bb33bfa9c96415b823c4b74e04ad4b755e961634528 languageName: node linkType: hard "@azure/opentelemetry-instrumentation-azure-sdk@npm:^1.0.0-beta.5": - version: 1.0.0-beta.8 - resolution: "@azure/opentelemetry-instrumentation-azure-sdk@npm:1.0.0-beta.8" + version: 1.0.0-beta.9 + resolution: "@azure/opentelemetry-instrumentation-azure-sdk@npm:1.0.0-beta.9" dependencies: "@azure/core-tracing": "npm:^1.2.0" "@azure/logger": "npm:^1.0.0" "@opentelemetry/api": "npm:^1.9.0" - "@opentelemetry/core": "npm:^1.30.1" - "@opentelemetry/instrumentation": "npm:^0.57.1" + "@opentelemetry/core": "npm:^2.0.0" + "@opentelemetry/instrumentation": "npm:^0.200.0" + "@opentelemetry/sdk-trace-web": "npm:^2.0.0" tslib: "npm:^2.7.0" - checksum: 10/8fe7ee9f2e15f4daaa66c58b728560c01122295e6a323d8898373846973e9ad6f8cfc8b71faf0d34c43fefe5e280ade27affd30c157ae6a11c60b8cf33d47d0a + checksum: 10/eb28a6242cf24722287dc3faaa5a91e3d954458d27ff9ec2326a83402e8b4dfcf53c8fc3341eaaa7b9c643279aa552fe99a64e4cc373cfe4b5910d4ebf74214b languageName: node linkType: hard @@ -107,32 +109,32 @@ __metadata: linkType: hard "@babel/compat-data@npm:^7.27.2": - version: 7.28.0 - resolution: "@babel/compat-data@npm:7.28.0" - checksum: 10/1a56a5e48c7259f72cc4329adeca38e72fd650ea09de267ea4aa070e3da91e5c265313b6656823fff77d64a8bab9554f276c66dade9355fdc0d8604deea015aa + version: 7.28.4 + resolution: "@babel/compat-data@npm:7.28.4" + checksum: 10/95b7864e6b210c84c069743966da448c0cb50015a4de5e18dd755776a0b5e53c4653e74f26700aed8de922eaa3b8844fc5fc5b29bc64830249d2abe914aec832 languageName: node linkType: hard "@babel/core@npm:^7.7.5": - version: 7.28.3 - resolution: "@babel/core@npm:7.28.3" + version: 7.28.4 + resolution: "@babel/core@npm:7.28.4" dependencies: - "@ampproject/remapping": "npm:^2.2.0" "@babel/code-frame": "npm:^7.27.1" "@babel/generator": "npm:^7.28.3" "@babel/helper-compilation-targets": "npm:^7.27.2" "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.3" - "@babel/parser": "npm:^7.28.3" + "@babel/helpers": "npm:^7.28.4" + "@babel/parser": "npm:^7.28.4" "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.3" - "@babel/types": "npm:^7.28.2" + "@babel/traverse": "npm:^7.28.4" + "@babel/types": "npm:^7.28.4" + "@jridgewell/remapping": "npm:^2.3.5" convert-source-map: "npm:^2.0.0" debug: "npm:^4.1.0" gensync: "npm:^1.0.0-beta.2" json5: "npm:^2.2.3" semver: "npm:^6.3.1" - checksum: 10/0faded84edcfd80f9a5ccc35abd46267360bba23ac295291becc8b8f9c95220f1914491b83b15e297201b19af78bbaf2ad48c2dc9d86b92f3f16a06938de8c72 + checksum: 10/0593295241fac9be567145ef16f3858d34fc91390a9438c6d47476be9823af4cc0488c851c59702dd46b968e9fd46d17ddf0105ea30195ca85f5a66b4044c519 languageName: node linkType: hard @@ -213,24 +215,24 @@ __metadata: languageName: node linkType: hard -"@babel/helpers@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/helpers@npm:7.28.3" +"@babel/helpers@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/helpers@npm:7.28.4" dependencies: "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.2" - checksum: 10/6d39031bf07a001c731e5e23e024b3d5e4885a140ce7d46e17f10f0d819f8bdb974204b3aa7127e95b63a009abf0df0d81573ceeac6a8f9a3b28bde3d2e16dd1 + "@babel/types": "npm:^7.28.4" + checksum: 10/5a70a82e196cf8808f8a449cc4780c34d02edda2bb136d39ce9d26e63b615f18e89a95472230c3ce7695db0d33e7026efeee56f6454ed43480f223007ed205eb languageName: node linkType: hard -"@babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/parser@npm:7.28.3" +"@babel/parser@npm:^7.27.2, @babel/parser@npm:^7.28.3, @babel/parser@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/parser@npm:7.28.4" dependencies: - "@babel/types": "npm:^7.28.2" + "@babel/types": "npm:^7.28.4" bin: parser: ./bin/babel-parser.js - checksum: 10/9fa08282e345b9d892a6757b2789a9a53a00f7b7b34d6254a4ee0bf32c5eb275919091ea96d6f136a948d5de9c8219235957d04a36ab7378a9d93a4cf0799155 + checksum: 10/f54c46213ef180b149f6a17ea765bf40acc1aebe2009f594e2a283aec69a190c6dda1fdf24c61a258dbeb903abb8ffb7a28f1a378f8ab5d333846ce7b7e23bf1 languageName: node linkType: hard @@ -245,28 +247,28 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.3": - version: 7.28.3 - resolution: "@babel/traverse@npm:7.28.3" +"@babel/traverse@npm:^7.27.1, @babel/traverse@npm:^7.28.3, @babel/traverse@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/traverse@npm:7.28.4" dependencies: "@babel/code-frame": "npm:^7.27.1" "@babel/generator": "npm:^7.28.3" "@babel/helper-globals": "npm:^7.28.0" - "@babel/parser": "npm:^7.28.3" + "@babel/parser": "npm:^7.28.4" "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.28.2" + "@babel/types": "npm:^7.28.4" debug: "npm:^4.3.1" - checksum: 10/fe521591b719db010a89d9a39874386d0d67b79ee7e947eee7a8ef944bd3277cd92f3b39723fc9790dc4fb77f26b818db95712e147c599b9c4d98921eb4bc70b + checksum: 10/c3099364b7b1c36bcd111099195d4abeef16499e5defb1e56766b754e8b768c252e856ed9041665158aa1b31215fc6682632756803c8fa53405381ec08c4752b languageName: node linkType: hard -"@babel/types@npm:^7.27.1, @babel/types@npm:^7.28.2": - version: 7.28.2 - resolution: "@babel/types@npm:7.28.2" +"@babel/types@npm:^7.27.1, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4": + version: 7.28.4 + resolution: "@babel/types@npm:7.28.4" dependencies: "@babel/helper-string-parser": "npm:^7.27.1" "@babel/helper-validator-identifier": "npm:^7.27.1" - checksum: 10/a8de404a2e3109651f346d892dc020ce2c82046068f4ce24de7f487738dfbfa7bd716b35f1dcd6d6c32dde96208dc74a56b7f56a2c0bcb5af0ddc56cbee13533 + checksum: 10/db50bf257aafa5d845ad16dae0587f57d596e4be4cbb233ea539976a4c461f9fbcc0bf3d37adae3f8ce5dcb4001462aa608f3558161258b585f6ce6ce21a2e45 languageName: node linkType: hard @@ -372,6 +374,16 @@ __metadata: languageName: node linkType: hard +"@jridgewell/remapping@npm:^2.3.5": + version: 2.3.5 + resolution: "@jridgewell/remapping@npm:2.3.5" + dependencies: + "@jridgewell/gen-mapping": "npm:^0.3.5" + "@jridgewell/trace-mapping": "npm:^0.3.24" + checksum: 10/c2bb01856e65b506d439455f28aceacf130d6c023d1d4e3b48705e88def3571753e1a887daa04b078b562316c92d26ce36408a60534bceca3f830aec88a339ad + languageName: node + linkType: hard + "@jridgewell/resolve-uri@npm:^3.1.0": version: 3.1.2 resolution: "@jridgewell/resolve-uri@npm:3.1.2" @@ -387,12 +399,12 @@ __metadata: linkType: hard "@jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.28": - version: 0.3.30 - resolution: "@jridgewell/trace-mapping@npm:0.3.30" + version: 0.3.31 + resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: "@jridgewell/resolve-uri": "npm:^3.1.0" "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10/f9fabe1122058f4fedc242bdc43fcaacb6b222c6fb712b7904c37704dcb16e50e07ca137ff99043da44292b18a8720296ff97f7703e960678b8ef51d0db4d250 + checksum: 10/da0283270e691bdb5543806077548532791608e52386cfbbf3b9e8fb00457859d1bd01d512851161c886eb3a2f3ce6fd9bcf25db8edf3bddedd275bd4a88d606 languageName: node linkType: hard @@ -432,12 +444,12 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/api-logs@npm:0.57.2": - version: 0.57.2 - resolution: "@opentelemetry/api-logs@npm:0.57.2" +"@opentelemetry/api-logs@npm:0.200.0": + version: 0.200.0 + resolution: "@opentelemetry/api-logs@npm:0.200.0" dependencies: "@opentelemetry/api": "npm:^1.3.0" - checksum: 10/8e3bac962e8f1fc93bfee6b433121bd2e07e8a8d1b86ef0d9d4a2c54d1759b64c74cf5da400f82f5ab5a4fe0da481726d8635fd1b15d123cf43090fa0adb8ea8 + checksum: 10/91c09934268d43070f9cc39a6e3c337aefddae9ed9533105673f577604bfa07b8d19a0015b4988c61279e7d5715c8df2644f0389308b2941374db7bb8504d35b languageName: node linkType: hard @@ -448,7 +460,7 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/core@npm:1.30.1, @opentelemetry/core@npm:^1.19.0, @opentelemetry/core@npm:^1.30.1": +"@opentelemetry/core@npm:1.30.1, @opentelemetry/core@npm:^1.19.0": version: 1.30.1 resolution: "@opentelemetry/core@npm:1.30.1" dependencies: @@ -459,19 +471,29 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/instrumentation@npm:^0.57.1": - version: 0.57.2 - resolution: "@opentelemetry/instrumentation@npm:0.57.2" +"@opentelemetry/core@npm:2.1.0, @opentelemetry/core@npm:^2.0.0": + version: 2.1.0 + resolution: "@opentelemetry/core@npm:2.1.0" dependencies: - "@opentelemetry/api-logs": "npm:0.57.2" + "@opentelemetry/semantic-conventions": "npm:^1.29.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 10/735bd1fe8a099c3aa3d7640875a5b30ab304f7e3b13efd622daabe47ff5470e9c453ad9fc119a5a0c0458ec2c7753f9a066afa32a269745b06b429ba7db90516 + languageName: node + linkType: hard + +"@opentelemetry/instrumentation@npm:^0.200.0": + version: 0.200.0 + resolution: "@opentelemetry/instrumentation@npm:0.200.0" + dependencies: + "@opentelemetry/api-logs": "npm:0.200.0" "@types/shimmer": "npm:^1.2.0" import-in-the-middle: "npm:^1.8.1" require-in-the-middle: "npm:^7.1.1" - semver: "npm:^7.5.2" shimmer: "npm:^1.2.1" peerDependencies: "@opentelemetry/api": ^1.3.0 - checksum: 10/b66b840e87976a5edf551a7011a395df8df5985571ac0506412943d07b4309fcc78fe71d3f55217a00f44384fbf61f59f1e54d544ab12f5490f6a7a56b71e02a + checksum: 10/8e168487b630596accb02e26d27384593fe006daeb34f2943bcf61450a9d663225052c4940a5335be52647ce3436965719560e0ab8aaf5380fa1f80c9aaca148 languageName: node linkType: hard @@ -487,6 +509,31 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/resources@npm:2.1.0": + version: 2.1.0 + resolution: "@opentelemetry/resources@npm:2.1.0" + dependencies: + "@opentelemetry/core": "npm:2.1.0" + "@opentelemetry/semantic-conventions": "npm:^1.29.0" + peerDependencies: + "@opentelemetry/api": ">=1.3.0 <1.10.0" + checksum: 10/8e2255443184fb889c54ed07fbcadb3b1595ce45219a39b5ac93e09648bf0b293098f84e2e47aec795deaf2d9d3db20302863f41e8e24195b2a19680eb9234fc + languageName: node + linkType: hard + +"@opentelemetry/sdk-trace-base@npm:2.1.0": + version: 2.1.0 + resolution: "@opentelemetry/sdk-trace-base@npm:2.1.0" + dependencies: + "@opentelemetry/core": "npm:2.1.0" + "@opentelemetry/resources": "npm:2.1.0" + "@opentelemetry/semantic-conventions": "npm:^1.29.0" + peerDependencies: + "@opentelemetry/api": ">=1.3.0 <1.10.0" + checksum: 10/bae338ed2f518e7873d17d28a756c1eed65990a5cc36930fba0f88a2e1f794ffa2eb2394f3eff1fd61420796536358534bce3be005c83812fc1a7f0ed9c61700 + languageName: node + linkType: hard + "@opentelemetry/sdk-trace-base@npm:^1.19.0": version: 1.30.1 resolution: "@opentelemetry/sdk-trace-base@npm:1.30.1" @@ -500,6 +547,18 @@ __metadata: languageName: node linkType: hard +"@opentelemetry/sdk-trace-web@npm:^2.0.0": + version: 2.1.0 + resolution: "@opentelemetry/sdk-trace-web@npm:2.1.0" + dependencies: + "@opentelemetry/core": "npm:2.1.0" + "@opentelemetry/sdk-trace-base": "npm:2.1.0" + peerDependencies: + "@opentelemetry/api": ">=1.0.0 <1.10.0" + checksum: 10/e639fa01101b4d824cf6137a439de318be722364b8e180106d5c563c0d307ead5cee1a9b9a8e1ce3896496df6a7068377ae39db190b9afa77cf6231e374caf49 + languageName: node + linkType: hard + "@opentelemetry/semantic-conventions@npm:1.28.0": version: 1.28.0 resolution: "@opentelemetry/semantic-conventions@npm:1.28.0" @@ -507,10 +566,10 @@ __metadata: languageName: node linkType: hard -"@opentelemetry/semantic-conventions@npm:^1.19.0": - version: 1.36.0 - resolution: "@opentelemetry/semantic-conventions@npm:1.36.0" - checksum: 10/f1939066c30147348b326840d67cc48e73072b762f2e2af5c3ea894268d64c62fc4e73fad49a72ed4a52a543b2fa0824c969a676e658ae727f75182f52104007 +"@opentelemetry/semantic-conventions@npm:^1.19.0, @opentelemetry/semantic-conventions@npm:^1.29.0": + version: 1.37.0 + resolution: "@opentelemetry/semantic-conventions@npm:1.37.0" + checksum: 10/919951c2ddbe5509dad26afc7b09d9a5e3c169de187013d1836d2771a7e840e85ea500725128b798d7659d89aff480c07fd039cf77858df3f3573a15063db7c3 languageName: node linkType: hard @@ -530,6 +589,51 @@ __metadata: languageName: node linkType: hard +"@redis/bloom@npm:5.10.0": + version: 5.10.0 + resolution: "@redis/bloom@npm:5.10.0" + peerDependencies: + "@redis/client": ^5.10.0 + checksum: 10/c9d3d3113a2f0ccaadaff74ad261e90581acc1929d36366172d0ce757631c8b40af04b7e5bb4a1dc5e31d02e1abca98d89cae598cbd1b8b516b24148168c37a7 + languageName: node + linkType: hard + +"@redis/client@npm:5.10.0": + version: 5.10.0 + resolution: "@redis/client@npm:5.10.0" + dependencies: + cluster-key-slot: "npm:1.1.2" + checksum: 10/c60d7de94afd943a2f9be19fd9595f94f67afa2b9b4e944bfe19321cd8578f9908705297f349c5ffb4b0630153ac762ab450d930b32c7900fe158e6e5c237375 + languageName: node + linkType: hard + +"@redis/json@npm:5.10.0": + version: 5.10.0 + resolution: "@redis/json@npm:5.10.0" + peerDependencies: + "@redis/client": ^5.10.0 + checksum: 10/68ee37f6b1c82ffaf4345af987f4497871f3ce8b26c83bf243127cd2464f6fbb998ffe34492eaf49b8ab153e99bb4b5e3fbd63582813e3a457a6ec71e25e7d8e + languageName: node + linkType: hard + +"@redis/search@npm:5.10.0": + version: 5.10.0 + resolution: "@redis/search@npm:5.10.0" + peerDependencies: + "@redis/client": ^5.10.0 + checksum: 10/677980fd6a74428b434a93486c0fec7319f91786f639c1c65196776f05ed9543062f78cc10900f2947c1c8d4d0a832626a025f8a4475ea2fb788a8a462b5521c + languageName: node + linkType: hard + +"@redis/time-series@npm:5.10.0": + version: 5.10.0 + resolution: "@redis/time-series@npm:5.10.0" + peerDependencies: + "@redis/client": ^5.10.0 + checksum: 10/17a3328b694f19a9de441f2b36b35dbf493ba877790abede5b599d7720420bf944cd33b5294381a2870c14fdd0a49a00fe9bd85e11938024568d19647d468275 + languageName: node + linkType: hard + "@rtsao/scc@npm:^1.1.0": version: 1.1.0 resolution: "@rtsao/scc@npm:1.1.0" @@ -560,15 +664,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^2.0.0": - version: 2.0.0 - resolution: "@sinonjs/commons@npm:2.0.0" - dependencies: - type-detect: "npm:4.0.8" - checksum: 10/bd6b44957077cd99067dcf401e80ed5ea03ba930cba2066edbbfe302d5fc973a108db25c0ae4930ee53852716929e4c94fa3b8a1510a51ac6869443a139d1e3d - languageName: node - linkType: hard - "@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" @@ -578,7 +673,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:11.2.2, @sinonjs/fake-timers@npm:^11.2.2": +"@sinonjs/fake-timers@npm:11.2.2": version: 11.2.2 resolution: "@sinonjs/fake-timers@npm:11.2.2" dependencies: @@ -587,21 +682,49 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^13.0.1": + version: 13.0.5 + resolution: "@sinonjs/fake-timers@npm:13.0.5" + dependencies: + "@sinonjs/commons": "npm:^3.0.1" + checksum: 10/11ee417968fc4dce1896ab332ac13f353866075a9d2a88ed1f6258f17cc4f7d93e66031b51fcddb8c203aa4d53fd980b0ae18aba06269f4682164878a992ec3f + languageName: node + linkType: hard + "@sinonjs/samsam@npm:^8.0.0": - version: 8.0.0 - resolution: "@sinonjs/samsam@npm:8.0.0" + version: 8.0.3 + resolution: "@sinonjs/samsam@npm:8.0.3" dependencies: - "@sinonjs/commons": "npm:^2.0.0" - lodash.get: "npm:^4.4.2" - type-detect: "npm:^4.0.8" - checksum: 10/0c9928a7d16a2428ba561e410d9d637c08014d549cac4979c63a6580c56b69378dba80ea01b17e8e163f2ca5dd331376dae92eae8364857ef827ae59dbcfe0ce + "@sinonjs/commons": "npm:^3.0.1" + type-detect: "npm:^4.1.0" + checksum: 10/af95fba2bcc4502ec2fd60cca568b89f0a4c21ef78c256fdf38bc2ce248f28f9001c63ce6e0aacdfcac8c6239ca8714b050560878a491af7b4836dce2451ff81 languageName: node linkType: hard -"@sinonjs/text-encoding@npm:^0.7.2": - version: 0.7.2 - resolution: "@sinonjs/text-encoding@npm:0.7.2" - checksum: 10/ec713fb44888c852d84ca54f6abf9c14d036c11a5d5bfab7825b8b9d2b22127dbe53412c68f4dbb0c05ea5ed61c64679bd2845c177d81462db41e0d3d7eca499 +"@sinonjs/text-encoding@npm:^0.7.3": + version: 0.7.3 + resolution: "@sinonjs/text-encoding@npm:0.7.3" + checksum: 10/f0cc89bae36e7ce159187dece7800b78831288f1913e9ae8cf8a878da5388232d2049740f6f4a43ec4b43b8ad1beb55f919f45eb9a577adb4a2a6eacb27b25fc + languageName: node + linkType: hard + +"@socket.io/component-emitter@npm:~3.1.0": + version: 3.1.2 + resolution: "@socket.io/component-emitter@npm:3.1.2" + checksum: 10/89888f00699eb34e3070624eb7b8161fa29f064aeb1389a48f02195d55dd7c52a504e52160016859f6d6dffddd54324623cdd47fd34b3d46f9ed96c18c456edc + languageName: node + linkType: hard + +"@socket.io/redis-adapter@npm:^8.3.0": + version: 8.3.0 + resolution: "@socket.io/redis-adapter@npm:8.3.0" + dependencies: + debug: "npm:~4.3.1" + notepack.io: "npm:~3.0.1" + uid2: "npm:1.0.0" + peerDependencies: + socket.io-adapter: ^2.5.4 + checksum: 10/e536492df65c16fb31a52f41a2c9cf94e712d2458f151351ea8a909e72a81d02892aa42abfc6acad7a4da0318e2b3ffed053879c510ccfb28b5e86cef2305b20 languageName: node linkType: hard @@ -619,6 +742,15 @@ __metadata: languageName: node linkType: hard +"@types/cors@npm:^2.8.12": + version: 2.8.19 + resolution: "@types/cors@npm:2.8.19" + dependencies: + "@types/node": "npm:*" + checksum: 10/9545cc532c9218754443f48a0c98c1a9ba4af1fe54a3425c95de75ff3158147bb39e666cb7c6bf98cc56a9c6dc7b4ce5b2cbdae6b55d5942e50c81b76ed6b825 + languageName: node + linkType: hard + "@types/json5@npm:^0.0.29": version: 0.0.29 resolution: "@types/json5@npm:0.0.29" @@ -626,12 +758,12 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*": - version: 24.3.0 - resolution: "@types/node@npm:24.3.0" +"@types/node@npm:*, @types/node@npm:>=10.0.0": + version: 24.5.2 + resolution: "@types/node@npm:24.5.2" dependencies: - undici-types: "npm:~7.10.0" - checksum: 10/1331c2d0e9a512ac27a016b4df3eff92317e4603dbbbab31731275dff14d3a04847a50c5776cbf94f99ff4dedac0ba5f721dce8cea020d8eea5e21711fd964b0 + undici-types: "npm:~7.12.0" + checksum: 10/a497aea88a12131b03382d933690b71c131ee890232596b8d5b73f0a20c90874001800b2bfc267bd37df8285bef911729b4773426be7d2dc13ef4c760904e47d languageName: node linkType: hard @@ -652,14 +784,14 @@ __metadata: languageName: node linkType: hard -"@typespec/ts-http-runtime@npm:^0.2.2": - version: 0.2.2 - resolution: "@typespec/ts-http-runtime@npm:0.2.2" +"@typespec/ts-http-runtime@npm:^0.3.0": + version: 0.3.1 + resolution: "@typespec/ts-http-runtime@npm:0.3.1" dependencies: http-proxy-agent: "npm:^7.0.0" https-proxy-agent: "npm:^7.0.0" tslib: "npm:^2.6.2" - checksum: 10/71aa7cea8fee84d3b3f076ab41b2b1b6a4761c2b08e05e475d20a00eeab89739e5d95b96aa633c6ebb33093c2eaeaf9b331c956ff21d3a204adfb1b607a7ba96 + checksum: 10/30cdf5f371e0e75a17f33a148942671079abe0bbb8acd8304cc2863a33ae60515a06018d3cdf1a8cc03885fef7d566d45e332434cedab2dce6cf6f0dccee0acc languageName: node linkType: hard @@ -670,7 +802,7 @@ __metadata: languageName: node linkType: hard -"accepts@npm:^1.3.7": +"accepts@npm:^1.3.7, accepts@npm:~1.3.4": version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: @@ -718,18 +850,18 @@ __metadata: linkType: hard "acorn@npm:^8.14.0": - version: 8.14.1 - resolution: "acorn@npm:8.14.1" + version: 8.15.0 + resolution: "acorn@npm:8.15.0" bin: acorn: bin/acorn - checksum: 10/d1379bbee224e8d44c3c3946e6ba6973e999fbdd4e22e41c3455d7f9b6f72f7ce18d3dc218002e1e48eea789539cf1cb6d1430c81838c6744799c712fb557d92 + checksum: 10/77f2de5051a631cf1729c090e5759148459cdb76b5f5c70f890503d629cf5052357b0ce783c0f976dd8a93c5150f59f6d18df1def3f502396a20f81282482fa4 languageName: node linkType: hard "agent-base@npm:^7.1.0, agent-base@npm:^7.1.2": - version: 7.1.3 - resolution: "agent-base@npm:7.1.3" - checksum: 10/3db6d8d4651f2aa1a9e4af35b96ab11a7607af57a24f3bc721a387eaa3b5f674e901f0a648b0caefd48f3fd117c7761b79a3b55854e2aebaa96c3f32cf76af84 + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10/79bef167247789f955aaba113bae74bf64aa1e1acca4b1d6bb444bdf91d82c3e07e9451ef6a6e2e35e8f71a6f97ce33e3d855a5328eb9fad1bc3cc4cfd031ed8 languageName: node linkType: hard @@ -797,9 +929,9 @@ __metadata: linkType: hard "ansi-styles@npm:^6.1.0": - version: 6.2.1 - resolution: "ansi-styles@npm:6.2.1" - checksum: 10/70fdf883b704d17a5dfc9cde206e698c16bcd74e7f196ab821511651aee4f9f76c9514bdfa6ca3a27b5e49138b89cb222a28caf3afe4567570139577f991df32 + version: 6.2.3 + resolution: "ansi-styles@npm:6.2.3" + checksum: 10/c49dad7639f3e48859bd51824c93b9eb0db628afc243c51c3dd2410c4a15ede1a83881c6c7341aa2b159c4f90c11befb38f2ba848c07c66c9f9de4bcd7cb9f30 languageName: node linkType: hard @@ -1038,6 +1170,22 @@ __metadata: languageName: node linkType: hard +"base64id@npm:2.0.0, base64id@npm:~2.0.0": + version: 2.0.0 + resolution: "base64id@npm:2.0.0" + checksum: 10/e3312328429e512b0713469c5312f80b447e71592cae0a5bddf3f1adc9c89d1b2ed94156ad7bb9f529398f310df7ff6f3dbe9550735c6a759f247c088ea67364 + languageName: node + linkType: hard + +"baseline-browser-mapping@npm:^2.8.3": + version: 2.8.6 + resolution: "baseline-browser-mapping@npm:2.8.6" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10/05c89fb1aa864a2a3b5fc9b7f3a4ed3e102ae4d6fa9ccf96a2b8f57fd0c995fb8b4e9ea3152b34c5661533a198026b713e1be415e96473322705b2fbd8dddc48 + languageName: node + linkType: hard + "basic-auth@npm:~2.0.1": version: 2.0.1 resolution: "basic-auth@npm:2.0.1" @@ -1125,16 +1273,17 @@ __metadata: linkType: hard "browserslist@npm:^4.24.0": - version: 4.25.4 - resolution: "browserslist@npm:4.25.4" + version: 4.26.2 + resolution: "browserslist@npm:4.26.2" dependencies: - caniuse-lite: "npm:^1.0.30001737" - electron-to-chromium: "npm:^1.5.211" - node-releases: "npm:^2.0.19" + baseline-browser-mapping: "npm:^2.8.3" + caniuse-lite: "npm:^1.0.30001741" + electron-to-chromium: "npm:^1.5.218" + node-releases: "npm:^2.0.21" update-browserslist-db: "npm:^1.1.3" bin: browserslist: cli.js - checksum: 10/6ee84526263204f66b4a19967d93f82f2a662c0cd951386e3859e29c6a4c10c32f2acf41940251f44a5daede56dbec91348d6153a1afab1fc052ecdb01d4adbc + checksum: 10/7f732f1a9c18c510aa146270d704b7b1acab52c9922147d453eecd70c926f21d97c7ac10f5303668d444fa60bd3b8778a63a797be249b0d348af4c3a644fa530 languageName: node linkType: hard @@ -1223,10 +1372,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:^1.0.30001737": - version: 1.0.30001739 - resolution: "caniuse-lite@npm:1.0.30001739" - checksum: 10/bdee0d0ba7b54dd619c3cd1a32d4e4aaeeda50625d24f020d6e148480b97e4cd5f7877e5eb41a25306583d494c206328a8eff300c752580baff4e57d78574ab5 +"caniuse-lite@npm:^1.0.30001741": + version: 1.0.30001743 + resolution: "caniuse-lite@npm:1.0.30001743" + checksum: 10/e55b13b4a547c9f610a68d5f5668a3239ada4a4aef5d198860397757ab7c03a5b0590675b7e82c5d3d57316b40499e1da52decb08a97ef3066a338871bbb5c37 languageName: node linkType: hard @@ -1237,6 +1386,7 @@ __metadata: "@hmcts/nodejs-healthcheck": "npm:^1.8.0" "@hmcts/nodejs-logging": "npm:^4.0.4" "@hmcts/properties-volume": "npm:^0.0.14" + "@socket.io/redis-adapter": "npm:^8.3.0" applicationinsights: "npm:2.9.8" body-parser: "npm:^1.20.1" chai: "npm:^4.3.6" @@ -1269,11 +1419,14 @@ __metadata: nyc: "npm:^15.0.0" or: "npm:^0.2.0" proxyquire: "npm:^2.1.3" + redis: "npm:^5.10.0" sinon: "npm:^18.0.1" sinon-chai: "npm:^3.5.0" sinon-express-mock: "npm:^2.2.1" + socket.io: "npm:^4.8.1" + socket.io-router-middleware: "npm:^1.1.2" sonar-scanner: "npm:^3.1.0" - supertest: "npm:^3.0.0" + supertest: "npm:^7.1.4" yarn: "npm:^1.22.22" languageName: unknown linkType: soft @@ -1456,7 +1609,7 @@ __metadata: languageName: node linkType: hard -"cluster-key-slot@npm:^1.0.6": +"cluster-key-slot@npm:1.1.2, cluster-key-slot@npm:^1.0.6": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" checksum: 10/516ed8b5e1a14d9c3a9c96c72ef6de2d70dfcdbaa0ec3a90bc7b9216c5457e39c09a5775750c272369070308542e671146120153062ab5f2f481bed5de2c925f @@ -1528,7 +1681,7 @@ __metadata: languageName: node linkType: hard -"component-emitter@npm:^1.2.0, component-emitter@npm:^1.3.0, component-emitter@npm:^1.3.1": +"component-emitter@npm:^1.3.0, component-emitter@npm:^1.3.1": version: 1.3.1 resolution: "component-emitter@npm:1.3.1" checksum: 10/94550aa462c7bd5a61c1bc480e28554aa306066930152d1b1844a0dd3845d4e5db7e261ddec62ae184913b3e59b55a2ad84093b9d3596a8f17c341514d6c483d @@ -1626,7 +1779,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.0, cookie@npm:^0.7.1": +"cookie@npm:^0.7.0, cookie@npm:^0.7.1, cookie@npm:~0.7.2": version: 0.7.2 resolution: "cookie@npm:0.7.2" checksum: 10/24b286c556420d4ba4e9bc09120c9d3db7d28ace2bd0f8ccee82422ce42322f73c8312441271e5eefafbead725980e5996cc02766dbb89a90ac7f5636ede608f @@ -1640,10 +1793,13 @@ __metadata: languageName: node linkType: hard -"core-util-is@npm:~1.0.0": - version: 1.0.3 - resolution: "core-util-is@npm:1.0.3" - checksum: 10/9de8597363a8e9b9952491ebe18167e3b36e7707569eed0ebf14f8bba773611376466ae34575bca8cfe3c767890c859c74056084738f09d4e4a6f902b2ad7d99 +"cors@npm:~2.8.5": + version: 2.8.5 + resolution: "cors@npm:2.8.5" + dependencies: + object-assign: "npm:^4" + vary: "npm:^1" + checksum: 10/66e88e08edee7cbce9d92b4d28a2028c88772a4c73e02f143ed8ca76789f9b59444eed6b1c167139e76fa662998c151322720093ba229f9941365ada5a6fc2c6 languageName: node linkType: hard @@ -1729,18 +1885,18 @@ __metadata: linkType: hard "debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.7, debug@npm:^4.4.0": - version: 4.4.1 - resolution: "debug@npm:4.4.1" + version: 4.4.3 + resolution: "debug@npm:4.4.3" dependencies: ms: "npm:^2.1.3" peerDependenciesMeta: supports-color: optional: true - checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe + checksum: 10/9ada3434ea2993800bd9a1e320bd4aa7af69659fb51cca685d390949434bc0a8873c21ed7c9b852af6f2455a55c6d050aa3937d52b3c69f796dab666f762acad languageName: node linkType: hard -"debug@npm:^3.1.0, debug@npm:^3.2.7": +"debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" dependencies: @@ -1749,6 +1905,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:~4.3.1, debug@npm:~4.3.2, debug@npm:~4.3.4": + version: 4.3.7 + resolution: "debug@npm:4.3.7" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10/71168908b9a78227ab29d5d25fe03c5867750e31ce24bf2c44a86efc5af041758bb56569b0a3d48a9b5344c00a24a777e6f4100ed6dfd9534a42c1dde285125a + languageName: node + linkType: hard + "decamelize@npm:^1.2.0": version: 1.2.0 resolution: "decamelize@npm:1.2.0" @@ -1923,10 +2091,10 @@ __metadata: languageName: node linkType: hard -"electron-to-chromium@npm:^1.5.211": - version: 1.5.211 - resolution: "electron-to-chromium@npm:1.5.211" - checksum: 10/6bf7cb481c7e83d50e1e958daf8b36e9a855c8e173fdafde09331dd96d0c170164f99aba009b0e7759fd8de859fa2f9162487cef896dbe2f5e11cd7c89877a52 +"electron-to-chromium@npm:^1.5.218": + version: 1.5.222 + resolution: "electron-to-chromium@npm:1.5.222" + checksum: 10/f1f7b21598ddf77a8e44f8e288bc0fb5c82c110ae1df7174a188ea7d2f81851d8e693d46ad2916e2ae0014b7368d331f8c5bee5175e572d7180f91153b251f8d languageName: node linkType: hard @@ -1976,6 +2144,30 @@ __metadata: languageName: node linkType: hard +"engine.io-parser@npm:~5.2.1": + version: 5.2.3 + resolution: "engine.io-parser@npm:5.2.3" + checksum: 10/eb0023fff5766e7ae9d59e52d92df53fea06d472cfd7b52e5d2c36b4c1dbf78cab5fde1052bcb3d4bb85bdb5aee10ae85d8a1c6c04676dac0c6cdf16bcba6380 + languageName: node + linkType: hard + +"engine.io@npm:~6.6.0": + version: 6.6.4 + resolution: "engine.io@npm:6.6.4" + dependencies: + "@types/cors": "npm:^2.8.12" + "@types/node": "npm:>=10.0.0" + accepts: "npm:~1.3.4" + base64id: "npm:2.0.0" + cookie: "npm:~0.7.2" + cors: "npm:~2.8.5" + debug: "npm:~4.3.1" + engine.io-parser: "npm:~5.2.1" + ws: "npm:~8.17.1" + checksum: 10/005b43b392d5b4b9bb196d1ae2a8cc1334a7dc70af3cfb50627d257de407ca1afae725fcd8571f9621cd12ed437abaac819c64cf22f09d5ae02b954a7e7bf4f8 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -2390,13 +2582,6 @@ __metadata: languageName: node linkType: hard -"extend@npm:^3.0.0": - version: 3.0.2 - resolution: "extend@npm:3.0.2" - checksum: 10/59e89e2dc798ec0f54b36d82f32a27d5f6472c53974f61ca098db5d4648430b725387b53449a34df38fd0392045434426b012f302b3cc049a6500ccf82877e4e - languageName: node - linkType: hard - "external-editor@npm:^3.0.3": version: 3.1.0 resolution: "external-editor@npm:3.1.0" @@ -2443,7 +2628,7 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.4": +"fdir@npm:^6.5.0": version: 6.5.0 resolution: "fdir@npm:6.5.0" peerDependencies: @@ -2599,20 +2784,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^2.3.1": - version: 2.5.5 - resolution: "form-data@npm:2.5.5" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.35" - safe-buffer: "npm:^5.2.1" - checksum: 10/4b6a8d07bb67089da41048e734215f68317a8e29dd5385a972bf5c458a023313c69d3b5d6b8baafbb7f808fa9881e0e2e030ffe61e096b3ddc894c516401271d - languageName: node - linkType: hard - "form-data@npm:^4.0.0, form-data@npm:^4.0.4": version: 4.0.4 resolution: "form-data@npm:4.0.4" @@ -2626,13 +2797,6 @@ __metadata: languageName: node linkType: hard -"formidable@npm:^1.2.0": - version: 1.2.6 - resolution: "formidable@npm:1.2.6" - checksum: 10/0ac4690a4664725051142b41c8e9de2072b4c9bde8e03bf07abe7c747d1e2cbefd2463de93938e6503cad628f2b06accf330885e2b6511f2511693af186ae08d - languageName: node - linkType: hard - "formidable@npm:^2.1.2": version: 2.1.5 resolution: "formidable@npm:2.1.5" @@ -3106,7 +3270,16 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:0.6.3, iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": +"iconv-lite@npm:0.7.0": + version: 0.7.0 + resolution: "iconv-lite@npm:0.7.0" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: 10/5bfc897fedfb7e29991ae5ef1c061ed4f864005f8c6d61ef34aba6a3885c04bd207b278c0642b041383aeac2d11645b4319d0ca7b863b0be4be0cde1c9238ca7 + languageName: node + linkType: hard + +"iconv-lite@npm:^0.6.2, iconv-lite@npm:^0.6.3": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" dependencies: @@ -3133,14 +3306,14 @@ __metadata: linkType: hard "import-in-the-middle@npm:^1.8.1": - version: 1.14.0 - resolution: "import-in-the-middle@npm:1.14.0" + version: 1.14.2 + resolution: "import-in-the-middle@npm:1.14.2" dependencies: acorn: "npm:^8.14.0" acorn-import-attributes: "npm:^1.9.5" cjs-module-lexer: "npm:^1.2.2" module-details-from-path: "npm:^1.0.3" - checksum: 10/c0b73a5637c0c7ec0ab19d5687a571dc864d90e0603dd45e28939624707def244a5dae402d7dccc1f5fc5b54ffcf18313a071d13048c390a495696d6b9f290fc + checksum: 10/45934b366d7f344e1cbfb6141ed93d3c2ced7021d2dd49b0e2474bab4f571e11f7f377c0f510f03e2d4ba61074d64b9f04677497d3f14106c8cc6f44c749f068 languageName: node linkType: hard @@ -3168,7 +3341,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 10/cd45e923bee15186c07fa4c89db0aace24824c482fb887b528304694b2aa6ff8a898da8657046a5dcf3e46cd6db6c61629551f9215f208d7c3f157cf9b290521 @@ -3572,13 +3745,6 @@ __metadata: languageName: node linkType: hard -"isarray@npm:~1.0.0": - version: 1.0.0 - resolution: "isarray@npm:1.0.0" - checksum: 10/f032df8e02dce8ec565cf2eb605ea939bdccea528dbcf565cdf92bfa2da9110461159d86a537388ef1acef8815a330642d7885b29010e8f7eac967c9993b65ab - languageName: node - linkType: hard - "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -3941,13 +4107,6 @@ __metadata: languageName: node linkType: hard -"lodash.get@npm:^4.4.2": - version: 4.4.2 - resolution: "lodash.get@npm:4.4.2" - checksum: 10/2a4925f6e89bc2c010a77a802d1ba357e17ed1ea03c2ddf6a146429f2856a216663e694a6aa3549a318cbbba3fd8b7decb392db457e6ac0b83dc745ed0a17380 - languageName: node - linkType: hard - "lodash.isarguments@npm:^3.0.0": version: 3.1.0 resolution: "lodash.isarguments@npm:3.1.0" @@ -4154,7 +4313,7 @@ __metadata: languageName: node linkType: hard -"methods@npm:^1.1.1, methods@npm:^1.1.2": +"methods@npm:^1.1.2": version: 1.1.2 resolution: "methods@npm:1.1.2" checksum: 10/a385dd974faa34b5dd021b2bbf78c722881bf6f003bfe6d391d7da3ea1ed625d1ff10ddd13c57531f628b3e785be38d3eed10ad03cebd90b76932413df9a1820 @@ -4175,7 +4334,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:^2.1.12, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -4202,7 +4361,7 @@ __metadata: languageName: node linkType: hard -"mime@npm:^1.3.4, mime@npm:^1.4.1": +"mime@npm:^1.3.4": version: 1.6.0 resolution: "mime@npm:1.6.0" bin: @@ -4477,15 +4636,15 @@ __metadata: linkType: hard "nise@npm:^6.0.0": - version: 6.0.0 - resolution: "nise@npm:6.0.0" + version: 6.1.1 + resolution: "nise@npm:6.1.1" dependencies: - "@sinonjs/commons": "npm:^3.0.0" - "@sinonjs/fake-timers": "npm:^11.2.2" - "@sinonjs/text-encoding": "npm:^0.7.2" + "@sinonjs/commons": "npm:^3.0.1" + "@sinonjs/fake-timers": "npm:^13.0.1" + "@sinonjs/text-encoding": "npm:^0.7.3" just-extend: "npm:^6.2.0" - path-to-regexp: "npm:^6.2.1" - checksum: 10/a11be5fd21ece95c80fda14a2cf80350404acc895467fc5104dc9ea9c0630614fcc83e10591ead96796b31aa2f3ccb7dc9198ed940d0f3e91e760bf5104d41a8 + path-to-regexp: "npm:^8.1.0" + checksum: 10/2d3175587cf0a351e2c91eb643fdc59d266de39f394a3ac0bace38571749d1e7f25341d763899245139b8f0d2ee048b2d3387d75ecf94c4897e947d5fc881eea languageName: node linkType: hard @@ -4603,10 +4762,10 @@ __metadata: languageName: node linkType: hard -"node-releases@npm:^2.0.19": - version: 2.0.19 - resolution: "node-releases@npm:2.0.19" - checksum: 10/c2b33b4f0c40445aee56141f13ca692fa6805db88510e5bbb3baadb2da13e1293b738e638e15e4a8eb668bb9e97debb08e7a35409b477b5cc18f171d35a83045 +"node-releases@npm:^2.0.21": + version: 2.0.21 + resolution: "node-releases@npm:2.0.21" + checksum: 10/5344d634b39d20f47c0d85a1c64567fdb9cf46f7b27ed3d141f752642faab47dae326835c2109636f823758afb16ffbed7b0c0fe6f800ef91cec9f2beb4f2b4a languageName: node linkType: hard @@ -4637,6 +4796,13 @@ __metadata: languageName: node linkType: hard +"notepack.io@npm:~3.0.1": + version: 3.0.1 + resolution: "notepack.io@npm:3.0.1" + checksum: 10/8c96fc32ea742d28df54f5d3b08b4ad18c513e972c586f02371870dd017e4e0c860df9536f4370cd992c3ef7760f0a1e9a6be68a57f3fefed67f9faa5ec37d4b + languageName: node + linkType: hard + "nyc@npm:^15.0.0": version: 15.1.0 resolution: "nyc@npm:15.1.0" @@ -4674,6 +4840,13 @@ __metadata: languageName: node linkType: hard +"object-assign@npm:^4": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: 10/fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f + languageName: node + linkType: hard + "object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": version: 1.13.4 resolution: "object-inspect@npm:1.13.4" @@ -4988,17 +5161,10 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:^6.2.1": - version: 6.2.2 - resolution: "path-to-regexp@npm:6.2.2" - checksum: 10/f7d11c1a9e02576ce0294f4efdc523c11b73894947afdf7b23a0d0f7c6465d7a7772166e770ddf1495a8017cc0ee99e3e8a15ed7302b6b948b89a6dd4eea895e - languageName: node - linkType: hard - -"path-to-regexp@npm:^8.0.0": - version: 8.2.0 - resolution: "path-to-regexp@npm:8.2.0" - checksum: 10/23378276a172b8ba5f5fb824475d1818ca5ccee7bbdb4674701616470f23a14e536c1db11da9c9e6d82b82c556a817bbf4eee6e41b9ed20090ef9427cbb38e13 +"path-to-regexp@npm:^8.0.0, path-to-regexp@npm:^8.1.0": + version: 8.3.0 + resolution: "path-to-regexp@npm:8.3.0" + checksum: 10/568f148fc64f5fd1ecebf44d531383b28df924214eabf5f2570dce9587a228e36c37882805ff02d71c6209b080ea3ee6a4d2b712b5df09741b67f1f3cf91e55a languageName: node linkType: hard @@ -5033,7 +5199,7 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.2": +"picomatch@npm:^4.0.3": version: 4.0.3 resolution: "picomatch@npm:4.0.3" checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5 @@ -5070,13 +5236,6 @@ __metadata: languageName: node linkType: hard -"process-nextick-args@npm:~2.0.0": - version: 2.0.1 - resolution: "process-nextick-args@npm:2.0.1" - checksum: 10/1d38588e520dab7cea67cbbe2efdd86a10cc7a074c09657635e34f035277b59fbb57d09d8638346bf7090f8e8ebc070c96fa5fd183b777fff4f5edff5e9466cf - languageName: node - linkType: hard - "process-on-spawn@npm:^1.0.0": version: 1.1.0 resolution: "process-on-spawn@npm:1.1.0" @@ -5174,29 +5333,14 @@ __metadata: linkType: hard "raw-body@npm:^3.0.0": - version: 3.0.0 - resolution: "raw-body@npm:3.0.0" + version: 3.0.1 + resolution: "raw-body@npm:3.0.1" dependencies: bytes: "npm:3.1.2" http-errors: "npm:2.0.0" - iconv-lite: "npm:0.6.3" + iconv-lite: "npm:0.7.0" unpipe: "npm:1.0.0" - checksum: 10/2443429bbb2f9ae5c50d3d2a6c342533dfbde6b3173740b70fa0302b30914ff400c6d31a46b3ceacbe7d0925dc07d4413928278b494b04a65736fc17ca33e30c - languageName: node - linkType: hard - -"readable-stream@npm:^2.3.5": - version: 2.3.8 - resolution: "readable-stream@npm:2.3.8" - dependencies: - core-util-is: "npm:~1.0.0" - inherits: "npm:~2.0.3" - isarray: "npm:~1.0.0" - process-nextick-args: "npm:~2.0.0" - safe-buffer: "npm:~5.1.1" - string_decoder: "npm:~1.1.1" - util-deprecate: "npm:~1.0.1" - checksum: 10/8500dd3a90e391d6c5d889256d50ec6026c059fadee98ae9aa9b86757d60ac46fff24fafb7a39fa41d54cb39d8be56cc77be202ebd4cd8ffcf4cb226cbaa40d4 + checksum: 10/3cc63e154147d15200ebf4fe3fb806682b268b8c6256ef3296f60025b07b67a028c1c92b3985b4ec1c7af08b7365ef91b0d0597b957c1c6ac40241b5f6b7d38b languageName: node linkType: hard @@ -5223,6 +5367,19 @@ __metadata: languageName: node linkType: hard +"redis@npm:^5.10.0": + version: 5.10.0 + resolution: "redis@npm:5.10.0" + dependencies: + "@redis/bloom": "npm:5.10.0" + "@redis/client": "npm:5.10.0" + "@redis/json": "npm:5.10.0" + "@redis/search": "npm:5.10.0" + "@redis/time-series": "npm:5.10.0" + checksum: 10/f88a7098e25798ebae8f9b0898835b60395285fc62d9e80c33e87aa317ed23970f8c9da416c4db8fd84a055a2735639aec15e2689c9c5bf6907bdc5c06207b7d + languageName: node + linkType: hard + "reflect.getprototypeof@npm:^1.0.6, reflect.getprototypeof@npm:^1.0.9": version: 1.0.10 resolution: "reflect.getprototypeof@npm:1.0.10" @@ -5390,6 +5547,13 @@ __metadata: languageName: node linkType: hard +"route-parser@npm:^0.0.5": + version: 0.0.5 + resolution: "route-parser@npm:0.0.5" + checksum: 10/d66f1b9e8ea4f22097f17a3a4a998acc02734aa3874194cbd46561ca355df7e78769c8d589a2bcb2bc2caa855a1f52dc46cf1affb6b2e6ab71826920cd8fe41c + languageName: node + linkType: hard + "router@npm:^2.2.0": version: 2.2.0 resolution: "router@npm:2.2.0" @@ -5432,14 +5596,14 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.1.2, safe-buffer@npm:~5.1.0, safe-buffer@npm:~5.1.1": +"safe-buffer@npm:5.1.2": version: 5.1.2 resolution: "safe-buffer@npm:5.1.2" checksum: 10/7eb5b48f2ed9a594a4795677d5a150faa7eb54483b2318b568dc0c4fc94092a6cce5be02c7288a0500a156282f5276d5688bce7259299568d1053b2150ef374a languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:^5.2.1": +"safe-buffer@npm:5.2.1": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 @@ -5708,6 +5872,50 @@ __metadata: languageName: node linkType: hard +"socket.io-adapter@npm:~2.5.2": + version: 2.5.5 + resolution: "socket.io-adapter@npm:2.5.5" + dependencies: + debug: "npm:~4.3.4" + ws: "npm:~8.17.1" + checksum: 10/e364733a4c34ff1d4a02219e409bd48074fd614b7f5b0568ccfa30dd553252a5b9a41056931306a276891d13ea76a19e2c6f2128a4675c37323f642896874d80 + languageName: node + linkType: hard + +"socket.io-parser@npm:~4.2.4": + version: 4.2.4 + resolution: "socket.io-parser@npm:4.2.4" + dependencies: + "@socket.io/component-emitter": "npm:~3.1.0" + debug: "npm:~4.3.1" + checksum: 10/4be500a9ff7e79c50ec25af11048a3ed34b4c003a9500d656786a1e5bceae68421a8394cf3eb0aa9041f85f36c1a9a737617f4aee91a42ab4ce16ffb2aa0c89c + languageName: node + linkType: hard + +"socket.io-router-middleware@npm:^1.1.2": + version: 1.1.2 + resolution: "socket.io-router-middleware@npm:1.1.2" + dependencies: + route-parser: "npm:^0.0.5" + checksum: 10/fecc2c304411766e3b26555826ab173bde874a6fc7e1dd28ba2cc3cb286bdd012958de68a164af38e0180255c322a2a522dc3cb1523e5cb97fab83673bc9aa31 + languageName: node + linkType: hard + +"socket.io@npm:^4.8.1": + version: 4.8.1 + resolution: "socket.io@npm:4.8.1" + dependencies: + accepts: "npm:~1.3.4" + base64id: "npm:~2.0.0" + cors: "npm:~2.8.5" + debug: "npm:~4.3.2" + engine.io: "npm:~6.6.0" + socket.io-adapter: "npm:~2.5.2" + socket.io-parser: "npm:~4.2.4" + checksum: 10/b9b362b7f63fc7ebb58482b8a3ade6c971da7783b7611dfeebaa8b02be23cb948137ec218491ccda8be57e434e97d65b64edf1e9811e5245b23a888d41636f4a + languageName: node + linkType: hard + "socks-proxy-agent@npm:^8.0.3": version: 8.0.5 resolution: "socks-proxy-agent@npm:8.0.5" @@ -5901,15 +6109,6 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:~1.1.1": - version: 1.1.1 - resolution: "string_decoder@npm:1.1.1" - dependencies: - safe-buffer: "npm:~5.1.0" - checksum: 10/7c41c17ed4dea105231f6df208002ebddd732e8e9e2d619d133cecd8e0087ddfd9587d2feb3c8caf3213cbd841ada6d057f5142cae68a4e62d3540778d9819b4 - languageName: node - linkType: hard - "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -5938,11 +6137,11 @@ __metadata: linkType: hard "strip-ansi@npm:^7.0.1": - version: 7.1.0 - resolution: "strip-ansi@npm:7.1.0" + version: 7.1.2 + resolution: "strip-ansi@npm:7.1.2" dependencies: ansi-regex: "npm:^6.0.1" - checksum: 10/475f53e9c44375d6e72807284024ac5d668ee1d06010740dec0b9744f2ddf47de8d7151f80e5f6190fc8f384e802fdf9504b76a7e9020c9faee7103623338be2 + checksum: 10/db0e3f9654e519c8a33c50fc9304d07df5649388e7da06d3aabf66d29e5ad65d5e6315d8519d409c15b32fa82c1df7e11ed6f8cd50b0e4404463f0c9d77c8d0b languageName: node linkType: hard @@ -5974,7 +6173,7 @@ __metadata: languageName: node linkType: hard -"superagent@npm:^10.2.2": +"superagent@npm:^10.2.2, superagent@npm:^10.2.3": version: 10.2.3 resolution: "superagent@npm:10.2.3" dependencies: @@ -5991,24 +6190,6 @@ __metadata: languageName: node linkType: hard -"superagent@npm:^3.8.3": - version: 3.8.3 - resolution: "superagent@npm:3.8.3" - dependencies: - component-emitter: "npm:^1.2.0" - cookiejar: "npm:^2.1.0" - debug: "npm:^3.1.0" - extend: "npm:^3.0.0" - form-data: "npm:^2.3.1" - formidable: "npm:^1.2.0" - methods: "npm:^1.1.1" - mime: "npm:^1.4.1" - qs: "npm:^6.5.1" - readable-stream: "npm:^2.3.5" - checksum: 10/7226a514ebff5aab000d4589367e28ff8e9768a522d84a8da81201a56dfa0d15e713b9508d369a4b6e5aaf814a45ca7b230fffdb6955fd34ba061c4002d61ef8 - languageName: node - linkType: hard - "superagent@npm:^8.0.9": version: 8.1.2 resolution: "superagent@npm:8.1.2" @@ -6027,13 +6208,13 @@ __metadata: languageName: node linkType: hard -"supertest@npm:^3.0.0": - version: 3.4.2 - resolution: "supertest@npm:3.4.2" +"supertest@npm:^7.1.4": + version: 7.1.4 + resolution: "supertest@npm:7.1.4" dependencies: methods: "npm:^1.1.2" - superagent: "npm:^3.8.3" - checksum: 10/7fa22c06f3367aceb8f713a0fefda0ca244247cef143f05fb3fde8f097df654d1c3b98db5090cb97c5b87c4f9f6b2bfa77d358caf71d1e3521516770fc77a0b4 + superagent: "npm:^10.2.3" + checksum: 10/ecb5d41f2b62b257dbdcabac245c32b8e8fb264fe2636dd85c2c883569d23dc14adc0a471abb84187cbdb49bc36ad870ad355b4a0b85973f510fd57fc229e6cc languageName: node linkType: hard @@ -6123,12 +6304,12 @@ __metadata: linkType: hard "tinyglobby@npm:^0.2.12": - version: 0.2.14 - resolution: "tinyglobby@npm:0.2.14" + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" dependencies: - fdir: "npm:^6.4.4" - picomatch: "npm:^4.0.2" - checksum: 10/3d306d319718b7cc9d79fb3f29d8655237aa6a1f280860a217f93417039d0614891aee6fc47c5db315f4fcc6ac8d55eb8e23e2de73b2c51a431b42456d9e5764 + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2 languageName: node linkType: hard @@ -6206,7 +6387,7 @@ __metadata: languageName: node linkType: hard -"type-detect@npm:^4.0.0, type-detect@npm:^4.0.8, type-detect@npm:^4.1.0": +"type-detect@npm:^4.0.0, type-detect@npm:^4.1.0": version: 4.1.0 resolution: "type-detect@npm:4.1.0" checksum: 10/e363bf0352427a79301f26a7795a27718624c49c576965076624eb5495d87515030b207217845f7018093adcbe169b2d119bb9b7f1a31a92bfbb1ab9639ca8dd @@ -6310,6 +6491,13 @@ __metadata: languageName: node linkType: hard +"uid2@npm:1.0.0": + version: 1.0.0 + resolution: "uid2@npm:1.0.0" + checksum: 10/7efad0da3839ef2bebc6fae4bd29905702cd64233b3907e3300aa2d7ea1a00c1ae8c41a5e16ca34ac2db2d25c5607d5989673e1df51a2a076fefbeed51605ec3 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.1.0": version: 1.1.0 resolution: "unbox-primitive@npm:1.1.0" @@ -6329,10 +6517,10 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~7.10.0": - version: 7.10.0 - resolution: "undici-types@npm:7.10.0" - checksum: 10/1f3fe777937690ab8a7a7bccabc8fdf4b3171f4899b5a384fb5f3d6b56c4b5fec2a51fbf345c9dd002ff6716fd440a37fa8fdb0e13af8eca8889f25445875ba3 +"undici-types@npm:~7.12.0": + version: 7.12.0 + resolution: "undici-types@npm:7.12.0" + checksum: 10/4a0f927c98828f76fb0d64f356e36e5ac6e074ae4c7bec08d6de8bc36b7cf08ae27a3518fa8eb703f51c1a675241e2d07359bbce63f5575299148a270cea7e43 languageName: node linkType: hard @@ -6384,13 +6572,6 @@ __metadata: languageName: node linkType: hard -"util-deprecate@npm:~1.0.1": - version: 1.0.2 - resolution: "util-deprecate@npm:1.0.2" - checksum: 10/474acf1146cb2701fe3b074892217553dfcf9a031280919ba1b8d651a068c9b15d863b7303cb15bd00a862b498e6cf4ad7b4a08fb134edd5a6f7641681cb54a2 - languageName: node - linkType: hard - "util@npm:^0.10.3": version: 0.10.4 resolution: "util@npm:0.10.4" @@ -6416,7 +6597,7 @@ __metadata: languageName: node linkType: hard -"vary@npm:^1.1.2": +"vary@npm:^1, vary@npm:^1.1.2": version: 1.1.2 resolution: "vary@npm:1.1.2" checksum: 10/31389debef15a480849b8331b220782230b9815a8e0dbb7b9a8369559aed2e9a7800cd904d4371ea74f4c3527db456dc8e7ac5befce5f0d289014dbdf47b2242 @@ -6650,6 +6831,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^7.4.6": + version: 7.5.10 + resolution: "ws@npm:7.5.10" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 10/9c796b84ba80ffc2c2adcdfc9c8e9a219ba99caa435c9a8d45f9ac593bba325563b3f83edc5eb067cc6d21b9a6bf2c930adf76dd40af5f58a5ca6859e81858f0 + languageName: node + linkType: hard + "y18n@npm:^4.0.1": version: 4.0.3 resolution: "y18n@npm:4.0.3"