diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7010f..a1298ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,21 @@ # Changelog -All notable changes to this project will be documented in this file. +All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- Leaderboard functionality when `@WorkingPlusPlus leaderboard` is sent (requires additional app permissions - see step 6 of the installation instructions), including a link to a full web-based leaderboard with a time-based token to protect your team's data +- Help message when `@WorkingPlusPlus help` is sent (requires the same additional app permissions as above) +- Contributors: a raft of new helper functions including `isUser`, `isPlural` and many more +- Contributors: additional tests to cover all previously added functionality (leaderboard functionality is not covered yet) + +### Changed +- Contributors: another set of significant structural re-organisations to set the app up for handling more 'direct commands' in future + +### Fixed +- Prevented +- or -+ from being interpreted as valid commands ([`005b69c`](https://github.com/tdmalone/working-plusplus/commit/005b69c6b297abf5c1014fd2dedc7db9e54b2900)) + ## [0.0.3] - 2018-08-11 ### Added @@ -26,7 +38,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) a - Contributors: quite a bit of reorganising to make it easier both to grow and to test the app ### Fixed -- Scores of -1 are now referred to as -1 point, rather than -1 points +- Scores of -1 are now referred to as -1 point, rather than -1 points ([`d7d92be`](https://github.com/tdmalone/working-plusplus/commit/d7d92be0cd31aed26afcac1d189d17381330f418)) ## [0.0.1] - 2018-08-06 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9dbfd27..959c3b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,8 +53,6 @@ You can run just a subset of tests: - Integration tests with `yarn integration-tests` - End-to-end tests with `yarn e2e-tests` -It is normal to see _some_ errors while running the integration and end-to-end tests, but keep an eye on the exit code of the process to determine if it is successful (run `echo $?` immediately after running `yarn test` - you're looking for an exit code of `0` for a pass). - You can modify the default testing behaviour by adjusting the relevant `scripts` in [`package.json`](package.json) or in some cases by passing additional [Jest configuration parameters](https://jestjs.io/docs/en/configuration.html) at the end of the test commands above. If you come across annoying *stylistic* linting rules, feel free to [change them](https://eslint.org/docs/rules/) in [`.eslintrc.js`](.eslintrc.js) as part of your pull request, providing they don't cause an adverse effect on existing code. diff --git a/README.md b/README.md index cd5fc01..bd2a07f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.com/tdmalone/working-plusplus.svg?branch=master)](https://travis-ci.com/tdmalone/working-plusplus) [![Codacy Badge](https://api.codacy.com/project/badge/Grade/d0d9b6c1d1c4430e9fad61bb60b5dc4e)](https://www.codacy.com/project/tdmalone/working-plusplus/dashboard) -[![Codacy Badge](https://api.codacy.com/project/badge/Coverage/d0d9b6c1d1c4430e9fad61bb60b5dc4e)](https://www.codacy.com/project/tdmalone/working-plusplus/files) +[![Codacy Badge](https://api.codacy.com/project/badge/Coverage/d0d9b6c1d1c4430e9fad61bb60b5dc4e)](https://www.codacy.com/app/tdmalone/working-plusplus/files) Like [plusplus.chat](https://plusplus.chat/), except this one actually works - because you can host it yourself! 😉 @@ -68,16 +68,40 @@ Completely open source, so do with it what you like. Or if you don't want to mak Via *Event Subscriptions* in the left menu. After switching on, enter your new Heroku app address - eg. `https://my-plusplus.herokuapp.com` - as the request URL. - Scroll down and, under *Subscribe to Bot Events*, add the `message.channels` and `message.groups` events, then click *Save Changes*. + Scroll down and, under *Subscribe to Bot Events*, select the relevant events for the features you want the app to support: + + * Select `message.channels` to support all general features in _public_ channels it is invited to + * Select `message.groups` to support all general features in _private_ channels it is invited to + * Select `app_mention` to support extended features such as leaderboards + + Finally, click *Save Changes*. If you wish, you can come back to this screen later and add or change the events the app handles. 1. **Invite your new bot to any channel in your Slack team.** 1. **Think of someone who's been awesome lately and send `@Someone++`!** -## Detailed Instructions +### More Information Further instructions, such as hosting elsewhere, upgrading, etc. are coming soon. +## Usage + +**Working PlusPlus++** will listen out for messages, in channels it has been invited to, for valid commands. Commands are accepted anywhere in a message - at the beginning, middle, or end - and are currently limited to one command per message (if multiple commands are sent, only the first one found will be handled). + +Currently supported general commands are: + +* `@Someone++`: Adds points to a user or a thing +* `@Someone--`: Subtracts points from a user or a thing + +Currently supported extended commands are: + +* `@WorkingPlusPlus leaderboard`: Displays the leaderboard for your Slack workspace +* `@WorkingPlusPlus help`: Displays a help message showing these commands + +If you set a different name for your bot when adding the app to your Slack workspace, use that name instead. + +ℹī¸ _Extended commands are supported if you've subscribed to the `app_mentions` event in your Slack app settings. See **Step 6** in the installation instructions above for further details._ + ## Contributing Your contributions are welcome! [Create an issue](https://github.com/tdmalone/working-plusplus/issues/new) if there's something you'd like to see or [send a pull request](https://github.com/tdmalone/working-plusplus/compare) if you can implement it yourself. @@ -89,18 +113,19 @@ For full details on contributing, including getting a local environment set up, Although it works, it's very basic. Potential enhancements include: * A way to retrieve the current version/git hash from Slack, for sanity-checking of deployments -* Leaderboard functionality (either, or both, via a full leaderboard on the web - with some sort of token or oauth - and a shorter leaderboard via a command in Slack) * The ability to customise the messages the bot sends back at runtime (eg. via environment variables) * Move to the newer, more secure method of calculating signatures for incoming Slack hooks * A way to look up someone's karma without necessarily `++`'ing or `--`'ing them (eg. `@username==`) * Support for posting back messages within threads, rather than automatically jumping back out to the channel -* Support for detecting multiple actions within one message +* Support for detecting multiple commands within one message * Natural language processing to figure out positive and negative sentiment automatically * Option to deduct karma instead of adding karma when someone tries to give themselves karma * Option to deduct karma automatically for swearing (with customisable word list?) * Record and make accessible how many karma points someone has _given_ * Set up a Dockerfile to make local development easier (i.e. to not require Node, Yarn or Postgres) * Improve error handling +* The ability to customise some of the leaderboard web functionality, such as colours and perhaps imagery as well +* Additional linting tools for CSS and HTML ## License diff --git a/index.js b/index.js index d641da8..47eccfe 100644 --- a/index.js +++ b/index.js @@ -9,10 +9,14 @@ 'use strict'; -const express = require( 'express' ), - slack = require( '@slack/client' ), +const app = require( './src/app' ), + slack = require( './src/slack' ); + +const fs = require( 'fs' ), + mime = require( 'mime' ), + express = require( 'express' ), bodyParser = require( 'body-parser' ), - app = require( './src/app' ); + slackClient = require( '@slack/client' ); /* eslint-disable no-process-env, no-magic-numbers */ const PORT = process.env.PORT || 80; // Let Heroku set the port. @@ -33,18 +37,30 @@ const bootstrap = ( options = {}) => { // Allow alternative implementations of both Express and Slack to be passed in. const server = options.express || express(); - app.setSlackClient( options.slack || new slack.WebClient( SLACK_OAUTH_ACCESS_TOKEN ) ); + slack.setSlackClient( options.slack || new slackClient.WebClient( SLACK_OAUTH_ACCESS_TOKEN ) ); server.use( bodyParser.json() ); server.enable( 'trust proxy' ); server.get( '/', app.handleGet ); server.post( '/', app.handlePost ); + // Static assets. + server.get( '/assets/*', ( request, response ) => { + const path = 'src/' + request._parsedUrl.path, + type = mime.getType( path ); + + response.setHeader( 'Content-Type', type ); + response.send( fs.readFileSync( path ) ); + }); + + // Additional routes. + server.get( '/leaderboard', app.handleGet ); + return server.listen( PORT, () => { console.log( 'Listening on port ' + PORT + '.' ); }); -}; +}; // Bootstrap. // If module was called directly, bootstrap now. if ( require.main === module ) { diff --git a/jest.config.js b/jest.config.js index 00a4743..4b65497 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,7 +8,7 @@ const config = { collectCoverage: true, - setupTestFrameworkScriptFile: 'jest-chain', + setupTestFrameworkScriptFile: './jest.plugins', testEnvironment: 'node', testMatch: [ diff --git a/jest.plugins.js b/jest.plugins.js new file mode 100644 index 0000000..db38561 --- /dev/null +++ b/jest.plugins.js @@ -0,0 +1,15 @@ +/** + * Defines plugins for use with Jest. + * + * @see https://jestjs.io/docs/en/configuration.html#setuptestframeworkscriptfile-string + */ + +'use strict'; + +// Allows assertions to be chained together, to reduce repetition. +// @see https://github.com/mattphillips/jest-chain#usage +require( 'jest-chain' ); + +// Adds a bunch of additional matchers to Jest. +// @see https://github.com/jest-community/jest-extended#api +require( 'jest-extended' ); diff --git a/package.json b/package.json index 1cea340..16b7341 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint --color --ignore-pattern '/coverage/' --ignore-pattern '!.*.js' \"**/*.js\"", "fix": "yarn lint --fix", "test": "jest --forceExit --runInBand", - "unit-tests": "SKIP_INTEGRATION_TESTS=true SKIP_E2E_TESTS=true jest", + "unit-tests": "SKIP_INTEGRATION_TESTS=true SKIP_E2E_TESTS=true jest --forceExit", "integration-tests": "SKIP_E2E_TESTS=true jest --forceExit integration-tests", "e2e-tests": "SKIP_INTEGRATION_TESTS=true jest --forceExit e2e-tests", "report-coverage": "codecov && cat ./coverage/lcov.info | codacy-coverage" @@ -29,6 +29,9 @@ "@slack/client": "^4.3.1", "body-parser": "^1.18.3", "express": "^4.16.3", + "handlebars": "^4.0.11", + "lodash.camelcase": "^4.3.0", + "mime": "^2.3.1", "pg": "^7.4.3" }, "devDependencies": { @@ -39,6 +42,7 @@ "eslint-plugin-jest": "^21.18.0", "jest": "^23.4.2", "jest-chain": "^1.0.3", + "jest-extended": "^0.8.1", "object-assign-deep": "^0.4.0" } } diff --git a/src/app.js b/src/app.js index 3a20811..da0d288 100644 --- a/src/app.js +++ b/src/app.js @@ -12,221 +12,15 @@ 'use strict'; -const pg = require( 'pg' ), - { getRandomMessage } = require( './messages' ), - operations = require( './operations' ); +const events = require( './events' ), + helpers = require( './helpers' ), + leaderboard = require( './leaderboard' ); -let slack; - -// Get environment variables. -/* eslint-disable no-process-env */ -const SLACK_VERIFICATION_TOKEN = process.env.SLACK_VERIFICATION_TOKEN, - DATABASE_URL = process.env.DATABASE_URL, - DATABASE_USE_SSL = 'false' === process.env.DATABASE_USE_SSL ? false : true; -/* eslint-enable no-process-env */ +// eslint-disable-next-line no-process-env +const SLACK_VERIFICATION_TOKEN = process.env.SLACK_VERIFICATION_TOKEN; const HTTP_403 = 403, - HTTP_500 = 500, - scoresTableName = 'scores', - postgresPoolConfig = { - connectionString: DATABASE_URL, - ssl: DATABASE_USE_SSL - }; - -const postgres = new pg.Pool( postgresPoolConfig ); - -/** - * Injects the Slack client to be used for all outgoing messages. - * - * @param {WebClient} client An instance of Slack's WebClient as documented at - * https://slackapi.github.io/node-slack-sdk/web_api and - * implemented at - * https://github.com/slackapi/node-slack-sdk/blob/master/src/WebClient.ts - * @returns {void} - */ -const setSlackClient = ( client ) => { - slack = client; -}; - -/** - * Determines whether or not incoming events from Slack can be handled by this app. - * - * @param {object} event A hash of a Slack event. See the documentation at - * https://api.slack.com/events-api#events_dispatched_as_json and - * https://api.slack.com/events/message for details. - * @returns {bool} Whether or not the app can handle the provided event. - */ -const isValidEvent = ( event ) => { - - // If the event has no type, something has gone wrong. - if ( 'undefined' === typeof event.type ) { - console.warn( 'Event data missing' ); - return false; - } - - // We only support the 'message' event. - if ( 'message' !== event.type ) { - console.warn( 'Invalid event received: ' + event.type ); - return false; - } - - // If the event has a subtype, we don't support it. - if ( 'undefined' !== typeof event.subtype ) { - console.warn( 'Unsupported event subtype: ' + event.subtype ); - return false; - } - - // If there's no text with the message, there's not a lot we can do. - if ( 'undefined' === typeof event.text || ! event.text.trim() ) { - console.warn( 'Message text missing' ); - return false; - } - - return true; - -}; // IsValidEvent. - -/** - * Gets the user or 'thing' that is being spoken about, and the 'operation' being done on it. - * We take the operation down to one character, and also support — due to iOS' replacement of --. - * - * @param {string} text The message text sent through in the event. - * @returns {object} An object containing both the 'item' being referred to - either a Slack user - * ID (eg. 'U12345678') or the name of a 'thing' (eg. 'NameOfThing'); and the - * 'operation' being done on it - expressed as a valid mathematical operation - * (i.e. + or -). - */ -const extractEventData = ( ( text ) => { - const data = text.match( /@([A-Za-z0-9]+?)>?\s*([-+]{2}|—{1})/ ); - - if ( ! data ) { - return false; - } - - return { - item: data[1], - operation: data[2].substring( 0, 1 ).replace( '—', '-' ) - }; -}); - -/** - * Sends a message back to the relevant Slack channel with a response. - * - * @param {string} item The Slack user ID (if user) or name (if thing) of the item being - * operated on. - * @param {string} operation The mathematical operation performed on the item's score. - * @param {int} score The item's score after potentially being updated by the operation. - * @param {object} event A hash of a Slack event. See the documentation at - * https://api.slack.com/events-api#events_dispatched_as_json and - * https://api.slack.com/events/message for details. - * @return {Promise} A Promise to send a Slack message back to the requesting channel. - */ -const respondToUser = ( item, operation, score, event ) => { - - const itemMaybeLinked = item.match( /U[A-Z0-9]{8}/ ) ? '<@' + item + '>' : item; - const operationName = operation.replace( '+', operations.PLUS ).replace( '-', operations.MINUS ); - const message = getRandomMessage( operationName, itemMaybeLinked, score ); - - return new Promise( ( resolve, reject ) => { - slack.chat.postMessage({ - channel: event.channel, - text: message - }).then( ( data ) => { - - if ( ! data.ok ) { - console.error( 'Error occurred posting response.' ); - return reject(); - } - - console.log( item + ' now on ' + score ); - resolve(); - - }); - - }); // Return new Promise. -}; // RespondToUser. - -/** - * Updates the score of an item in the database. If the item doesn't yet exist, it will be inserted - * into the database with an assumed initial score of 0. - * - * This function also sets up the database if it is not already ready, including creating the - * scores table and activating the Postgres case-insensitive extension. - * - * @param {string} item The Slack user ID (if user) or name (if thing) of the item being - * operated on. - * @param {string} operation The mathematical operation performed on the item's score. - * @return {int} The item's new score after the update has been applied. - */ -const updateScore = async( item, operation ) => { - - // Connect to the DB, and create a table if it's not yet there. - // We also set up the citext extension, so that we can easily be case insensitive. - const dbClient = await postgres.connect(); - await dbClient.query( '\ - CREATE EXTENSION IF NOT EXISTS citext; \ - CREATE TABLE IF NOT EXISTS ' + scoresTableName + ' (item CITEXT PRIMARY KEY, score INTEGER); \ - ' ); - - // Atomically record the action. - // TODO: Fix potential SQL injection issues here, even though we know the input should be safe. - await dbClient.query( '\ - INSERT INTO ' + scoresTableName + ' VALUES (\'' + item + '\', ' + operation + '1) \ - ON CONFLICT (item) DO UPDATE SET score = ' + scoresTableName + '.score ' + operation + ' 1; \ - ' ); - - // Get the new value. - // TODO: Fix potential SQL injection issues here, even though we know the input should be safe. - const dbSelect = await dbClient.query( '\ - SELECT score FROM ' + scoresTableName + ' WHERE item = \'' + item + '\'; \ - ' ); - - dbClient.release(); - return dbSelect.rows[0].score; - -}; // UpdateScore. - -/** - * Handles events sent from Slack. - * - * @param {object} event A hash of a Slack event. See the documentation at - * https://api.slack.com/events-api#events_dispatched_as_json and - * https://api.slack.com/events/message for details. - * @return {bool|Promise} Either `false` if the event cannot be handled, or a Promise to send a - * Slack message back to the requesting channel. - */ -const handleEvent = async( event ) => { - - const { item, operation } = extractEventData( event.text ); - - if ( ! item || ! operation ) { - return false; - } - - // If the user is trying to ++ themselves... - if ( item === event.user && '+' === operation ) { - - const message = getRandomMessage( operations.SELF, '<@' + event.user + '>', 0 ); - - slack.chat.postMessage({ - channel: event.channel, - text: message - }).then( ( data ) => { - console.log( - data.ok ? - item + ' tried to alter their own score.' : - 'Error occurred posting response to user altering their own score.' - ); - }); - - return false; - - } // If self ++. - - const score = await updateScore( item, operation ); - return respondToUser( item, operation, score, event ); - -}; // HandleEvent. + HTTP_500 = 500; /** * Simple logging of requests. @@ -247,8 +41,6 @@ const logRequest = ( request ) => { * WARNING: When checking the return value of this function, ensure you use strict equality so that * an error response is not misinterpreted as truthy. * - * TODO: Move to calculating the signature instead (newer, more secure method). - * * @param {string} suppliedToken The token supplied in the request. * @param {string} serverToken The token to validate against. * @return {object|bool} If invalid, an error object containing an 'error' with HTTP status code @@ -280,16 +72,39 @@ const validateToken = ( suppliedToken, serverToken ) => { }; // ValidateToken. /** - * Handles GET requests to the app. + * Handles GET requests to the app. At the moment this only really consists of an authenticated + * view of the full leaderboard. * * @param {express.req} request An Express request. See https://expressjs.com/en/4x/api.html#req. * @param {express.res} response An Express response. See https://expressjs.com/en/4x/api.html#res. * @return {void} */ -const handleGet = ( request, response ) => { +const handleGet = async( request, response ) => { logRequest( request ); - response.send( 'It works! However, this app only accepts POST requests for now.' ); -}; + + switch ( request.path.replace( /\/$/, '' ) ) { + + // Full leaderboard. This will only work when a valid, non-expired token and timestamp are + // provided - the full link can be retrieved by requesting the leaderboard within Slack. + // TODO: This should probably be split out into a separate function of sorts, like handlePost. + case '/leaderboard': + if ( helpers.isTimeBasedTokenStillValid( request.query.token, request.query.ts ) ) { + response.send( await leaderboard.getForWeb( request ) ); + } else { + response + .status( HTTP_403 ) + .send( 'Sorry, this link is no longer valid. Please request a new link in Slack.' ); + } + break; + + // A simple default GET response is sometimes useful for troubleshooting. + default: + response.send( 'It works! However, this app only accepts POST requests for now.' ); + break; + + } + +}; // HandleGet. /** * Handles POST requests to the app. @@ -297,7 +112,7 @@ const handleGet = ( request, response ) => { * @param {express.req} request An Express request. See https://expressjs.com/en/4x/api.html#req. * @param {express.res} response An Express response. See https://expressjs.com/en/4x/api.html#res. * @return {bool|Promise} Either `false` if the event cannot be handled, or a Promise as returned - * by `handleEvent()`. + * by `events.handleEvent()`. */ const handlePost = ( request, response ) => { logRequest( request ); @@ -329,20 +144,12 @@ const handlePost = ( request, response ) => { return false; } - // Handle the event now, if it's valid. - if ( isValidEvent( request.body.event ) ) { - return handleEvent( request.body.event ); - } + // Handle the event now. If the event is invalid, this will return false. + return events.handleEvent( request.body.event, request ); }; // HandlePost. module.exports = { - setSlackClient, - isValidEvent, - extractEventData, - respondToUser, - updateScore, - handleEvent, logRequest, validateToken, handleGet, diff --git a/src/assets/main.css b/src/assets/main.css new file mode 100644 index 0000000..78f840e --- /dev/null +++ b/src/assets/main.css @@ -0,0 +1,108 @@ +/** + * Main stylesheet for web-based pages for Working PlusPlus++. + * + * @author Tim Malone + */ + +* { + transition: all .5s; +} + +body { + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; + background-color: #7DB83C; +} + +.wrapper { + width: 90%; + max-width: 900px; + margin: 3em auto; +} + +.wrapper:after { + display: table; + clear: both; + content: ''; +} + +.site-title, +h1, +h2 { + text-align: center; +} + +h1 { + margin: .5em 0 1em; +} + +.leaderboard { + box-sizing: border-box; + margin-bottom: 3em; + padding: 10px 25px 25px; + border-radius: 200px 100px; + background-color: #fff; +} + +ol { + list-style-type: none; +} + +li { + line-height: 1.5em; +} + +.rank:after { + content: '.'; + display: inline; +} + +.item { + font-weight: bold; +} + +.score { + margin-left: .25em; + font-size: .95em; + color: #aaa; +} + +@media only screen and (min-width: 430px) { + + body { + font-size: 16px; + } + + .leaderboard { + padding: 20px 50px 50px; + } + +} /* min-width: 430 */ + +@media only screen and (min-width: 800px) { + + .leaderboard { + margin-bottom: 0; + width: 50%; + opacity: .8; + transform: scale(.9); + } + + .leaderboard:hover { + opacity: 1; + transform: scale(1); + } + + .leaderboard:nth-child(odd) { + float: left; + clear: left; + transform-origin: top left; + } + + .leaderboard:nth-child(even) { + float: right; + transform-origin: top right; + border-radius: 100px 200px; + } + +} /* min-width: 800 */ diff --git a/src/events.js b/src/events.js new file mode 100644 index 0000000..1611268 --- /dev/null +++ b/src/events.js @@ -0,0 +1,234 @@ +/** + * Handles incoming events, using Slack's Events API. See also send.js, which handles outgoing + * messages sent back to Slack. + * + * @see https://api.slack.com/events-api + */ + +'use strict'; + +const slack = require( './slack' ), + points = require( './points' ), + helpers = require( './helpers' ), + messages = require( './messages' ), + operations = require( './operations' ), + leaderboard = require( './leaderboard' ); + +const camelCase = require( 'lodash.camelcase' ); + +/** + * Handles an attempt by a user to 'self plus' themselves, which includes both logging the attempt + * and letting the user know it wasn't successful. + * + * @param {object} user The ID of the user (Uxxxxxxxx) who tried to self plus. + * @param {object} channel The ID of the channel (Cxxxxxxxx for public channels or Gxxxxxxxx for + * private channels - aka groups) that the message was sent from. + * @return {Promise} A Promise to send a Slack message back to the requesting channel. + */ +const handleSelfPlus = ( user, channel ) => { + console.log( user + ' tried to alter their own score.' ); + const message = messages.getRandomMessage( operations.operations.SELF, user ); + return slack.sendMessage( message, channel ); +}; + +/** + * Handles a plus or minus against a user, and then notifies the channel of the new score. + * + * @param {string} item The Slack user ID (if user) or name (if thing) of the item being + * operated on. + * @param {string} operation The mathematical operation performed on the item's score. + * @param {object} channel The ID of the channel (Cxxxxxxxx for public channels or Gxxxxxxxx for + * private channels - aka groups) that the message was sent from. + * @return {Promise} A Promise to send a Slack message back to the requesting channel after the + * points have been updated. + */ +const handlePlusMinus = async( item, operation, channel ) => { + const score = await points.updateScore( item, operation ), + operationName = operations.getOperationName( operation ), + message = messages.getRandomMessage( operationName, item, score ); + + return slack.sendMessage( message, channel ); +}; + +/** + * Sends a random thank you message to the requesting channel. + * + * @param {object} event A hash of a validated Slack 'app_mention' event. See the docs at + * https://api.slack.com/events-api#events_dispatched_as_json and + * https://api.slack.com/events/app_mention for details. + * @returns {Promise} A Promise to send the Slack message. + */ +const sayThankyou = ( event ) => { + + const thankyouMessages = [ + 'Don\'t mention it!', + 'You\'re welcome.', + 'Pleasure!', + 'No thank YOU!', + ( + '++ for taking the time to say thanks!\n...' + + 'just kidding, I can\'t `++` you. But it\'s the thought that counts, right??' + ) + ]; + + const randomKey = Math.floor( Math.random() * thankyouMessages.length ), + message = '<@' + event.user + '> ' + thankyouMessages[ randomKey ]; + + return slack.sendMessage( message, event.channel ); + +}; // SayThankyou. + +/** + * Sends a help message, explaining the bot's commands, to the requesting channel. + * + * @param {object} event A hash of a validated Slack 'app_mention' event. See the docs at + * https://api.slack.com/events-api#events_dispatched_as_json and + * https://api.slack.com/events/app_mention for details. + * @returns {Promise} A Promise to send the Slack message. + */ +const sendHelp = ( event ) => { + + const botUserID = helpers.extractUserID( event.text ); + + const message = ( + 'Sure, here\'s what I can do:\n\n' + + 'â€ĸ `@Someone++`: Add points to a user or a thing\n' + + 'â€ĸ `@Someone--`: Subtract points from a user or a thing\n' + + 'â€ĸ `<@' + botUserID + '> leaderboard`: Display the leaderboard\n' + + 'â€ĸ `<@' + botUserID + '> help`: Display this message\n\n' + + 'You\'ll need to invite me to a channel before I can recognise ' + + '`++` and `--` commands in it.\n\n' + + 'If you\'re a developer, you can teach me new things! ' + + 'See to get started.' + ); + + return slack.sendMessage( message, event.channel ); + +}; // SendHelp. + +const handlers = { + + /** + * Handles standard incoming 'message' events sent from Slack. + * + * Assumes basic validation has been done before receiving the event. See handleEvent(). + * + * @param {object} event A hash of a validated Slack 'message' event. See the documentation at + * https://api.slack.com/events-api#events_dispatched_as_json and + * https://api.slack.com/events/message for details. + * @return {bool|Promise} Either `false` if the event cannot be handled, or a Promise to send a + * Slack message back to the requesting channel. + */ + message: ( event ) => { + + // Extract the relevant data from the message text. + const { item, operation } = helpers.extractPlusMinusEventData( event.text ); + + if ( ! item || ! operation ) { + return false; + } + + // Bail if the user is trying to ++ themselves... + if ( item === event.user && '+' === operation ) { + handleSelfPlus( event.user, event.channel ); + return false; + } + + // Otherwise, let's go! + return handlePlusMinus( item, operation, event.channel ); + + }, // Message event. + + /** + * Handles 'app_mention' events sent from Slack, primarily by looking for known app commands, and + * then handing the command off for processing. + * + * @param {object} event A hash of a validated Slack 'app_mention' event. See the docs at + * https://api.slack.com/events-api#events_dispatched_as_json and + * https://api.slack.com/events/app_mention for details. + * @param {object} request The incoming Express request object for this event. + * @return {bool|Promise} Either `false` if the event cannot be handled, or a Promise - usually + * to send a Slack message back to the requesting channel - which will be + * handled by the command's own handler. + */ + appMention: ( event, request ) => { + + const appCommandHandlers = { + leaderboard: leaderboard.handler, + help: sendHelp, + thx: sayThankyou, + thanks: sayThankyou, + thankyou: sayThankyou + }; + + const validCommands = Object.keys( appCommandHandlers ), + appCommand = helpers.extractCommand( event.text, validCommands ); + + if ( appCommand ) { + return appCommandHandlers[appCommand]( event, request ); + } + + const defaultMessage = ( + 'Sorry, I\'m not quite sure what you\'re asking me. I\'m not very smart - there\'s only a ' + + 'few things I\'ve been trained to do. Send me `help` for more details.' + ); + + return slack.sendMessage( defaultMessage, event.channel ); + + } // AppMention event. +}; // Handlers. + +/** + * Determines whether or not incoming events from Slack can be handled by this app, and if so, + * passes the event off to its handler function. + * + * @param {object} event A hash of a Slack event. See the documentation at + * https://api.slack.com/events-api#events_dispatched_as_json and + * https://api.slack.com/events/message for details. + * @param {object} request The incoming Express request object for this event. + * @return {bool|Promise} Either `false` if the event cannot be handled, or a Promise as returned + * by the event's handler function. + */ +const handleEvent = ( event, request ) => { + + // If the event has no type, something has gone wrong. + if ( 'undefined' === typeof event.type ) { + console.warn( 'Event data missing' ); + return false; + } + + // If the event has a subtype, we don't support it. + // TODO: We could look at this in the future, in particular, the bot_message subtype, which would + // allow us to react to messages sent by other bots. However, we'd have to be careful to + // filter appropriately, because otherwise we'll also react to messages from ourself. + // Because the 'help' output contains commands in it, that could look interesting! + if ( 'undefined' !== typeof event.subtype ) { + console.warn( 'Unsupported event subtype: ' + event.subtype ); + return false; + } + + // If there's no text with the event, there's not a lot we can do. + if ( 'undefined' === typeof event.text || ! event.text.trim() ) { + console.warn( 'Event text missing' ); + return false; + } + + // Providing we have a handler for the event, let's handle it! + const eventName = camelCase( event.type ); + if ( handlers[ eventName ] instanceof Function ) { + return handlers[ eventName ] ( event, request ); + } + + console.warn( 'Invalid event received: ' + event.type ); + return false; + +}; // HandleEvent. + +module.exports = { + handleSelfPlus, + handlePlusMinus, + sayThankyou, + sendHelp, + handlers, + handleEvent +}; diff --git a/src/helpers.js b/src/helpers.js new file mode 100644 index 0000000..b5dfeed --- /dev/null +++ b/src/helpers.js @@ -0,0 +1,236 @@ +/** + * Contains assorted helper functions. + * + * @author Tim Malone + */ + +'use strict'; + +const slack = require( './slack' ); + +const fs = require( 'fs' ), + crypto = require( 'crypto' ), + handlebars = require( 'handlebars' ); + +const templates = {}; + +/* eslint-disable no-process-env */ +const envSecret1 = process.env.SLACK_VERIFICATION_TOKEN, + envSecret2 = process.env.DATABASE_URL; +/* eslint-enable no-process-env */ + +const ONE_DAY = 60 * 60 * 24, // eslint-disable-line no-magic-numbers + TOKEN_TTL = ONE_DAY, + MILLISECONDS_TO_SECONDS = 1000; + +/** + * Given a message and a list of commands, extracts the first command mentioned in the message. + * + * TODO: May need to ensure that commands are whole words, so a smaller command doesn't get + * detected inside a larger one. + * + * @param {string} message The message text to search. + * @param {array} commands The commands to look for. + * @return {string|Boolean} Either the first command found, or false if no commands were found. + */ +const extractCommand = ( message, commands ) => { + + let firstLocation = Number.MAX_SAFE_INTEGER, + firstCommand; + + for ( const command of commands ) { + const location = message.indexOf( command ); + if ( -1 !== location && location < firstLocation ) { + firstLocation = location; + firstCommand = command; + } + } + + return firstCommand ? firstCommand : false; + +}; // ExtractCommand. + +/** + * Gets the user or 'thing' that is being spoken about, and the 'operation' being done on it. + * We take the operation down to one character, and also support — due to iOS' replacement of --. + * + * @param {string} text The message text sent through in the event. + * @returns {object} An object containing both the 'item' being referred to - either a Slack user + * ID (eg. 'U12345678') or the name of a 'thing' (eg. 'NameOfThing'); and the + * 'operation' being done on it - expressed as a valid mathematical operation + * (i.e. + or -). + */ +const extractPlusMinusEventData = ( text ) => { + const data = text.match( /@([A-Za-z0-9]+?)>?\s*(\+{2}|-{2}|—{1})/ ); + + if ( ! data ) { + return false; + } + + return { + item: data[1], + operation: data[2].substring( 0, 1 ).replace( '—', '-' ) + }; + +}; // ExtractPlusMinusEventData. + +/** + * Extracts a valid Slack user ID from a string of text. + * + * @param {string} text The string in question. + * @returns {string} The first matched Slack user ID in the string, or an empty string if a match + * could not be found. + * @see ::isUser + */ +const extractUserID = ( text ) => { + const match = text.match( /U[A-Z0-9]{8}/ ); + return match ? match[0] : ''; +}; + +/** + * Generates a time-based token based on secrets from the environment. + * + * @param {string} ts A timestamp to hash into the token. + * @returns {string} A token, that can be re-checked later using the same timestamp. + */ +const getTimeBasedToken = ( ts ) => { + + if ( ! ts ) { + throw Error( 'Timestamp not provided when getting time-based token.' ); + } + + return crypto + .createHmac( 'sha256', envSecret1 ) + .update( ts + envSecret2 ) + .digest( 'hex' ); +}; + +/** + * Returns the current time as a standard Unix epoch timestamp. + * + * @returns {integer} The current Unix timestamp. + */ +const getTimestamp = () => { + return Math.floor( Date.now() / MILLISECONDS_TO_SECONDS ); +}; + +/** + * Determines whether or not a number should be referred to as a plural - eg. anything but 1 or -1. + * + * @param {integer} number The number in question. + * @returns {Boolean} Whether or not the number is a plural. + */ +const isPlural = ( number ) => { + return 1 !== Math.abs( number ); +}; + +/** + * Validates a time-based token to ensure it is both still valid, and that it can be successfully + * re-hashed using the expected secrets. + * + * @param {string} token The token to validate. + * @param {integer} ts The timestamp the token was supplied with. + * @returns {boolean} Whether or not the token is valid. + */ +const isTimeBasedTokenStillValid = ( token, ts ) => { + const now = getTimestamp(); + + // Don't support tokens too far from the past. + if ( now > parseInt( ts ) + TOKEN_TTL ) { + return false; + } + + // Don't support tokens from the future. + if ( now < ts ) { + return false; + } + + const hash = getTimeBasedToken( ts ); + + if ( hash !== token ) { + return false; + } + + return true; +}; + +/** + * Determines whether or not a string represents a Slack user ID - eg. U12345678. + * + * @param {string} item The string in question. + * @returns {Boolean} Whether or not the string is a Slack user ID. + * @see ::extractUserID() + */ +const isUser = ( item ) => { + return item.match( /U[A-Z0-9]{8}/ ) ? true : false; +}; + +/** + * Takes an item and returns it maybe linked using Slack's 'mrkdwn' format (their own custom + * version of markdown). + * + * @param {string} item A raw 'item' - either a Slack user ID, or the name of a 'thing'. + * @return {string} The item linked with Slack mrkdwn + * @see https://api.slack.com/docs/message-formatting#linking_to_channels_and_users + */ +const maybeLinkItem = ( item ) => { + return isUser( item ) ? '<@' + item + '>' : item; +}; + +/** + * Renders HTML for the browser, using Handlebars. Includes a standard header and footer. + * + * @param {string} templatePath Path to the Handlebars-compatible template file to render; that is, + * a file containing valid HTML, with variable interpolations as + * required. The path should be relative to the app's entry-point + * (which is usually an index.js in the root of the repository). + * @param {object} context The Handlebars-compatible context to render; that is, an object with + * values for the variables referenced in the template. Also include + * standard variables referenced in the header and footer, such as + * 'title'. See the contents of ./html/ for more details. Some + * variables may have defaults provided, which can be overridden. + * @param {object} request Optional. The Express request object that resulted in this + * rendering job being run. Can be used to provide additional context + * to the templates. + * @returns {string} HTML ready to be rendered in the browser. + * @see https://handlebarsjs.com/ + */ +const render = async( templatePath, context = {}, request = {}) => { + + // Retrieve the header and footer HTML, if we don't already have it in memory. + if ( ! templates.header ) templates.header = fs.readFileSync( 'src/html/header.html', 'utf8' ); + if ( ! templates.footer ) templates.footer = fs.readFileSync( 'src/html/footer.html', 'utf8' ); + + // Retrieve the requested template HTML if it is not already in memory. + if ( ! templates[ templatePath ]) { + console.log( 'Retrieving template ' + templatePath + '.' ); + templates[ templatePath ] = fs.readFileSync( templatePath, 'utf8' ); + } + + /* eslint-disable camelcase */ // Handlebars templates commonly use snake_case instead. + const defaults = { + site_title: ( + request.query.botUser ? + await slack.getUserName( request.query.botUser ) : + 'Working PlusPlus++' + ) + }; + /* eslint-enable camelcase */ + + const output = templates.header + templates[ templatePath ] + templates.footer; + return handlebars.compile( output )( Object.assign( defaults, context ) ); + +}; // Render. + +module.exports = { + extractCommand, + extractPlusMinusEventData, + extractUserID, + getTimeBasedToken, + getTimestamp, + isPlural, + isTimeBasedTokenStillValid, + isUser, + maybeLinkItem, + render +}; diff --git a/src/html/footer.html b/src/html/footer.html new file mode 100644 index 0000000..8b03368 --- /dev/null +++ b/src/html/footer.html @@ -0,0 +1,3 @@ + + + diff --git a/src/html/header.html b/src/html/header.html new file mode 100644 index 0000000..63b3084 --- /dev/null +++ b/src/html/header.html @@ -0,0 +1,17 @@ + + + + {{title}} - {{site_title}} + + + + +
+ +
+ {{site_title}} +
+ +

