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/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..26a0d565 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'); @@ -43,9 +42,9 @@ if (config.util.getEnv('NODE_ENV') === 'test') { debug(`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()); app.use(authCheckerUserOnlyFilter); app.use('/', activity); 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/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..c62eb394 --- /dev/null +++ b/app/socket/index.js @@ -0,0 +1,37 @@ +const config = require('config'); +const IORouter = require('socket.io-router-middleware'); +const SocketIO = require('socket.io'); + +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) => { + const activityService = ActivityService(config, redis); + const socketServer = SocketIO(server, { + allowEIO3: true, + cors: { + origin: '*', + methods: ['GET', 'POST'], + credentials: true + }, + }); + const handlers = Handlers(activityService, socketServer); + const watcher = redis.duplicate(); + pubSub.init(watcher, handlers.notify); + router.init(socketServer, new IORouter(), handlers); + + 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..609eeb74 --- /dev/null +++ b/app/socket/router/index.js @@ -0,0 +1,74 @@ +const utils = require('../utils'); + +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) => { + // 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(); + }); + + // On client connection, attach the router and track the socket. + io.on('connection', (socket) => { + router.addConnection(socket); + router.addUser(socket.id, JSON.parse(socket.handshake.query.user)); + utils.log(socket, '', `connected (${router.getConnections().length} total)`); + // 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', () => { + 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..5ecf6c03 --- /dev/null +++ b/app/socket/service/activity-service.js @@ -0,0 +1,143 @@ +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) => { + if (caseId) { + redis.publish(keys.case.base(caseId), Date.now().toString()); + } + }; + + const getSocketActivity = async (socketId) => { + if (socketId) { + const key = keys.socket(socketId); + return JSON.parse(await redis.get(key)); + } + return null; + }; + + const getUserDetails = async (userIds) => { + if (Array.isArray(userIds) && userIds.length > 0) { + // 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 removeSocketActivity = async (socketId) => { + const removedCaseId = await doRemoveSocketActivity(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) => { + 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 + }; +}; diff --git a/app/socket/service/handlers.js b/app/socket/service/handlers.js new file mode 100644 index 00000000..a41fda84 --- /dev/null +++ b/app/socket/service/handlers.js @@ -0,0 +1,67 @@ +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]); + + // Then add this new activity to redis, which will also clear out the old activity. + await activityService.addActivity(caseId, utils.toUser(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]); + 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) { + 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); + } + + return { + activityService, + addActivity, + notify, + removeSocketActivity, + socketServer, + watch + }; +}; diff --git a/app/socket/utils/get.js b/app/socket/utils/get.js new file mode 100644 index 00000000..91f9d728 --- /dev/null +++ b/app/socket/utils/get.js @@ -0,0 +1,20 @@ +const keys = require('../redis/keys'); + +const get = { + caseActivities: (caseIds, activity, now) => { + 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) => { + 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/util/utils.js b/app/util/utils.js index 28ba42fe..3ef5572e 100644 --- a/app/util/utils.js +++ b/app/util/utils.js @@ -7,3 +7,54 @@ 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; + } + + const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`; + + // 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 () => { + const addr = server.address(); + const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`; + logTo(`Listening on ${bind}`); + }; +}; diff --git a/charts/ccd-case-activity-api/values.preview.template.yaml b/charts/ccd-case-activity-api/values.preview.template.yaml index af6b90c6..487af142 100644 --- a/charts/ccd-case-activity-api/values.preview.template.yaml +++ b/charts/ccd-case-activity-api/values.preview.template.yaml @@ -6,7 +6,12 @@ nodejs: REDIS_PORT: 6379 REDIS_PASSWORD: fake-password REDIS_SSL_ENABLED: "" + CORS_ORIGIN_WHITELIST: "*" keyVaults: redis: enabled: true + architecture: standalone + auth: + enabled: true + password: "fake-password" diff --git a/config/custom-environment-variables.yaml b/config/custom-environment-variables.yaml index 631efb5a..c5a44250 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 cf3b21f2..a4251053 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -12,6 +12,9 @@ redis: keyPrefix: "activity:" activityTtlSec: 5 userDetailsTtlSec: 2 + socket: + activityTtlSec: 30 + userDetailsTtlSec: 3600 cache: user_info_enabled: true user_info_ttl: 600 diff --git a/package.json b/package.json index 412bf52d..6b4a5c5d 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": "^16.15.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'", @@ -53,7 +54,9 @@ "nocache": "^2.1.0", "node-cache": "^5.1.0", "node-cron": "^1.2.1", - "node-fetch": "^2.6.7" + "node-fetch": "^2.6.7", + "socket.io": "^4.1.2", + "socket.io-router-middleware": "^1.1.2" }, "devDependencies": { "chai": "^4.3.6", @@ -86,6 +89,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.3", "json5": "^2.2.2", diff --git a/server.js b/server.js index 22c822ff..9e9e676e 100755 --- a/server.js +++ b/server.js @@ -3,94 +3,43 @@ /** * 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); - -/** - * Normalize a port into a number, string, or false. - */ - -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 server = http.createServer(app); /** - * Event listener for HTTP server "error" event. + * 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 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; - } -} +const redis = require('./app/redis/redis-client'); +require('./app/socket')(server, redis); /** - * Event listener for HTTP server "listening" event. + * Listen on provided port, on all network interfaces. */ - -function onListening() { - - var addr = server.address(); - - var bind = typeof addr === 'string' - ? 'pipe ' + addr - : 'port ' + addr.port; - - debug('Listening on ' + bind); -} +server.listen(port); +server.on('error', onServerError(port, console.error, process.exit)); +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..34f8b893 --- /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; + }) +}); \ No newline at end of file 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..ef9644e6 --- /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 = { id: 'a', name: 'John 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.id); + 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 cd44f180..b611ff02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -312,6 +312,13 @@ __metadata: languageName: node linkType: hard +"@socket.io/component-emitter@npm:~3.1.0": + version: 3.1.0 + resolution: "@socket.io/component-emitter@npm:3.1.0" + checksum: db069d95425b419de1514dffe945cc439795f6a8ef5b9465715acf5b8b50798e2c91b8719cbf5434b3fe7de179d6cdcd503c277b7871cb3dd03febb69bdd50fa + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -333,6 +340,13 @@ __metadata: languageName: node linkType: hard +"@types/cookie@npm:^0.4.1": + version: 0.4.1 + resolution: "@types/cookie@npm:0.4.1" + checksum: 3275534ed69a76c68eb1a77d547d75f99fedc80befb75a3d1d03662fb08d697e6f8b1274e12af1a74c6896071b11510631ba891f64d30c78528d0ec45a9c1a18 + languageName: node + linkType: hard + "@types/cookiejar@npm:*": version: 2.1.1 resolution: "@types/cookiejar@npm:2.1.1" @@ -340,6 +354,15 @@ __metadata: languageName: node linkType: hard +"@types/cors@npm:^2.8.12": + version: 2.8.13 + resolution: "@types/cors@npm:2.8.13" + dependencies: + "@types/node": "*" + checksum: 7ef197ea19d2e5bf1313b8416baa6f3fd6dd887fd70191da1f804f557395357dafd8bc8bed0ac60686923406489262a7c8a525b55748f7b2b8afa686700de907 + languageName: node + linkType: hard + "@types/node@npm:*": version: 13.7.7 resolution: "@types/node@npm:13.7.7" @@ -347,6 +370,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=10.0.0": + version: 18.15.0 + resolution: "@types/node@npm:18.15.0" + checksum: d81372276dd5053b1743338b61a2178ff9722dc609189d01fc7d1c2acd539414039e0e4780678730514390dad3f29c366a28c29e8dbd5b0025651181f6dd6669 + languageName: node + linkType: hard + "@types/superagent@npm:^3.8.3": version: 3.8.7 resolution: "@types/superagent@npm:3.8.7" @@ -374,7 +404,7 @@ __metadata: languageName: node linkType: hard -"accepts@npm:~1.3.8": +"accepts@npm:~1.3.4, accepts@npm:~1.3.8": version: 1.3.8 resolution: "accepts@npm:1.3.8" dependencies: @@ -641,6 +671,13 @@ __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: 581b1d37e6cf3738b7ccdd4d14fe2bfc5c238e696e2720ee6c44c183b838655842e22034e53ffd783f872a539915c51b0d4728a49c7cc678ac5a758e00d62168 + languageName: node + linkType: hard + "basic-auth@npm:~2.0.0": version: 2.0.0 resolution: "basic-auth@npm:2.0.0" @@ -818,6 +855,8 @@ __metadata: sinon: ^9.0.0 sinon-chai: ^3.5.0 sinon-express-mock: ^2.2.1 + socket.io: ^4.1.2 + socket.io-router-middleware: ^1.1.2 sonar-scanner: ^3.1.0 supertest: ^3.0.0 languageName: unknown @@ -1181,6 +1220,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:~0.4.1": + version: 0.4.2 + resolution: "cookie@npm:0.4.2" + checksum: a00833c998bedf8e787b4c342defe5fa419abd96b32f4464f718b91022586b8f1bafbddd499288e75c037642493c83083da426c6a9080d309e3bd90fd11baa9b + languageName: node + linkType: hard + "cookiejar@npm:^2.1.4": version: 2.1.4 resolution: "cookiejar@npm:2.1.4" @@ -1195,6 +1241,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:~2.8.5": + version: 2.8.5 + resolution: "cors@npm:2.8.5" + dependencies: + object-assign: ^4 + vary: ^1 + checksum: ced838404ccd184f61ab4fdc5847035b681c90db7ac17e428f3d81d69e2989d2b680cc254da0e2554f5ed4f8a341820a1ce3d1c16b499f6e2f47a1b9b07b5006 + languageName: node + linkType: hard + "cross-env@npm:^5.2.0": version: 5.2.1 resolution: "cross-env@npm:5.2.1" @@ -1256,7 +1312,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:~4.3.1, debug@npm:~4.3.2": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: @@ -1474,6 +1530,31 @@ __metadata: languageName: node linkType: hard +"engine.io-parser@npm:~5.0.3": + version: 5.0.6 + resolution: "engine.io-parser@npm:5.0.6" + checksum: e92255b5463593cafe6cdc90577f107b39056c9c9337a8ee3477cb274337da1fe4ff53e9b3ad59d0478878e1d55ab15e973e2a91d0334d25ea99d8d6f8032f26 + languageName: node + linkType: hard + +"engine.io@npm:~6.4.1": + version: 6.4.1 + resolution: "engine.io@npm:6.4.1" + dependencies: + "@types/cookie": ^0.4.1 + "@types/cors": ^2.8.12 + "@types/node": ">=10.0.0" + accepts: ~1.3.4 + base64id: 2.0.0 + cookie: ~0.4.1 + cors: ~2.8.5 + debug: ~4.3.1 + engine.io-parser: ~5.0.3 + ws: ~8.11.0 + checksum: b3921c35911d18b851153b97c1ad49f24ae068f01ddc17cd4d40b47a581d1317a8a1ed62665f63d07d076366b926b08a185d672573fedd186ee3304f9fa542d2 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -3551,8 +3632,8 @@ __metadata: linkType: hard "node-fetch@npm:^2.6.7": - version: 2.6.7 - resolution: "node-fetch@npm:2.6.7" + version: 2.6.9 + resolution: "node-fetch@npm:2.6.9" dependencies: whatwg-url: ^5.0.0 peerDependencies: @@ -3560,7 +3641,7 @@ __metadata: peerDependenciesMeta: encoding: optional: true - checksum: 8d816ffd1ee22cab8301c7756ef04f3437f18dace86a1dae22cf81db8ef29c0bf6655f3215cb0cdb22b420b6fe141e64b26905e7f33f9377a7fa59135ea3e10b + checksum: acb04f9ce7224965b2b59e71b33c639794d8991efd73855b0b250921382b38331ffc9d61bce502571f6cc6e11a8905ca9b1b6d4aeb586ab093e2756a1fd190d0 languageName: node linkType: hard @@ -3699,6 +3780,13 @@ __metadata: languageName: node linkType: hard +"object-assign@npm:^4": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f + languageName: node + linkType: hard + "object-inspect@npm:^1.7.0": version: 1.7.0 resolution: "object-inspect@npm:1.7.0" @@ -4383,6 +4471,13 @@ __metadata: languageName: node linkType: hard +"route-parser@npm:^0.0.5": + version: 0.0.5 + resolution: "route-parser@npm:0.0.5" + checksum: 81619e9857f1359405be5d2fc4be22727851cc60a55a70f92a4800fef4c22dd3c78ad55b745afa229dffdca95835186c51c6012f47d3327c8ead42f55e4920f7 + languageName: node + linkType: hard + "run-async@npm:^2.4.0": version: 2.4.0 resolution: "run-async@npm:2.4.0" @@ -4635,6 +4730,48 @@ __metadata: languageName: node linkType: hard +"socket.io-adapter@npm:~2.5.2": + version: 2.5.2 + resolution: "socket.io-adapter@npm:2.5.2" + dependencies: + ws: ~8.11.0 + checksum: 481251c3547221e57eb5cb247d0b1a3cde4d152a4c1c9051cc887345a7770e59f3b47f1011cac4499e833f01fcfc301ed13c4ec6e72f7dbb48a476375a6344cd + languageName: node + linkType: hard + +"socket.io-parser@npm:~4.2.1": + version: 4.2.2 + resolution: "socket.io-parser@npm:4.2.2" + dependencies: + "@socket.io/component-emitter": ~3.1.0 + debug: ~4.3.1 + checksum: ba929645cb252e23d9800f00c77092480d07cc5d6c97a5d11f515ef636870ea5b3ad6f62b7ba6147b4d703efc92588064f5638a0a0841c8530e4ac50c4b1197a + 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: ^0.0.5 + checksum: 880f88ee28c0815f945e5cd1c06cacbc3bc41954c8ac600a0fd4476561b492f19d21e750ef4dd6b4855edc26efd0143da328b6d671ea613ed25451bd7d47b297 + languageName: node + linkType: hard + +"socket.io@npm:^4.1.2": + version: 4.6.1 + resolution: "socket.io@npm:4.6.1" + dependencies: + accepts: ~1.3.4 + base64id: ~2.0.0 + debug: ~4.3.2 + engine.io: ~6.4.1 + socket.io-adapter: ~2.5.2 + socket.io-parser: ~4.2.1 + checksum: 447941727142669b3709c3ae59ed790a2c3ad312d935400e2e25fdf59a95cdc92ebcf6b000ab2042a2a77ae51bb87598b40845a8d3b1f6ea6a0dd1df9c8f8459 + languageName: node + linkType: hard + "socks-proxy-agent@npm:^7.0.0": version: 7.0.0 resolution: "socks-proxy-agent@npm:7.0.0" @@ -5226,7 +5363,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: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b @@ -5375,6 +5512,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^7.4.6": + version: 7.5.9 + resolution: "ws@npm:7.5.9" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: c3c100a181b731f40b7f2fddf004aa023f79d64f489706a28bc23ff88e87f6a64b3c6651fbec3a84a53960b75159574d7a7385709847a62ddb7ad6af76f49138 + languageName: node + linkType: hard + "y18n@npm:^4.0.1": version: 4.0.3 resolution: "y18n@npm:4.0.3"