+ {{title}} +

diff --git a/src/html/leaderboard.html b/src/html/leaderboard.html new file mode 100644 index 0000000..27de702 --- /dev/null +++ b/src/html/leaderboard.html @@ -0,0 +1,26 @@ + +
+

Users

+
    + {{#each users}} +
  1. + {{this.rank}} + {{this.item}} + {{this.score}} +
  2. + {{/each}} +
+
+ +
+

Things

+
    + {{#each things}} +
  1. + {{this.rank}} + {{this.item}} + {{this.score}} +
  2. + {{/each}} +
+
diff --git a/src/leaderboard.js b/src/leaderboard.js new file mode 100644 index 0000000..0389360 --- /dev/null +++ b/src/leaderboard.js @@ -0,0 +1,208 @@ +/** + * Contains logic for returning the leaderboard. + * + * @author Tim Malone + */ + +'use strict'; + +const slack = require( './slack' ), + points = require( './points' ), + helpers = require( './helpers' ); + +const querystring = require( 'querystring' ); + +/** + * Gets the URL for the full leaderboard, including a token to ensure that it is only viewed by + * someone who has access to this Slack team. + * + * @param {object} request The Express request object that resulted in this handler being run. + * @returns {string} The leaderboard URL, which will be picked up in ../index.js when called. + */ +const getLeaderboardUrl = ( request ) => { + + const hostname = request.headers.host, + ts = helpers.getTimestamp(); + + const params = { + token: helpers.getTimeBasedToken( ts ), + ts, + botUser: helpers.extractUserID( request.body.event.text ) + }; + + const url = 'https://' + hostname + '/leaderboard?' + querystring.stringify( params ); + return url; + +}; // GetLeaderboardUrl. + +/** + * Ranks items by their scores, returning them in a human readable list complete with emoji for the + * winner. Items which draw will be given the same rank, and the next rank will then be skipped. + * + * For example, 2 users on 54 would draw 1st. The next user on 52 would be 3rd, and the final on 34 + * would be 4th. + * + * @param {array} topScores An array of score objects, usually pre-retrieved by + * points.retrieveTopScores(). These *must* be in 'top score' order (i.e. + * descending order), otherwise ranking will not function correctly. Score + * objects contain 'item' and 'score' properties. + * @param {string} itemType The type of item to rank. Accepts 'users' or 'things'. Only one type + * can be ranked at a time. + * @param {string} format The format to return the results in. 'slack' returns or 'object'. + * + * @returns {array} An array, in rank order, of either of either human-readable Slack strings (if + * format is 'slack') or objects containing 'rank', 'item' and 'score' values (if + * format is 'object'). + */ +const rankItems = async( topScores, itemType = 'users', format = 'slack' ) => { + + let lastScore, lastRank, output; + const items = []; + + for ( const score of topScores ) { + + let item = score.item; + const isUser = helpers.isUser( score.item ) ? true : false; + + // Skip if this item is not the item type we're ranking. + if ( isUser && 'users' !== itemType || ! isUser && 'users' === itemType ) { + continue; + } + + // For users, we need to link the item (for Slack) or get their real name (for other formats). + if ( isUser ) { + item = ( + 'slack' === format ? helpers.maybeLinkItem( item ) : await slack.getUserName( item ) + ); + } + + const itemTitleCase = item.substring( 0, 1 ).toUpperCase() + item.substring( 1 ), + plural = helpers.isPlural( score.score ) ? 's' : ''; + + // Determine the rank by keeping it the same as the last user if the score is the same, or + // otherwise setting it to the same as the item count (and adding 1 to deal with 0-base count). + const rank = score.score === lastScore ? lastRank : items.length + 1; + + switch ( format ) { + case 'slack': + + output = ( + rank + '. ' + itemTitleCase + ' [' + score.score + ' point' + plural + ']' + ); + + // If this is the first item, it's the winner! + if ( ! items.length ) { + output += ' ' + ( isUser ? ':muscle:' : ':tada:' ); + } + + break; + + case 'object': + output = { + rank, + item: itemTitleCase, + score: score.score + ' point' + plural + }; + break; + } + + items.push( output ); + + lastRank = rank; + lastScore = score.score; + + } // For scores. + + return items; + +}; // RankItems. + +/** + * Retrieves and sends the current partial leaderboard (top scores only) to the requesting Slack + * channel. + * + * @param {object} event A hash of a validated Slack 'app_mention' event. See the docs at + * https://api.slack.com/events-api#events_dispatched_as_json and + * https://api.slack.com/events/app_mention for details. + * @param {object} request The Express request object that resulted in this handler being run. + * @returns {Promise} A Promise to send the Slack message. + */ +const getForSlack = async( event, request ) => { + + const limit = 5; + + const scores = await points.retrieveTopScores(), + users = await rankItems( scores, 'users' ), + things = await rankItems( scores, 'things' ); + + const messageText = ( + 'Here you go. ' + + 'Or see the <' + getLeaderboardUrl( request ) + '|whole list>.' + ); + + const message = { + attachments: [ + { + text: messageText, + color: 'good', // Slack's 'green' colour. + fields: [ + { + title: 'Users', + value: users.slice( 0, limit ).join( '\n' ), + short: true + }, + { + title: 'Things', + value: things.slice( 0, limit ).join( '\n' ), + short: true + } + ] + } + ] + }; + + console.log( 'Sending the leaderboard.' ); + return slack.sendMessage( message, event.channel ); + +}; // GetForSlack. + +/** + * Retrieves and returns HTML for the full leaderboard, for displaying on the web. + * + * @param {object} request The Express request object that resulted in this handler being run. + * @returns {string} HTML for the browser. + */ +const getForWeb = async( request ) => { + + const scores = await points.retrieveTopScores(), + users = await rankItems( scores, 'users', 'object' ), + things = await rankItems( scores, 'things', 'object' ); + + const data = { + users, + things, + title: 'Leaderboard' + }; + + return helpers.render( 'src/html/leaderboard.html', data, request ); + +}; // GetForWeb. + +/** + * The default handler for this command when invoked over Slack. + * + * @param {*} event See the documentation for getForSlack. + * @param {*} request See the documentation for getForSlack. + * @returns {*} See the documentation for getForSlack. + */ +const handler = async( event, request ) => { + return getForSlack( event, request ); +}; + +module.exports = { + getLeaderboardUrl, + rankItems, + getForSlack, + getForWeb, + handler +}; diff --git a/src/messages.js b/src/messages.js index 3621077..c0245ff 100644 --- a/src/messages.js +++ b/src/messages.js @@ -9,7 +9,9 @@ 'use strict'; -const operations = require( './operations' ); +const helpers = require( './helpers' ), + operations = require( './operations' ).operations; + const messages = {}; messages[ operations.PLUS ] = [ @@ -81,20 +83,15 @@ messages[ operations.SELF ] = [ * * @param {string} operation The name of the operation to retrieve potential messages for. * See operations.js. - * @param {string} item The subject of the message, either "<@user>" or "object". - * @param {integer} score The item's current score. + * @param {string} item The subject of the message, eg. 'U12345678' or 'SomeRandomThing'. + * @param {integer} score The item's current score. Defaults to 0 if not supplied. * * @returns {string} A random message from the chosen pool. */ -const getRandomMessage = ( operation, item, score ) => { +const getRandomMessage = ( operation, item, score = 0 ) => { const messageSets = messages[ operation ]; - - let setRandom, - set, - totalProbability = 0, - chosenSet = null, - format = ''; + let format = ''; switch ( operation ) { case operations.MINUS: @@ -107,37 +104,38 @@ const getRandomMessage = ( operation, item, score ) => { break; default: - throw 'Invalid operation: ' + operation; + throw Error ( 'Invalid operation: ' + operation ); } - for ( set of messageSets ) { + let totalProbability = 0; + for ( const set of messageSets ) { totalProbability += set.probability; } - setRandom = Math.floor( Math.random() * totalProbability ); + let chosenSet = null, + setRandom = Math.floor( Math.random() * totalProbability ); - for ( set of messageSets ) { + for ( const set of messageSets ) { setRandom -= set.probability; if ( 0 > setRandom ) { chosenSet = set.set; - break; } } if ( null === chosenSet ) { - throw ( - 'Could not find set for ' + operation + ' ran out of sets with ' + setRandom + ' remaining' + throw Error( + 'Could not find set for ' + operation + ' (ran out of sets with ' + setRandom + ' remaining)' ); } - const plural = 1 === Math.abs( score ) ? '' : 's'; - const max = chosenSet.length - 1; - const random = Math.floor( Math.random() * max ); - const message = chosenSet[ random ]; + const plural = helpers.isPlural( score ) ? 's' : '', + max = chosenSet.length - 1, + random = Math.floor( Math.random() * max ), + message = chosenSet[ random ]; - const formattedMessage = format.replace( '', item ) + const formattedMessage = format.replace( '', helpers.maybeLinkItem( item ) ) .replace( '', score ) .replace( '', plural ) .replace( '', message ); @@ -147,6 +145,6 @@ const getRandomMessage = ( operation, item, score ) => { }; // GetRandomMessage. module.exports = { - getRandomMessage, - messages + messages, + getRandomMessage }; diff --git a/src/operations.js b/src/operations.js index a78ee02..6805aa1 100644 --- a/src/operations.js +++ b/src/operations.js @@ -1,11 +1,36 @@ /** - * Provides constants for operations. + * Provides constants and supporting functions for operations. */ 'use strict'; -module.exports = { +const operations = { PLUS: 'plus', MINUS: 'minus', SELF: 'selfPlus' }; + +/** + * Given a mathematical operation, returns the name of that operation. + * + * @param {string} operation A mathematical operation such as '+' or '-'. + * @return {string} The name of the operation. + */ +const getOperationName = ( operation ) => { + let operationName = ''; + + /* eslint-disable max-statements-per-line */ + switch ( operation ) { + case '+': operationName = operations.PLUS; break; + case '-': operationName = operations.MINUS; break; + } + /* eslint-enable max-statements-per-line */ + + return operationName ? operationName : false; + +}; + +module.exports = { + operations, + getOperationName +}; diff --git a/src/points.js b/src/points.js new file mode 100644 index 0000000..abc201b --- /dev/null +++ b/src/points.js @@ -0,0 +1,99 @@ +/** + * All the stuff that handles the giving, taking away, or otherwise querying of points. + * + * NOTE: As the functions here pretty much deal exclusively with the database, they generally + * aren't unit tested, as that would require anyone who runs the tests to also have a Postgres + * server. Instead, the functions in this file are well covered via the integration and + * end-to-end tests. + * + * @author Tim Malone + */ + +'use strict'; + +const pg = require( 'pg' ); + +/* eslint-disable no-process-env */ +const DATABASE_URL = process.env.DATABASE_URL, + DATABASE_USE_SSL = 'false' === process.env.DATABASE_USE_SSL ? false : true; +/* eslint-enable no-process-env */ + +const scoresTableName = 'scores', + postgresPoolConfig = { + connectionString: DATABASE_URL, + ssl: DATABASE_USE_SSL + }; + +const postgres = new pg.Pool( postgresPoolConfig ); + +/** + * Retrieves all scores from the database, ordered from highest to lowest. + * + * TODO: Add further smarts to retrieve only a limited number of scores, to avoid having to query + * everything. Note that this isn't just LIMIT, because we'll need to apply the limit + * separately to both users (/U[A-Z0-9]{8}/) and things (everything else) & return both sets. + * + * @return {array} An array of entries, each an object containing 'item' (string) and 'score' + * (integer) properties. + */ +const retrieveTopScores = async() => { + + const query = 'SELECT * FROM ' + scoresTableName + ' ORDER BY score DESC'; + + const dbClient = await postgres.connect(), + result = await dbClient.query( query ), + scores = result.rows; + + await dbClient.release(); + + return scores; + +}; + +/** + * Updates the score of an item in the database. If the item doesn't yet exist, it will be inserted + * into the database with an assumed initial score of 0. + * + * This function also sets up the database if it is not already ready, including creating the + * scores table and activating the Postgres case-insensitive extension. + * + * @param {string} item The Slack user ID (if user) or name (if thing) of the item being + * operated on. + * @param {string} operation The mathematical operation performed on the item's score. + * @return {int} The item's new score after the update has been applied. + */ +const updateScore = async( item, operation ) => { + + // Connect to the DB, and create a table if it's not yet there. + // We also set up the citext extension, so that we can easily be case insensitive. + const dbClient = await postgres.connect(); + await dbClient.query( '\ + CREATE EXTENSION IF NOT EXISTS citext; \ + CREATE TABLE IF NOT EXISTS ' + scoresTableName + ' (item CITEXT PRIMARY KEY, score INTEGER); \ + ' ); + + // Atomically record the action. + // TODO: Fix potential SQL injection issues here, even though we know the input should be safe. + await dbClient.query( '\ + INSERT INTO ' + scoresTableName + ' VALUES (\'' + item + '\', ' + operation + '1) \ + ON CONFLICT (item) DO UPDATE SET score = ' + scoresTableName + '.score ' + operation + ' 1; \ + ' ); + + // Get the new value. + // TODO: Fix potential SQL injection issues here, even though we know the input should be safe. + const dbSelect = await dbClient.query( '\ + SELECT score FROM ' + scoresTableName + ' WHERE item = \'' + item + '\'; \ + ' ); + + await dbClient.release(); + const score = dbSelect.rows[0].score; + + console.log( item + ' now on ' + score ); + return score; + +}; // UpdateScore. + +module.exports = { + retrieveTopScores, + updateScore +}; diff --git a/src/slack.js b/src/slack.js new file mode 100644 index 0000000..0f3802a --- /dev/null +++ b/src/slack.js @@ -0,0 +1,120 @@ +/** + * Handles sending of messages - i.e. outgoing messages - back to Slack, via Slack's Web API. See + * also ./events.js, which handles incoming messages from subscribed events. + * + * TODO: This file should probably be renamed to 'slack.js' so it can handle all other requests to + * the Slack APIs rather than just sending. + * + * @see https://api.slack.com/web + */ + +'use strict'; + +let slack, users; + +/** + * Injects the Slack client to be used for all outgoing messages. + * + * @param {WebClient} client An instance of Slack's WebClient as documented at + * https://slackapi.github.io/node-slack-sdk/web_api and + * implemented at + * https://github.com/slackapi/node-slack-sdk/blob/master/src/WebClient.ts + * @returns {void} + */ +const setSlackClient = ( client ) => { + slack = client; +}; + +/** + * Retrieves a list of all users in the linked Slack team. Caches it in memory. + * + * @returns {object} A collection of Slack user objects, indexed by the user IDs (Uxxxxxxxx). + */ +const getUserList = async() => { + + if ( users ) { + return users; + } + + console.log( 'Retrieving user list from Slack.' ); + + users = {}; + const userList = await slack.users.list(); + + if ( ! userList.ok ) { + throw Error( 'Error occurred retrieving user list from Slack.' ); + } + + for ( const user of userList.members ) { + users[ user.id ] = user; + } + + return users; + +}; // GetUserList. + +/** + * Given a Slack user ID, returns the user's real name or optionally, the user's username. If the + * user *does not* have a real name set, their username is returned regardless. + * + * @param {string} userId A Slack user ID in the format Uxxxxxxxx. + * @param {bool} username Whether the username should always be returned instead of the real name. + * @returns {string} The user's real name, as per their Slack profile. + */ +const getUserName = async( userId, username = false ) => { + + const users = await getUserList(), + user = users[ userId ]; + + if ( 'undefined' === typeof user ) { + return '(unknown)'; + } + + return username || ! user.profile.real_name ? user.name : user.profile.real_name; + +}; + +/** + * Sends a message to a Slack channel. + * + * @param {string|Object} text Either message text to send, or a Slack message payload. See the + * docs at https://api.slack.com/methods/chat.postMessage and + * https://api.slack.com/docs/message-formatting. + * @param {string} channel The ID of the channel to send the message to. Can alternatively + * be provided as part of the payload in the previous argument. + * @return {Promise} A Promise to send the message to Slack. + */ +const sendMessage = ( text, channel ) => { + + let payload = { + channel, + text + }; + + // If 'text' was provided as an object instead, merge it into the payload. + if ( 'object' === typeof text ) { + delete payload.text; + payload = Object.assign( payload, text ); + } + + return new Promise( ( resolve, reject ) => { + slack.chat.postMessage( payload ).then( ( data ) => { + + if ( ! data.ok ) { + console.error( 'Error occurred posting response.' ); + return reject(); + } + + resolve(); + + }); + + }); // Return new Promise. +}; // SendMessage. + +module.exports = { + setSlackClient, + getUserList, + getUserName, + sendMessage +}; diff --git a/tests/.eslintrc.js b/tests/.eslintrc.js index e91e547..800d9b9 100644 --- a/tests/.eslintrc.js +++ b/tests/.eslintrc.js @@ -8,10 +8,13 @@ module.exports = { rules: { + 'jest/expect-expect': [ 'error' ], + 'jest/valid-expect-in-promise': [ 'error' ], 'no-empty-function': 'off', 'no-loop-func': 'off', 'no-magic-numbers': 'off', 'no-process-env': 'off', - 'max-nested-callbacks': [ 'error', { max: 5 } ] + 'max-nested-callbacks': [ 'error', { max: 5 } ], + 'max-params': [ 'error', { max: 5 } ] } }; diff --git a/tests/_config.js b/tests/_config.js index ec939ff..0d8f410 100644 --- a/tests/_config.js +++ b/tests/_config.js @@ -23,7 +23,10 @@ module.exports = { host: 'localhost', method: 'POST', port: PORT, - headers: { 'Content-Type': 'application/json' } + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'Test Runner' + } } }; diff --git a/tests/_runner.js b/tests/_runner.js index 934c212..9821927 100644 --- a/tests/_runner.js +++ b/tests/_runner.js @@ -1,6 +1,6 @@ /** * Custom test runner that makes an HTTP request to the app and facilitates checking the outcome. - * Primarily used for end-to-end tests but also useful for some integration tests. + * Used for end-to-end testing. * * @author Tim Malone */ diff --git a/tests/app.js b/tests/app.js index 5fdfb37..8ba1f28 100644 --- a/tests/app.js +++ b/tests/app.js @@ -2,6 +2,7 @@ * Unit tests on the main app.js file. * * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api * @author Tim Malone */ @@ -9,10 +10,7 @@ 'use strict'; -const app = require( '../src/app' ), - slackClientMock = require( './mocks/slack' ); - -app.setSlackClient( slackClientMock ); +const app = require( '../src/app' ); // Catch all console output during tests. console.error = jest.fn(); @@ -20,191 +18,6 @@ console.info = jest.fn(); console.log = jest.fn(); console.warn = jest.fn(); -describe( 'isValidEvent', () => { - - it( 'reports an event with message and text as valid', () => { - const event = { - type: 'message', - text: 'Hello' - }; - - expect( app.isValidEvent( event ) ).toBe( true ); - }); - - it( 'reports an event with missing type as invalid', () => { - const event = { text: 'Hello' }; - expect( app.isValidEvent( event ) ).toBe( false ); - }); - - it( 'reports an event without type \'message\' as invalid', () => { - const event = { - type: 'random', - text: 'Hello' - }; - - expect( app.isValidEvent( event ) ).toBe( false ); - }); - - it( 'reports an event with a subtype as invalid', () => { - const event = { - type: 'message', - subtype: 'random', - text: 'Hello' - }; - - expect( app.isValidEvent( event ) ).toBe( false ); - }); - - it( 'reports an event without text as invalid', () => { - const event = { type: 'message' }; - expect( app.isValidEvent( event ) ).toBe( false ); - }); - - it( 'reports an event with only a space for text as invalid', () => { - const event = { - type: 'message', - text: ' ' - }; - - expect( app.isValidEvent( event ) ).toBe( false ); - }); - -}); // IsValidEvent. - -describe( 'extractEventData', () => { - - it( 'drops message without an @ symbol', () => { - expect( app.extractEventData( 'Hello++' ) ).toBe( false ); - }); - - it( 'drops messages without a valid operation', () => { - expect( app.extractEventData( '@Hello' ) ).toBe( false ); - }); - - it( 'drops messages without a valid user/item', () => { - expect( app.extractEventData( '@++' ) ).toBe( false ); - }); - - it( 'extracts a \'thing\' and operation from the start of a message', () => { - expect( app.extractEventData( '@SomethingRandom++ that was awesome' ) ).toEqual({ - item: 'SomethingRandom', - operation: '+' - }); - }); - - it( 'extracts a user and operation from the start of a message', () => { - expect( app.extractEventData( '<@U87654321>++ that was awesome' ) ).toEqual({ - item: 'U87654321', - operation: '+' - }); - }); - - it( 'extracts data in the middle of a message', () => { - expect( app.extractEventData( 'Hey @SomethingRandom++ that was awesome' ) ).toEqual({ - item: 'SomethingRandom', - operation: '+' - }); - }); - - it( 'extracts data at the end of a message', () => { - expect( app.extractEventData( 'Awesome work @SomethingRandom++' ) ).toEqual({ - item: 'SomethingRandom', - operation: '+' - }); - }); - - const itemsToMatch = [ - { - supplied: '<@U1234567890>', - expected: 'U1234567890' - }, - { - supplied: '@SomethingRandom', - expected: 'SomethingRandom' - }, - { - supplied: '@SomethingRandom123', - expected: 'SomethingRandom123' - } - ]; - - const operationsToMatch = [ - { - supplied: '++', - expected: '+' - }, - { - supplied: '--', - expected: '-' - }, - { - supplied: '—', // Emdash, which iOS replaces -- with. - expected: '-' - } - ]; - - const operationsNotToMatch = [ - '+', - '-' - ]; - - for ( const item of itemsToMatch ) { - - for ( const operation of operationsToMatch ) { - for ( let iterator = 0; 1 >= iterator; iterator++ ) { - - const space = 1 === iterator ? ' ' : '', - messageText = item.supplied + space + operation.supplied, - testName = ( - 'matches ' + messageText + ' as ' + item.expected + ' and ' + operation.expected - ); - - it( testName, () => { - const result = app.extractEventData( messageText ); - expect( result ).toEqual({ - item: item.expected, - operation: operation.expected - }); - }); - - } // For iterator. - } // For operationsToMatch. - - for ( const operation of operationsNotToMatch ) { - const messageText = item.supplied + operation; - it( 'does NOT match ' + messageText, () => { - expect( app.extractEventData( messageText ) ).toBe( false ); - }); - } - - } // For itemsToMatch. -}); // ExtractEventData. - -/** - * These functions are both suitably covered in the end-to-end tests, and are probably difficult to - * test as individual units. - */ -describe( 'respondToUser', () => {}); -describe( 'updateScore', () => {}); - -describe( 'handleEvent', () => { - - it( 'drops a user trying to ++ themselves', () => { - const event = { - type: 'message', - text: '<@U12345678>++', - user: 'U12345678' - }; - - expect.hasAssertions(); - - return app.handleEvent( event ).then( ( data ) => { - expect( data ).toBe( false ); - }); - }); - -}); - describe( 'logRequest', () => { it( 'logs request data to stdout', () => { @@ -259,7 +72,7 @@ describe( 'validateToken', () => { }); it( 'returns true for a token that DOES match', () => { - expect( app.validateToken( 'something', 'something' ) ).toBe( true ); + expect( app.validateToken( 'something', 'something' ) ).toBeTrue(); }); }); // ValidateToken. @@ -321,7 +134,7 @@ describe( 'handlePost', () => { mockExpress.request.body.challenge = Math.random().toString(); const result = app.handlePost( mockExpress.request, mockExpress.response ); expect( receivedResponse ).toBe( mockExpress.request.body.challenge ); - expect( result ).toBe( false ); + expect( result ).toBeFalse(); }); diff --git a/tests/e2e-tests.js b/tests/e2e-tests.js index 6e781e5..b5b7eaf 100644 --- a/tests/e2e-tests.js +++ b/tests/e2e-tests.js @@ -2,6 +2,7 @@ * End-to-end tests. * * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api * @author Tim Malone */ @@ -61,37 +62,41 @@ afterAll( () => { describe( 'The database', () => { - it( 'stores a ++ for a new \'thing\' (ThingA) and returns a score of 1', ( done ) => { - expect.hasAssertions(); - runner( '@ThingA++', { itemToCheck: 'ThingA' }, ( result ) => { - expect( result ).toBe( 1 ); - done(); - }); - }); + const thingTable = [ + [ '++', 'new ', 'ThingA', 1 ], + [ '++', 'existing', 'ThingA', 2 ], + [ '--', 'new ', 'ThingB', -1 ], + [ '--', 'existing', 'ThingB', -2 ] + ]; - it( 'stores a -- for a new \'thing\' (ThingB) and returns a score of -1', ( done ) => { - expect.hasAssertions(); - runner( '@ThingB--', { itemToCheck: 'ThingB' }, ( result ) => { - expect( result ).toBe( -1 ); - done(); - }); - }); + const userTable = [ + [ '++', 'new ', 'U00000100', 1 ], + [ '++', 'existing', 'U00000100', 2 ], + [ '--', 'new ', 'U00000200', -1 ], + [ '--', 'existing', 'U00000200', -2 ] + ]; - it( 'stores a ++ for an existing \'thing\' (ThingA) and returns a score of 2', ( done ) => { - expect.hasAssertions(); - runner( '@ThingA++', { itemToCheck: 'ThingA' }, ( result ) => { - expect( result ).toBe( 2 ); - done(); - }); - }); + it.each( thingTable )( + 'stores a %s for a %s thing (%s) and returns a score of %d', + ( operation, description, thing, score, done ) => { + expect.hasAssertions(); + runner( '@' + thing + operation, { itemToCheck: thing }, ( result ) => { + expect( result ).toBe( score ); + done(); + }); + } + ); - it( 'stores a -- for an existing \'thing\' (ThingB) and returns a score of -2', ( done ) => { - expect.hasAssertions(); - runner( '@ThingB--', { itemToCheck: 'ThingB' }, ( result ) => { - expect( result ).toBe( -2 ); - done(); - }); - }); + it.each( userTable )( + 'stores a %s for a %s user (%s) and returns a score of %d', + ( operation, description, user, score, done ) => { + expect.hasAssertions(); + runner( '<@' + user + '>' + operation, { itemToCheck: user }, ( result ) => { + expect( result ).toBe( score ); + done(); + }); + } + ); it( 'stores a ++ for ThInGa in a different case and returns a score of 3', ( done ) => { expect.hasAssertions(); @@ -101,46 +106,14 @@ describe( 'The database', () => { }); }); - it( 'stores a ++ for a new user (100) and returns a score of 1', ( done ) => { - expect.hasAssertions(); - runner( '<@U00000100>++', { itemToCheck: 'U00000100' }, ( result ) => { - expect( result ).toBe( 1 ); - done(); - }); - }); - - it( 'stores a -- for a new user (200) and returns a score of -1', ( done ) => { - expect.hasAssertions(); - runner( '<@U00000200>--', { itemToCheck: 'U00000200' }, ( result ) => { - expect( result ).toBe( -1 ); - done(); - }); - }); - - it( 'stores a ++ for an existing user (100) and returns a score of 2', ( done ) => { - expect.hasAssertions(); - runner( '<@U00000100>++', { itemToCheck: 'U00000100' }, ( result ) => { - expect( result ).toBe( 2 ); - done(); - }); - }); - - it( 'stores a -- for an existing user (200) and returns a score of -2', ( done ) => { - expect.hasAssertions(); - runner( '<@U00000200>--', { itemToCheck: 'U00000200' }, ( result ) => { - expect( result ).toBe( -2 ); - done(); - }); - }); - it( 'refuses a self ++ for an existing user (100) and still returns a score of 2', ( done ) => { expect.hasAssertions(); - const user = 'U00000100', - options = { - itemToCheck: user, - extraBody: { event: { user } } - }; + const user = 'U00000100'; + const options = { + itemToCheck: user, + extraBody: { event: { user } } + }; runner( '<@' + user + '>++', options, ( result ) => { expect( result ).toBe( 2 ); @@ -158,7 +131,7 @@ describe( 'The database', () => { }; runner( '<@' + user + '>++', options, ( result ) => { - expect( result ).toBe( false ); + expect( result ).toBeFalse(); done(); }); }); @@ -253,119 +226,60 @@ describe( 'Slack messaging', () => { }); }); - it( 'contains a user\'s link (user 100) and a score of 4 after another ++', ( done ) => { - expect.hasAssertions(); - const user = 'U00000100'; - - slackClientMock.chat.postMessage.mockClear(); - runner( '<@' + user + '>++', () => { - - expect( slackClientMock.chat.postMessage ) - .toHaveBeenCalledTimes( 1 ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringContaining( '<@' + user + '>' ) }) - ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringMatching( /\s4\b/ ) }) - ); - - done(); - - }); - }); - - it( 'contains a user\'s link (user 200) and a score of -4 after another --', ( done ) => { - expect.hasAssertions(); - const user = 'U00000200'; - - slackClientMock.chat.postMessage.mockClear(); - runner( '<@' + user + '>--', () => { - - expect( slackClientMock.chat.postMessage ) - .toHaveBeenCalledTimes( 1 ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringContaining( '<@' + user + '>' ) }) - ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringMatching( /\s-4\b/ ) }) - ); - - done(); - - }); - }); - - it( 'contains the singular \'point\' after a ++ for new ThingC (i.e. score 1)', ( done ) => { - expect.hasAssertions(); - const thing = 'ThingC'; - - slackClientMock.chat.postMessage.mockClear(); - runner( '@' + thing + '++', () => { - - expect( slackClientMock.chat.postMessage ) - .toHaveBeenCalledTimes( 1 ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringMatching( /\spoint\b/ ) }) - ); - - done(); - - }); - }); + const userTable = [ + [ 'U00000100', 4, '++' ], + [ 'U00000200', -4, '--' ] + ]; - it( 'contains the plural \'points\' after another ++ for ThingC (i.e. score 2)', ( done ) => { - expect.hasAssertions(); - const thing = 'ThingC'; + it.each( userTable )( + 'contains a user\'s link (%s) and a score of %d after another %s', + ( user, score, operation, done ) => { + expect.hasAssertions(); + const scoreRegExp = new RegExp( '\\s' + score + '\\b' ); - slackClientMock.chat.postMessage.mockClear(); - runner( '@' + thing + '++', () => { + slackClientMock.chat.postMessage.mockClear(); + runner( '<@' + user + '>' + operation, () => { - expect( slackClientMock.chat.postMessage ) - .toHaveBeenCalledTimes( 1 ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringMatching( /\spoints\b/ ) }) - ); + expect( slackClientMock.chat.postMessage ) + .toHaveBeenCalledTimes( 1 ) + .toHaveBeenCalledWith( + expect.objectContaining({ text: expect.stringContaining( '<@' + user + '>' ) }) + ) + .toHaveBeenCalledWith( + expect.objectContaining({ text: expect.stringMatching( scoreRegExp ) }) + ); - done(); + done(); + }); }); - }); - - it( 'contains the singular \'point\' after a -- for new ThingD (i.e. score -1)', ( done ) => { - expect.hasAssertions(); - const thing = 'ThingD'; - - slackClientMock.chat.postMessage.mockClear(); - runner( '@' + thing + '--', () => { - expect( slackClientMock.chat.postMessage ) - .toHaveBeenCalledTimes( 1 ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringMatching( /\spoint\b/ ) }) - ); - - done(); - - }); - }); + const thingTable = [ + [ 'point', '++', 'ThingC', 1 ], + [ 'points', '++', 'ThingC', 2 ], + [ 'point', '--', 'ThingD', 1 ], + [ 'points', '--', 'ThingD', 2 ] + ]; - it( 'contains the plural \'points\' after another -- for ThingD (i.e. score -2)', ( done ) => { - expect.hasAssertions(); - const thing = 'ThingD'; + it.each( thingTable )( + 'contains \'%s\' after a %s for %s (i.e. score %d)', + ( word, operation, thing, sampleScore, done ) => { + expect.hasAssertions(); + const wordRegExp = new RegExp( '\\s' + word + '\\b' ); - slackClientMock.chat.postMessage.mockClear(); - runner( '@' + thing + '--', () => { + slackClientMock.chat.postMessage.mockClear(); + runner( '@' + thing + operation, () => { - expect( slackClientMock.chat.postMessage ) - .toHaveBeenCalledTimes( 1 ) - .toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringMatching( /\spoints\b/ ) }) - ); + expect( slackClientMock.chat.postMessage ) + .toHaveBeenCalledTimes( 1 ) + .toHaveBeenCalledWith( + expect.objectContaining({ text: expect.stringMatching( wordRegExp ) }) + ); - done(); + done(); + }); }); - }); it( 'contains the plural \'points\' for a score of 0 (ThingE++ then --)', ( done ) => { expect.hasAssertions(); @@ -386,42 +300,26 @@ describe( 'Slack messaging', () => { }); }); - const operations = [ - { - name: 'plus', - operation: '++', - extraData: {} - }, - { - name: 'minus', - operation: '--', - extraData: {} - }, - { - name: 'selfPlus', - operation: '++', - extraData: { event: { user: 'U12345678' } } - } + const operationsTable = [ + [ 'plus', '++', {} ], + [ 'minus', '--', {} ], + [ 'selfPlus', '++', { event: { user: 'U12345678' } } ] ]; - for ( const operation of operations ) { - - const testName = ( - 'sends a message from the ' + operation.name + ' collection for a ' + operation.operation - ); - - it( testName, ( done ) => { + it.each( operationsTable )( + 'sends a message from the %s collection for a %s', + ( name, operation, extraData, done ) => { expect.hasAssertions(); slackClientMock.chat.postMessage.mockClear(); - const messageText = '<@U12345678>' + operation.operation, - options = { extraBody: operation.extraData }; + const messageText = '<@U12345678>' + operation, + options = { extraBody: extraData }; runner( messageText, options, async() => { const postMessageCall = slackClientMock.chat.postMessage.mock.calls[0], payload = postMessageCall[0], - collection = messages.messages[ operation.name ]; + collection = messages.messages[ name ]; let messageFoundInCollection = false; @@ -436,15 +334,13 @@ describe( 'Slack messaging', () => { } expect( slackClientMock.chat.postMessage ).toHaveBeenCalledTimes( 1 ); - expect( messageFoundInCollection ).toBe( true ); + expect( messageFoundInCollection ).toBeTrue(); done(); }); }); - } // For operation. - it( 'sends messages back to the channel they were sent from', ( done ) => { expect.hasAssertions(); diff --git a/tests/events.js b/tests/events.js new file mode 100644 index 0000000..8c3b64a --- /dev/null +++ b/tests/events.js @@ -0,0 +1,260 @@ +/** + * Unit tests on the event handlers in events.js. + * + * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api + * @author Tim Malone + */ + +/* global jest */ + +'use strict'; + +const events = require( '../src/events' ); +const handlers = events.handlers; + +const slack = require( '../src/slack' ), + points = require( '../src/points' ), + messages = require( '../src/messages' ); + +const slackClientMock = require( './mocks/slack' ); +slack.setSlackClient( slackClientMock ); + +slack.sendMessage = jest.fn(); +points.updateScore = jest.fn(); +messages.getRandomMessage = jest.fn(); + +// Catch all console output during tests. +console.error = jest.fn(); +console.info = jest.fn(); +console.log = jest.fn(); +console.warn = jest.fn(); + +// Clear module cache + mock counts due to us sometimes messing with mocks. +beforeEach( () => { + jest.resetModules(); + messages.getRandomMessage.mockClear(); + slack.sendMessage.mockClear(); +}); + +describe( 'handleSelfPlus', () => { + + const user = 'U12345678', + channel = 'C12345678'; + + it( 'logs an attempt by a user to increment their own score', () => { + events.handleSelfPlus( user, channel ); + expect( console.log ).toHaveBeenCalledTimes( 1 ); + }); + + it( 'gets a message from the \'self plus\' collection', () => { + events.handleSelfPlus( user, channel ); + + expect( messages.getRandomMessage ) + .toHaveBeenCalledTimes( 1 ) + .toHaveBeenCalledWith( 'selfPlus', user ); + }); + + it( 'sends a message back to the user and channel that called it', () => { + const slack = require( '../src/slack' ), + events = require( '../src/events' ); + + slack.sendMessage = jest.fn(); + slack.setSlackClient( slackClientMock ); + + events.handleSelfPlus( user, channel ); + + expect( slack.sendMessage ) + .toHaveBeenCalledTimes( 1 ) + .toHaveBeenCalledWith( expect.stringContaining( user ), channel ); + }); + +}); + +describe( 'handlePlusMinus', () => { + + const item = 'SomeRandomThing', + channel = 'C12345678', + score = 5; + + /** @returns {integer} Returns a fake score. */ + const updateScoreMock = () => { + return score; + }; + + it( 'calls the score updater to update an item\'s score', () => { + const slack = require( '../src/slack' ), + points = require( '../src/points' ), + events = require( '../src/events' ); + + slack.setSlackClient( slackClientMock ); + points.updateScore = jest.fn(); + + events.handlePlusMinus( item, '+', channel ); + + expect( points.updateScore ) + .toHaveBeenCalledTimes( 1 ) + .toHaveBeenCalledWith( item, '+' ); + }); + + it.each([ [ 'plus', '+' ], [ 'minus', '-' ] ])( + 'gets a message from the \'%s\' collection', + ( operationName, operation ) => { + expect.hasAssertions(); + + const slack = require( '../src/slack' ), + points = require( '../src/points' ), + events = require( '../src/events' ), + messages = require( '../src/messages' ); + + slack.setSlackClient( slackClientMock ); + points.updateScore = jest.fn( updateScoreMock ); + messages.getRandomMessage = jest.fn(); + + return events.handlePlusMinus( item, operation, channel ).then( () => { + expect( messages.getRandomMessage ) + .toHaveBeenCalledTimes( 1 ) + .toHaveBeenCalledWith( operationName, item, score ); + }); + } + ); + + it( 'sends a message back to the channel that called it', () => { + expect.hasAssertions(); + + const slack = require( '../src/slack' ), + points = require( '../src/points' ), + events = require( '../src/events' ); + + slack.setSlackClient( slackClientMock ); + points.updateScore = jest.fn(); + slack.sendMessage = jest.fn(); + + return events.handlePlusMinus( item, '+', channel ).then( () => { + expect( slack.sendMessage ) + .toHaveBeenCalledTimes( 1 ) + .toHaveBeenCalledWith( expect.stringContaining( item ), channel ); + }); + }); + +}); + +describe( 'handlers.message', () => { + + const eventType = 'message'; + + it( 'returns false if a valid item cannot be extracted', () => { + const event = { + type: eventType, + text: '@Invalid#Item++' + }; + + expect( handlers.message( event ) ).toBeFalse(); + }); + + it( 'returns false if a valid operation cannot be extracted', () => { + const event = { + type: eventType, + text: '<@U12345678>+-+' // Invalid operation. + }; + + expect( handlers.message( event ) ).toBeFalse(); + }); + + it( 'returns false if a user trying to ++ themselves', () => { + const event = { + type: eventType, + text: '<@U12345678>++', + user: 'U12345678' + }; + + expect( handlers.message( event ) ).toBeFalse(); + }); + +}); // HandleMessageEvent. + +describe( 'handlers.appMention', () => { + + const eventType = 'app_mention'; + + const appCommandTable = [ + [ 'leaderboard', 'leaderboard.js' ] + ]; + + it.each( appCommandTable )( 'calls the app command handler for %s', ( command, handlerFile ) => { + + const event = { + type: eventType, + text: '<@U00000000> ' + command + }; + + const events = require( '../src/events' ), + commandHandler = require( '../src/' + handlerFile ); + + commandHandler.handler = jest.fn(); + events.handlers.appMention( event ); + expect( commandHandler.handler ).toHaveBeenCalledTimes( 1 ); + + }); + +}); // Handlers.appMention. + +describe( 'handleEvent', () => { + + const validEvents = [ + [ 'message', '@Hello++' ], + [ 'app_mention', '<@U12345678> can haz leaderboard' ] + ]; + + const request = { + headers: { host: 'test.local' } + }; + + it.each( validEvents )( 'returns a Promise for a \'%s\' event with text', ( type, text ) => { + const event = { + type, + text + }; + + expect( events.handleEvent( event, request ) instanceof Promise ).toBeTrue(); + }); + + it.each( validEvents )( 'reports a \'%s\' event without text as invalid', ( type ) => { + const event = { type }; + expect( events.handleEvent( event ) ).toBeFalse(); + }); + + it.each( validEvents )( 'reports a \'%s\' event with a space for text as invalid', ( type ) => { + const event = { + type, + text: ' ' + }; + + expect( events.handleEvent( event ) ).toBeFalse(); + }); + + it( 'reports an event with missing type as invalid', () => { + const event = { text: 'Hello' }; + expect( events.handleEvent( event ) ).toBeFalse(); + }); + + it( 'reports an event with some random type as invalid', () => { + const event = { + type: 'random', + text: 'Hello' + }; + + expect( events.handleEvent( event ) ).toBeFalse(); + }); + + it( 'reports an event with a subtype as invalid', () => { + const event = { + type: 'message', + subtype: 'random', + text: 'Hello' + }; + + expect( events.handleEvent( event ) ).toBeFalse(); + }); + +}); // HandleEvent. diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..bc50cb3 --- /dev/null +++ b/tests/helpers.js @@ -0,0 +1,290 @@ +/** + * Unit tests on the helpers in helpers.js. + * + * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api + * @author Tim Malone + */ + +/* global jest */ + +'use strict'; + +const helpers = require( '../src/helpers' ); + +const MILLISECONDS_TO_SECONDS = 1000; + +describe( 'extractCommand', () => { + + const commands = [ + 'test-command', + 'something-else', + 'another-command' + ]; + + it( 'returns a valid command from a message containing only that command', () => { + const message = '<@U12345678> test-command'; + expect( helpers.extractCommand( message, commands ) ).toEqual( 'test-command' ); + }); + + it( 'returns a valid command from the start of a message', () => { + const message = '<@U12345678> test-command would be great'; + expect( helpers.extractCommand( message, commands ) ).toEqual( 'test-command' ); + }); + + it( 'returns a valid command from the middle of a message', () => { + const message = '<@U12345678> can I have a test-command please'; + expect( helpers.extractCommand( message, commands ) ).toEqual( 'test-command' ); + }); + + it( 'returns a valid command from the end of a message', () => { + const message = '<@U12345678> I would love to see a test-command'; + expect( helpers.extractCommand( message, commands ) ).toEqual( 'test-command' ); + }); + + it( 'returns the first valid command in a message with multiple', () => { + const message = '<@U12345678> looking for something-else rather than a test-command'; + expect( helpers.extractCommand( message, commands ) ).toEqual( 'something-else' ); + }); + + it( 'returns the first valid command in a message with multiple (with order switched)', () => { + const message = '<@U12345678> looking for a test-command rather than something-else'; + expect( helpers.extractCommand( message, commands ) ).toEqual( 'test-command' ); + }); + + it( 'returns false if it cannot find a valid command in a message', () => { + const message = '<@U12345678> there is nothing actionable here'; + expect( helpers.extractCommand( message, commands ) ).toBeFalse(); + }); + +}); + +describe( 'extractPlusMinusEventData', () => { + + it( 'drops message without an @ symbol', () => { + expect( helpers.extractPlusMinusEventData( 'Hello++' ) ).toBeFalse(); + }); + + it( 'drops messages without a valid operation', () => { + expect( helpers.extractPlusMinusEventData( '@Hello' ) ).toBeFalse(); + }); + + it( 'drops messages without a valid user/item', () => { + expect( helpers.extractPlusMinusEventData( '@++' ) ).toBeFalse(); + }); + + it( 'extracts a \'thing\' and operation from the start of a message', () => { + expect( helpers.extractPlusMinusEventData( '@SomethingRandom++ that was awesome' ) ).toEqual({ + item: 'SomethingRandom', + operation: '+' + }); + }); + + it( 'extracts a user and operation from the start of a message', () => { + expect( helpers.extractPlusMinusEventData( '<@U87654321>++ that was awesome' ) ).toEqual({ + item: 'U87654321', + operation: '+' + }); + }); + + it( 'extracts data in the middle of a message', () => { + expect( helpers.extractPlusMinusEventData( 'Hey @SomethingRandom++ you\'re great' ) ).toEqual({ + item: 'SomethingRandom', + operation: '+' + }); + }); + + it( 'extracts data at the end of a message', () => { + expect( helpers.extractPlusMinusEventData( 'Awesome work @SomethingRandom++' ) ).toEqual({ + item: 'SomethingRandom', + operation: '+' + }); + }); + + const itemsToMatch = [ + { + supplied: '<@U1234567890>', + expected: 'U1234567890' + }, + { + supplied: '@SomethingRandom', + expected: 'SomethingRandom' + }, + { + supplied: '@SomethingRandom123', + expected: 'SomethingRandom123' + } + ]; + + const operationsToMatch = [ + { + supplied: '++', + expected: '+' + }, + { + supplied: '--', + expected: '-' + }, + { + supplied: '—', // Emdash, which iOS replaces -- with. + expected: '-' + } + ]; + + const operationsNotToMatch = [ + '+', + '-' + ]; + + for ( const item of itemsToMatch ) { + + for ( const operation of operationsToMatch ) { + for ( let iterator = 0; 1 >= iterator; iterator++ ) { + + const space = 1 === iterator ? ' ' : '', + messageText = item.supplied + space + operation.supplied, + testName = ( + 'matches ' + messageText + ' as ' + item.expected + ' and ' + operation.expected + ); + + it( testName, () => { + const result = helpers.extractPlusMinusEventData( messageText ); + expect( result ).toEqual({ + item: item.expected, + operation: operation.expected + }); + }); + + } // For iterator. + } // For operationsToMatch. + + for ( const operation of operationsNotToMatch ) { + const messageText = item.supplied + operation; + it( 'does NOT match ' + messageText, () => { + expect( helpers.extractPlusMinusEventData( messageText ) ).toBeFalse(); + }); + } + + } // For itemsToMatch. +}); // ExtractPlusMinusEventData. + +describe( 'getTimeBasedToken', () => { + + it( 'returns a string', () => { + expect( helpers.getTimeBasedToken( helpers.getTimestamp() ) ).toBeString(); + }); + + it( 'throws if a timestamp is not provided', () => { + expect( () => { + helpers.getTimeBasedToken(); + }).toThrow(); + }); + + it( 'provides a different token if called with a different timestamp', () => { + const token1 = helpers.getTimeBasedToken( 123456789 ); + const token2 = helpers.getTimeBasedToken( 123123123 ); + expect( token1 ).not.toEqual( token2 ); + }); + +}); + +describe( 'getTimestamp', () => { + + it( 'returns an integer', () => { + expect( helpers.getTimestamp() ) + .toBeNumber() + .not.toBeString(); + }); + + it( 'returns the current unix epoch', () => { + const now = Math.floor( Date.now() / MILLISECONDS_TO_SECONDS ); + expect( helpers.getTimestamp() ).toBeWithin( now - 5, now + 1 ); + }); + +}); + +describe( 'isPlural', () => { + + const table = [ + [ true, -11 ], + [ true, -2 ], + [ false, -1 ], + [ true, 0 ], + [ false, 1 ], + [ true, 2 ], + [ true, 11 ] + ]; + + it.each( table )( 'returns %p for %d', ( result, number ) => { + expect( helpers.isPlural( number ) ).toBe( result ); + }); + +}); + +describe( 'isTimeBasedTokenStillValid', () => { + + it( 'returns true for a token created just now', () => { + const now = helpers.getTimestamp(), + token = helpers.getTimeBasedToken( now ); + + expect( helpers.isTimeBasedTokenStillValid( token, now ) ).toBeTrue(); + }); + + it( 'returns true for a token created an hour ago', () => { + const now = helpers.getTimestamp(), + oneHourAgo = now - 60 * 60, + token = helpers.getTimeBasedToken( oneHourAgo ); + + expect( helpers.isTimeBasedTokenStillValid( token, oneHourAgo ) ).toBeTrue(); + }); + + it( 'returns false for a token created with a different timestamp', () => { + const now = helpers.getTimestamp(), + token = helpers.getTimeBasedToken( now - 1 ); + + expect( helpers.isTimeBasedTokenStillValid( token, now ) ).toBeFalse(); + }); + + it( 'returns false for a token created in the future', () => { + const now = helpers.getTimestamp(), + theFuture = now + 10, + token = helpers.getTimeBasedToken( theFuture ); + + expect( helpers.isTimeBasedTokenStillValid( token, theFuture ) ).toBeFalse(); + }); + + it( 'returns false for a token created two days ago', () => { + const now = helpers.getTimestamp(), + twoDaysAgo = now - 60 * 60 * 24 * 2, + token = helpers.getTimeBasedToken( twoDaysAgo ); + + expect( helpers.isTimeBasedTokenStillValid( token, twoDaysAgo ) ).toBeFalse(); + }); + +}); // IsTimeBasedTokenStillValid. + +describe( 'isUser', () => { + + it( 'returns true for a Slack user ID', () => { + expect( helpers.isUser( 'U00000000' ) ).toBeTrue(); + }); + + it( 'returns false for something other than a Slack user ID', () => { + expect( helpers.isUser( 'SomethingRandom' ) ).toBeFalse(); + }); + +}); + +describe( 'maybeLinkItem', () => { + + it( 'returns an item as-is if it is not a Slack user ID', () => { + const item = 'something'; + expect( helpers.maybeLinkItem( item ) ).toBe( item ); + }); + + it( 'returns an item linked with Slack mrkdwn if it looks like a Slack user ID', () => { + const item = 'U12345678'; + expect( helpers.maybeLinkItem( item ) ).toBe( '<@' + item + '>' ); + }); + +}); // MaybeLinkItem. diff --git a/tests/integration-tests.js b/tests/integration-tests.js index 700eb08..21524cc 100644 --- a/tests/integration-tests.js +++ b/tests/integration-tests.js @@ -2,6 +2,7 @@ * Integration tests. * * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api * @see https://jestjs.io/docs/en/asynchronous.html * @see https://jestjs.io/docs/en/mock-functions#mocking-modules * @author Tim Malone @@ -15,15 +16,18 @@ * Environment Configuration. ****************************************************************/ -const http = require( 'http' ), - pg = require( 'pg' ), - app = require( '../src/app' ), - config = require( './_config' ), - runner = require( './_runner' ), - slackClientMock = require( './mocks/slack' ); +const app = require( '../src/app' ), + points = require( '../src/points' ); -const originalProcessEnv = process.env; -const postgres = new pg.Pool( config.postgresPoolConfig ); +const pathToListener = '../'; + +const pg = require( 'pg' ), + http = require( 'http' ); + +const config = require( './_config' ); + +const originalProcessEnv = process.env, + postgres = new pg.Pool( config.postgresPoolConfig ); /**************************************************************** * Jest Setup. @@ -58,7 +62,7 @@ describe( 'The Express server', () => { it( 'returns HTTP 200 for GET operations', ( done ) => { expect.hasAssertions(); - const listener = require( '../' )(); + const listener = require( pathToListener )(); listener.on( 'listening', () => { http.get( 'http://localhost:' + config.PORT, ( response ) => { @@ -73,7 +77,7 @@ describe( 'The Express server', () => { it( 'correctly returns the Slack event challenge value', ( done ) => { expect.assertions( 2 ); - const listener = require( '../' )(); + const listener = require( pathToListener )(); const requestBody = { challenge: Math.random().toString() }; listener.on( 'listening', () => { @@ -100,7 +104,7 @@ describe( 'The Express server', () => { expect.hasAssertions(); delete process.env.SLACK_VERIFICATION_TOKEN; - const listener = require( '../' )(); + const listener = require( pathToListener )(); listener.on( 'listening', () => { http.request( config.defaultRequestOptions, ( response ) => { @@ -116,7 +120,7 @@ describe( 'The Express server', () => { expect.hasAssertions(); process.env.SLACK_VERIFICATION_TOKEN = 'xxxxxxxxxxxxxxxxxxxxxxxx'; - const listener = require( '../' )(); + const listener = require( pathToListener )(); listener.on( 'listening', () => { http.request( config.defaultRequestOptions, ( response ) => { @@ -131,7 +135,7 @@ describe( 'The Express server', () => { it( 'returns HTTP 403 when verification token is incorrect', ( done ) => { expect.hasAssertions(); - const listener = require( '../' )(); + const listener = require( pathToListener )(); const body = { token: 'something_is_not_right' }; listener.on( 'listening', () => { @@ -169,7 +173,7 @@ describe( 'The Express server', () => { mockExpress.response.send.mockClear(); mockExpress.request.headers['x-slack-retry-num'] = 1; const result = app.handlePost( mockExpress.request, mockExpress.response ); - expect( result ).toBe( false ); + expect( result ).toBeFalse(); expect( mockExpress.response.send ).toHaveBeenCalledTimes( 1 ); }); @@ -182,6 +186,9 @@ describe( 'The Express server', () => { describe( 'The database', () => { + const defaultUser = 'U00000000', + defaultItem = 'something'; + const tableExistsQuery = 'SELECT EXISTS ( ' + 'SELECT 1 FROM information_schema.tables ' + 'WHERE table_name = \'' + config.scoresTableName + '\'' + @@ -194,7 +201,7 @@ describe( 'The database', () => { const dbClient = await postgres.connect(); const query = await dbClient.query( tableExistsQuery ); await dbClient.release(); - expect( query.rows[0].exists ).toBe( false ); + expect( query.rows[0].exists ).toBeFalse(); }); it( 'does not yet have the case-insensitive extension', async() => { @@ -205,28 +212,14 @@ describe( 'The database', () => { expect( query.rowCount ).toBe( 0 ); }); - /** - * Provides a 'first request' and a test that it successfully creates the database table. - * - * @param {callable} done A callback to use for alerting Jest that the test is complete. - * @return {void} - */ - const doFirstRequest = ( done ) => { + it( 'creates the ' + config.scoresTableName + ' table on the first request', async() => { expect.hasAssertions(); - const listener = require( '../' )({ slack: slackClientMock }); - - listener.on( 'listening', () => { - runner( '@something++', async( dbClient ) => { - listener.close(); - const query = await dbClient.query( tableExistsQuery ); - await dbClient.release(); - expect( query.rows[0].exists ).toBe( true ); - done(); - }); - }); - }; - - it( 'creates the ' + config.scoresTableName + ' table on the first request', doFirstRequest ); + await points.updateScore( defaultItem, '++' ); + const dbClient = await postgres.connect(); + const query = await dbClient.query( tableExistsQuery ); + await dbClient.release(); + expect( query.rows[0].exists ).toBeTrue(); + }); it( 'also creates the case-insensitive extension on the first request', async() => { expect.hasAssertions(); @@ -236,6 +229,37 @@ describe( 'The database', () => { expect( query.rowCount ).toBe( 1 ); }); - it( 'does not cause errors on subsequent requests', doFirstRequest ); + /* eslint-disable jest/expect-expect */ + // TODO: This test really should have an assertion, but I can't figure out how to catch the error + // properly... it's possible that updateScore needs rewriting to catch properly. In the + // meantime, this test *does* actually work like expected. + it( 'does not cause any errors on a second request when everything already exists', async() => { + await points.updateScore( defaultItem, '++' ); + }); + /* eslint-enable jest/expect-expect */ + + it( 'returns a list of top scores in the correct order', async() => { + expect.hasAssertions(); + + const expectedScores = [ + { + item: defaultUser, + score: 3 + }, + { + item: defaultItem, + score: 2 + } + ]; + + // Give us a few additional scores so we can check the order works. + await points.updateScore( defaultUser, '++' ); + await points.updateScore( defaultUser, '++' ); + await points.updateScore( defaultUser, '++' ); + + const topScores = await points.retrieveTopScores(); + expect( topScores ).toEqual( expectedScores ); + + }); }); // Postgres tests. diff --git a/tests/leaderboard.js b/tests/leaderboard.js new file mode 100644 index 0000000..763c629 --- /dev/null +++ b/tests/leaderboard.js @@ -0,0 +1,69 @@ +/** + * Unit tests on the code in leaderboard.js. + * + * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api + * @author Tim Malone + */ + +/* global jest */ + +'use strict'; + +const leaderboard = require( '../src/leaderboard' ), + helpers = require( '../src/helpers' ); + +describe( 'getLeaderboardUrl', () => { + + const MILLISECONDS_TO_SECONDS = 1000; + + const request = { + headers: { host: 'test.local' }, + body: { event: { text: '<@U00000000> test' } } + }; + + const leaderboardUrl = leaderboard.getLeaderboardUrl( request ), + parsedUrl = new URL( leaderboardUrl ), + token = parsedUrl.searchParams.get( 'token' ), + ts = parsedUrl.searchParams.get( 'ts' ), + now = Math.floor( Date.now() / MILLISECONDS_TO_SECONDS ); + + it( 'returns a well-formed URL that doesn\'t need fixing', () => { + expect( leaderboardUrl ).toBe( parsedUrl.href ); + }); + + it( 'returns a URL with a hostname', () => { + expect( parsedUrl.hostname ).toBeString(); + }); + + it( 'returns a URL with a path', () => { + expect( parsedUrl.pathname ).toBeString(); + }); + + it( 'returns an HTTPS URL', () => { + expect( parsedUrl.protocol ).toBe( 'https:' ); + }); + + it( 'includes a token parameter for authorization', () => { + expect( token ).toBeString(); + }); + + it( 'includes a ts (timestamp) parameter for authorization', () => { + expect( ts ).toBeString(); + }); + + it( 'has a ts (timestamp) parameter that was just created', () => { + expect( ts ).toBeWithin( now - 5, now + 1 ); + }); + + it( 'has a token that can be validated using the timestamp', () => { + expect( helpers.isTimeBasedTokenStillValid( token, now ) ).toBeTrue(); + }); + + it( 'has a token that fails to validate with a different timestamp', () => { + expect( helpers.isTimeBasedTokenStillValid( token, now - 1 ) ).toBeFalse(); + }); + +}); // GetLeaderboardUrl. + +// TODO: Add tests for handler(). diff --git a/tests/messages.js b/tests/messages.js index 2e233d5..663d773 100644 --- a/tests/messages.js +++ b/tests/messages.js @@ -4,6 +4,7 @@ * TODO: Expand tests. * * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api * @author Tim Malone */ @@ -19,14 +20,16 @@ const operations = [ 'selfPlus' ]; -for ( const operation of operations ) { - it( 'returns a message for the ' + operation + ' operation', () => { - expect( typeof messages.getRandomMessage( operation ) ).toBe( 'string' ); +describe( 'getRandomMessage', () => { + + it.each( operations )( 'returns a message for the %s operation', ( operation ) => { + expect( typeof messages.getRandomMessage( operation, 'RandomThing' ) ).toBe( 'string' ); + }); + + it( 'throws an error for an invalid operation', () => { + expect( () => { + messages.getRandomMessage( 'INVALID_OPERATION', 'RandomThing' ); + }).toThrow(); }); -} -it( 'throws an error for an invalid operation', () => { - expect( () => { - messages.getRandomMessage( 'INVALID_OPERATION' ); - }).toThrow(); -}); +}); // GetRandomMessage. diff --git a/tests/mocks/slack.js b/tests/mocks/slack.js index bb852f7..e2b9059 100644 --- a/tests/mocks/slack.js +++ b/tests/mocks/slack.js @@ -8,12 +8,19 @@ /* eslint-disable no-empty-function */ -module.exports = { - chat: { - postMessage: ( payload ) => { // eslint-disable-line no-unused-vars - return new Promise( ( resolve ) => { - resolve({ ok: true }); - }); - } +const options = { + shouldPostMessageSucceed: true +}; + +const chat = { + postMessage: ( payload ) => { // eslint-disable-line no-unused-vars + return new Promise( ( resolve ) => { + resolve({ ok: options.shouldPostMessageSucceed }); + }); } }; + +module.exports = { + options, + chat +}; diff --git a/tests/operations.js b/tests/operations.js new file mode 100644 index 0000000..234b6d6 --- /dev/null +++ b/tests/operations.js @@ -0,0 +1,37 @@ +/** + * Unit tests on operations.js. + * + * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api + * @author Tim Malone + */ + +/* global jest */ + +'use strict'; + +const operations = require( '../src/operations' ); + +it( 'exports constants for operations', () => { + expect( operations.operations ) + .toBeObject() + .toHaveProperty( 'PLUS' ) + .toHaveProperty( 'MINUS' ) + .toHaveProperty( 'SELF' ); +}); + +describe( 'getOperationName', () => { + + it( 'returns \'plus\' when given +', () => { + expect( operations.getOperationName( '+' ) ).toBe( 'plus' ); + }); + + it( 'returns \'minus\' when given -', () => { + expect( operations.getOperationName( '-' ) ).toBe( 'minus' ); + }); + + it( 'returns false when given an invalid operation', () => { + expect( operations.getOperationName( 'some invalid operation' ) ).toBeFalse(); + }); + +}); diff --git a/tests/slack.js b/tests/slack.js new file mode 100644 index 0000000..472a317 --- /dev/null +++ b/tests/slack.js @@ -0,0 +1,124 @@ +/** + * Unit tests on the code in send.js. + * + * @see https://jestjs.io/docs/en/expect + * @see https://github.com/jest-community/jest-extended#api + * @author Tim Malone + */ + +/* global jest */ + +'use strict'; + +const slack = require( '../src/slack' ); +const pathToMock = './mocks/slack'; + +// Catch all console output during tests. +console.error = jest.fn(); +console.info = jest.fn(); +console.log = jest.fn(); +console.warn = jest.fn(); + +// Clear module cache due to us sometimes messing with the mock. +beforeEach( () => { + jest.resetModules(); +}); + +describe( 'setSlackClient', () => { + + it( 'accepts a single parameter (that is later used as the Slack API client)', () => { + expect( slack.setSlackClient ).toHaveLength( 1 ); + }); + +}); + +describe( 'sendMessage', () => { + + const payload = { + text: 'Hello there', + channel: 'C12345678' + }; + + it( 'sends message text to a channel when provided as two arguments', () => { + expect.assertions( 1 ); + const slackClientMock = require( pathToMock ); + slack.setSlackClient( slackClientMock ); + + // Re-mock the client so we can listen to it. + slackClientMock.chat.postMessage = jest.fn(); + + return slack.sendMessage( payload.text, payload.channel ).catch( () => { + expect( slackClientMock.chat.postMessage ).toHaveBeenCalledWith( payload ); + }); + }); + + it( 'sends a message to a channel with a full payload as one argument', () => { + expect.assertions( 1 ); + const slackClientMock = require( pathToMock ); + slack.setSlackClient( slackClientMock ); + + // Re-mock the client so we can listen to it. + slackClientMock.chat.postMessage = jest.fn(); + + const payload = { + text: 'Hello there', + channel: 'C12345678', + attachments: [ + { + 'text': 'Attachment text' + } + ] + }; + + return slack.sendMessage( payload ).catch( () => { + expect( slackClientMock.chat.postMessage ).toHaveBeenCalledWith( payload ); + }); + }); + + it( 'sends a message to a channel when payload and channel are passed separately', () => { + expect.assertions( 1 ); + const slackClientMock = require( pathToMock ); + slack.setSlackClient( slackClientMock ); + + // Re-mock the client so we can listen to it. + slackClientMock.chat.postMessage = jest.fn(); + + const channel = 'C12345678'; + const payload = { + text: 'Hello there', + attachments: [ + { + 'text': 'Attachment text' + } + ] + }; + + return slack.sendMessage( payload, channel ).catch( () => { + payload.channel = channel; + expect( slackClientMock.chat.postMessage ).toHaveBeenCalledWith( payload ); + }); + }); + + it( 'returns a Promise and resolves it if the message succeeds', () => { + expect.assertions( 1 ); + const slackClientMock = require( pathToMock ); + slack.setSlackClient( slackClientMock ); + + slackClientMock.options.shouldPostMessageSucceed = true; + return slack.sendMessage( payload.text, payload.channel ).then( ( data ) => { + expect( data ).toBeNil(); + }); + }); + + it( 'returns a Promise and rejects it if the message fails', () => { + expect.assertions( 1 ); + const slackClientMock = require( pathToMock ); + slack.setSlackClient( slackClientMock ); + + slackClientMock.options.shouldPostMessageSucceed = false; + return slack.sendMessage( payload.text, payload.channel ).catch( ( error ) => { + expect( error ).toBeNil(); + }); + }); + +}); // SendMessage. diff --git a/yarn.lock b/yarn.lock index fe783c3..ed604c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1129,7 +1129,11 @@ eslint-config-wordpress@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/eslint-config-wordpress/-/eslint-config-wordpress-2.0.0.tgz#5201206c6964d648315232edf6dfbd2e925e4cd6" -eslint-plugin-jest@^21.18.0, eslint-plugin-jest@^21.2.0: +eslint-plugin-jest@^21.18.0: + version "21.21.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-21.21.0.tgz#f0afd138c4acb5f0cd7698318fb49c7d49f3bf45" + +eslint-plugin-jest@^21.2.0: version "21.18.0" resolved "https://registry.yarnpkg.com/eslint-plugin-jest/-/eslint-plugin-jest-21.18.0.tgz#d7305969a9c1902f895468791d968fcf08b5c0b7" @@ -1338,6 +1342,17 @@ expand-range@^1.8.1: dependencies: fill-range "^2.1.0" +expect@^22.1.0: + version "22.4.3" + resolved "https://registry.yarnpkg.com/expect/-/expect-22.4.3.tgz#d5a29d0a0e1fb2153557caef2674d4547e914674" + dependencies: + ansi-styles "^3.2.0" + jest-diff "^22.4.3" + jest-get-type "^22.4.3" + jest-matcher-utils "^22.4.3" + jest-message-util "^22.4.3" + jest-regex-util "^22.4.3" + expect@^23.4.0: version "23.4.0" resolved "https://registry.yarnpkg.com/expect/-/expect-23.4.0.tgz#6da4ecc99c1471253e7288338983ad1ebadb60c3" @@ -1715,7 +1730,7 @@ growly@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/growly/-/growly-1.3.0.tgz#f10748cbe76af964b7c96c93c6bcc28af120c081" -handlebars@^4.0.3: +handlebars@^4.0.11, handlebars@^4.0.3: version "4.0.11" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.0.11.tgz#630a35dfe0294bc281edae6ffc5d329fc7982dcc" dependencies: @@ -2345,6 +2360,15 @@ jest-config@^23.4.2: jest-validate "^23.4.0" pretty-format "^23.2.0" +jest-diff@^22.4.3: + version "22.4.3" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-22.4.3.tgz#e18cc3feff0aeef159d02310f2686d4065378030" + dependencies: + chalk "^2.0.1" + diff "^3.2.0" + jest-get-type "^22.4.3" + pretty-format "^22.4.3" + jest-diff@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-23.2.0.tgz#9f2cf4b51e12c791550200abc16b47130af1062a" @@ -2382,7 +2406,15 @@ jest-environment-node@^23.4.0: jest-mock "^23.2.0" jest-util "^23.4.0" -jest-get-type@^22.1.0: +jest-extended@^0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/jest-extended/-/jest-extended-0.8.1.tgz#cde6b202dbed5c455e5dd383b365d0c9ee3420eb" + dependencies: + expect "^22.1.0" + jest-get-type "^22.4.3" + jest-matcher-utils "^22.0.0" + +jest-get-type@^22.1.0, jest-get-type@^22.4.3: version "22.4.3" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-22.4.3.tgz#e3a8504d8479342dd4420236b322869f18900ce4" @@ -2421,6 +2453,14 @@ jest-leak-detector@^23.2.0: dependencies: pretty-format "^23.2.0" +jest-matcher-utils@^22.0.0, jest-matcher-utils@^22.4.3: + version "22.4.3" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-22.4.3.tgz#4632fe428ebc73ebc194d3c7b65d37b161f710ff" + dependencies: + chalk "^2.0.1" + jest-get-type "^22.4.3" + pretty-format "^22.4.3" + jest-matcher-utils@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-23.2.0.tgz#4d4981f23213e939e3cedf23dc34c747b5ae1913" @@ -2429,6 +2469,16 @@ jest-matcher-utils@^23.2.0: jest-get-type "^22.1.0" pretty-format "^23.2.0" +jest-message-util@^22.4.3: + version "22.4.3" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-22.4.3.tgz#cf3d38aafe4befddbfc455e57d65d5239e399eb7" + dependencies: + "@babel/code-frame" "^7.0.0-beta.35" + chalk "^2.0.1" + micromatch "^2.3.11" + slash "^1.0.0" + stack-utils "^1.0.1" + jest-message-util@^23.4.0: version "23.4.0" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-23.4.0.tgz#17610c50942349508d01a3d1e0bda2c079086a9f" @@ -2443,6 +2493,10 @@ jest-mock@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-23.2.0.tgz#ad1c60f29e8719d47c26e1138098b6d18b261134" +jest-regex-util@^22.4.3: + version "22.4.3" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-22.4.3.tgz#a826eb191cdf22502198c5401a1fc04de9cef5af" + jest-regex-util@^23.3.0: version "23.3.0" resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-23.3.0.tgz#5f86729547c2785c4002ceaa8f849fe8ca471bc5" @@ -2743,6 +2797,10 @@ locate-path@^2.0.0: p-locate "^2.0.0" path-exists "^3.0.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -2882,6 +2940,10 @@ mime@1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" +mime@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.3.1.tgz#b1621c54d63b97c47d3cfe7f7215f7d64517c369" + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -3431,6 +3493,13 @@ preserve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b" +pretty-format@^22.4.3: + version "22.4.3" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-22.4.3.tgz#f873d780839a9c02e9664c8a082e9ee79eaac16f" + dependencies: + ansi-regex "^3.0.0" + ansi-styles "^3.2.0" + pretty-format@^23.2.0: version "23.2.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-23.2.0.tgz#3b0aaa63c018a53583373c1cb3a5d96cc5e83017"