diff --git a/.babelrc b/.babelrc index c13c5f6..143a4f7 100644 --- a/.babelrc +++ b/.babelrc @@ -1,3 +1,4 @@ { - "presets": ["es2015"] + "presets": ["es2015", "stage-0"], + "retainLines": true } diff --git a/.eslintrc b/.eslintrc index 8fab803..6c3a0b4 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,8 +1,10 @@ { "extends": "airbnb/base", + "ecmaFeatures": { "destructuring": true }, "rules": { "brace-style": [2, "stroustrup", { "allowSingleLine": true }], "no-param-reassign": 1, + "no-unused-vars": [2, {"args": "after-used", "argsIgnorePattern": "^__"}], "no-shadow": 1, "default-case": 1, "new-cap": 0, diff --git a/Gruntfile.js b/Gruntfile.js index 0a9819a..62a4922 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,11 +1,11 @@ -require('babel-core/register'); +require('babel-core/register')({plugins: ['rewire']}); module.exports = function (grunt) { 'use strict'; - var testFiles = ['test/unit/**/*.js'], - srcFiles = ['api/**/*.js'], - jsFiles = srcFiles.concat(testFiles); + var testFiles = ['test/unit/**/*.js']; + var srcFiles = ['api/**/*.js']; + var jsFiles = srcFiles.concat(testFiles); // Set test environment here for cross-platform process.env.NODE_ENV='test'; diff --git a/api/app.js b/api/app.js index c1bd5d9..98d0fb4 100644 --- a/api/app.js +++ b/api/app.js @@ -6,14 +6,12 @@ import logger from 'morgan'; import cors from 'cors'; import addStatusCodes from 'express-json-status-codes'; import log from 'winston'; -// import redis from 'ioredis'; import CFG from '../config'; import routes from './routes'; import dispatcher from './dispatcher'; -import database from './services/database'; +import database from './helpers/database'; -// const redisClient = Redis(CFG.redisURL); const extendedExpress = addStatusCodes(express); log.level = CFG.logLevel; @@ -22,7 +20,7 @@ const setupApp = function() { .use(logger('dev')) .use(compression()) .use(cors()) - .use(bodyParser.urlencoded({extended: true})) + .use(bodyParser.json()) .use(enrouten(routes)) .disable('x-powered-by') .listen(CFG.port, function(err) { @@ -32,11 +30,7 @@ const setupApp = function() { }; database(); - const app = setupApp(); dispatcher(app); -database(); - export { app }; - diff --git a/api/constants/EXT_EVENT_API.js b/api/constants/EXT_EVENT_API.js index c72b89b..fa3f4d5 100644 --- a/api/constants/EXT_EVENT_API.js +++ b/api/constants/EXT_EVENT_API.js @@ -14,6 +14,7 @@ module.exports = { JOIN_ROOM: 'JOIN_ROOM', LEAVE_ROOM: 'LEAVE_ROOM', + UPDATE_BOARD: 'UPDATE_BOARD', CREATE_IDEA: 'CREATE_IDEA', DESTROY_IDEA: 'DESTROY_IDEA', @@ -25,13 +26,43 @@ module.exports = { REMOVE_IDEA: 'REMOVE_IDEA', GET_COLLECTIONS: 'GET_COLLECTIONS', + START_TIMER: 'START_TIMER', + DISABLE_TIMER: 'DISABLE_TIMER', + + ENABLE_IDEAS: 'ENABLE_IDEAS', + DISABLE_IDEAS: 'DISABLE_IDEAS', + FORCE_VOTE: 'FORCE_VOTE', + FORCE_RESULTS: 'FORCE_RESULTS', + GET_STATE: 'GET_STATE', + GET_OPTIONS: 'GET_OPTIONS', + + GET_USERS: 'GET_USERS', + // Past-tense responses + RECEIVED_STATE: 'RECEIVED_STATE', + RECEIVED_OPTIONS: 'RECEIVED_OPTIONS', + RECEIVED_CONSTANTS: 'RECEIVED_CONSTANTS', RECEIVED_IDEAS: 'RECEIVED_IDEAS', RECEIVED_COLLECTIONS: 'RECEIVED_COLLECTIONS', JOINED_ROOM: 'JOINED_ROOM', LEFT_ROOM: 'LEFT_ROOM', + UPDATED_BOARD: 'UPDATED_BOARD', + + STARTED_TIMER: 'STARTED_TIMER', + DISABLED_TIMER: 'DISABLED_TIMER', + TIMER_EXPIRED: 'TIMER_EXPIRED', + GET_TIME: 'GET_TIME', + RECEIVED_TIME: 'RECEIVED_TIME', + + ENABLED_IDEAS: 'ENABLED_IDEAS', + DISABLED_IDEAS: 'DISABLE_IDEAS', + FORCED_VOTE: 'FORCED_VOTE', + FORCED_RESULTS: 'FORCED_RESULTS', + + READY_TO_VOTE: 'READY_TO_VOTE', + FINISHED_VOTING: 'FINISHED_VOTING', UPDATED_IDEAS: 'UPDATED_IDEAS', UPDATED_COLLECTIONS: 'UPDATED_COLLECTIONS', @@ -42,4 +73,14 @@ module.exports = { REMOVED_ADMIN: 'REMOVED_ADMIN', ADDED_PENDING_USER: 'ADDED_PENDING_USER', REMOVED_PENDING_USER: 'REMOVED_PENDING_USER', + RECEIVED_USERS: 'RECEIVED_USERS', + + GET_VOTING_ITEMS: 'GET_VOTING_ITEMS', + RECEIVED_VOTING_ITEMS: 'RECEIVED_VOTING_ITEMS', + VOTE: 'VOTE', + VOTED: 'VOTED', + READY_USER: 'READY_USER', + READIED_USER: 'READIED_USER', + GET_RESULTS: 'GET_RESULTS', + RECEIVED_RESULTS: 'RECEIVED_RESULTS', }; diff --git a/api/controllers/v1/auth/validate.js b/api/controllers/v1/auth/validate.js index e2f18d6..f37d106 100644 --- a/api/controllers/v1/auth/validate.js +++ b/api/controllers/v1/auth/validate.js @@ -4,16 +4,18 @@ * Validates a given token, returns the Mongo user object */ -import { isNull } from '../../../services/ValidatorService'; +import { values } from 'ramda'; import { verify } from '../../../services/TokenService'; import { JsonWebTokenError } from 'jsonwebtoken'; +import { anyAreNil } from '../../../helpers/utils'; export default function validate(req, res) { - const userToken = req.body.userToken; + const { userToken } = req.body; + const required = { userToken }; - if (isNull(userToken)) { - return res.badRequest( - {message: 'Not all required parameters were supplied'}); + if (anyAreNil(values(required))) { + return res.badRequest({ ...required, + message: 'Not all required parameters were supplied'}); } return verify(userToken) @@ -24,7 +26,7 @@ export default function validate(req, res) { return res.unauthorized({error: err, message: 'Invalid userToken'}); }) .catch((err) => { - return res.internalServerError( - {error: err, message: 'Something went wrong on ther server'}); + return res.internalServerError({error: err, + message: 'Something went wrong on the server'}); }); } diff --git a/api/controllers/v1/boards/create.js b/api/controllers/v1/boards/create.js index c89d4ad..544d633 100644 --- a/api/controllers/v1/boards/create.js +++ b/api/controllers/v1/boards/create.js @@ -3,10 +3,24 @@ * */ -import boardService from '../../../services/BoardService'; +import { values } from 'ramda'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { create as createBoard } from '../../../services/BoardService'; +import { anyAreNil } from '../../../helpers/utils'; export default function create(req, res) { - boardService.create() - .then((boardId) => res.created({boardId: boardId})) - .catch((err) => res.serverError(err)); + const { userToken, boardName, boardDesc } = req.body; + const required = { userToken }; + + if (anyAreNil(values(required))) { + return res.badRequest({ ...required, + message: 'Not all required parameters were supplied'}); + } + + return verifyAndGetId(userToken) + .then((userId) => { + return createBoard(userId, boardName, boardDesc) + .then((boardId) => res.created({boardId: boardId})) + .catch((err) => res.serverError(err)); + }); } diff --git a/api/controllers/v1/boards/destroy.js b/api/controllers/v1/boards/destroy.js index c834ba6..b796f0a 100644 --- a/api/controllers/v1/boards/destroy.js +++ b/api/controllers/v1/boards/destroy.js @@ -5,18 +5,20 @@ * @help :: See http://sailsjs.org/#!/documentation/concepts/Controllers */ +import { values } from 'ramda'; import boardService from '../../../services/BoardService'; -import { isNull } from '../../../services/ValidatorService'; +import { anyAreNil } from '../../../helpers/utils'; export default function destroy(req, res) { - const boardId = req.param('boardId'); + const { boardId } = req.body; + const required = { boardId }; - if (isNull(boardId)) { - return res.badRequest( - {message: 'Not all required parameters were supplied'}); + if (anyAreNil(values(required))) { + return res.badRequest({ ...required, + message: 'Not all required parameters were supplied'}); } - boardService.destroy(boardId) + return boardService.destroy(boardId) .then(() => res.ok({boardId: boardId})) .catch((err) => res.serverError(err)); } diff --git a/api/controllers/v1/users/create.js b/api/controllers/v1/users/create.js index 19b5586..31e7b88 100644 --- a/api/controllers/v1/users/create.js +++ b/api/controllers/v1/users/create.js @@ -3,19 +3,23 @@ * */ +import { values } from 'ramda'; import userService from '../../../services/UserService'; -import { isNull } from '../../../services/ValidatorService'; +import { anyAreNil } from '../../../helpers/utils'; export default function create(req, res) { - const username = req.body.username; + const { username } = req.body; + const required = { username }; - if (isNull(username)) { - return res.badRequest( - {message: 'Not all required parameters were supplied'}); + if (anyAreNil(values(required))) { + return res.badRequest({...required, + message: 'Not all required parameters were supplied'}); } - userService.create(username) - .then((user) => res.created(user)) + return userService.create(username) + .then(([token, user]) => ( + res.created({token: token, username: username, userId: user.id}) + )) .catch((err) => { res.internalServerError(err); }); diff --git a/api/dispatcher.js b/api/dispatcher.js index 9fc6dbb..dbef3f1 100644 --- a/api/dispatcher.js +++ b/api/dispatcher.js @@ -1,136 +1,80 @@ /** * Dispatcher - * */ import sio from 'socket.io'; import _ from 'lodash'; import log from 'winston'; -// import socketioJwt from 'socketio-jwt'; import stream from './event-stream'; -import getConstants from './handlers/v1/constants/index'; -import joinRoom from './handlers/v1/rooms/join'; -import leaveRoom from './handlers/v1/rooms/leave'; -import createIdea from './handlers/v1/ideas/create'; -import destroyIdea from './handlers/v1/ideas/destroy'; -import getIdeas from './handlers/v1/ideas/index'; -import createCollection from './handlers/v1/ideaCollections/create'; -import destroyCollection from './handlers/v1/ideaCollections/destroy'; -import addIdea from './handlers/v1/ideaCollections/addIdea'; -import removeIdea from './handlers/v1/ideaCollections/removeIdea'; -import getCollections from './handlers/v1/ideaCollections/index'; - -import EXT_EVENTS from './constants/EXT_EVENT_API'; -import INT_EVENTS from './constants/INT_EVENT_API'; +import events from './events'; +import { BROADCAST, EMIT_TO, JOIN, LEAVE } from './constants/INT_EVENT_API'; +import { handleLeaving } from './services/BoardService'; const dispatcher = function(server) { const io = sio(server, { origins: '*:*', - logger: { - debug: log.debug, - info: log.info, - error: log.error, - warn: log.warn, - }, }); - /** - * In the future we will authenticate all communication within a room w/JWT - * CFG.jwt has a secret string and a timeout period for auth. - * - * @todo Implement a JWT token service so that client can actually get tokens - * - * @example Client code for using this authorization: - * - * sio.of('/rooms', function(socket) { - * socket.on('authenticated', function () { - * // do other things - * }) - * // send the jwt, stored in retrieved from the server or stored in LS - * .emit('authenticate', {token: jwt}); - * }) - * - * io.of('/rooms') - * .on('connection', socketioJwt.authorize(CFG.jwt)); - * - * io.on('authenticated', function(socket) { - * console.log(socket.decoded_token); - * }); - */ - io.on('connection', function(socket) { log.info(`User with ${socket.id} has connected`); - socket.on(EXT_EVENTS.GET_CONSTANTS, (req) => { - log.verbose(EXT_EVENTS.GET_CONSTANTS, req); - getConstants(_.merge({socket: socket}, req)); - }); - - socket.on(EXT_EVENTS.JOIN_ROOM, (req) => { - log.verbose(EXT_EVENTS.JOIN_ROOM, req); - joinRoom(_.merge({socket: socket}, req)); - }); - socket.on(EXT_EVENTS.LEAVE_ROOM, (req) => { - log.verbose(EXT_EVENTS.LEAVE_ROOM, req); - leaveRoom(_.merge({socket: socket}, req)); + _.forEach(events, function(method, event) { + socket.on(event, (req) => { + log.info(event, req); + method(_.merge({socket: socket}, req)); + }); }); - socket.on(EXT_EVENTS.CREATE_IDEA, (req) => { - log.verbose(EXT_EVENTS.CREATE_IDEA, req); - createIdea(_.merge({socket: socket}, req)); - }); - socket.on(EXT_EVENTS.DESTROY_IDEA, (req) => { - log.verbose(EXT_EVENTS.DESTROY_IDEA, req); - destroyIdea(_.merge({socket: socket}, req)); - }); - socket.on(EXT_EVENTS.GET_IDEAS, (req) => { - log.verbose(EXT_EVENTS.GET_IDEAS, req); - getIdeas(_.merge({socket: socket}, req)); - }); + socket.on('disconnect', function() { + log.info(`User with ${socket.id} has disconnected`); - socket.on(EXT_EVENTS.CREATE_COLLECTION, (req) => { - log.verbose(EXT_EVENTS.CREATE_COLLECTION, req); - createCollection(_.merge({socket: socket}, req)); - }); - socket.on(EXT_EVENTS.DESTROY_COLLECTION, (req) => { - log.verbose(EXT_EVENTS.DESTROY_COLLECTION, req); - destroyCollection(_.merge({socket: socket}, req)); - }); - socket.on(EXT_EVENTS.ADD_IDEA, (req) => { - log.verbose(EXT_EVENTS.ADD_IDEA, req); - addIdea(_.merge({socket: socket}, req)); - }); - socket.on(EXT_EVENTS.REMOVE_IDEA, (req) => { - log.verbose(EXT_EVENTS.REMOVE_IDEA, req); - removeIdea(_.merge({socket: socket}, req)); - }); - socket.on(EXT_EVENTS.GET_COLLECTIONS, (req) => { - log.verbose(EXT_EVENTS.GET_COLLECTIONS, req); - getCollections(_.merge({socket: socket}, req)); + handleLeaving(socket.id); }); }); - stream.on(INT_EVENTS.BROADCAST, (req) => { - log.info(INT_EVENTS.BROADCAST, req.event); + /** + * @param {Object} req should be of the form: + * {event, boardId, res} + */ + stream.on(BROADCAST, (req) => { + log.info(BROADCAST, req.event); log.info('\t', JSON.stringify(req.res, null, 2)); io.in(req.boardId).emit(req.event, req.res); }); - stream.on(INT_EVENTS.EMIT_TO, (req) => { - log.info(INT_EVENTS.EMIT_TO, req.event); + /** + * @param {Object} req should be of the form: + * {event, socket, res} + */ + stream.on(EMIT_TO, (req) => { + log.info(EMIT_TO, req.event); log.info('\t', JSON.stringify(req.res, null, 2)); io.to(req.socket.id).emit(req.event, req.res); }); - stream.on(INT_EVENTS.JOIN, (req) => { - log.info(INT_EVENTS.JOIN, req.boardId); - req.socket.join(req.boardId); + /** + * @param {Object} req should be of the form: + * {boardId, userId, socket, res, + * cbRes: {event, res}} + */ + stream.on(JOIN, (req) => { + log.info(JOIN, req.boardId, req.userId); + req.socket.join(req.boardId, function() { + io.in(req.boardId).emit(req.cbRes.event, req.cbRes.res); + }); }); - stream.on(INT_EVENTS.LEAVE, (req) => { - log.info(INT_EVENTS.LEAVE, req.boardId); - req.socket.leave(req.boardId); + /** + * @param {Object} req should be of the form: + * {boardId, userId, socket, res, + * cbRes: {event, res}} + */ + stream.on(LEAVE, (req) => { + log.info(LEAVE, req.boardId, req.userId); + req.socket.leave(req.boardId, function() { + io.in(req.boardId).emit(req.cbRes.event, req.cbRes.res); + }); }); }; diff --git a/api/event-stream.js b/api/event-stream.js index 4bb7cff..5b9ce40 100644 --- a/api/event-stream.js +++ b/api/event-stream.js @@ -8,7 +8,8 @@ import { EventEmitter } from 'events'; -import INT_EVENTS from './constants/INT_EVENT_API'; +import { BROADCAST, EMIT_TO, JOIN, LEAVE } from './constants/INT_EVENT_API'; +import { JOINED_ROOM, LEFT_ROOM } from './constants/EXT_EVENT_API'; /** * Helper method to construct a standard object to send to Socket.io @@ -61,33 +62,45 @@ function error(code, event, data, socket, msg) { class EventStream extends EventEmitter { /** - * Emits a broadcast event to tell socket.io to broadcast to a room - * - * @param {Object} req can be anything to pass along to client - * @param {Object} req.boardId what board to send to - */ + * Emits a broadcast event to tell socket.io to broadcast to a room + * + * @param {Object} req can be anything to pass along to client + * @param {Object} req.boardId what board to send to + */ broadcast(req) { - this.emit(INT_EVENTS.BROADCAST, req); + this.emit(BROADCAST, req); } /** - * Emits an event to a specific socket - * - * @param {Object} req - * @param {Object} req.socket the socket to emit to - * @param {Object} req.event the socket event - * @param {Object} req.res data to send to client - */ + * Emits an event to a specific socket + * + * @param {Object} req + * @param {Object} req.socket the socket to emit to + * @param {Object} req.event the socket event + * @param {Object} req.res data to send to client + */ emitTo(req) { - this.emit(INT_EVENTS.EMIT_TO, req); + this.emit(EMIT_TO, req); } - join(socket, boardId) { - this.emit(INT_EVENTS.JOIN, {socket: socket, boardId: boardId}); + /** + * Emits an event to modify a users state in a room, either join or leave. + * Unlike other emits/broadcasts these have are special cases that + * hard-code the events and responses + * + * @param {Object} req + * @param {Object} req.socket the socket to emit to + * @param {Object} req.boardId + * @param {Object} req.userId + */ + join({socket, boardId, userId, boardState}) { + const cbRes = success(200, JOINED_ROOM, {boardId, userId, ...boardState}); + this.emit(JOIN, {socket, boardId, userId, cbRes}); } - leave(socket, boardId) { - this.emit(INT_EVENTS.LEAVE, {socket: socket, boardId: boardId}); + leave({socket, boardId, userId}) { + const cbRes = success(200, LEFT_ROOM, {boardId, userId}); + this.emit(LEAVE, {socket, boardId, userId, cbRes}); } /** diff --git a/api/events.js b/api/events.js new file mode 100644 index 0000000..1041be5 --- /dev/null +++ b/api/events.js @@ -0,0 +1,68 @@ +import getConstants from './handlers/v1/constants/index'; +import joinRoom from './handlers/v1/rooms/join'; +import leaveRoom from './handlers/v1/rooms/leave'; +import createIdea from './handlers/v1/ideas/create'; +import destroyIdea from './handlers/v1/ideas/destroy'; +import getIdeas from './handlers/v1/ideas/index'; +import createCollection from './handlers/v1/ideaCollections/create'; +import destroyCollection from './handlers/v1/ideaCollections/destroy'; +import addIdea from './handlers/v1/ideaCollections/addIdea'; +import removeIdea from './handlers/v1/ideaCollections/removeIdea'; +import getCollections from './handlers/v1/ideaCollections/index'; +import readyUser from './handlers/v1/voting/ready'; +import getResults from './handlers/v1/voting/results'; +import vote from './handlers/v1/voting/vote'; +import getVoteItems from './handlers/v1/voting/voteList'; +import startTimerCountdown from './handlers/v1/timer/start'; +import disableTimer from './handlers/v1/timer/stop'; +import getTimeRemaining from './handlers/v1/timer/get'; +import enableIdeas from './handlers/v1/state/enableIdeaCreation'; +import disableIdeas from './handlers/v1/state/disableIdeaCreation'; +import forceVote from './handlers/v1/state/forceVote'; +import forceResults from './handlers/v1/state/forceResults'; +import getCurrentState from './handlers/v1/state/get'; +import updateBoard from './handlers/v1/rooms/update'; +import getOptions from './handlers/v1/rooms/getOptions'; +import getUsers from './handlers/v1/rooms/getUsers'; + +import * as EVENTS from './constants/EXT_EVENT_API'; + +const eventMap = {}; + +eventMap[EVENTS.GET_CONSTANTS] = getConstants; + +eventMap[EVENTS.JOIN_ROOM] = joinRoom; +eventMap[EVENTS.LEAVE_ROOM] = leaveRoom; +eventMap[EVENTS.UPDATE_BOARD] = updateBoard; +eventMap[EVENTS.GET_OPTIONS] = getOptions; +eventMap[EVENTS.GET_USERS] = getUsers; + +eventMap[EVENTS.CREATE_IDEA] = createIdea; +eventMap[EVENTS.DESTROY_IDEA] = destroyIdea; +eventMap[EVENTS.GET_IDEAS] = getIdeas; + +eventMap[EVENTS.CREATE_COLLECTION] = createCollection; +eventMap[EVENTS.DESTROY_COLLECTION] = destroyCollection; +eventMap[EVENTS.ADD_IDEA] = addIdea; +eventMap[EVENTS.REMOVE_IDEA] = removeIdea; +eventMap[EVENTS.GET_COLLECTIONS] = getCollections; + +eventMap[EVENTS.GET_VOTING_ITEMS] = getVoteItems; +eventMap[EVENTS.READY_USER] = readyUser; + +eventMap[EVENTS.GET_RESULTS] = getResults; +eventMap[EVENTS.VOTE] = vote; + +eventMap[EVENTS.START_TIMER] = startTimerCountdown; +eventMap[EVENTS.DISABLE_TIMER] = disableTimer; + +eventMap[EVENTS.ENABLE_IDEAS] = enableIdeas; +eventMap[EVENTS.DISABLE_IDEAS] = disableIdeas; + +eventMap[EVENTS.FORCE_VOTE] = forceVote; +eventMap[EVENTS.FORCE_RESULTS] = forceResults; + +eventMap[EVENTS.GET_STATE] = getCurrentState; +eventMap[EVENTS.GET_TIME] = getTimeRemaining; + +export default eventMap; diff --git a/api/handlers/v1/constants/index.js b/api/handlers/v1/constants/index.js index 9db731e..e125b0e 100644 --- a/api/handlers/v1/constants/index.js +++ b/api/handlers/v1/constants/index.js @@ -1,13 +1,21 @@ /** * ConstantsController +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object */ +import { isNil } from 'ramda'; import constantsService from '../../../services/ConstantsService'; import { RECEIVED_CONSTANTS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function index(req) { - const {socket} = req; + const { socket } = req; + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } return stream.emitTo({event: RECEIVED_CONSTANTS, code: 200, diff --git a/api/handlers/v1/ideaCollections/addIdea.js b/api/handlers/v1/ideaCollections/addIdea.js index cc5694c..d2661b6 100644 --- a/api/handlers/v1/ideaCollections/addIdea.js +++ b/api/handlers/v1/ideaCollections/addIdea.js @@ -6,27 +6,29 @@ * @param {string} req.boardId * @param {string} req.content the content of the idea to create * @param {string} req.key key of the collection +* @param {string} req.userToken */ -import R from 'ramda'; +import { partialRight, isNil, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; import { addIdea as addIdeaToCollection } from '../../../services/IdeaCollectionService'; -import { stripNestedMap as strip } from '../../../helpers/utils'; +import { stripNestedMap as strip, anyAreNil } from '../../../helpers/utils'; import { UPDATED_COLLECTIONS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function addIdea(req) { const { socket, boardId, content, key, userToken } = req; - const addThisIdeaBy = R.partialRight(addIdeaToCollection, [boardId, key, content]); + const required = { boardId, content, key, userToken }; - if (isNull(socket)) { + const addThisIdeaBy = partialRight(addIdeaToCollection, + [boardId, key, content]); + + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(content) || isNull(key) || isNull(userToken)) { - return stream.badRequest(UPDATED_COLLECTIONS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(UPDATED_COLLECTIONS, required, socket); } return verifyAndGetId(userToken) diff --git a/api/handlers/v1/ideaCollections/create.js b/api/handlers/v1/ideaCollections/create.js index 23ce66d..4351bef 100644 --- a/api/handlers/v1/ideaCollections/create.js +++ b/api/handlers/v1/ideaCollections/create.js @@ -6,34 +6,36 @@ * @param {string} req.boardId * @param {string} req.content the content of the idea to initialize the * collection with. +* @param {string} req.userToken */ -import R from 'ramda'; +import { partialRight, merge, isNil, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; import { create as createCollection } from '../../../services/IdeaCollectionService'; -import { stripNestedMap as strip } from '../../../helpers/utils'; +import { stripNestedMap as strip, anyAreNil } from '../../../helpers/utils'; import { UPDATED_COLLECTIONS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function create(req) { const { socket, boardId, content, top, left, userToken } = req; - const createThisCollectionBy = R.partialRight(createCollection, [boardId, content]); + const required = { boardId, content, top, left, userToken }; - if (isNull(socket)) { + const createThisCollectionBy = partialRight(createCollection, + [boardId, content]); + + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(content) || isNull(userToken)) { - return stream.badRequest(UPDATED_COLLECTIONS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(UPDATED_COLLECTIONS, required, socket); } return verifyAndGetId(userToken) .then(createThisCollectionBy) .then(([created, allCollections]) => { return stream.ok(UPDATED_COLLECTIONS, - R.merge({key: created.key, top: top, left: left}, + merge({key: created.key, top: top, left: left}, strip(allCollections)), boardId); }) .catch(JsonWebTokenError, (err) => { diff --git a/api/handlers/v1/ideaCollections/destroy.js b/api/handlers/v1/ideaCollections/destroy.js index 6e28619..1b0f83e 100644 --- a/api/handlers/v1/ideaCollections/destroy.js +++ b/api/handlers/v1/ideaCollections/destroy.js @@ -5,26 +5,28 @@ * @param {Object} req.socket the connecting socket object * @param {string} req.boardId * @param {string} req.key key of the collection +* @param {string} req.userToken */ +import { isNil, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; -import { destroy as removeCollection } from '../../../services/IdeaCollectionService'; -import { stripNestedMap as strip } from '../../../helpers/utils'; +import { destroyByKey as removeCollection } from '../../../services/IdeaCollectionService'; +import { stripNestedMap as strip, anyAreNil } from '../../../helpers/utils'; import { UPDATED_COLLECTIONS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function destroy(req) { const { socket, boardId, key, userToken } = req; + const required = { boardId, key, userToken }; + const removeThisCollectionBy = () => removeCollection(boardId, key); - if (isNull(socket)) { + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(key) || isNull(userToken)) { - return stream.badRequest(UPDATED_COLLECTIONS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(UPDATED_COLLECTIONS, required, socket); } return verifyAndGetId(userToken) diff --git a/api/handlers/v1/ideaCollections/index.js b/api/handlers/v1/ideaCollections/index.js index c3f261b..f08718a 100644 --- a/api/handlers/v1/ideaCollections/index.js +++ b/api/handlers/v1/ideaCollections/index.js @@ -4,26 +4,28 @@ * @param {Object} req * @param {Object} req.socket the connecting socket object * @param {string} req.boardId +* @param {string} req.userToken */ +import { isNil, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; import { getIdeaCollections } from '../../../services/IdeaCollectionService'; -import { stripNestedMap as strip } from '../../../helpers/utils'; +import { stripNestedMap as strip, anyAreNil } from '../../../helpers/utils'; import { RECEIVED_COLLECTIONS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function index(req) { const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + const getCollections = () => getIdeaCollections(boardId); - if (isNull(socket)) { + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(userToken)) { - return stream.badRequest(RECEIVED_COLLECTIONS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_COLLECTIONS, required, socket); } return verifyAndGetId(userToken) diff --git a/api/handlers/v1/ideaCollections/removeIdea.js b/api/handlers/v1/ideaCollections/removeIdea.js index 319d978..5157b6d 100644 --- a/api/handlers/v1/ideaCollections/removeIdea.js +++ b/api/handlers/v1/ideaCollections/removeIdea.js @@ -6,27 +6,29 @@ * @param {string} req.boardId * @param {string} req.content the content of the idea to create * @param {string} req.key key of the collection +* @param {string} req.userToken */ -import R from 'ramda'; +import { partialRight, isNil, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; import { removeIdea as removeIdeaFromCollection } from '../../../services/IdeaCollectionService'; -import { stripNestedMap as strip } from '../../../helpers/utils'; +import { stripNestedMap as strip, anyAreNil } from '../../../helpers/utils'; import { UPDATED_COLLECTIONS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function removeIdea(req) { const { socket, boardId, content, key, userToken } = req; - const removeThisIdeaBy = R.partialRight(removeIdeaFromCollection, [boardId, key, content]); + const required = { boardId, content, key, userToken }; - if (isNull(socket)) { + const removeThisIdeaBy = partialRight(removeIdeaFromCollection, + [boardId, key, content]); + + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(content) || isNull(key) || isNull(userToken)) { - return stream.badRequest(UPDATED_COLLECTIONS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(UPDATED_COLLECTIONS, required, socket); } return verifyAndGetId(userToken) diff --git a/api/handlers/v1/ideas/create.js b/api/handlers/v1/ideas/create.js index 89b776d..add50d1 100644 --- a/api/handlers/v1/ideas/create.js +++ b/api/handlers/v1/ideas/create.js @@ -5,27 +5,28 @@ * @param {Object} req.socket the connecting socket object * @param {string} req.boardId * @param {string} req.content the content of the idea to create +* @param {string} req.userToken */ -import R from 'ramda'; +import { partialRight, isNil, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; import { create as createIdea } from '../../../services/IdeaService'; -import { stripMap as strip } from '../../../helpers/utils'; +import { stripMap as strip, anyAreNil } from '../../../helpers/utils'; import { UPDATED_IDEAS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function create(req) { const { socket, boardId, content, userToken } = req; - const createThisIdeaBy = R.partialRight(createIdea, [boardId, content]); + const required = { boardId, content, userToken }; - if (isNull(socket)) { + const createThisIdeaBy = partialRight(createIdea, [boardId, content]); + + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(content) || isNull(userToken)) { - return stream.badRequest(UPDATED_IDEAS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(UPDATED_IDEAS, required, socket); } return verifyAndGetId(userToken) diff --git a/api/handlers/v1/ideas/destroy.js b/api/handlers/v1/ideas/destroy.js index f4db65b..3217dd8 100644 --- a/api/handlers/v1/ideas/destroy.js +++ b/api/handlers/v1/ideas/destroy.js @@ -5,38 +5,52 @@ * @param {Object} req.socket the connecting socket object * @param {string} req.boardId * @param {string} req.content the content of the idea to remove +* @param {string} req.userToken */ -import R from 'ramda'; +import { curry, isNil, __, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; import { destroy } from '../../../services/IdeaService'; -import { stripMap as strip } from '../../../helpers/utils'; +import { stripMap as strip, anyAreNil } from '../../../helpers/utils'; import { UPDATED_IDEAS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; +import Promise from 'bluebird'; export default function remove(req) { const { socket, boardId, content, userToken } = req; - const destroyThisIdeaBy = R.partialRight(destroy, [boardId, content]); + const required = { boardId, content, userToken }; - if (isNull(socket)) { + const destroyThisIdeaBy = curry(destroy, __, __, content); + + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(userToken)) { - return stream.badRequest(UPDATED_IDEAS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(UPDATED_IDEAS, required, socket); } - return verifyAndGetId(userToken) - .then(destroyThisIdeaBy) - .then((allIdeas) => { - return stream.ok(UPDATED_IDEAS, strip(allIdeas), boardId); - }) - .catch(JsonWebTokenError, (err) => { - return stream.unauthorized(UPDATED_IDEAS, err.message, socket); - }) - .catch((err) => { - return stream.serverError(UPDATED_IDEAS, err.message, socket); - }); + return Promise.all([ + Board.findOne({boardId: boardId}), + verifyAndGetId(userToken), + ]) + .spread(destroyThisIdeaBy) + .then((allIdeas) => { + return Promise.all([ + getIdeaCollections(boardId), + Promise.resolve(allIdeas), + ]); + }) + .then(([ideaCollections, allIdeas]) => { + return Promise.all([ + stream.ok(UPDATED_IDEAS, strip(allIdeas), boardId), + stream.ok(UPDATED_COLLECTIONS, strip(ideaCollections), boardId), + ]); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(UPDATED_IDEAS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(UPDATED_IDEAS, err.message, socket); + }); } diff --git a/api/handlers/v1/ideas/index.js b/api/handlers/v1/ideas/index.js index 84ffa7c..4fe6a50 100644 --- a/api/handlers/v1/ideas/index.js +++ b/api/handlers/v1/ideas/index.js @@ -4,26 +4,28 @@ * @param {Object} req * @param {Object} req.socket the connecting socket object * @param {string} req.boardId +* @param {string} req.userToken */ +import { isNil, values } from 'ramda'; import { JsonWebTokenError } from 'jsonwebtoken'; -import { isNull } from '../../../services/ValidatorService'; import { verifyAndGetId } from '../../../services/TokenService'; import { getIdeas } from '../../../services/IdeaService'; -import { stripMap as strip } from '../../../helpers/utils'; +import { stripMap as strip, anyAreNil } from '../../../helpers/utils'; import { RECEIVED_IDEAS } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function index(req) { const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + const getTheseIdeas = () => getIdeas(boardId); - if (isNull(socket)) { + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(userToken)) { - return stream.badRequest(RECEIVED_IDEAS, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_IDEAS, required, socket); } return verifyAndGetId(userToken) diff --git a/api/handlers/v1/rooms/getOptions.js b/api/handlers/v1/rooms/getOptions.js new file mode 100644 index 0000000..388a3f4 --- /dev/null +++ b/api/handlers/v1/rooms/getOptions.js @@ -0,0 +1,37 @@ +/** +* Rooms#getUsers +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId the id of the room to join +*/ + +import { isNil, values } from 'ramda'; +import { getBoardOptions } from '../../../services/BoardService'; +import { NotFoundError } from '../../../helpers/extendable-error'; +import { anyAreNil } from '../../../helpers/utils'; +import { RECEIVED_OPTIONS } from '../../../constants/EXT_EVENT_API'; +import stream from '../../../event-stream'; + +export default function getOptions(req) { + const { socket, boardId } = req; + const required = { boardId }; + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_OPTIONS, required, socket); + } + + return getBoardOptions(boardId) + .then((options) => { + return stream.okTo(RECEIVED_OPTIONS, options, socket); + }) + .catch(NotFoundError, (err) => { + return stream.notFound(RECEIVED_OPTIONS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(RECEIVED_OPTIONS, err.message, socket); + }); +} diff --git a/api/handlers/v1/rooms/getUsers.js b/api/handlers/v1/rooms/getUsers.js new file mode 100644 index 0000000..8c44b4d --- /dev/null +++ b/api/handlers/v1/rooms/getUsers.js @@ -0,0 +1,36 @@ +/** +* Rooms#getUsers +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId the id of the room to join +*/ + +import { isNil, values } from 'ramda'; +import { getUsers as getUsersOnBoard } from '../../../services/BoardService'; +import { anyAreNil } from '../../../helpers/utils'; +import { RECEIVED_USERS } from '../../../constants/EXT_EVENT_API'; +import stream from '../../../event-stream'; + +export default function getUsers(req) { + const { socket, boardId } = req; + const required = { boardId }; + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_USERS, required, socket); + } + + return getUsersOnBoard(boardId) + .then((users) => { + return stream.ok(socket, users, boardId); + }) + .catch(NotFoundError, (err) => { + return stream.notFound(RECEIVED_USERS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(RECEIVED_USERS, err.message, socket); + }); +} diff --git a/api/handlers/v1/rooms/join.js b/api/handlers/v1/rooms/join.js index 332695b..61a68ed 100644 --- a/api/handlers/v1/rooms/join.js +++ b/api/handlers/v1/rooms/join.js @@ -4,34 +4,49 @@ * @param {Object} req * @param {Object} req.socket the connecting socket object * @param {string} req.boardId the id of the room to join +* @param {string} req.userToken */ -import { isNull } from '../../../services/ValidatorService'; -import BoardService from '../../../services/BoardService'; +import { isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { NotFoundError, ValidationError, + UnauthorizedError } from '../../../helpers/extendable-error'; +import { anyAreNil } from '../../../helpers/utils'; +import { addUser, hydrateRoom } from '../../../services/BoardService'; +import { verifyAndGetId } from '../../../services/TokenService'; import { JOINED_ROOM } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function join(req) { const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; - if (isNull(socket)) { + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(userToken)) { - return stream.badRequest(JOINED_ROOM, {}, socket); + if (anyAreNil(values(required))) { + return stream.badRequest(JOINED_ROOM, required, socket); } - return BoardService.exists(boardId) - .then((exists) => { - if (exists) { - stream.join(socket, boardId); - return stream.ok(JOINED_ROOM, - `User with socket id ${socket.id} joined board ${boardId}`, - boardId); - } - else { - return stream.notFound(JOINED_ROOM, 'Board not found', socket); - } + return verifyAndGetId(userToken) + .then((userId) => addUser(boardId, userId, socket.id)) + .then((userId) => { + + return hydrateRoom(boardId) + .then((boardState) => { + return stream.join({socket, boardId, userId, boardState}); + }) + .catch(NotFoundError, (err) => { + return stream.notFound(JOINED_ROOM, err.data, socket, err.message); + }) + .catch(UnauthorizedError, JsonWebTokenError, (err) => { + return stream.unauthorized(JOINED_ROOM, err.data, socket, err.message); + }) + .catch(ValidationError, (err) => { + return stream.serverError(JOINED_ROOM, err.data, socket, err.message); + }) + .catch((err) => { + return stream.serverError(JOINED_ROOM, err.message, socket); }); + }); } diff --git a/api/handlers/v1/rooms/leave.js b/api/handlers/v1/rooms/leave.js index 4c31c76..4f18c22 100644 --- a/api/handlers/v1/rooms/leave.js +++ b/api/handlers/v1/rooms/leave.js @@ -4,25 +4,35 @@ * @param {Object} req * @param {Object} req.socket the connecting socket object * @param {string} req.boardId the id of the room to leave +* @param {string} req.userToken */ -import { isNull } from '../../../services/ValidatorService'; +import { isNil, values } from 'ramda'; +import { handleLeavingUser } from '../../../services/BoardService'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { anyAreNil } from '../../../helpers/utils'; import { LEFT_ROOM } from '../../../constants/EXT_EVENT_API'; import stream from '../../../event-stream'; export default function leave(req) { const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + let userId; - if (isNull(socket)) { + if (isNil(socket)) { return new Error('Undefined request socket in handler'); } - - if (isNull(boardId) || isNull(userToken)) { - return stream.badRequest(LEFT_ROOM, {}, socket); - } - else { - stream.leave(socket, boardId); - return stream.ok(LEFT_ROOM, {}, boardId, - `User with socket id ${socket.id} left board ${boardId}`); + if (anyAreNil(values(required))) { + return stream.badRequest(LEFT_ROOM, required, socket); } + + return verifyAndGetId(userToken) + .then((userId) => handleLeavingUser(userId, socket.id)) + .then(() => { + return stream.leave({socket, boardId, userId}); + }) + .catch((err) => { + stream.serverError(LEFT_ROOM, err.message, socket); + throw err; + }); } diff --git a/api/handlers/v1/rooms/update.js b/api/handlers/v1/rooms/update.js new file mode 100644 index 0000000..3d6a109 --- /dev/null +++ b/api/handlers/v1/rooms/update.js @@ -0,0 +1,46 @@ +/** +* Rooms#update +*/ + +import { isNil, values } from 'ramda'; +import Promise from 'bluebird'; + +import { UPDATED_BOARD } from '../../../constants/EXT_EVENT_API'; +import stream from '../../../event-stream'; +import { errorIfNotAdmin } from '../../../services/BoardService'; +import { findBoard } from '../../../services/BoardService'; +import { update as updateBoard } from '../../../services/BoardService'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { UnauthorizedError } from '../../../helpers/extendable-error'; +import { strip, anyAreNil } from '../../../helpers/utils'; + +export default function update(req) { + const { socket, boardId, userToken, updates } = req; + const required = { boardId, userToken, updates }; + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(UPDATED_BOARD, required, socket); + } + + return Promise.all([ + findBoard(boardId), + verifyAndGetId(userToken), + ]) + .spread(errorIfNotAdmin) + .then(([board /* , userId */]) => updateBoard(board, updates)) + .then((updatedBoard) => { + return stream.ok(UPDATED_BOARD, strip(updatedBoard), boardId); + }) + .catch(JsonWebTokenError, UnauthorizedError, (err) => { + stream.unauthorized(UPDATED_BOARD, err.message, socket); + throw err; + }) + .catch((err) => { + stream.serverError(UPDATED_BOARD, err.message, socket); + throw err; + }); +} diff --git a/api/handlers/v1/state/disableIdeaCreation.js b/api/handlers/v1/state/disableIdeaCreation.js new file mode 100644 index 0000000..e1ab5f3 --- /dev/null +++ b/api/handlers/v1/state/disableIdeaCreation.js @@ -0,0 +1,43 @@ +/** +* Ideas#disable +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { createIdeaCollections } from '../../../services/StateService'; +import { DISABLED_IDEAS } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function disableIdeaCreation(req) { + const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + + const setState = partial(createIdeaCollections, [boardId, true]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(DISABLED_IDEAS, required, socket); + } + + return verifyAndGetId(userToken) + .then(setState) + .then((state) => { + return stream.ok(DISABLED_IDEAS, {boardId: boardId, state: state}, + boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(DISABLED_IDEAS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(DISABLED_IDEAS, err.message, socket); + }); +} diff --git a/api/handlers/v1/state/enableIdeaCreation.js b/api/handlers/v1/state/enableIdeaCreation.js new file mode 100644 index 0000000..df7e1d6 --- /dev/null +++ b/api/handlers/v1/state/enableIdeaCreation.js @@ -0,0 +1,43 @@ +/** +* Ideas#enable +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { createIdeasAndIdeaCollections } from '../../../services/StateService'; +import { ENABLED_IDEAS } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function enableIdeaCreation(req) { + const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + + const setState = partial(createIdeasAndIdeaCollections, [boardId, true]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(ENABLED_IDEAS, required, socket); + } + + return verifyAndGetId(userToken) + .then(setState) + .then((state) => { + return stream.ok(ENABLED_IDEAS, {boardId: boardId, state: state}, + boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(ENABLED_IDEAS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(ENABLED_IDEAS, err.message, socket); + }); +} diff --git a/api/handlers/v1/state/forceResults.js b/api/handlers/v1/state/forceResults.js new file mode 100644 index 0000000..bf6d09f --- /dev/null +++ b/api/handlers/v1/state/forceResults.js @@ -0,0 +1,43 @@ +/** +* Ideas#enable +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { createIdeaCollections } from '../../../services/StateService'; +import { FORCED_RESULTS } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function forceResults(req) { + const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + + const setState = partial(createIdeaCollections, [boardId, true]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(FORCED_RESULTS, required, socket); + } + + return verifyAndGetId(userToken) + .then(setState) + .then((state) => { + return stream.ok(FORCED_RESULTS, {boardId: boardId, state: state}, + boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(FORCED_RESULTS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(FORCED_RESULTS, err.message, socket); + }); +} diff --git a/api/handlers/v1/state/forceVote.js b/api/handlers/v1/state/forceVote.js new file mode 100644 index 0000000..6955921 --- /dev/null +++ b/api/handlers/v1/state/forceVote.js @@ -0,0 +1,42 @@ +/** +* Ideas#enable +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { voteOnIdeaCollections } from '../../../services/StateService'; +import { FORCED_VOTE } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function forceVote(req) { + const { socket, boardid, usertoken } = req; + const required = { boardid, usertoken }; + + const setState = partial(voteOnIdeaCollections, [boardId, true]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(FORCED_VOTE, required, socket); + } + + return verifyAndGetId(userToken) + .then(setState) + .then((state) => { + return stream.ok(FORCED_VOTE, {boardId: boardId, state: state}, boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(FORCED_VOTE, err.message, socket); + }) + .catch((err) => { + return stream.serverError(FORCED_VOTE, err.message, socket); + }); +} diff --git a/api/handlers/v1/state/get.js b/api/handlers/v1/state/get.js new file mode 100644 index 0000000..c043a5d --- /dev/null +++ b/api/handlers/v1/state/get.js @@ -0,0 +1,43 @@ +/** +* Ideas#enable +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken to authenticate the user +*/ + +import { isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { getState } from '../../../services/StateService'; +import { RECEIVED_STATE } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function get(req) { + const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + + const getThisState = () => getState(boardId); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_STATE, required, socket); + } + + return verifyAndGetId(userToken) + .then(getThisState) + .then((state) => { + return stream.ok(RECEIVED_STATE, {boardId: boardId, state: state}, + boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(RECEIVED_STATE, err.message, socket); + }) + .catch((err) => { + return stream.serverError(RECEIVED_STATE, err.message, socket); + }); +} diff --git a/api/handlers/v1/timer/get.js b/api/handlers/v1/timer/get.js new file mode 100644 index 0000000..5a69b04 --- /dev/null +++ b/api/handlers/v1/timer/get.js @@ -0,0 +1,44 @@ +/** +* TimerService#getTimeLeft +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.content the content of the idea to create +* @param {string} req.userToken +*/ + +import { isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { getTimeLeft } from '../../../services/TimerService'; +import { RECEIVED_TIME } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function getTime(req) { + const { socket, boardId, timerId, userToken } = req; + const required = { boardId, timerId, userToken }; + + const getThisTimeLeft = () => getTimeLeft(timerId); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_TIME, required, socket); + } + + return verifyAndGetId(userToken) + .then(getThisTimeLeft) + .then((timeLeft) => { + return stream.okTo(RECEIVED_TIME, {boardId: boardId, timeLeft: timeLeft}, + socket); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(RECEIVED_TIME, err.message, socket); + }) + .catch((err) => { + return stream.serverError(RECEIVED_TIME, err.message, socket); + }); +} diff --git a/api/handlers/v1/timer/start.js b/api/handlers/v1/timer/start.js new file mode 100644 index 0000000..ea39a8c --- /dev/null +++ b/api/handlers/v1/timer/start.js @@ -0,0 +1,51 @@ +/** +* TimerService#startTimer +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.content the content of the idea to create +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { UnauthorizedError } from '../../../helpers/extendable-error'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { startTimer } from '../../../services/TimerService'; +import { errorIfNotAdmin } from '../../../services/BoardService'; +import { STARTED_TIMER } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function start(req) { + const { socket, boardId, timerLengthInMS, userToken } = req; + const required = { boardId, timerLengthInMS, userToken }; + + const startThisTimer = partial(startTimer, [boardId, timerLengthInMS]); + const errorIfNotAdminOnThisBoard = partial(errorIfNotAdmin, [boardId]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(STARTED_TIMER, required, socket); + } + + return verifyAndGetId(userToken) + .then(errorIfNotAdminOnThisBoard) + .then(startThisTimer) + .then((timerId) => { + return stream.ok(STARTED_TIMER, {boardId: boardId, timerId: timerId}, + boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(STARTED_TIMER, err.message, socket); + }) + .catch(UnauthorizedError, (err) => { + return stream.unauthorized(STARTED_TIMER, err.message, socket); + }) + .catch((err) => { + return stream.serverError(STARTED_TIMER, err.message, socket); + }); +} diff --git a/api/handlers/v1/timer/stop.js b/api/handlers/v1/timer/stop.js new file mode 100644 index 0000000..5e67a70 --- /dev/null +++ b/api/handlers/v1/timer/stop.js @@ -0,0 +1,51 @@ +/** +* TimerService#stopTimer +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.content the content of the idea to create +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { UnauthorizedError } from '../../../helpers/extendable-error'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { stopTimer } from '../../../services/TimerService'; +import { errorIfNotAdmin } from '../../../services/BoardService'; +import { DISABLED_TIMER } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function stop(req) { + const { socket, boardId, timerId, userToken } = req; + const required = { boardId, timerId, userToken }; + + const stopThisTimer = () => stopTimer(timerId); + const errorIfNotAdminOnThisBoard = partial(errorIfNotAdmin, [boardId]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(DISABLED_TIMER, required, socket); + } + + return verifyAndGetId(userToken) + .then(errorIfNotAdminOnThisBoard) + .then(stopThisTimer) + .then(() => { + return stream.ok(DISABLED_TIMER, {boardId: boardId}, + boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(DISABLED_TIMER, err.message, socket); + }) + .catch(UnauthorizedError, (err) => { + return stream.unauthorized(DISABLED_TIMER, err.message, socket); + }) + .catch((err) => { + return stream.serverError(DISABLED_TIMER, err.message, socket); + }); +} diff --git a/api/handlers/v1/voting/ready.js b/api/handlers/v1/voting/ready.js new file mode 100644 index 0000000..9c100de --- /dev/null +++ b/api/handlers/v1/voting/ready.js @@ -0,0 +1,42 @@ +/** +* Voting#ready +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { setUserReadyToVote } from '../../../services/VotingService'; +import { READIED_USER } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function ready(req) { + const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + + const setUserReadyHere = partial(setUserReadyToVote, [boardId]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(READIED_USER, required, socket); + } + + return verifyAndGetId(userToken) + .then(setUserReadyHere) + .then(() => { + return stream.ok(READIED_USER, {}, boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(READIED_USER, err.message, socket); + }) + .catch((err) => { + return stream.serverError(READIED_USER, err.message, socket); + }); +} diff --git a/api/handlers/v1/voting/results.js b/api/handlers/v1/voting/results.js new file mode 100644 index 0000000..b58d595 --- /dev/null +++ b/api/handlers/v1/voting/results.js @@ -0,0 +1,42 @@ +/** +* Voting#results +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { getResults } from '../../../services/VotingService'; +import { RECEIVED_RESULTS } from '../../../constants/EXT_EVENT_API'; +import { stripNestedMap as strip, anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function results(req) { + const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + + const getTheseResults = () => getResults(boardId); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_RESULTS, required, socket); + } + + return verifyAndGetId(userToken) + .then(getTheseResults) + .then((allResults) => { + return stream.ok(RECEIVED_RESULTS, strip(allResults), boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(RECEIVED_RESULTS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(RECEIVED_RESULTS, err.message, socket); + }); +} diff --git a/api/handlers/v1/voting/vote.js b/api/handlers/v1/voting/vote.js new file mode 100644 index 0000000..aa472d9 --- /dev/null +++ b/api/handlers/v1/voting/vote.js @@ -0,0 +1,43 @@ +/** +* Voting#vote +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { curry, __, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { vote as incrementVote } from '../../../services/VotingService'; +import { VOTED } from '../../../constants/EXT_EVENT_API'; +import { anyAreNil } from '../../../helpers/utils'; +import stream from '../../../event-stream'; + +export default function vote(req) { + const { socket, boardId, key, increment, userToken } = req; + const required = { boardId, key, increment, userToken }; + + const incrementVotesForThis = + curry(incrementVote)(boardId, __, key, increment); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(VOTED, required, socket); + } + + return verifyAndGetId(userToken) + .then(incrementVotesForThis) + .then(() => { + return stream.okTo(VOTED, {}, boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(VOTED, err.message, socket); + }) + .catch((err) => { + return stream.serverError(VOTED, err.message, socket); + }); +} diff --git a/api/handlers/v1/voting/voteList.js b/api/handlers/v1/voting/voteList.js new file mode 100644 index 0000000..a4a267c --- /dev/null +++ b/api/handlers/v1/voting/voteList.js @@ -0,0 +1,42 @@ +/** +* Voting#getVoteList +* +* @param {Object} req +* @param {Object} req.socket the connecting socket object +* @param {string} req.boardId +* @param {string} req.userToken +*/ + +import { partial, isNil, values } from 'ramda'; +import { JsonWebTokenError } from 'jsonwebtoken'; +import { verifyAndGetId } from '../../../services/TokenService'; +import { getVoteList } from '../../../services/VotingService'; +import { stripNestedMap as strip, anyAreNil } from '../../../helpers/utils'; +import { RECEIVED_VOTING_ITEMS } from '../../../constants/EXT_EVENT_API'; +import stream from '../../../event-stream'; + +export default function voteList(req) { + const { socket, boardId, userToken } = req; + const required = { boardId, userToken }; + + const getThisVoteList = partial(getVoteList, [boardId]); + + if (isNil(socket)) { + return new Error('Undefined request socket in handler'); + } + if (anyAreNil(values(required))) { + return stream.badRequest(RECEIVED_VOTING_ITEMS, required, socket); + } + + return verifyAndGetId(userToken) + .then(getThisVoteList) + .then((collections) => { + return stream.ok(RECEIVED_VOTING_ITEMS, strip(collections), boardId); + }) + .catch(JsonWebTokenError, (err) => { + return stream.unauthorized(RECEIVED_VOTING_ITEMS, err.message, socket); + }) + .catch((err) => { + return stream.serverError(RECEIVED_VOTING_ITEMS, err.message, socket); + }); +} diff --git a/api/services/database.js b/api/helpers/database.js similarity index 81% rename from api/services/database.js rename to api/helpers/database.js index a59960c..15e405e 100644 --- a/api/services/database.js +++ b/api/helpers/database.js @@ -12,21 +12,21 @@ import Promise from 'bluebird'; * A singleton that sets up the connection to mongoose if it hasn't already * been established. Otherwise it returns undefined. * @param {Function} cb - optional callback to trigger when connected - * @returns {undefined} + * @returns {Mongoose} */ const database = (cb) => { if (mongoose.connection.db) { - if (cb) cb(); + if (cb) cb(null, mongoose); return mongoose; } mongoose.Promise = Promise; mongoose.connect(CFG.mongoURL, CFG.mongoOpts); - mongoose.connection.on('error', (err) => log.error(err)); + mongoose.connection.on('error', (err) => log.error(err, mongoose)); mongoose.connection.once('open', () => { log.info(`Connected to Mongoose ${CFG.mongoURL}`); - if (cb) cb(); + if (cb) cb(null, mongoose); }); return mongoose; diff --git a/api/helpers/extendable-error.js b/api/helpers/extendable-error.js index 602fa0c..2f599a3 100644 --- a/api/helpers/extendable-error.js +++ b/api/helpers/extendable-error.js @@ -11,8 +11,18 @@ import ExtendableError from 'es6-error'; -export class NotFoundError extends ExtendableError { -} +class CustomDataError extends ExtendableError { -export class ValidationError extends ExtendableError { + constructor(message = '', data = {}) { + super(message); + this.data = data; + } } + +export class NotFoundError extends CustomDataError { } + +export class ValidationError extends CustomDataError { } + +export class NoOpError extends CustomDataError { } + +export class UnauthorizedError extends CustomDataError { } diff --git a/api/helpers/key-val-store.js b/api/helpers/key-val-store.js new file mode 100644 index 0000000..6fd3010 --- /dev/null +++ b/api/helpers/key-val-store.js @@ -0,0 +1,11 @@ +/** + * key-val-store + * Currently sets up an ioredis instance + * + * @file Creates a singleton for a Redis connection + */ + +import Redis from 'ioredis'; +import CFG from '../../config'; + +module.exports = new Redis(CFG.redisURL); diff --git a/api/helpers/utils.js b/api/helpers/utils.js index b0ec8a4..66dc6a7 100644 --- a/api/helpers/utils.js +++ b/api/helpers/utils.js @@ -1,57 +1,93 @@ -import R from 'ramda'; - -const utils = { - /** - * The results of Mongoose queries are objects which have a number of methods - * that aren't relevant when we send them to client. The easiest way to get rid - * of them is to use the built in toString method which just includes the data. - * - * This helper method wraps this operation up neatly - * @param {MongooseObject} mongooseResult - * @return {Object} - */ - toPlainObject: (mongooseResult) => { - return JSON.parse(JSON.stringify(mongooseResult)); - }, - - /** - * {_id: 1} => {} - * @param {MongooseObject} mongooseResult - * @param {Array} omitBy - * @return {Object} - */ - strip: (mongooseResult, omitBy = ['_id']) => { - return R.pipe(R.omit(omitBy), utils.toPlainObject)(mongooseResult); - }, - - /** - * [{_id: 1}, {_id: 2}] => [{}, {}] - * or - * {1: {_id: 1}, 2: {_id: 2}} => {1: {}, 2: {}} - * @param {MongooseObject} mongooseResult - * @param {Array} omitBy - * @return {Object} - */ - stripMap: (mongooseResult, omitBy = ['_id']) => { - return R.pipe(R.map(R.omit(omitBy)), utils.toPlainObject)(mongooseResult); - }, - - /** - * {1: {_id: 1, arrKey: [{_id: 2}]}} => {1: {arrKey: [{}]}} - * @param {MongooseObject} mongooseResult - * @param {Array} omitBy - * @param {String} arrKey - * @return {Object} - */ - stripNestedMap: (mongooseResult, omitBy = ['_id'], arrKey = 'ideas') => { - const stripNested = (obj) => { - obj[arrKey] = R.map(R.omit(omitBy))(obj[arrKey]); - return obj; - }; - return R.pipe(R.map(R.omit(omitBy)), - R.map(stripNested), - utils.toPlainObject)(mongooseResult); - }, +import { T, any, either, cond, isNil, isEmpty, + curry, pipe, map, omit } from 'ramda'; + +const utils = {}; + +/** + * The results of Mongoose queries are objects which have a number of methods + * that aren't relevant when we send them to client. The easiest way to get rid + * of them is to use the built in toString method which just includes the data. + * + * This helper method wraps this operation up neatly + * @param {MongooseObject} mongooseResult + * @return {Object} + */ +utils.toPlainObject = (mongooseResult) => { + return JSON.parse(JSON.stringify(mongooseResult)); +}; + +/** + * {_id: 1} => {} + * @param {MongooseObject} mongooseResult + * @param {Array} omitBy + * @return {Object} + */ +utils.strip = (mongooseResult, omitBy = ['_id']) => { + return pipe(omit(omitBy), utils.toPlainObject)(mongooseResult); +}; + +/** + * [{_id: 1}, {_id: 2}] => [{}, {}] + * or + * {1: {_id: 1}, 2: {_id: 2}} => {1: {}, 2: {}} + * @param {MongooseObject} mongooseResult + * @param {Array} omitBy + * @return {Object} + */ +utils.stripMap = (mongooseResult, omitBy = ['_id']) => { + return pipe(map(omit(omitBy)), utils.toPlainObject)(mongooseResult); +}; + +/** + * {1: {_id: 1, arrKey: [{_id: 2}]}} => {1: {arrKey: [{}]}} + * @param {MongooseObject} mongooseResult + * @param {Array} omitBy + * @param {String} arrKey + * @return {Object} + */ +utils.stripNestedMap = (mongooseResult, + omitBy = ['_id'], arrKey = 'ideas') => { + + const stripNested = (obj) => { + obj[arrKey] = map(omit(omitBy))(obj[arrKey]); + return obj; + }; + + return pipe(map(omit(omitBy)), + map(stripNested), + utils.toPlainObject)(mongooseResult); }; +/** + * @param {Array} arrayOfValues + * @returns {Boolean} True if any value is null or undefined + */ +utils.anyAreNil = any(isNil); + +/** + * @param {Type} type + * @returns {Boolean} true if nil or empty + */ +utils.isNilorEmpty = either(isNil, isEmpty); + +/** + * @param {X -> Boolean} predicate + * @param {Type} default + * @param {Type} x value to check + * @returns {Type} default if predicate is false, if true pass x along + */ +utils.ifPdefaultTo = curry((predicate, def, value) => ( + cond([ + [predicate, () => def], + [T, (x) => x], + ])(value) +)); + +/* + * @param {Type} default + * @param {Type} x value to check + * @returns {Type} default if x is nil or empty, if true pass x along + */ +utils.emptyDefaultTo = utils.ifPdefaultTo(either(isNil, isEmpty)); + module.exports = utils; diff --git a/api/models/Board.js b/api/models/Board.js index b417fdc..e90cf32 100644 --- a/api/models/Board.js +++ b/api/models/Board.js @@ -6,6 +6,11 @@ import mongoose from 'mongoose'; import shortid from 'shortid'; import IdeaCollection from './IdeaCollection.js'; import Idea from './Idea.js'; +import Result from './Result'; + +const adminEditables = ['isPublic', 'boardName', 'boardDesc', + 'userColorsEnabled', 'numResultsShown', + 'numResultsReturn']; const schema = new mongoose.Schema({ isPublic: { @@ -17,13 +22,40 @@ const schema = new mongoose.Schema({ type: String, unique: true, default: shortid.generate, + adminEditable: false, + }, + + boardName: { + type: String, + trim: true, }, - name: { + boardDesc: { type: String, trim: true, }, + userColorsEnabled: { + type: Boolean, + default: true, + }, + + numResultsShown: { + type: Number, + default: 25, + }, + + numResultsReturn: { + type: Number, + default: 5, + }, + + round: { + type: Number, + default: 0, + min: 0, + }, + users: [ { type: mongoose.Schema.ObjectId, @@ -37,25 +69,22 @@ const schema = new mongoose.Schema({ ref: 'User', }, ], - - pendingUsers: [ - { - type: mongoose.Schema.ObjectId, - ref: 'User', - }, - ], }); schema.post('remove', function(next) { - // @TODO remove from cache - // Remove all models that depend on the removed Board IdeaCollection.model.remove({boardId: this.boardId}) .then(() => Idea.model.remove({boardId: this.boardId})) + .then(() => Result.model.remove({boardId: this.boardId})) .then(() => next()); next(); }); +schema.statics.findBoard = function(boardId) { + return this.findOne({boardId: boardId}); +}; + export { schema }; +export const adminEditableFields = adminEditables; export const model = mongoose.model('Board', schema); diff --git a/api/models/Idea.js b/api/models/Idea.js index 92eca1a..5c8e267 100644 --- a/api/models/Idea.js +++ b/api/models/Idea.js @@ -6,6 +6,8 @@ */ import mongoose from 'mongoose'; +import { model as IdeaCollection } from './IdeaCollection'; +import _ from 'lodash'; const schema = new mongoose.Schema({ // Which board the idea belongs to @@ -14,11 +16,6 @@ const schema = new mongoose.Schema({ required: true, }, - isActive: { - type: Boolean, - default: true, - }, - // Who created the idea, used for color coding the ideas userId: { type: mongoose.Schema.ObjectId, @@ -35,6 +32,27 @@ const schema = new mongoose.Schema({ }); // Middleware + +// Remove the idea and remove it from all idea collections on the board too +// @TODO: Figure out what to do about last person to edit the collecitons +schema.pre('remove', function(next) { + const self = this; + // Get all of the idea collections on a board and populate their ideas + // Loop through all of the collections + // Remove the idea that matches by mongo id from each collection + IdeaCollection.find({boardId: self.boardId}) + .then((collections) => { + return _.map(collections, function(collection) { + _.map(collection.ideas, function(collectionIdea) { + if (collectionIdea.id === self.id) { + return collection.ideas.pull(self.id); + } + }); + }); + }) + .then(() => next()); +}); + schema.pre('save', function(next) { const self = this; diff --git a/api/models/IdeaCollection.js b/api/models/IdeaCollection.js index 8af1993..720cf5f 100644 --- a/api/models/IdeaCollection.js +++ b/api/models/IdeaCollection.js @@ -27,10 +27,10 @@ const schema = new mongoose.Schema({ }, ], - // whether the idea collection is draggable - draggable: { - type: Boolean, - default: true, + votes: { + type: Number, + default: 0, + min: 0, }, // Last user to have modified the collection @@ -51,7 +51,7 @@ schema.pre('save', function(next) { else { // Remove duplicates from the ideas array const uniqueArray = _.uniq(this.ideas, function(idea) { - return String(idea); + return String(idea.id); }); if (this.ideas.length !== uniqueArray.length) { self.invalidate('ideas', 'Idea collections must have unique ideas'); @@ -73,7 +73,7 @@ schema.pre('save', function(next) { */ schema.statics.findByKey = function(boardId, key) { return this.findOne({boardId: boardId, key: key}) - .select('ideas key') + .select('ideas key votes') .populate('ideas', 'content') .exec(); }; @@ -86,7 +86,7 @@ schema.statics.findByKey = function(boardId, key) { */ schema.statics.findOnBoard = function(boardId) { return this.find({boardId: boardId}) - .select('ideas key') + .select('ideas key votes') .populate('ideas', 'content') .exec(); }; diff --git a/api/models/Passport.js b/api/models/Passport.js deleted file mode 100644 index 560ac8c..0000000 --- a/api/models/Passport.js +++ /dev/null @@ -1,111 +0,0 @@ -const bcrypt = require('bcryptjs'); - -/** - * Hash a passport password. - * - * @param {Object} password - * @param {Function} next - */ -function hashPassword(passport, next) { - if (passport.password) { - bcrypt.hash(passport.password, 10, function(err, hash) { - passport.password = hash; - next(err, passport); - }); - } - else { - next(null, passport); - } -} - -/** - * Passport Model - * - * The Passport model handles associating authenticators with users. An authen- - * ticator can be either local (password) or third-party (provider). A single - * user can have multiple passports, allowing them to connect and use several - * third-party strategies in optional conjunction with a password. - * - * Since an application will only need to authenticate a user once per session, - * it makes sense to encapsulate the data specific to the authentication process - * in a model of its own. This allows us to keep the session itself as light- - * weight as possible as the application only needs to serialize and deserialize - * the user, but not the authentication data, to and from the session. - */ -const Passport = { - attributes: { - // Required field: Protocol - // - // Defines the protocol to use for the passport. When employing the local - // strategy, the protocol will be set to 'local'. When using a third-party - // strategy, the protocol will be set to the standard used by the third- - // party service (e.g. 'oauth', 'oauth2', 'openid'). - protocol: { type: 'alphanumeric', required: true }, - - // Local fields: Password, Access Token - // - // When the local strategy is employed, a password will be used as the - // means of authentication along with either a username or an email. - // - // accessToken is used to authenticate API requests. it is generated when a - // passport (with protocol 'local') is created for a user. - password: { type: 'string', minLength: 8 }, - accessToken: { type: 'string' }, - - // Provider fields: Provider, identifer and tokens - // - // "provider" is the name of the third-party auth service in all lowercase - // (e.g. 'github', 'facebook') whereas "identifier" is a provider-specific - // key, typically an ID. These two fields are used as the main means of - // identifying a passport and tying it to a local user. - // - // The "tokens" field is a JSON object used in the case of the OAuth stan- - // dards. When using OAuth 1.0, a `token` as well as a `tokenSecret` will - // be issued by the provider. In the case of OAuth 2.0, an `accessToken` - // and a `refreshToken` will be issued. - provider: { type: 'alphanumericdashed' }, - identifier: { type: 'string' }, - tokens: { type: 'json' }, - - // Associations - // - // Associate every passport with one, and only one, user. This requires an - // adapter compatible with associations. - // - // For more information on associations in Waterline, check out: - // https://github.com/balderdashy/waterline - user: { model: 'User', required: true }, - - /** - * Validate password used by the local strategy. - * - * @param {string} password The password to validate - * @param {Function} next - */ - validatePassword: function(password, next) { - bcrypt.compare(password, this.password, next); - }, - }, - - /** - * Callback to be run before creating a Passport. - * - * @param {Object} passport The soon-to-be-created Passport - * @param {Function} next - */ - beforeCreate: function(passport, next) { - hashPassword(passport, next); - }, - - /** - * Callback to be run before updating a Passport. - * - * @param {Object} passport Values to be updated - * @param {Function} next - */ - beforeUpdate: function(passport, next) { - hashPassword(passport, next); - }, -}; - -module.exports = Passport; diff --git a/api/models/Result.js b/api/models/Result.js index 2737ff7..cae45a9 100644 --- a/api/models/Result.js +++ b/api/models/Result.js @@ -2,9 +2,17 @@ * Result - Container for ideas and votes * @file */ -const mongoose = require('mongoose'); + +import mongoose from 'mongoose'; +import shortid from 'shortid'; const schema = new mongoose.Schema({ + key: { + type: String, + unique: true, + default: shortid.generate, + }, + // Which board the collection belongs to boardId: { type: String, @@ -15,6 +23,14 @@ const schema = new mongoose.Schema({ type: Number, default: 0, min: 0, + required: true, + }, + + round: { + type: Number, + default: 0, + min: 0, + required: true, }, // archive of ideas in the collection @@ -26,20 +42,39 @@ const schema = new mongoose.Schema({ ], // Last user to have modified the collection - lastUpdated: { + lastUpdatedId: { type: mongoose.Schema.ObjectId, ref: 'User', }, }); // statics -schema.statics.findByIndex = function(boardId, index) { - return this.find({boardId: boardId}) +/** + * Find a single collections identified by a boardId and collection key + * @param {String} boardId + * @param {String} key + * @returns {Promise} - resolves to a mongo ideaCollection with its ideas + * populated + */ +schema.statics.findByKey = function(boardId, key) { + return this.findOne({boardId: boardId, key: key}) + .select('ideas key') .populate('ideas', 'content') - .then((collections) => collections[index]); + .exec(); }; -const model = mongoose.model('Result', schema); +/** + * Find all collections associated with a given board + * @param {String} boardId + * @returns {Promise} - resolves to a key/value pair of collection keys and + * collection objects + */ +schema.statics.findOnBoard = function(boardId) { + return this.find({boardId: boardId}) + .select('ideas key round') + .populate('ideas', 'content') + .exec(); +}; -module.exports.schema = schema; -module.exports.model = model; +export { schema }; +export const model = mongoose.model('Result', schema); diff --git a/api/services/BoardService.js b/api/services/BoardService.js index f2ddfa9..b6c5e85 100644 --- a/api/services/BoardService.js +++ b/api/services/BoardService.js @@ -1,52 +1,171 @@ /** -* BoardService: contains actions related to users and boards. -*/ + * BoardService + * contains actions related to users and boards. + */ + import Promise from 'bluebird'; -import { toPlainObject } from '../helpers/utils'; +import { isNil, isEmpty, pick, contains, find, propEq, map } from 'ramda'; + +import { toPlainObject, stripNestedMap, + stripMap, emptyDefaultTo } from '../helpers/utils'; +import { NotFoundError, UnauthorizedError, + NoOpError } from '../helpers/extendable-error'; import { model as Board } from '../models/Board'; +import { adminEditableFields } from '../models/Board'; import { model as User } from '../models/User'; -import { isNull } from './ValidatorService'; -import { NotFoundError, ValidationError } from '../helpers/extendable-error'; -import R from 'ramda'; +import inMemory from './KeyValService'; +import { getIdeaCollections } from './IdeaCollectionService'; +import { getIdeas } from './IdeaService'; +import { createIdeasAndIdeaCollections } from './StateService'; +import { isRoomReadyToVote, isRoomDoneVoting } from './VotingService'; + +// Private +const maybeThrowNotFound = (obj, msg = 'Board not found') => { + if (isNil(obj)) { + throw new NotFoundError(msg); + } + else { + return Promise.resolve(obj); + } +}; -const boardService = {}; +const self = {}; /** * Create a board in the database * @returns {Promise} the created boards boardId */ -boardService.create = function(userId) { - return new Board({users: [userId], admins: [userId]}).save() - .then((result) => result.boardId); +self.create = function(userId, name, desc) { + const boardName = emptyDefaultTo('Project Title', name); + const boardDesc = emptyDefaultTo('This is a description.', desc); + + return new Board({users: [userId], admins: [userId], boardName, boardDesc}) + .save() + .then((result) => { + return createIdeasAndIdeaCollections(result.boardId, false, '') + .then(() => result.boardId); + }); }; /** * Remove a board from the database * @param {String} boardId the boardId of the board to remove */ -boardService.destroy = function(boardId) { +self.destroy = function(boardId) { return Board.remove({boardId: boardId}); }; +/** +* Update a board's name and boardDesc in the database +* @param {Document} board - The mongo board model to update +* @param {Object self.findBoard(board.boardId)) + .then((updatedBoard) => + pick(adminEditableFields, toPlainObject(updatedBoard))); +}; + +/** +* Find a board with populated users and admins +* @param {String} boardId - the boardId to check +* @returns {Promise} - The mongo board model found +*/ +self.findBoard = function(boardId) { + return Board.findBoard(boardId); +}; + +/** + * Find boards for user + * @param {String} username + * @returns {Promise<[MongooseObjects]|Error>} Boards for the given user + */ +self.getBoardsForUser = function(userId) { + return Board.find({users: userId}) + .then(maybeThrowNotFound); +}; + /** * Find if a board exists * @param {String} boardId the boardId to check * @returns {Promise} whether the board exists */ -boardService.exists = function(boardId) { +self.exists = function(boardId) { return Board.find({boardId: boardId}).limit(1) .then((r) => (r.length > 0) ? true : false); }; +/** +* Get the board options +* @param {String} boardId: id of the board +* @returns {Promise}: returns an object with the board options +*/ +self.getBoardOptions = function(boardId) { + return Board.findOne({boardId: boardId}) + .select('-_id userColorsEnabled numResultsShown numResultsReturn boardName boardDesc') + .then(toPlainObject) + .then((board) => { + if (isNil(board)) { + throw new NotFoundError(`Board with id ${boardId} does not exist`); + } + + return board; + }); +}; + /** * Find all users on a board + * @TODO perhaps faster to grab userId's in Redis and find those Mongo docs? + * Would need to test performance of Query+Populate to Redis+FindByIds * @param {String} boardId the boardId to retrieve the users from * @returns {Promise} */ -boardService.getUsers = function(boardId) { +self.getUsers = function(boardId) { return Board.findOne({boardId: boardId}) .populate('users') - .exec((board) => board.users); + .then((board) => toPlainObject(board)) + .then((board) => { + if (isNil(board)) { + throw new NotFoundError(`Board with id ${boardId} does not exist`); + } + + return board; + }) + .then(({users}) => { + if (isEmpty(users)) { + throw new NotFoundError(`Board with id ${boardId} has no users`); + } + + return users; + }); +}; + +/** +* Gets the user id associated with a connected socket id +* @param {String} socketId +* @returns {Promise} +*/ +self.getUserIdFromSocketId = function(socketId) { + return inMemory.getUserFromSocket(socketId); +}; + +/** +* Get all the connected users in a room from Redis +* @param {String} boardId +* @returns {Promise} returns an array of user ids +*/ +self.getAllUsersInRoom = function(boardId) { + return inMemory.getUsersInRoom(boardId); }; /** @@ -54,7 +173,7 @@ boardService.getUsers = function(boardId) { * @param {String} boardId the boardId to retrieve the admins from * @returns {Promise} */ -boardService.getAdmins = function(boardId) { +self.getAdmins = function(boardId) { return Board.findOne({boardId: boardId}) .populate('admins') .exec((board) => board.admins); @@ -65,31 +184,93 @@ boardService.getAdmins = function(boardId) { * @param {String} boardId the boardId to retrieve the pendingUsers from * @returns {Promise} */ -boardService.getPendingUsers = function(boardId) { +self.getPendingUsers = function(boardId) { return Board.findOne({boardId: boardId}) .populate('pendingUsers') .exec((board) => board.pendingUsers); }; -boardService.addUser = function(boardId, userId) { +self.validateBoardAndUser = function(boardId, userId) { return Promise.join(Board.findOne({boardId: boardId}), User.findById(userId)) .then(([board, user]) => { - if (isNull(board)) { - throw new NotFoundError(`Board (${boardId}) does not exist`); + if (isNil(board)) { + throw new NotFoundError( + `Board ${boardId} does not exist`, {board: boardId}); } - else if (isNull(user)) { - throw new NotFoundError(`User (${userId}) does not exist`); + if (isNil(user)) { + throw new NotFoundError( + `User ${userId} does not exist`, {user: userId}); } - else if (boardService.isUser(board, userId)) { - throw new ValidationError( - `User (${userId}) already exists on the board (${boardId})`); + return [board, user]; + }); +}; + +/** + * Adds a user to a board in Mongoose and Redis + * @param {String} boardId + * @param {String} userId + * @param {String} socketId + * @returns {Promise<[Mongoose,Redis]|Error> } resolves to a tuple response + */ +self.addUser = function(boardId, userId, socketId) { + return self.validateBoardAndUser(boardId, userId) + .then(([board, __]) => { + if (self.isUser(board, userId)) { + return self.addUserToRedis(boardId, userId, socketId); } else { - board.users.push(userId); - return board.save(); + return Promise.all([ + self.addUserToMongo(board, userId), + self.addUserToRedis(boardId, userId, socketId), + ]); } - }); + }) + .return(userId); +}; + +/** +* Adds the user to a board on mongo +* @param {MongoBoard} board: the mongo board +* @param {String} userId +* @param {Promise} +*/ +self.addUserToMongo = function(board, userId) { + board.users.push(userId); + return board.save(); +}; + +/** +* Removes the user from the board on mongo +* @param {String} boardId +* @param {String} userId +* @returns {Promise} +*/ +self.removeUserFromMongo = function(boardId, userId) { + board.users.pull(userId); + return board.save(); +}; + +/** +* Adds the user id to redis +* @param {String} boardId +* @param {String} userId +* @param {String} socketId +* @returns {Promise} +*/ +self.addUserToRedis = function(boardId, userId, socketId) { + return inMemory.addConnectedUser(boardId, userId, socketId); +}; + +/** +* Removes the user id from redis +* @param {String} boardId +* @param {String} userId +* @param {String} socketId +* @returns {Promise} +*/ +self.removeUserFromRedis = function(boardId, userId, socketId) { + return inMemory.removeConnectedUser(boardId, userId, socketId); }; /** @@ -98,28 +279,26 @@ boardService.addUser = function(boardId, userId) { * @param {String} userId the userId to add as admin * @returns {Promise} user object that was added */ -boardService.addAdmin = function(boardId, userId) { - const userIsOnBoard = R.partialRight(boardService.isUser, [userId]); - const userIsAdmin = R.partialRight(boardService.isAdmin, [userId]); +self.addAdmin = function(boardId, userId) { return Board.findOne({boardId: boardId}) .then((board) => { - return Promise.join(Promise.resolve(board), - userIsOnBoard(board), - userIsAdmin(board)); - }) - .then(([board, isUser, isAdmin]) => { - if (isUser && !isAdmin) { + const userOnThisBoard = self.isUser(board, userId); + const adminOnThisBoard = self.isAdmin(board, userId); + + if (userOnThisBoard && !adminOnThisBoard) { board.admins.push(userId); return board.save(); } - else if (!isUser) { - throw new NotFoundError( - `User (${userId}) does not exist on the board (${boardId})`); + else if (adminOnThisBoard) { + throw new NoOpError( + `User ${userId} is already an admin on the board ${boardId}`, + {user: userId, board: boardId}); } - else if (isAdmin) { - throw new ValidationError( - `User (${userId}) is already an admin on the board (${boardId})`); + else if (!userOnThisBoard) { + throw new NotFoundError( + `User ${userId} does not exist on the board ${boardId}`, + {user: userId, board: boardId}); } }); }; @@ -131,8 +310,8 @@ boardService.addAdmin = function(boardId, userId) { * @param {String} userId the userId to add as admin * @returns {Boolean} whether the user was on the board */ -boardService.isUser = function(board, userId) { - return R.contains(toPlainObject(userId), toPlainObject(board.users)); +self.isUser = function(board, userId) { + return contains(toPlainObject(userId), toPlainObject(board.users)); }; /** @@ -142,8 +321,97 @@ boardService.isUser = function(board, userId) { * @param {String} userId the userId to add as admin * @returns {Promise} whether the user was an admin */ -boardService.isAdmin = function(board, userId) { - return R.contains(toPlainObject(userId), toPlainObject(board.admins)); +self.isAdmin = function(board, userId) { + return contains(toPlainObject(userId), toPlainObject(board.admins)); +}; + +self.errorIfNotAdmin = function(board, userId) { + if (self.isAdmin(board, userId)) { + return Promise.resolve([board, userId]); + } + else { + throw new UnauthorizedError( + `User ${userId} is not authorized to update board`, + {user: userId}); + } +}; + +/** +* Checks if there are collections on the board +* @param {String} boardId: id of the board +* @returns {Promise}: return if the board has collections or not +*/ +self.areThereCollections = function(boardId) { + return getIdeaCollections(boardId) + .then((collections) => { + if (collections.length > 0) { + return true; + } + else { + return false; + } + }); +}; + +/** +* Generates all of the necessary board/room data to send to client +* @param {String} boardId +* @param {String} userId +* @returns {Promise}: returns all of the generated board/room data +*/ +self.hydrateRoom = function(boardId) { + const hydratedRoom = {}; + return Promise.all([ + Board.findOne({boardId: boardId}), + getIdeaCollections(boardId), + getIdeas(boardId), + self.getBoardOptions(boardId), + self.getAllUsersInRoom(boardId), + self.getUsers(boardId), + ]) + .then(([board, collections, ideas, options, userIds, usersOnBoard]) => { + hydratedRoom.collections = stripNestedMap(collections); + hydratedRoom.ideas = stripMap(ideas); + hydratedRoom.room = { boardName: board.boardName, + boardDesc: board.boardDesc, + userColorsEnabled: options.userColorsEnabled, + numResultsShown: options.numResultsShown, + numResultsReturn: options.numResultsReturn }; + + const users = map((anId) => ( + find(propEq('_id', anId), usersOnBoard) + ), userIds); + + hydratedRoom.room.users = users.map((user) => { + return { + userId: user._id, + username: user.username, + isAdmin: self.isAdmin(board, user._id), + }; + }); + + return hydratedRoom; + }); }; -module.exports = boardService; +self.handleLeaving = (socketId) => + self.getUserIdFromSocketId(socketId) + .then((userId) => self.handleLeavingUser(userId)); + +self.handleLeavingUser = (userId, socketId) => + self.getBoardsForUser(userId) + .then((boards) => { + return Promise.filter(boards, () => { + return inMemory.isSocketInRoom(socketId); + }); + }) + .get(0) + .then((board) => self.removeUserFromRedis(board.boardId, userId, socketId)) + .tap(([boardId, /* userId */, /* socketId */]) => { + return Promise.all([ + isRoomReadyToVote(boardId), + isRoomDoneVoting(boardId), + ]); + }); + +module.exports = self; diff --git a/api/services/IdeaCollectionService.js b/api/services/IdeaCollectionService.js index 567233a..05df9a6 100644 --- a/api/services/IdeaCollectionService.js +++ b/api/services/IdeaCollectionService.js @@ -1,9 +1,9 @@ import _ from 'lodash'; +import { isNil } from 'ramda'; import { model as IdeaCollection } from '../models/IdeaCollection'; import ideaService from './IdeaService'; -import { isNull } from './ValidatorService'; -const ideaCollectionService = {}; +const self = {}; /** * Finds a single IdeaCollection based on boardId and key @@ -14,10 +14,10 @@ const ideaCollectionService = {}; * @returns {Promise} resolves to a single collection as a Mongoose * result object or rejects with a not found error */ -ideaCollectionService.findByKey = function(boardId, key) { +self.findByKey = function(boardId, key) { return IdeaCollection.findByKey(boardId, key) .then((collection) => { - if (isNull(collection)) { + if (isNil(collection)) { throw new Error(`IdeaCollection with key ${key} not found on board ${boardId}`); } else { @@ -32,18 +32,20 @@ ideaCollectionService.findByKey = function(boardId, key) { * @param {String} content - the content of an Idea to create the collection * @returns {Promise} resolves to all collections on a board */ -ideaCollectionService.create = function(userId, boardId, content) { - +self.create = function(userId, boardId, content) { return ideaService.findByContent(boardId, content) .then((idea) => new IdeaCollection({lastUpdatedId: userId, boardId: boardId, ideas: [idea.id]}).save()) .then((created) => new Promise((fulfill, reject) => { - ideaCollectionService.getIdeaCollections(boardId) + self.getIdeaCollections(boardId) .then((allCollections) => fulfill([created, allCollections])) .catch((err) => reject(err)); })); }; +// add a collection back to the workspace +// self.createFromResult = function(result) {}; + /** * Remove an IdeaCollection from a board then delete the model * @param {String} boardId @@ -52,11 +54,19 @@ ideaCollectionService.create = function(userId, boardId, content) { * @todo Potentially want to add a userId to parameters track who destroyed the * idea collection model */ -ideaCollectionService.destroy = function(boardId, key) { - - return ideaCollectionService.findByKey(boardId, key) +self.destroyByKey = function(boardId, key) { + return self.findByKey(boardId, key) .then((collection) => collection.remove()) - .then(() => ideaCollectionService.getIdeaCollections(boardId)); + .then(() => self.getIdeaCollections(boardId)); +}; + +/** + * @param {IdeaCollection} collection - an already found mongoose collection + * @returns {Promise} - resolves to all the collections on the board +*/ +self.destroy = function(boardId, collection) { + return collection.remove() + .then(() => self.getIdeaCollections(boardId)); }; /** @@ -67,24 +77,24 @@ ideaCollectionService.destroy = function(boardId, key) { * @param {String} content - The content of an Idea to add or remove * @returns {Promise} - resolves to all the collections on the board */ -ideaCollectionService.changeIdeas = function(operation, userId, boardId, key, content) { +self.changeIdeas = function(operation, userId, boardId, key, content) { let method; if (operation.toLowerCase() === 'add') method = 'push'; else if (operation.toLowerCase() === 'remove') method = 'pull'; else throw new Error(`Invalid operation ${operation}`); return Promise.all([ - ideaCollectionService.findByKey(boardId, key), + self.findByKey(boardId, key), ideaService.findByContent(boardId, content), ]) .then(([collection, idea]) => { if (operation.toLowerCase() === 'remove' && collection.ideas.length === 1) { - return ideaCollectionService.destroy(collection); + return self.destroy(boardId, collection); } else { collection.ideas[method](idea.id); return collection.save() - .then(() => ideaCollectionService.getIdeaCollections(boardId)); + .then(() => self.getIdeaCollections(boardId)); } }); }; @@ -96,31 +106,57 @@ ideaCollectionService.changeIdeas = function(operation, userId, boardId, key, co * @param {String} content - The content of an Idea to add * @returns {Promise} - resolves to all the collections on the board */ -ideaCollectionService.addIdea = function(userId, boardId, key, content) { - - return ideaCollectionService.changeIdeas('add', userId, boardId, key, content); +self.addIdea = function(userId, boardId, key, content) { + return self.changeIdeas('add', userId, boardId, key, content); }; /** * Remove an Idea from an Idea collection * @param {String} boardId - * @param {String} key - The key of the collection to remove + * @param {String} key - The key of the collection to remove from * @param {String} content - The content of an Idea to remove * @returns {Promise} - resolves to all the collections on the board */ -ideaCollectionService.removeIdea = function(userId, boardId, key, content) { - - return ideaCollectionService.changeIdeas('remove', userId, boardId, key, content); +self.removeIdea = function(userId, boardId, key, content) { + return self.changeIdeas('remove', userId, boardId, key, content); }; /** * @param {String} boardId * @returns {Promise} - resolves to all the collections on the board */ -ideaCollectionService.getIdeaCollections = function(boardId) { - +self.getIdeaCollections = function(boardId) { return IdeaCollection.findOnBoard(boardId) .then((collections) => _.indexBy(collections, 'key')); }; -module.exports = ideaCollectionService; +// destroy duplicate collections +self.removeDuplicates = function(boardId) { + return IdeaCollection.find({boardId: boardId}) + .then((collections) => { + const dupCollections = []; + + for (let i = 0; i < collections.length - 1; i++) { + for (let c = i + 1; c < collections.length; c++) { + if (collections[i].ideas.length === collections[c].ideas.length) { + const concatArray = (collections[i].ideas.concat(collections[c].ideas)); + const deduped = _.unique(concatArray, String); + + if (deduped.length === collections[i].ideas.length) { + dupCollections.push(collections[i]); + break; + } + } + } + } + return dupCollections; + }) + .then((dupCollections) => { + return _.map(dupCollections, (collection) => { + return IdeaCollection.remove({key: collection.key, boardId: collection.boardId}); + }); + }) + .all(); +}; + +module.exports = self; diff --git a/api/services/IdeaService.js b/api/services/IdeaService.js index 4f571a3..5c3c8eb 100644 --- a/api/services/IdeaService.js +++ b/api/services/IdeaService.js @@ -5,18 +5,19 @@ * @module IdeaService api/services/IdeaService */ +import { isNil } from 'ramda'; import { model as Idea } from '../models/Idea.js'; -import { isNull } from './ValidatorService'; +import { errorIfNotAdmin } from './BoardService'; -const ideaService = {}; +const self = {}; // Private const maybeThrowNotFound = (obj, boardId, content) => { - if (isNull(obj)) { + if (isNil(obj)) { throw new Error(`Idea with content ${content} not found on board ${boardId}`); } else { - return obj; + return Promise.resolve(obj); } }; @@ -28,10 +29,10 @@ const maybeThrowNotFound = (obj, boardId, content) => { * @returns {Promise} - resolves to a client friendly response of all ideas on * the given board */ -ideaService.create = function(userId, boardId, ideaContent) { +self.create = function(userId, boardId, ideaContent) { return new Idea({boardId: boardId, userId: userId, content: ideaContent}).save() - .then(() => ideaService.getIdeas(boardId)); + .then(() => self.getIdeas(boardId)); }; /** @@ -44,12 +45,15 @@ ideaService.create = function(userId, boardId, ideaContent) { * to include that in requests to client. How can we DRY that out so we don't * repeat logic everywhere? */ -ideaService.destroy = function(boardId, ideaContent) { - - return Idea.findOne({boardId: boardId, content: ideaContent}).exec() - .then((idea) => maybeThrowNotFound(idea, boardId, ideaContent)) - .then((idea) => idea.remove()) - .then(() => ideaService.getIdeas(boardId)); +self.destroy = function(board, userId, ideaContent) { + // Check for admin permissions + return errorIfNotAdmin(board, userId) + .then(() => { + return Idea.findOne({boardId: board.boardId, content: ideaContent}).exec() + .then((idea) => maybeThrowNotFound(idea, board.boardId, ideaContent)) + .then((idea) => idea.remove()) + .then(() => self.getIdeas(board.boardId)); + }); }; /** @@ -61,13 +65,13 @@ ideaService.destroy = function(boardId, ideaContent) { * @returns {Promise} resolves to a single idea as a Mongoose result object or * rejects with a not found error */ -ideaService.findByContent = function(boardId, ideaContent) { +self.findByContent = function(boardId, ideaContent) { return Idea.findByContent(boardId, ideaContent) .then((idea) => maybeThrowNotFound(idea, boardId, ideaContent)); }; -ideaService.getIdeas = function(boardId) { +self.getIdeas = function(boardId) { return Idea.findOnBoard(boardId); }; -module.exports = ideaService; +module.exports = self; diff --git a/api/services/KeyValService.js b/api/services/KeyValService.js new file mode 100644 index 0000000..90292bf --- /dev/null +++ b/api/services/KeyValService.js @@ -0,0 +1,391 @@ +/** + * KeyValService + * A light wrapper around ioredis to encode our key/val store business logic + * + * The 'Schema', i.e. what we're storing and under which key + * + * // State + * `${boardId}-state`: { createIdeaCollections, + * createIdeaAndIdeaCollections, + * voteOnIdeaCollections } + * + * // Voting + * `${boardId}-voting-${usedId}`: [ref('IdeaCollections'), ...], + * `${boardId}-voting-ready`: [ref('Users'), ...], + * `${boardId}-voting-done`: [ref('Users'), ...], + * + * // Users + * `${boardId}-current-users`: [ref('Users'), ...] + */ + +import { contains, curry, unless } from 'ramda'; +import Redis from '../helpers/key-val-store'; +import {NoOpError} from '../helpers/extendable-error'; +import Promise from 'bluebird'; + +const self = {}; + +// @TODO: +// Modify the tests and make new unit tests for new features + +/** + * Use these as the sole way of creating keys to set in Redis + */ + +// A Redis set created for every board +// It holds the user ids of users ready to vote +const votingReadyKey = (boardId) => `${boardId}-voting-ready`; +// A Redis set created for every board +// It holds the user ids of users done voting +const votingDoneKey = (boardId) => `${boardId}-voting-done`; +// A Redis set created for every user on every board +// It holds the ids of the idea collections that the user still has to vote on +// When empty the user is done voting +const votingListPerUser = curry((boardId, userId) => { + return `${boardId}-voting-${userId}`; +}); +// A Redis set created for every board +// It holds the socket ids of users currently in the board +const currentSocketConnectionsKey = (boardId) => `${boardId}-current-users`; +// A Redis key for socketId: userId combos +const socketUserIdSetKey = (socketId) => `socket-${socketId}-user`; +// A Redis string created for every board +// It holds a JSON string representing the state of the board +const stateKey = (boardId) => `${boardId}-state`; + +/** + * Takes the response of a mutating Redis actions (e.g. sadd) and throws a + * NoOp Error if the action failed to change anything. Otherwise it just lets + * the input fall through. + * @param {Integer} numberOfOperations - result of a Redis call + * @throws {NoOpError} + * @returns {Integer} + */ +const maybeThrowIfNoOp = (numberOfOperations) => { + if (numberOfOperations <= 0) throw new NoOpError('Redis call did nothing'); + else return numberOfOperations; +}; + +/** + * Takes the response of a Redis creation action (e.g. set) and throws a + * NoOp Error if the action was not succesful. Otherwise it returns true + * @param {String} response - result of a Redis call + * @throws {NoOpError} + * @returns {True} + */ +const maybeThrowIfUnsuccessful = (response) => { + if (response !== 'OK') throw new NoOpError('Redis call did not return OK'); + else return true; +}; + +/** + * Takes the response of a Redis creation action (e.g. get) and throws a + * NoOp Error if response is null or undefined. Otherwise it returns the response + * @param {String} response - result of a Redis call + * @throws {NoOpError} + * @returns {String} the response from Redis + */ +const maybeThrowIfNull = (response) => { + if (response) return response; + else throw new NoOpError('Redis call did not find the key and returned null'); +}; + +const trueOrFalse = (response) => { + if (response === 1) return true; + else return false; +}; + +self.getSetMembers = curry((setKeyGen, boardId) => { + return Redis.smembers(setKeyGen(boardId)); +}); + +/** + * Change a user's status in a room in redis + * Curried for easy partial application + * @example + * changeUser('add')(BOARDID, USERID) + * changeUser(R.__, BOARDID, USERID)('remove') + * @param {'add'|'remove'} operation + * @param {Function} keyGen method for creating the key when given the boardId + * @param {String} boardId + * @param {String} userId + * @returns {Promise} + */ +self.changeUser = curry((operation, keyGen, boardId, userId) => { + let method; + + if (operation.toLowerCase() === 'add') method = 'sadd'; + else if (operation.toLowerCase() === 'remove') method = 'srem'; + else throw new Error(`Invalid operation ${operation}`); + + return Redis[method](keyGen(boardId), userId) + .then(maybeThrowIfNoOp) + .then(() => userId); +}); + +/** + * Change a user's vote list in a room in redis + * @param {'add'|'remove'} operation + * @param {Function} keyGen method for creating the key when given the boardId + * @param {String} boardId + * @param {String} userId + * @param {Array|String} val - Array of collection keys or single collection key + * @returns {Promise} + */ +self.changeUserVotingList = curry((operation, keyGen, boardId, userId, val) => { + let method; + + if (operation.toLowerCase() === 'add') method = 'sadd'; + else if (operation.toLowerCase() === 'remove') method = 'srem'; + else throw new Error(`Invalid operation ${operation}`); + + return Redis[method](keyGen(boardId, userId), val) + .then(maybeThrowIfNoOp) + .then(() => val); +}); + +/** + * Get all the collection keys to vote on currently for a particular user id + * @param {String} boardId + * @param {String} userId + * @returns {Promise} resolves to an array of collection keys + */ +self.getUserVotingList = curry((keyGen, boardId, userId) => { + return Redis.smembers(keyGen(boardId, userId)); +}); + +/** +* Clears the set of collection keys to vote on +* @param {Function} keyGen +* @param {String} boardId +* @param {String} userId +* @returns {Promise} +*/ +self.clearVotingSetKey = curry((keyGen, boardId, userId) => { + return Redis.del(keyGen(boardId, userId)) + .then(maybeThrowIfNoOp); +}); + +/** + * Sets a JSON string version of the given val to the key generated + * by keyGen(boardId) + * @param {Function} keyGen + * @param {String} id + * @param {Object} val + * @returns {Promise} + */ +self.setObjectKey = curry((keyGen, id, val) => { + return Redis.set(keyGen(id), JSON.stringify(val)) + .then(maybeThrowIfUnsuccessful); +}); + +/** + * Sets a JSON string version of the given val to the key generated + * by keyGen(boardId) + * @param {Function} keyGen + * @param {String} id + * @param {String} val + * @returns {Promise} + */ +self.setStringKey = curry((keyGen, id, val) => { + return Redis.set(keyGen(id), val) + .then(maybeThrowIfUnsuccessful); +}); + +/** + * Gets a string for the given key generated by keyGen(id) + * @param {Function} keyGen + * @param {String} id + * @returns {Promise} + */ +self.getKey = curry((keyGen, id) => { + return Redis.get(keyGen(id)) + .then(maybeThrowIfNull); +}); + +/** + * Deletes the key in Redis generated by the keygen(id) + * @param {Function} keyGen + * @param {String} id + * @returns {Promise} + */ +self.clearKey = curry((keyGen, id) => { + return Redis.del(keyGen(id)) + .then(maybeThrowIfNoOp); +}); + +/** + * @param {Function} keyGen + * @param {String} boardId + * @returns {Promise} + */ +self.checkKey = curry((keyGen, boardId) => { + return Redis.exists((keyGen(boardId))) + .then((ready) => ready === 1); +}); + +/** + * @param {Function} keyGen + * @param {String} boardId + * @param {String} userId + * @returns {Promise} + */ +self.checkSetExists = curry((keyGen, boardId, userId) => { + return Redis.exists((keyGen(boardId, userId))) + .then((ready) => ready === 1); +}); + +/** + * Publicly available (curried) API for modifying Redis + */ + +/** + * @param {String} boardId + * @param {String} socketId + * @returns {Promise} + */ +self.connectSocketToRoom = self.changeUser('add', + currentSocketConnectionsKey); +self.disconnectSocketFromRoom = self.changeUser('remove', + currentSocketConnectionsKey); + +/** + * Create or delete a socket : user pair, using the socketId + * @param {String} socketId + * @param {String} userId + */ +self.connectSocketToUser = self.setStringKey(socketUserIdSetKey); +self.disconnectSocketFromUser = self.clearKey(socketUserIdSetKey); + +/** +* @param {'add'|'remove'} operation +* @param {String} boardId +* @param {String} userId +* @param {String} socketId +* @returns {Promise} Returns an array of the socketId and userId +*/ +self.addConnectedUser = curry((boardId, userId, socketId) => { + return Promise.all([ + self.connectSocketToUser(socketId, userId), + self.connectSocketToRoom(boardId, socketId), + ]) + .then(() => [boardId, userId, socketId]); +}); + +/** + * Disconnects socket from given room, but does not disassociate it from its + * user. If that was the last connection the user had to the room, then + * remove them from the relevant voting lists +* @param {String} boardId +* @param {String} userId +* @param {String} socketId + */ +self.removeConnectedUser = curry((boardId, userId, socketId) => { + return self.disconnectSocketFromRoom(boardId, socketId) + .then(() => self.isUserInRoom(boardId, userId)) + .then((isInRoom) => { + return unless(isInRoom, () => Promise.all([ + self.unreadyUser(boardId, userId), + self.unfinishVoteUser(boardId, userId), + ])); + }) + .then(() => [boardId, userId, socketId]); +}); + +/** + * @param {String} socketId + * @returns {Promise} + */ +self.getUserFromSocket = self.getKey(socketUserIdSetKey); + +/** + * Get all the users currently connected to the room + * @param {String} boardId + * @returns {Promise} resolves to an array of userIds + */ +self.getUsersInRoom = (boardId) => { + return self.getSetMembers(currentSocketConnectionsKey, boardId) + .then((socketIds) => { + const userIdPromises = socketIds.map((socketId) => { + return self.getUserFromSocket(socketId); + }); + + return Promise.all(userIdPromises) + .then((userIds) => { + return userIds; + }); + }); +}; + +/** + * @param {String} boardId + * @param {String} userId + * @returns {Promise} + */ +self.isUserInRoom = curry((boardId, userId) => { + return self.getUsersInRoom(boardId) + .then((users) => contains(userId, users)); +}); + +self.isSocketInRoom = curry((boardId, socketId) => { + return Redis.sismember(currentSocketConnectionsKey(boardId), socketId) + .then(trueOrFalse); +}); + +self.getUsersDoneVoting = self.getSetMembers(votingDoneKey); +self.getUsersReadyToVote = self.getSetMembers(votingReadyKey); + +/** + * @param {String} boardId + * @returns {Promise} + */ +self.clearCurrentSocketConnections = self.clearKey(currentSocketConnectionsKey); +self.clearCurrentSocketUserIds = self.clearKey(socketUserIdSetKey); +self.clearVotingReady = self.clearKey(votingReadyKey); +self.clearVotingDone = self.clearKey(votingDoneKey); + +/** + * @param {String} boardId + * @param {Object|Array|String} value - what the key points to + * @returns {Promise} + */ +self.setBoardState = self.setObjectKey(stateKey); +self.checkBoardStateExists = self.checkKey(stateKey); +self.clearBoardState = self.clearKey(stateKey); + +/** +* @param {String} boardId +* @returns {Promise} +*/ +self.getBoardState = self.getKey(stateKey); + +/** + * @param {String} boardId + * @param {String} userId + * @returns {Promise} + */ +self.readyUserToVote = self.changeUser('add', votingReadyKey); +self.readyUserDoneVoting = self.changeUser('add', votingDoneKey); + +/** + * @param {String} boardId + * @param {String} userId + * @param {Array|String} val - array of strings or single string + * @returns {Promise} - the val passed in + */ +self.addToUserVotingList = self.changeUserVotingList('add', votingListPerUser); +self.removeFromUserVotingList = self.changeUserVotingList('remove', votingListPerUser); + +/** + * @param {String} boardId + * @param {String} userId + * @returns {Promise} - the array of members in the set + */ +self.getCollectionsToVoteOn = self.getUserVotingList(votingListPerUser); +self.checkUserVotingListExists = self.checkSetExists(votingListPerUser); +self.clearUserVotingList = self.clearVotingSetKey(votingListPerUser); + +self.unreadyUser = self.changeUser('remove', votingReadyKey); +self.unfinishVoteUser = self.changeUser('remove', votingDoneKey); + +export default self; diff --git a/api/services/ResultService.js b/api/services/ResultService.js new file mode 100644 index 0000000..003735b --- /dev/null +++ b/api/services/ResultService.js @@ -0,0 +1,9 @@ +import { model as Result } from '../models/Result'; +const self = {}; + +self.create = function(boardId, userId, ideas, round, votes) { + return new Result({boardId: boardId, lastUpdatedId: userId, + ideas: ideas, round: round, votes: votes}).save(); +}; + +module.exports = self; diff --git a/api/services/StateService.js b/api/services/StateService.js new file mode 100644 index 0000000..7dff7e2 --- /dev/null +++ b/api/services/StateService.js @@ -0,0 +1,142 @@ +/** + State Service + + @file Contains logic for controlling the state of a board +*/ +import BoardService from './BoardService'; +import TokenService from './TokenService'; +import KeyValService from './KeyValService'; +import Promise from 'bluebird'; +const self = {}; + +self.StateEnum = { + createIdeasAndIdeaCollections: { + createIdeas: true, + createIdeaCollections: true, + voting: false, + results: true, + }, + createIdeaCollections: { + createIdeas: false, + createIdeaCollections: true, + voting: false, + results: true, + }, + voteOnIdeaCollections: { + createIdeas: false, + createIdeaCollections: false, + voting: true, + results: false, + }, +}; + +/** +* Check if an action requires admin permissions and verify the user is an admin +* @param {Boolean} requiresAdmin: whether or not the state change requires an admin +* @param {String} boardId: the if of the board +* @param {String} userToken: the encrypted token containing a user id +* @returns {Promise} +*/ +function checkRequiresAdmin(requiresAdmin, boardId, userToken) { + if (requiresAdmin) { + return TokenService.verifyAndGetId(userToken) + .then((userId) => BoardService.errorIfNotAdmin(boardId, userId)); + } + else { + return Promise.resolve(false); + } +} + +/** +* Set the current state of the board on Redis. +* Returns a promise containing the boolean showing the success of setting the state +* @param {String} boardId: The string id generated for the board (not the mongo id) +* @param {Object} state: The state object to be set on Redis +* @param {Boolean} requiresAdmin: Whether or not the state change needs an admin +* @param {String} userToken: token representing an encrypted user id +* @returns {Promise} Promise containing the state object +*/ +self.setState = function(boardId, state, requiresAdmin, userToken) { + return checkRequiresAdmin(requiresAdmin, boardId, userToken) + .then(() => self.removeState(boardId)) + .then(() => KeyValService.setBoardState(boardId, state)); +}; + +/** +* Get the current state of the board from Redis. Returns a promise containing the state. +* @param {String} boardId: The string id generated for the board (not the mongo id) +* @returns {Promise{Object}} +* @TODO Figure out what to do for a default state if the server crashes and resets +*/ +self.getState = function(boardId) { + return KeyValService.getBoardState(boardId); +}; + +/** +* Remove the current state. Used for transitioning to remove current state key +* @param {String} boardId: The string id generated for the board (not the mongo id) +* @returns {Promise}: returns the number of keys deleted +*/ +self.removeState = function(boardId) { + return KeyValService.checkBoardStateExists(boardId) + .then((exists) => { + if (exists) { + return KeyValService.clearBoardState(boardId); + } + else { + return false; + } + }); +}; + +/** +* Set the state to create ideas and idea collections +* @param {String} boardId: the id of the board +* @param {Boolean} requiresAdmin: whether or not the state change needs an admin +* @param {String} userToken: the encrypted token containing a user id +* @returns {Promise} +*/ +self.createIdeasAndIdeaCollections = function(boardId, requiresAdmin, userToken) { + return self.setState(boardId, + self.StateEnum.createIdeasAndIdeaCollections, + requiresAdmin, + userToken); +}; + +/** +* Set the state to create idea collections +* @param {String} boardId: the id of the board +* @param {Boolean} requiresAdmin: whether or not the state change needs an admin +* @param {String} userToken: the encrypted token containing a user id +* @returns {Promise} +*/ +self.createIdeaCollections = function(boardId, requiresAdmin, userToken) { + return self.setState(boardId, + self.StateEnum.createIdeaCollections, + requiresAdmin, + userToken); +}; + +/** +* Set the state to vote on idea collections +* @param {String} boardId: the id of the board +* @param {Boolean} requiresAdmin: whether or not the state change needs an admin +* @param {String} userToken: the encrypted token containing a user id +* @returns {Promise} +*/ +self.voteOnIdeaCollections = function(boardId, requiresAdmin, userToken) { + BoardService.areThereCollections(boardId) + .then((hasCollections) => { + if (hasCollections) { + return self.setState(boardId, + self.StateEnum.voteOnIdeaCollections, + requiresAdmin, + userToken); + } + else { + throw new Error('Board cannot transition to voting without collections'); + } + }); +}; + +module.exports = self; diff --git a/api/services/TimerService.js b/api/services/TimerService.js new file mode 100644 index 0000000..2639879 --- /dev/null +++ b/api/services/TimerService.js @@ -0,0 +1,81 @@ +/** + Timer Service + + @file Contains the logic for the server-side timer used for voting on client-side +*/ + +const config = require('../../config'); +const radicchio = require('radicchio')(config.redisURL); +const EXT_EVENTS = require('../constants/EXT_EVENT_API'); +const stream = require('../event-stream').default; +const StateService = require('./StateService'); +const self = {}; + +radicchio.on('expired', function(timerDataObj) { + const boardId = timerDataObj.boardId; + + StateService.createIdeaCollections(boardId, false, null) + .then((state) => { + stream.ok(EXT_EVENTS.TIMER_EXPIRED, {boardId: boardId, state: state}, boardId); + }); +}); + +/** +* Returns a promise containing a the timer id +* @param {string} boardId: The string id generated for the board (not the mongo id) +* @param {number} timerLengthInMS: A number containing the amount of milliseconds the timer should last +*/ +self.startTimer = function(boardId, timerLengthInMS) { + const dataObj = {boardId: boardId}; + + return new Promise(function(resolve, reject) { + try { + radicchio.startTimer(timerLengthInMS, dataObj) + .then((timerId) => { + resolve(timerId); + }); + } + catch (e) { + reject(e); + } + }); +}; + +/** +* Returns a promise containing a data object associated with the timer +* @param {string} timerId: The timer id to stop +*/ +self.stopTimer = function(timerId) { + return new Promise(function(resolve, reject) { + try { + radicchio.deleteTimer(timerId) + .then((data) => { + delete data.boardId; + resolve(data); + }); + } + catch (e) { + reject(e); + } + }); +}; + +/** +* Returns a promise containing the time left +* @param {string} timerId: The timer id to get the time left on +*/ +self.getTimeLeft = function(timerId) { + return new Promise(function(resolve, reject) { + try { + radicchio.getTimeLeft(timerId) + .then((timerObj) => { + resolve(timerObj.timeLeft); + }); + } + catch (e) { + reject(e); + } + }); +}; + +module.exports = self; diff --git a/api/services/TokenService.js b/api/services/TokenService.js index 18bc674..ae692c2 100644 --- a/api/services/TokenService.js +++ b/api/services/TokenService.js @@ -8,14 +8,14 @@ import jwt from 'jsonwebtoken'; import Promise from 'bluebird'; import CFG from '../../config'; -const tokenService = {}; +const self = {}; /** * Wraps jwt#sign in a promise for our async APIs and binds our secret * @param {Object} - user object * @returns {Promise} - that immediately resolves to a token */ -tokenService.encode = function(authData) { +self.encode = function(authData) { try { return Promise.resolve(jwt.sign(authData, CFG.jwt.secret)); } @@ -30,7 +30,7 @@ tokenService.encode = function(authData) { * @returns {Promise tokenService.encode(user)); + .then((user) => ( + Promise.all([ + tokenService.encode(toPlainObject(user)), + Promise.resolve(user), + ])) + ); }; /** * Remove a user from the database + * @XXX This does not look like the correct way to query for a user * @param {String} userId - mongoId of the user * @returns {Promise} */ -userService.destroy = function(userId) { +self.destroy = function(userId) { return User.model.remove({userId: userId}).save(); }; -export default userService; +export default self; diff --git a/api/services/ValidatorService.js b/api/services/ValidatorService.js deleted file mode 100644 index 1bd6898..0000000 --- a/api/services/ValidatorService.js +++ /dev/null @@ -1,9 +0,0 @@ -/** -* Extend node's validator package and expose it to the application as a service. -*/ - -const valid = require('validator'); - -valid.extend('isntNull', (str) => !valid.isNull(str)); - -module.exports = valid; diff --git a/api/services/VotingService.js b/api/services/VotingService.js new file mode 100644 index 0000000..1beec8a --- /dev/null +++ b/api/services/VotingService.js @@ -0,0 +1,395 @@ +/** +* VotingSerivce +* +* contains logic and actions for voting, archiving collections, start and +* ending the voting state +*/ + +import { model as Board } from '../models/Board'; +import { model as Result } from '../models/Result'; +import { model as IdeaCollection } from '../models/IdeaCollection'; +import Promise from 'bluebird'; +import InMemory from './KeyValService'; +import _ from 'lodash'; +import log from 'winston'; +import { groupBy, prop } from 'ramda'; +import { UnauthorizedError } from '../helpers/extendable-error'; +import IdeaCollectionService from './IdeaCollectionService'; +import ResultService from './ResultService'; +import StateService from './StateService'; + +const self = {}; + +const maybeIncrementCollectionVote = function(query, update, increment) { + if (increment) { + return IdeaCollection.findOneAndUpdate(query, update); + } + else { + return Promise.resolve(false); + } +}; + +/** +* Increments the voting round and removes duplicate collections +* @param {String} boardId: id of the board to setup voting for +* @param {Boolean} requiresAdmin: whether or not action requires admin +* @param {String} userToken: the encrypted token containing a user id +* @return {Promise<>} +* @TODO Possible future optimization: Use promise.all after findOneAndUpdate +*/ +self.startVoting = function(boardId, requiresAdmin, userToken) { + // increment the voting round on the board model + return Board.findOneAndUpdate({boardId: boardId}, {$inc: { round: 1 }}) + // remove duplicate collections + .then(() => IdeaCollectionService.removeDuplicates(boardId)) + .then(() => InMemory.clearVotingReady(boardId)) + .then(() => StateService.voteOnIdeaCollections(boardId, requiresAdmin, userToken)); +}; + +/** +* @TODO: Bring back the top # results to the board as new idea collections +* Handles transferring the collections that were voted on into results +* @param {String} boardId of the baord to finish voting for +* @return {Promise} +*/ +self.finishVoting = function(boardId, requiresAdmin, userToken) { + return Board.findOne({boardId: boardId}) + .then((board) => { + // send all collections to results + return IdeaCollection.find({boardId: boardId}) + .then((collections) => { + return collections.map((collection) => { + return Promise.all([ + ResultService.create(boardId, collection.lastUpdatedId, + collection.ideas, board.round, collection.votes), + IdeaCollectionService.destroy(boardId, collection), + ]); + }); + }); + }) + .then(() => InMemory.clearVotingDone(boardId)) + .then(() => StateService.createIdeaCollections(boardId, requiresAdmin, userToken)); +}; + +/** +* Calls startVoting with admin permission to force the board to start voting +* @param {String} boardId: board id of the current board +* @param {String} userToken: token containing encrypted user id +* @returns {Promise: returns the state of the board} +*/ +self.forceStartVoting = function(boardId, userToken) { + return self.startVoting(boardId, true, userToken); +}; + +/** +* Calls finishVoting with admin permission to force the board to finish voting +* @param {String} boardId: board id of the current board +* @param {String} userToken: token containing encrypted user id +* @returns {Promise: returns the state of the board} +*/ +self.forceFinishVoting = function(boardId, userToken) { + return self.finishVoting(boardId, false, userToken); +}; + +/** +* Mark a user as ready to progress +* Used for both readying up for voting, and when done voting +* @param {String} votingAction: voting action to check for ('start' or 'finish') +* @param {String} boardId: id for the board +* @param {String} userId: id for the user to ready +* @return {Promise}: returns if the room is ready to progress +*/ +self.setUserReady = function(votingAction, boardId, userId) { + let method; + + if (votingAction.toLowerCase() === 'start') method = 'readyUserToVote'; + else if (votingAction.toLowerCase() === 'finish') { + method = 'readyUserDoneVoting'; + } + else throw new Error(`Invalid voting action ${votingAction}`); + // in Redis, push UserId into appropriate ready list + return InMemory[method](boardId, userId) + .then(() => self.isRoomReady(votingAction, boardId)); +}; + +/** +* Sets the user ready to vote +* @param {String} boardId: id of the board +* @param {String} userId: id of the user +* @returns {Promise}: returns if the room is ready to vote +*/ +self.setUserReadyToVote = function(boardId, userId) { + // Clear the user's voting list if it still exists (from forced state transition) + return InMemory.checkUserVotingListExists(boardId, userId) + .then((exists) => { + if (exists) { + return InMemory.clearUserVotingList(boardId, userId); + } + else { + return false; + } + }) + .then(() => self.isUserReadyToVote(boardId)) + .then((readyToVote) => { + if (readyToVote) { + throw new UnauthorizedError('User is already ready to vote.'); + } + + return self.setUserReady('start', boardId, userId); + }); +}; + +/** +* Sets the user ready to finish voting +* @param {String} boardId: id of the board +* @param {String} userId: id of the user +* @returns {Promise}: returns if the room is done voting +*/ +self.setUserReadyToFinishVoting = function(boardId, userId) { + return self.isUserDoneVoting(boardId, userId) + .then((doneVoting) => { + if (doneVoting) { + throw new UnauthorizedError('User is already ready to finish voting'); + } + + return self.setUserReady('finish', boardId, userId); + }); +}; + +/** +* Checks if the room is ready to proceed based on voting action passed in +* @param {String} votingAction: voting action to check for ('start' or 'finish') +* @param {String} boardId: id of the board +* @returns {Promise}: returns if the room is ready to proceed +*/ +self.isRoomReady = function(votingAction, boardId) { + let method; + let action; + let requiredBoardState; + // Get the connected users + return InMemory.getUsersInRoom(boardId) + .then((userIds) => { + if (userIds.length === 0) { + // throw new Error('No users are currently connected to the room'); + log.info(`No users are currently connected to room ${boardId}.`); + return []; + } + // Check if the users are ready to move forward based on voting action + if (votingAction.toLowerCase() === 'start') { + method = 'isUserReadyToVote'; + action = 'startVoting'; + requiredBoardState = StateService.StateEnum.createIdeaCollections; + } + else if (votingAction.toLowerCase() === 'finish') { + method = 'isUserDoneVoting'; + action = 'finishVoting'; + requiredBoardState = StateService.StateEnum.voteOnIdeaCollections; + } + else throw new Error(`Invalid votingAction ${votingAction}`); + + return userIds.map((userId) => { + return self[method](boardId, userId) + .then((isReady) => { + return {ready: isReady}; + }); + }); + }) + .then((promises) => { + if (promises.length === 0) { + return []; + } + + return Promise.all(promises); + }) + // Check if all the users are ready to move forward + .then((userStates) => { + let roomReadyToMoveForward; + + if (userStates.length === 0) roomReadyToMoveForward = false; + else roomReadyToMoveForward = _.every(userStates, {'ready': true}); + + if (roomReadyToMoveForward) { + // Transition the board state + return StateService.getState(boardId) + .then((state) => { + if (_.isEqual(state, requiredBoardState)) { + return self[action](boardId, false, ''); + } + else { + throw new Error('Current board state does not allow for readying'); + } + }) + .then(() => true); + } + else { + return false; + } + }); +}; + +/** +* Checks to see if the room is ready to vote +* Should be called every time a user is ready to vote or leaves the room +* @param {String} boardId: board id of the board to check +* @returns {Promise}: returns if the room is ready to vote or not +*/ +self.isRoomReadyToVote = function(boardId) { + return self.isRoomReady('start', boardId); +}; + +/** +* Checks to see if the room is ready to finish voting +* Should be called every time a user is done voting or leaves the room +* @param {String} boardId: board id of the board to check +* @returns {Promise}: returns if the room is finished voting +*/ +self.isRoomDoneVoting = function(boardId) { + return self.isRoomReady('finish', boardId); +}; + +/** +* Checks if the user is ready to move forward based on the voting action +* @param {String} votingAction: voting action to check for ('start' or 'finish') +* @param {String} boardId: the id of the board +* @param {String} userId: the user id of the user to check +* @returns {Promise}: returns if the user is ready to proceed +*/ +self.isUserReady = function(votingAction, boardId, userId) { + let method; + + if (votingAction.toLowerCase() === 'start') method = 'getUsersReadyToVote'; + else if (votingAction.toLowerCase() === 'finish') method = 'getUsersDoneVoting'; + else throw new Error(`Invald votingAction ${votingAction}`); + + return InMemory[method](boardId) + .then((userIds) => { + if (userIds.indexOf(userId) > -1) { + return true; + } + else { + return false; + } + }); +}; + +/** +* Check if a connected user is ready to vote +* @param {String} boardId: board id to get the users ready to vote +* @param {String} userId: user id to check if ready to vote +* @return {Promise}: returns if the user is ready to vote or not +*/ +self.isUserReadyToVote = function(boardId, userId) { + return self.isUserReady('start', boardId, userId); +}; + +/** +* Check if a connected user is done voting +* @param {String} boardId: board id to get the users done voting +* @param {String} userId: user id to check if done voting +* @return {Promise}: returns if the user is done voting or not +*/ +self.isUserDoneVoting = function(boardId, userId) { + return self.isUserReady('finish', boardId, userId); +}; + +self.getVoteList = function(boardId, userId) { + // Check if the user has a redis key for collections to vote on that exists + return InMemory.checkUserVotingListExists(boardId, userId) + .then((exists) => { + if (!exists) { + // Check if the user is done voting in case of refresh or disconnect + return self.isUserDoneVoting(boardId, userId) + .then((ready) => { + if (ready) { + return []; + } + else { + // User has not voted on any collections yet so get all collections + return IdeaCollectionService.getIdeaCollections(boardId) + .then((collections) => { + const collectionKeys = _.map(collections, function(collection) { + return collection.key; + }); + // Stick the collection keys into redis and associate with the user + return InMemory.addToUserVotingList(boardId, userId, collectionKeys); + }); + } + }); + } + else { + // Get remaining collections to vote on from the collection keys in Redis + return InMemory.getCollectionsToVoteOn(boardId, userId) + .then((collectionKeys) => { + return _.map(collectionKeys, function(collectionKey) { + return IdeaCollection.findByKey(boardId, collectionKey); + }); + }) + .then((promises) => { + return Promise.all(promises); + }); + } + }); +}; + +/** +* Requires that the board is in the voting state +* Find the specific collection and increment its votes or remove it from the +* user's voting list in Redis +* @param {String} boardId +* @param {String} userId +* @param {String} key of collection to vote for +* @param {Boolean} wether to increment the vote for the collection +* @return {Promise} the key or array of keys voted on +*/ +self.vote = function(boardId, userId, key, increment) { + const query = {boardId: boardId, key: key}; + const updatedData = {$inc: { votes: 1 }}; + + // @TODO: Add a new stub for this function to finish test and push to github + return self.wasCollectionVotedOn(boardId, userId, key) + .then(() => { + return maybeIncrementCollectionVote(query, updatedData, increment); + }) + .then(() => InMemory.removeFromUserVotingList(boardId, userId, key)) + .then(() => InMemory.getCollectionsToVoteOn(boardId, userId)) + .then((collections) => { + if (collections.length === 0) { + return self.setUserReadyToFinishVoting(boardId, userId); + } + else { + return false; + } + }); +}; + +/** +* Checks to see if a collection was already voted on by a user +* @param {String} boardId +* @param {String} userId +* @param {String} key: collection key to check +* @param {Promise} +*/ +self.wasCollectionVotedOn = function(boardId, userId, key) { + return InMemory.getCollectionsToVoteOn(boardId, userId) + .then((collections) => { + if (collections.indexOf(key) === -1) { + throw new UnauthorizedError('Collection was already voted on or does not exist'); + } + else { + return false; + } + }); +}; + +/** +* Rounds are sorted newest -> oldest +* @param {String} boardId to fetch results for +* @returns {Promise} nested array containing all rounds of voting +*/ +self.getResults = function(boardId) { + // fetch all results for the board + return Result.findOnBoard(boardId) + .then((results) => groupBy(prop('round'))(results)); +}; + +module.exports = self; diff --git a/package.json b/package.json index 1467da5..958b8ff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "StormServer", - "version": "0.2.0", + "version": "0.3.0", "description": "Storm app server", "scripts": { "start": "node index.js", @@ -23,12 +23,16 @@ "dependencies": { "agenda": "^0.7.5", "babel-core": "^6.1.2", - "babel-preset-es2015": "^6.1.2", + "babel-preset-es2015": "^6.5.0", + "babel-preset-stage-0": "^6.5.0", + "babel-template": "^6.3.13", + "babel-types": "^6.3.24", "bcryptjs": "^2.2.2", "bluebird": "^3.0.1", "body-parser": "^1.14.1", "compression": "^1.6.0", "cors": "^2.7.1", + "dtimer": "^0.2.0", "es6-error": "^2.0.2", "express": "^4.13.3", "express-enrouten": "^1.2.1", @@ -46,19 +50,22 @@ "morgan": "^1.6.1", "passport": "^0.3.0", "passport-http-bearer": "^1.0.1", + "radicchio": "^1.1.2", "ramda": "^0.18.0", "rc": "~0.5.0", + "redis": "^2.4.2", "shortid": "^2.2.2", "socket.io": "^1.3.7", "socketio-jwt": "^4.3.3", - "validator": "^4.0.6", "winston": "^2.1.1" }, "devDependencies": { "babel-eslint": "^4.1.3", + "babel-plugin-rewire": "^1.0.0-beta-3", + "babel-plugin-syntax-jsx": "^6.3.13", "chai": "^3.3.0", + "chai-array": "0.0.2", "chai-as-promised": "^5.1.0", - "dupertest": "^1.0.2", "eslint": "^1.5.0", "eslint-config-airbnb": "0.0.8", "grunt-eslint": "^17.1.0", @@ -71,7 +78,6 @@ "sinomocha": "^0.2.4", "sinon": "^1.17.2", "sinon-chai": "^2.8.0", - "socket.io-client": "^1.3.7", - "supertest": "^1.1.0" + "socket.io-client": "^1.3.7" } } diff --git a/test/constants.js b/test/constants.js new file mode 100644 index 0000000..5946b95 --- /dev/null +++ b/test/constants.js @@ -0,0 +1,16 @@ +/** + * Test specific constants for use in Monky factories + * + * Import only the ones you need + * @example + * import {BOARDID, RESULT_KEY} from './constants'; + */ + +export const BOARDID = '1'; +export const BOARDID_2 = '2'; +export const USERNAME = 'brapnis#n'; +export const RESULT_KEY = 'result1'; +export const COLLECTION_KEY = 'collection'; +export const COLLECTION_KEY_2 = 'collection2'; +export const IDEA_CONTENT = 'idea'; +export const IDEA_CONTENT_2 = 'idea2'; diff --git a/test/fixtures.js b/test/fixtures.js new file mode 100644 index 0000000..e937cfa --- /dev/null +++ b/test/fixtures.js @@ -0,0 +1,52 @@ +import R from 'ramda'; + +import mongoose from 'mongoose'; +import Monky from 'monky'; + +import {schema as BoardSchema} from '../api/models/Board'; +import {schema as UserSchema} from '../api/models/User'; +import {schema as IdeaSchema} from '../api/models/Idea'; +import {schema as IdeaCollectionSchema} from '../api/models/IdeaCollection'; +import {schema as ResultSchema} from '../api/models/Result'; + +import {BOARDID, USERNAME, RESULT_KEY, + COLLECTION_KEY, IDEA_CONTENT} from './constants'; +import database from '../api/helpers/database'; + +export const monky = new Monky(mongoose); + +export const connectDB = R.once(database); + +export const setupFixtures = R.once((err, db, cb) => { + mongoose.model('Board', BoardSchema); + mongoose.model('User', UserSchema); + mongoose.model('Idea', IdeaSchema); + mongoose.model('IdeaCollection', IdeaCollectionSchema); + mongoose.model('Result', ResultSchema); + + monky.factory('Board', {boardId: BOARDID}); + + monky.factory('User', {username: USERNAME}); + + monky.factory('Idea', { + boardId: BOARDID, + content: `${IDEA_CONTENT}#n`, + userId: monky.ref('User')}); + + monky.factory('IdeaCollection', { + key: `${COLLECTION_KEY}#n`, + boardId: BOARDID, + ideas: [monky.ref('Idea')], + lastUpdatedId: monky.ref('User')}); + + monky.factory('Result', { + key: RESULT_KEY, + boardId: BOARDID, + round: 0, + votes: 0, + ideas: [monky.ref('Idea')], + lastUpdatedId: monky.ref('User')}); + + if (err) cb(err); + else cb(); +}); diff --git a/test/unit/handlers/IdeaCollectionHandlers.test.js b/test/unit/handlers/IdeaCollectionHandlers.test.js index 5fe4ded..9ab5d1d 100644 --- a/test/unit/handlers/IdeaCollectionHandlers.test.js +++ b/test/unit/handlers/IdeaCollectionHandlers.test.js @@ -1,7 +1,4 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinonChai from 'sinon-chai'; -import sinomocha from 'sinomocha'; +import {expect} from 'chai'; import _ from 'lodash'; import Promise from 'bluebird'; @@ -16,11 +13,6 @@ import TokenService from '../../../api/services/TokenService'; import stream from '../../../api/event-stream'; import EXT_EVENTS from '../../../api/constants/EXT_EVENT_API'; -chai.use(chaiAsPromised); -chai.use(sinonChai); -sinomocha(); -const expect = chai.expect; - const [IDEA_1, IDEA_2, IDEA_3, IDEA_4] = [1, 2, 3, 4].map((n) => ({_id: n, content: n})); const [RES_IDEA_1, RES_IDEA_2, RES_IDEA_3, RES_IDEA_4] = diff --git a/test/unit/services/BoardService.test.js b/test/unit/services/BoardService.test.js index 72de50c..4bd3485 100644 --- a/test/unit/services/BoardService.test.js +++ b/test/unit/services/BoardService.test.js @@ -1,135 +1,193 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import mochaMongoose from 'mocha-mongoose'; -import Monky from 'monky'; -import sinomocha from 'sinomocha'; -import { toPlainObject } from '../../../api/helpers/utils'; -import { NotFoundError } from '../../../api/helpers/extendable-error'; - -import CFG from '../../../config'; -import database from '../../../api/services/database'; -import BoardService from '../../../api/services/BoardService'; - -import {schema as BoardSchema, model as BoardModel} from '../../../api/models/Board'; -import {schema as UserSchema} from '../../../api/models/User'; +import {expect} from 'chai'; -chai.use(chaiAsPromised); -sinomocha(); -const expect = chai.expect; +import {Types} from 'mongoose'; +import {monky} from '../../fixtures'; +import {BOARDID} from '../../constants'; -mochaMongoose(CFG.mongoURL); -const mongoose = database(); -const monky = new Monky(mongoose); - -const DEF_BOARDID = 'boardid'; - -mongoose.model('Board', BoardSchema); -mongoose.model('User', UserSchema); - -monky.factory('Board', {boardId: DEF_BOARDID}); -monky.factory('User', {username: 'yolo'}); +import { toPlainObject } from '../../../api/helpers/utils'; +import { NotFoundError, NoOpError } from '../../../api/helpers/extendable-error'; +import {model as BoardModel} from '../../../api/models/Board'; +import BoardService from '../../../api/services/BoardService'; +import KeyValService from '../../../api/services/KeyValService'; describe('BoardService', function() { + const SOCKETID = 'socket123'; - before((done) => { - database(done); - }); + const resetRedis = function(socketId) { + return Promise.all([ + KeyValService.clearCurrentSocketConnections(BOARDID), + KeyValService.clearCurrentSocketUserIds(socketId), + ]); + }; describe('#create()', () => { - let USER_ID; + let USERID; beforeEach((done) => { monky.create('User') .then((user) => { - USER_ID = user._id; + USERID = user._id; done(); }); }); it('should create a board and return the correct boardId', (done) => { - BoardService.create(USER_ID) + return BoardService.create(USERID, 'title', 'description') .then((createdBoardId) => { - try { - expect(createdBoardId).to.be.a('string'); - expect(BoardService.exists(createdBoardId)) - .to.become(true).notify(done); - } - catch (e) { - done(e); - } + return Promise.all([ + Promise.resolve(createdBoardId), + BoardModel.findOne({boardId: createdBoardId}), + ]); + }) + .then(([boardId, board]) => { + expect(boardId).to.be.a('string'); + expect(board.boardName).to.equal('title'); + expect(board.boardDesc).to.equal('description'); + expect(BoardService.exists(boardId)).to.eventually.be.true; + done(); }); }); it('should add the creating user as the admin', () => { - return expect(BoardService.create(USER_ID)) + return expect(BoardService.create(USERID)) .to.be.fulfilled - .then((boardId) => { - return BoardModel.findOne({boardId: boardId}) - .then((board) => { - expect(toPlainObject(board.admins[0])).to.equal(toPlainObject(USER_ID)); - expect(toPlainObject(board.users[0])).to.equal(toPlainObject(USER_ID)); + .then((createdBoardId) => { + return BoardModel.findOne({boardId: createdBoardId}) + .then((createdBoard) => { + expect(toPlainObject(createdBoard.admins[0])) + .to.equal(toPlainObject(USERID)); + expect(toPlainObject(createdBoard.users[0])) + .to.equal(toPlainObject(USERID)); }); }); }); }); - describe('#addUser(boardId, userId)', function() { - let DEF_USERID; + describe('#addUser(boardId, userId, socketId)', function() { + let USERID; beforeEach((done) => { Promise.all([ monky.create('Board'), monky.create('User') .then((user) => { - DEF_USERID = user.id; + USERID = user.id; done(); }), ]); }); - it('should add the existing user as an admin on the board', function(done) { - BoardService.addUser(DEF_BOARDID, DEF_USERID) - .then((board) => { - expect(toPlainObject(board.users[0])).to.equal(DEF_USERID); + afterEach((done) => { + resetRedis(SOCKETID) + .then(() => { + done(); + }) + .catch(function() { + done(); + }); + }); + + xit('should add the existing user to the board', function(done) { + return BoardService.addUser(BOARDID, USERID, SOCKETID) + .then(([socketId, userId]) => { + return Promise.all([ + BoardModel.findOne({boardId: BOARDID}), + Promise.resolve(socketId), + Promise.resolve(userId), + ]); + }) + .then(([board, socketId, userId]) => { + expect(toPlainObject(board.users[0])).to.equal(USERID); + expect(socketId).to.equal(SOCKETID); + expect(userId).to.equal(USERID); done(); }); }); it('should reject if the user does not exist on the board', function() { - const userThatDoesntExist = mongoose.Types.ObjectId(); - return expect(BoardService.addUser(DEF_BOARDID, userThatDoesntExist)) - .to.be.rejectedWith(NotFoundError, /does not exist/); + const userThatDoesntExist = Types.ObjectId(); + return expect(BoardService.addUser(BOARDID, userThatDoesntExist)) + .to.be.rejectedWith(NotFoundError, new RegExp(userThatDoesntExist, 'gi')); }); }); - describe('#addAdmin(boardId, userId)', function() { - let DEF_USERID; + describe('#removeUser(boardId, userId, socketId)', function() { + let USERID; beforeEach((done) => { - monky.create('User') - .then((user) => { - monky.create('Board', {boardId: DEF_BOARDID, users: [user]}) - .then((board) => { - DEF_USERID = board.users[0].id; - done(); - }); + return Promise.all([ + monky.create('Board'), + monky.create('User'), + ]) + .then(([__, user]) => { + USERID = user.id; + return BoardService.addUser(BOARDID, USERID, SOCKETID); + }) + .then(() => { + done(); + }); + }); + + afterEach((done) => { + resetRedis(SOCKETID) + .then(() => { + done(); + }) + .catch(function() { + done(); + }); + }); + + xit('should remove the existing user from the board', function(done) { + BoardService.removeUser(BOARDID, USERID, SOCKETID) + .then(([socketId, userId]) => { + expect(socketId).to.equal(SOCKETID); + expect(userId).to.equal(USERID); + done(); }); }); - it('should add the existing user as an admin on the board', function(done) { - BoardService.addAdmin(DEF_BOARDID, DEF_USERID) + it('should reject if the user does not exist on the board', function() { + const userThatDoesntExist = Types.ObjectId(); + return expect(BoardService.addUser(BOARDID, userThatDoesntExist)) + .to.be.rejectedWith(NotFoundError, new RegExp(userThatDoesntExist, 'gi')); + }); + }); + + describe('#addAdmin(boardId, userId)', function() { + let USERID; + let USERID_2; + + beforeEach((done) => { + Promise.all([ + monky.create('User'), + monky.create('User'), + ]) + .then((users) => { + monky.create('Board', {boardId: BOARDID, users: users, admins: [users[1]]}) .then((board) => { - expect(toPlainObject(board.admins[0])).to.equal(DEF_USERID); + USERID = board.users[0].id; + USERID_2 = board.users[1].id; done(); }); + }); + }); + + it('should add the existing user as an admin on the board', function() { + return BoardService.addAdmin(BOARDID, USERID) + .then((board) => { + return expect(toPlainObject(board.admins)).to.include(USERID); + }); }); it('should reject if the user does not exist on the board', function() { - return expect(BoardService.addAdmin(DEF_BOARDID, 'user-not-on-the-board')) + return expect(BoardService.addAdmin(BOARDID, 'user-not-on-the-board')) .to.be.rejectedWith(NotFoundError, /does not exist/); }); - xit('should reject if the user is already an admin on the board', function() { + it('should reject if the user is already an admin on the board', function() { + return expect(BoardService.addAdmin(BOARDID, USERID_2)) + .to.be.rejectedWith(NoOpError, /is already an admin on the board/); }); }); @@ -137,33 +195,214 @@ describe('BoardService', function() { }); describe('#isUser(board, userId)', function() { - let DEF_USERID; + let USERID; beforeEach((done) => { monky.create('User') .then((user) => { - monky.create('Board', {boardId: DEF_BOARDID, users: [user]}) + return monky.create('Board', {boardId: BOARDID, users: [user]}) .then((board) => { - DEF_USERID = board.users[0].id; + USERID = board.users[0].id; done(); }); }); }); it('should return true when the user exists', function() { - return BoardModel.findOne({boardId: DEF_BOARDID}) + return BoardModel.findOne({boardId: BOARDID}) .then((board) => { - return expect(BoardService.isUser(board, DEF_USERID)) + return expect(BoardService.isUser(board, USERID)) .to.equal(true); }); }); it('should return false when the user doesn\'t exists', function() { - return BoardModel.findOne({boardId: DEF_BOARDID}) + return BoardModel.findOne({boardId: BOARDID}) .then((board) => { return expect(BoardService.isUser(board, 'a nonexistant userid')) .to.equal(false); }); }); }); + + describe('#getUsers(boardId)', () => { + beforeEach((done) => { + Promise.all([ + monky.create('User'), + ]) + .then((users) => { + monky.create('Board', {boardId: BOARDID, users: users}) + .then(() => { + done(); + }); + }); + }); + + it('Should throw a not found error if the board does not exist', () => { + return expect(BoardService.getUsers('notRealId')).to.eventually.be.rejectedWith(NotFoundError); + }); + + it('Should return the users on the board', (done) => { + return BoardService.getUsers(BOARDID) + .then((users) => { + expect(users).to.have.length(1); + done(); + }); + }); + }); + + describe('#getBoardOptions(boardId)', () => { + beforeEach((done) => { + monky.create('Board') + .then(() => { + done(); + }); + }); + + it('Should return the board options', (done) => { + return BoardService.getBoardOptions(BOARDID) + .then((options) => { + expect(options).to.be.an('object'); + expect(options).to.have.property('userColorsEnabled'); + expect(options).to.have.property('numResultsShown'); + expect(options).to.have.property('numResultsReturn'); + done(); + }); + }); + }); + + describe('#getBoardsForUser(userId)', function() { + const BOARDID_A = 'abc123'; + const BOARDID_B = 'def456'; + let USERID; + + beforeEach(() => { + return monky.create('User') + .then((user) => { USERID = user.id; return user;}) + .then((user) => { + return Promise.all([ + monky.create('Board', {boardId: BOARDID_A, users: [user]}), + monky.create('Board', {boardId: BOARDID_B, users: [user]}), + monky.create('Board'), + ]); + }); + }); + + it('should return the boards a user is a part of', function() { + return expect(BoardService.getBoardsForUser(USERID)) + .to.eventually.have.length(2); + }); + }); + + xdescribe('#getBoardForSocket(socketId)', function() { + let USERID; + + beforeEach((done) => { + return monky.create('User') + .then((user) => { + USERID = user.id; + return monky.create('Board', {boardId: BOARDID, users: [user]}); + }) + .then(() => { + return BoardService.addUser(BOARDID, USERID, SOCKETID); + }) + .then(() => { + done(); + }); + }); + + afterEach((done) => { + resetRedis(SOCKETID) + .then(() => { + done(); + }) + .catch(function() { + done(); + }); + }); + + xit('should return the board the socket is connected to', function(done) { + return BoardService.getBoardForSocket(SOCKETID) + .then((board) => { + expect(board.boardId).to.equal(BOARDID); + done(); + }); + }); + }); + + describe('#getAllUsersInRoom(boardId)', function() { + let USERID; + + beforeEach((done) => { + return monky.create('User') + .then((user) => { + USERID = user.id; + return monky.create('Board', {boardId: BOARDID}); + }) + .then(() => { + return BoardService.addUser(BOARDID, USERID, SOCKETID); + }) + .then(() => { + done(); + }); + }); + + afterEach((done) => { + resetRedis(SOCKETID) + .then(() => { + done(); + }) + .catch(function() { + done(); + }); + }); + + it('should return the user ids connected to a room', function(done) { + return BoardService.getAllUsersInRoom(BOARDID) + .then(([userId]) => { + expect(userId).to.equal(USERID); + done(); + }); + }); + }); + + describe('#hydrateRoom(boardId)', function() { + let USERID; + + beforeEach(() => { + return Promise.all([ + monky.create('User'), + monky.create('User'), + ]) + .then((users) => { + return Promise.all([ + monky.create('Board', {boardId: BOARDID, users: users, + admins: users[0], boardName: 'name', boardDesc: 'description'}), + monky.create('Idea'), + ]); + }) + .then(([board, idea]) => { + USERID = board.admins[0].id; + return monky.create('IdeaCollection', {boardId: BOARDID, ideas: [idea]}); + }); + }); + + xit('Should generate all of the data for a board to send back on join', function(done) { + BoardService.hydrateRoom(BOARDID) + .then((hydratedRoom) => { + expect(hydratedRoom.collections).to.have.length(1); + expect(hydratedRoom.ideas).to.have.length(1); + expect(hydratedRoom.room.boardName).to.be.a('string'); + expect(hydratedRoom.room.boardDesc).to.be.a('string'); + expect(hydratedRoom.room.userColorsEnabled).to.be.false; + expect(hydratedRoom.room.numResultsShown).to.equal(25); + expect(hydratedRoom.room.numResultsReturn).to.equal(5); + // expect(hydratedRoom.room.users).to.have.length(1); + // expect(hydratedRoom.isAdmin).to.be.false; + expect(hydratedRoom.room.users[0]).to.have.property('userId', USERID); + expect(hydratedRoom.isAdmin).to.be.true; + done(); + }); + }); + }); }); diff --git a/test/unit/services/IdeaCollectionService.test.js b/test/unit/services/IdeaCollectionService.test.js index c2aca07..2b0658c 100644 --- a/test/unit/services/IdeaCollectionService.test.js +++ b/test/unit/services/IdeaCollectionService.test.js @@ -1,47 +1,15 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import mochaMongoose from 'mocha-mongoose'; -import Monky from 'monky'; +import {expect} from 'chai'; import Promise from 'bluebird'; -import sinomocha from 'sinomocha'; import _ from 'lodash'; -import CFG from '../../../config'; -import database from '../../../api/services/database'; -import IdeaCollectionService from '../../../api/services/IdeaCollectionService'; - -chai.use(chaiAsPromised); -sinomocha(); -const expect = chai.expect; - -mochaMongoose(CFG.mongoURL); -const mongoose = database(); -const monky = new Monky(mongoose); +import {monky} from '../../fixtures'; +import {BOARDID, BOARDID_2, COLLECTION_KEY, + IDEA_CONTENT, IDEA_CONTENT_2} from '../../constants'; -const DEF_BOARDID = '1'; -const DEF_BOARDID_2 = '2'; -const DEF_USERNAME = 'brapnis'; -const DEF_COLLECTION_KEY = 'collection1'; - -mongoose.model('Board', require('../../../api/models/Board.js').schema); -mongoose.model('User', require('../../../api/models/User.js').schema); -mongoose.model('Idea', require('../../../api/models/Idea.js').schema); -mongoose.model('IdeaCollection', require('../../../api/models/IdeaCollection.js').schema); -mongoose.model('User', require('../../../api/models/User.js').schema); - -monky.factory('Board', {boardId: DEF_BOARDID}); -monky.factory('User', {username: DEF_USERNAME}); -monky.factory('Idea', {boardId: DEF_BOARDID, content: 'idea1', - userId: monky.ref('User')}); -monky.factory('IdeaCollection', {boardId: DEF_BOARDID, ideas: monky.ref('Idea'), - lastUpdatedId: monky.ref('User')}); +import IdeaCollectionService from '../../../api/services/IdeaCollectionService'; describe('IdeaCollectionService', function() { - before((done) => { - database(done); - }); - describe('#getIdeaCollections(boardId)', () => { beforeEach((done) => { @@ -57,10 +25,10 @@ describe('IdeaCollectionService', function() { .then((allIdeas) => monky.create('IdeaCollection', { ideas: allIdeas, key: 'collection1' })), // Board 2 - monky.create('Board', {boardId: DEF_BOARDID_2}), - monky.create('Idea', {boardId: DEF_BOARDID_2}) + monky.create('Board', {boardId: BOARDID_2}), + monky.create('Idea', {boardId: BOARDID_2}) .then((allIdeas) => monky.create('IdeaCollection', - {boardId: DEF_BOARDID, ideas: allIdeas, key: 'collection2' })), + {boardId: BOARDID, ideas: allIdeas, key: 'collection2' })), ]) .then(() => { done(); @@ -68,7 +36,7 @@ describe('IdeaCollectionService', function() { }); it('should return all collections on a board', () => { - return expect(IdeaCollectionService.getIdeaCollections(DEF_BOARDID)) + return expect(IdeaCollectionService.getIdeaCollections(BOARDID)) .to.be.fulfilled .then((collections) => { expect(collections) @@ -94,17 +62,17 @@ describe('IdeaCollectionService', function() { monky.create('Board') .then(() => monky.create('Idea')) .then((idea) => monky.create('IdeaCollection', - { ideas: idea, key: 'collection1' })) + { ideas: [idea], key: 'collection1' })) .then(() => done()); }); it(`should return a single collection if it exists on the given board`, () => { - return expect(IdeaCollectionService.findByKey(DEF_BOARDID, 'collection1')) + return expect(IdeaCollectionService.findByKey(BOARDID, 'collection1')) .to.eventually.be.an('object'); }); it(`should throw an error if the collection doesn't exist on the given board`, () => { - return expect(IdeaCollectionService.findByKey(DEF_BOARDID, 'collection2')) + return expect(IdeaCollectionService.findByKey(BOARDID, 'collection2')) .to.rejectedWith(/IdeaCollection with key \w+ not found on board \w+/); }); }); @@ -125,10 +93,10 @@ describe('IdeaCollectionService', function() { }); it('Should create an IdeaCollection with a single existing Idea', () => { - return expect(IdeaCollectionService.create(USER_ID, DEF_BOARDID, 'idea 1')) + return expect(IdeaCollectionService.create(USER_ID, BOARDID, 'idea 1')) .to.be.fulfilled .then((collections) => { - const COLLECTION_KEY = _.keys(collections[1])[0]; + const THIS_COLLECTION_KEY = _.keys(collections[1])[0]; expect(collections) .to.be.an('array') @@ -140,10 +108,10 @@ describe('IdeaCollectionService', function() { expect(collections[1]) .to.be.an('object'); - expect(collections[1][COLLECTION_KEY].ideas) + expect(collections[1][THIS_COLLECTION_KEY].ideas) .to.have.length(1); - expect(collections[1][COLLECTION_KEY].ideas[0]) + expect(collections[1][THIS_COLLECTION_KEY].ideas[0]) .to.be.an('object') .and.have.property('content'); }); @@ -163,54 +131,64 @@ describe('IdeaCollectionService', function() { monky.create('Board'), monky.create('User') .then((user) => {USER_ID = user.id; return user;}), - monky.create('Idea', {boardid: DEF_BOARDID, content: 'idea2'}), - monky.create('IdeaCollection', {key: DEF_COLLECTION_KEY}), + monky.create('Idea', {boardid: BOARDID, content: 'idea1'}), + monky.create('Idea', {boardid: BOARDID, content: 'idea2'}), + monky.create('IdeaCollection', {key: COLLECTION_KEY}), ]) .then(() => { - done(); + IdeaCollectionService.addIdea(USER_ID, BOARDID, COLLECTION_KEY, 'idea1') + .then(() => { + done(); + }); }); }); it('Should add an idea to an idea collection', () => { - return expect(IdeaCollectionService.addIdea(USER_ID, DEF_BOARDID, - DEF_COLLECTION_KEY, 'idea2')) - .to.eventually.have.property(DEF_COLLECTION_KEY); + return expect(IdeaCollectionService.addIdea(USER_ID, BOARDID, + COLLECTION_KEY, 'idea2')) + .to.eventually.have.property(COLLECTION_KEY); }); - xit('Should reject adding a duplicate idea to an exiting idea collection', () => { - return expect(IdeaCollectionService.addIdea(USER_ID, DEF_BOARDID, - DEF_COLLECTION_KEY, 'idea1')) + it('Should reject adding a duplicate idea to an existing idea collection', () => { + return expect(IdeaCollectionService.addIdea(USER_ID, BOARDID, + COLLECTION_KEY, 'idea1')) .to.be.rejectedWith(/Idea collections must have unique ideas/); }); }); describe('#removeIdea()', () => { - const collectionWith1Idea = '1'; - const collectionWith2Ideas = '2'; + const collectionWith1Idea = 'collection1'; + const collectionWith2Ideas = 'collection2'; + let USER_ID; beforeEach((done) => { - Promise.all([ - monky.create('Board', {boardId: '1'}), - monky.create('Idea', {boardId: '1', content: 'idea1'}), - monky.create('Idea', {boardId: '1', content: 'idea2'}), - monky.create('IdeaCollection', {boardId: '1', content: 'idea1', + return Promise.all([ + monky.create('Board'), + monky.create('User') + .then((user) => {USER_ID = user.id; return user;}), + monky.create('Idea', {content: IDEA_CONTENT}), + monky.create('Idea', {content: IDEA_CONTENT_2}), + ]) + .spread((__, ___, idea1, idea2) => { + return Promise.all([ + monky.create('IdeaCollection', {boardId: BOARDID, ideas: [idea1], key: collectionWith1Idea}), - monky.create('IdeaCollection', {boardId: '1', content: 'idea1', + monky.create('IdeaCollection', {boardId: BOARDID, ideas: [idea1, idea2], key: collectionWith2Ideas}), - ]) - .then(() => { - IdeaCollectionService.addIdea('1', collectionWith2Ideas, 'idea2') - .then(done()); + ]) + .then(() => done()); }); }); it('Should remove an idea from an idea collection', () => { - expect(IdeaCollectionService.removeIdea('1', collectionWith2Ideas, 'idea1')) - .to.eventually.have.length(1); + return expect(IdeaCollectionService.removeIdea(USER_ID, BOARDID, collectionWith2Ideas, + IDEA_CONTENT)) + .to.eventually.have.deep.property('collection2.ideas').with.length(1); }); it('Should destroy an idea collection when it is empty', () => { - expect(IdeaCollectionService.removeIdea('1', collectionWith1Idea, 'idea1')) + return expect(IdeaCollectionService.removeIdea(USER_ID, BOARDID, collectionWith1Idea, + IDEA_CONTENT)) .to.eventually.not.have.key(collectionWith1Idea); }); }); @@ -218,9 +196,9 @@ describe('IdeaCollectionService', function() { describe('#destroy()', () => { beforeEach((done) => { Promise.all([ - monky.create('Board', {boardId: '1'}), - monky.create('Idea', {boardId: '1', content: 'idea1'}), - monky.create('IdeaCollection', {boardId: '1', key: DEF_COLLECTION_KEY}), + monky.create('Board'), + monky.create('Idea'), + monky.create('IdeaCollection', {key: COLLECTION_KEY}), ]) .then(() => { done(); @@ -228,8 +206,53 @@ describe('IdeaCollectionService', function() { }); it('destroy an idea collection', () => { - return expect(IdeaCollectionService.destroy('1', DEF_COLLECTION_KEY)) - .to.be.eventually.become({}); + return IdeaCollectionService.findByKey(BOARDID, COLLECTION_KEY) + .then((collection) => { + return expect(IdeaCollectionService.destroy(BOARDID, collection)) + .to.eventually.become({}); + }); + }); + + it('destroy an idea collection by key', () => { + return expect(IdeaCollectionService.destroyByKey(BOARDID, COLLECTION_KEY)) + .to.eventually.become({}); + }); + }); + + describe('#removeDuplicates()', () => { + const collection1 = '1'; + const duplicate = '2'; + const diffCollection = '3'; + + beforeEach((done) => { + return Promise.all([ + monky.create('Board'), + Promise.all([ + monky.create('Idea'), + monky.create('Idea'), + ]) + .then((allIdeas) => { + return Promise.all([ + monky.create('IdeaCollection', + {ideas: allIdeas[0], key: collection1 }), + monky.create('IdeaCollection', + {ideas: allIdeas[0], key: duplicate }), + monky.create('IdeaCollection', + {ideas: allIdeas[1], key: diffCollection }), + ]); + }), + ]) + .then(() => { + done(); + }); + }); + + it('Should only remove duplicate ideaCollections', () => { + return IdeaCollectionService.removeDuplicates(BOARDID) + .then(() => IdeaCollectionService.getIdeaCollections(BOARDID)) + .then((collections) => { + expect(Object.keys(collections)).to.have.length(2); + }); }); }); }); diff --git a/test/unit/services/IdeaService.test.js b/test/unit/services/IdeaService.test.js index a1870bf..2440002 100644 --- a/test/unit/services/IdeaService.test.js +++ b/test/unit/services/IdeaService.test.js @@ -1,37 +1,15 @@ -import chai from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import mochaMongoose from 'mocha-mongoose'; -import Monky from 'monky'; +import {expect} from 'chai'; import Promise from 'bluebird'; -import sinomocha from 'sinomocha'; -import CFG from '../../../config'; -import database from '../../../api/services/database'; -import IdeaService from '../../../api/services/IdeaService'; - -chai.use(chaiAsPromised); -sinomocha(); -const expect = chai.expect; - -mochaMongoose(CFG.mongoURL); -const mongoose = database(); -const monky = new Monky(mongoose); +import {monky} from '../../fixtures'; +import {BOARDID, BOARDID_2, + IDEA_CONTENT, IDEA_CONTENT_2} from '../../constants'; -mongoose.model('Board', require('../../../api/models/Board.js').schema); -mongoose.model('Idea', require('../../../api/models/Idea.js').schema); -mongoose.model('User', require('../../../api/models/User.js').schema); - -monky.factory('Board', {boardId: '1'}); -monky.factory('User', {username: 'brapnis#n'}); -monky.factory('Idea', {boardId: '1', content: 'idea number #n', - userId: monky.ref('User')}); +import IdeaService from '../../../api/services/IdeaService'; +import IdeaCollectionService from '../../../api/services/IdeaCollectionService'; describe('IdeaService', function() { - before((done) => { - database(done); - }); - describe('#index(boardId)', () => { beforeEach((done) => { Promise.all([ @@ -39,13 +17,13 @@ describe('IdeaService', function() { monky.create('Idea'), monky.create('Idea'), monky.create('Idea'), - monky.create('Idea', {boardId: '2'}), + monky.create('Idea', {boardId: BOARDID_2}), ]) .then(() => done()); }); it('should return the only the ideas on the specified board', (done) => { - IdeaService.getIdeas('1') + IdeaService.getIdeas(BOARDID) .then((ideas) => { try { expect(ideas.length).to.equal(3); @@ -58,7 +36,7 @@ describe('IdeaService', function() { }); it('should return an array of objects with just the content string', (done) => { - IdeaService.getIdeas('1') + IdeaService.getIdeas(BOARDID) .then((ideas) => { try { expect(ideas).to.be.an('array'); @@ -81,8 +59,8 @@ describe('IdeaService', function() { Promise.all([ monky.create('User').then((user) => {USER_ID = user._id; return user;}), monky.create('Board'), - monky.create('Board', {boardId: 2}), - monky.create('Idea', {content: '1'}), + monky.create('Board', {boardId: BOARDID_2}), + monky.create('Idea', {content: IDEA_CONTENT}), ]) .then(() => { done(); @@ -90,17 +68,17 @@ describe('IdeaService', function() { }); it('should not create duplicates on a board and throw correct validation error', (done) => { - expect(IdeaService.create(USER_ID, '1', '1')) + expect(IdeaService.create(USER_ID, BOARDID, IDEA_CONTENT)) .to.be.rejectedWith(/content must be unique/).notify(done); }); it('should allow duplicates on different boards', (done) => { - expect(IdeaService.create(USER_ID, '2', '1')) + expect(IdeaService.create(USER_ID, BOARDID_2, IDEA_CONTENT)) .to.not.be.rejected.notify(done); }); it('should return all the ideas in the correct format to send back to client', (done) => { - IdeaService.create(USER_ID, '1', 'blah') + IdeaService.create(USER_ID, BOARDID, 'a new idea on the board') .then((ideas) => { try { expect(ideas).to.be.an('array'); @@ -116,48 +94,56 @@ describe('IdeaService', function() { }); }); - describe('#destroy(boardId, ideaContent)', () => { - beforeEach((done) => { - Promise.all([ - monky.create('Board'), - monky.create('Idea', {content: '1'}), - monky.create('Idea', {content: '2'}), - ]) - .then(() => { - done(); - }); - }); + describe('#destroy(board, userId, ideaContent)', () => { + let boardObj; + let userId; - it('should destroy the correct idea from the board', (done) => { - IdeaService.destroy('1', '2') + beforeEach((done) => { + monky.create('User') + .then((user) => { + userId = user.id; + return user.id; + }) .then(() => { - IdeaService.getIdeas('1') + return monky.create('Board', {admins: [userId]}); + }) + .then((board) => { + boardObj = board; + + return Promise.all([ + monky.create('Idea', {boardId: BOARDID, content: IDEA_CONTENT}), + monky.create('Idea', {boardId: BOARDID, content: IDEA_CONTENT_2}), + ]) .then((ideas) => { - try { - expect(ideas[0].content).to.equal('1'); - done(); - } - catch (e) { - done(e); - } + monky.create('IdeaCollection', {boardId: BOARDID, ideas: ideas}); + done(); }); }); }); - it('should return all the ideas in the correct format to send back to client', (done) => { - IdeaService.destroy('1', '2') - .then((ideas) => { - try { - expect(ideas).to.be.an('array'); - expect(ideas[0]).to.be.an('object'); - expect(ideas[0]).to.have.property('content').and.be.a('string'); - expect(ideas[0]).to.not.contain.keys(['userId', '_id', 'boardId']); - done(); - } - catch (e) { - done(e); - } + xit('should destroy the correct idea from the board', (done) => { + return IdeaService.destroy(boardObj, userId, IDEA_CONTENT) + .then(() => { + return Promise.all([ + IdeaService.getIdeas(BOARDID), + IdeaCollectionService.getIdeaCollections(BOARDID), + ]); + }) + .then(([ideas, collections]) => { + expect(ideas).to.have.deep.property('[0].content', IDEA_CONTENT_2); + expect(collections.collection1).to.have.property('ideas') + .to.not.have.members([IDEA_CONTENT]); + done(); }); }); + + it('should return all the ideas in the correct format to send back to client', () => { + return expect(IdeaService.destroy(boardObj, userId, IDEA_CONTENT)) + .to.eventually.be.an('array') + .and.to.have.deep.property('[0]') + .and.to.not.respondTo('userId') + .and.to.not.respondTo('boardId') + .and.to.respondTo('content'); + }); }); }); diff --git a/test/unit/services/KeyValService.test.js b/test/unit/services/KeyValService.test.js new file mode 100644 index 0000000..cbb802a --- /dev/null +++ b/test/unit/services/KeyValService.test.js @@ -0,0 +1,92 @@ +import {expect} from 'chai'; +import Promise from 'bluebird'; + +import {BOARDID, USERNAME} from '../../constants'; + +import KeyValService from '../../../api/services/KeyValService'; + +let RedisStub; +const keyGen = (boardId) => `key-for-${boardId}`; + +describe('KeyValService', function() { + + beforeEach(function() { + RedisStub = { + sadd: this.spy(() => Promise.resolve(1)), + srem: this.spy(() => Promise.resolve(1)), + }; + KeyValService.__Rewire__('Redis', RedisStub); + }); + + afterEach(function() { + KeyValService.__ResetDependency__('Redis'); + }); + + describe('#changeUser(operation, boardId, userId)', function() { + + it('should succesfully call sadd', function() { + return expect(KeyValService.changeUser('add', keyGen, + BOARDID, USERNAME)) + .to.eventually.equal(USERNAME) + .then(() => { + expect(RedisStub.sadd).to.have.been.called; + expect(RedisStub.srem).to.not.have.been.called; + }); + }); + + it('should succesfully call srem', function() { + return expect(KeyValService.changeUser('remove', keyGen, + BOARDID, USERNAME)) + .to.eventually.equal(USERNAME) + .then(function() { + expect(RedisStub.srem).to.have.been.called; + expect(RedisStub.sadd).to.not.have.been.called; + }); + }); + + describe('#readyUser|#finishVoteUser(boardId, userId)', function() { + [KeyValService.readyUserToVote, + KeyValService.readyUserDoneVoting] + .forEach(function(subject) { + xit('should succesfully call sadd and return the userId', function() { + return expect(subject(BOARDID, USERNAME)) + .to.eventually.equal(USERNAME) + .then(function() { + expect(RedisStub.sadd).to.have.been.called; + expect(RedisStub.srem).to.not.have.been.called; + }); + }); + }); + }); + + describe('#addUser(boardId, userId, socketId)', function() { + const SOCKETID = 'socketId123'; + + xit('should successfully call sadd and return the socketId-userId', function() { + return expect(KeyValService.addUser(BOARDID, USERNAME, SOCKETID)) + .to.eventually.include(SOCKETID).and.include(USERNAME) + .then(function() { + expect(RedisStub.sadd).to.have.been.called; + expect(RedisStub.srem).to.not.have.been.called; + }); + }); + }); + + describe('#removeUser(boardId, userId, socketId)', function() { + const SOCKETID = 'socketId123'; + + xit('should succesfully call sadd and return the userId', function() { + return expect(KeyValService.removeUser(BOARDID, USERNAME, SOCKETID)) + .to.eventually.include(SOCKETID).and.include(USERNAME) + .then(function() { + expect(RedisStub.srem).to.have.been.called; + expect(RedisStub.sadd).to.not.have.been.called; + }); + }); + }); + }); + + xdescribe('#getUsers(boardId)', () => false); + xdescribe('#clearKey(boardId)', () => false); + xdescribe('#setKey(boardId)', () => false); +}); diff --git a/test/unit/services/StateService.test.js b/test/unit/services/StateService.test.js new file mode 100644 index 0000000..68156bb --- /dev/null +++ b/test/unit/services/StateService.test.js @@ -0,0 +1,26 @@ +import {expect} from 'chai'; + +import StateService from '../../../api/services/StateService'; + +describe('StateService', function() { + + describe('#setState(boardId, state)', () => { + xit('Should set the state of the board in Redis', (done) => { + StateService.setState('1', StateService.StateEnum.createIdeasAndIdeaCollections) + .then((result) => { + expect(result).to.be.true; + done(); + }); + }); + }); + + describe('#getState(boardId)', () => { + xit('Should get the state of the board from Redis', (done) => { + StateService.getState('1') + .then((result) => { + expect(result).to.be.an('object'); + done(); + }); + }); + }); +}); diff --git a/test/unit/services/TimerService.test.js b/test/unit/services/TimerService.test.js new file mode 100644 index 0000000..845fc3a --- /dev/null +++ b/test/unit/services/TimerService.test.js @@ -0,0 +1,55 @@ +import {expect} from 'chai'; +import TimerService from '../../../api/services/TimerService'; + +describe('TimerService', function() { + + describe('#startTimer(boardId, timerLengthInSeconds)', () => { + it('Should start the server timer', (done) => { + TimerService.startTimer('abc123', '10000') + .then((timerId) => { + expect(timerId).to.be.a('string'); + done(); + }); + }); + }); + + describe('#stopTimer(boardId)', () => { + const timerObj = {}; + + beforeEach(function(done) { + TimerService.startTimer('abc123', '10000') + .then((timerId) => { + timerObj.timerId = timerId; + done(); + }); + }); + + it('Should stop the server timer on Redis', (done) => { + TimerService.stopTimer(timerObj.timerId) + .then((timerDataObj) => { + expect(timerDataObj).to.be.an('object'); + done(); + }); + }); + }); + + describe('#getTimeLeft(boardId)', () => { + const timerObj = {}; + + beforeEach(function(done) { + TimerService.startTimer('abc123', '10000') + .then((timerId) => { + timerObj.timerId = timerId; + done(); + }); + }); + + it('Should get the time left on the sever timer from Redis', (done) => { + TimerService.getTimeLeft(timerObj.timerId) + .then((timeLeft) => { + expect(timeLeft).to.be.a('number'); + done(); + }); + }); + }); +}); diff --git a/test/unit/services/TokenService.test.js b/test/unit/services/TokenService.test.js index 75eab5b..f1c2c4d 100644 --- a/test/unit/services/TokenService.test.js +++ b/test/unit/services/TokenService.test.js @@ -1,11 +1,9 @@ -import chai from 'chai'; +import {expect} from 'chai'; import jwt from 'jsonwebtoken'; import TokenService from '../../../api/services/TokenService'; import CFG from '../../../config'; -const expect = chai.expect; - describe('TokenService', function() { const userObj = {_id: '1', username: 'peter-is-stupid'}; diff --git a/test/unit/services/UtilsService.test.js b/test/unit/services/UtilsService.test.js index dc8b563..ad424b0 100644 --- a/test/unit/services/UtilsService.test.js +++ b/test/unit/services/UtilsService.test.js @@ -1,9 +1,8 @@ -import chai from 'chai'; +import { expect } from 'chai'; +import { isEmpty } from 'ramda'; import utils from '../../../api/helpers/utils'; -const expect = chai.expect; - describe('UtilsService', () => { // Objects with _id prop const TEST_OBJ = {_id: 'stuff', key: 'adsf'}; @@ -46,4 +45,19 @@ describe('UtilsService', () => { .to.deep.equal(RES_NESTED_OBJ); }); }); + + describe('#emptyDefaultTo(default, val)', () => { + + it('should ifPdefaultTo(isEmpty, true, [])', () => { + expect(utils.ifPdefaultTo(isEmpty, true, [])).to.equal(true); + }); + + it('should defaultTo(true, [])', () => { + expect(utils.emptyDefaultTo(true, [])).to.equal(true); + }); + + it('should defaultTo(true, "blah")', () => { + expect(utils.emptyDefaultTo(true, 'blah')).to.equal('blah'); + }); + }); }); diff --git a/test/unit/services/VotingService.test.js b/test/unit/services/VotingService.test.js new file mode 100644 index 0000000..7724eb5 --- /dev/null +++ b/test/unit/services/VotingService.test.js @@ -0,0 +1,523 @@ +import {expect} from 'chai'; +import Promise from 'bluebird'; +import _ from 'lodash'; +import {monky} from '../../fixtures'; +import {BOARDID} from '../../constants'; + +import VotingService from '../../../api/services/VotingService'; +import RedisService from '../../../api/helpers/key-val-store'; +import KeyValService from '../../../api/services/KeyValService'; +import StateService from '../../../api/services/StateService'; +import IdeaCollectionService from '../../../api/services/IdeaCollectionService'; +import ResultService from '../../../api/services/ResultService'; + +import {model as Board} from '../../../api/models/Board'; +import {model as IdeaCollection} from '../../../api/models/IdeaCollection'; +import { UnauthorizedError } from '../../../api/helpers/extendable-error'; + +const resetRedis = (userId) => { + return Promise.all([ + RedisService.del(`${BOARDID}-current-users`), + RedisService.del(`${BOARDID}-ready`), + RedisService.del(`${BOARDID}-voting-${userId}`), + RedisService.del(`${BOARDID}-state`), + ]); +}; + +describe('VotingService', function() { + describe('#startVoting(boardId)', () => { + let boardFindOneAndUpdateStub; + let removeDuplicateCollectionsStub; + let clearVotingReadyStub; + let voteOnIdeaCollectionsStub; + + before(function() { + boardFindOneAndUpdateStub = this.stub(Board, 'findOneAndUpdate') + .returns(Promise.resolve('Returned a board')); + removeDuplicateCollectionsStub = this.stub(IdeaCollectionService, 'removeDuplicates') + .returns(Promise.resolve('Returns all of the unique collections')); + clearVotingReadyStub = this.stub(KeyValService, 'clearVotingReady') + .returns(Promise.resolve('Cleared voting ready key')); + voteOnIdeaCollectionsStub = this.stub(StateService, 'voteOnIdeaCollections') + .returns(Promise.resolve('Set state to vote on collections')); + }); + + xit('Should set up voting stage', () => { + return expect(VotingService.startVoting(BOARDID, false, '')).to.be.fulfilled + .then(() => { + expect(boardFindOneAndUpdateStub).to.have.been.called; + expect(removeDuplicateCollectionsStub).to.have.been.called; + expect(clearVotingReadyStub).to.have.been.called; + expect(voteOnIdeaCollectionsStub).to.have.been.called; + }); + }); + }); + + describe('#finishVoting(boardId)', () => { + let boardFindOneStub; + let ideaCollectionFindStub; + let ideaCollectionDestroyStub; + let resultCreateStub; + let clearVotingDoneStub; + let stateCreateIdeaCollectionsStub; + + const boardObj = { round: 0 }; + + const collections = [ + {collection1: {ideas: ['idea1', 'idea2'], votes: 0, lastUpdatedId: 'user1'}}, + ]; + + before(function() { + boardFindOneStub = this.stub(Board, 'findOne') + .returns(Promise.resolve(boardObj)); + ideaCollectionFindStub = this.stub(IdeaCollection, 'find') + .returns(Promise.resolve(collections)); + resultCreateStub = this.stub(ResultService, 'create') + .returns(Promise.resolve('Called result service create')); + ideaCollectionDestroyStub = this.stub(IdeaCollectionService, 'destroy') + .returns(Promise.resolve('Called idea collection service destroy')); + clearVotingDoneStub = this.stub(KeyValService, 'clearVotingDone') + .returns(Promise.resolve('Called KeyValService clearVotingDone')); + stateCreateIdeaCollectionsStub = this.stub(StateService, 'createIdeaCollections') + .returns(Promise.resolve('Called state service createIdeaCollections')); + }); + + xit('Should remove current idea collections and create results', () => { + return expect(VotingService.finishVoting(BOARDID, false, '')).to.be.fulfilled + .then(() => { + expect(boardFindOneStub).to.have.returned; + expect(ideaCollectionFindStub).to.have.returned; + expect(resultCreateStub).to.have.been.called; + expect(ideaCollectionDestroyStub).to.have.been.called; + expect(clearVotingDoneStub).to.have.been.called; + expect(stateCreateIdeaCollectionsStub).to.have.been.called; + }); + }); + }); + + describe('#isRoomReady(votingAction, boardId)', () => { + let USERID; + let requiredState; + let getUsersInRoomStub; + let isUserReadyToVoteStub; + let isUserDoneVotingStub; + let getStateStub; + let startVotingStub; + let finishVotingStub; + + const users = ['user1', 'user2']; + + before(function() { + getUsersInRoomStub = this.stub(KeyValService, 'getUsersInRoom') + .returns(Promise.resolve(users)); + startVotingStub = this.stub(VotingService, 'startVoting') + .returns(Promise.resolve('start voting called')); + finishVotingStub = this.stub(VotingService, 'finishVoting') + .returns(Promise.resolve('finish voting called')); + }); + + afterEach((done) => { + resetRedis(USERID) + .then(() => { + done(); + }); + }); + + it('Should result in the room not being ready to vote', function() { + requiredState = StateService.StateEnum.createIdeaCollections; + + isUserReadyToVoteStub = this.stub(VotingService, 'isUserReadyToVote') + .returns(Promise.resolve(false)); + + getStateStub = this.stub(StateService, 'getState') + .returns(Promise.resolve(requiredState)); + + return expect(VotingService.isRoomReady('start', BOARDID)).to.be.fulfilled + .then((readyToVote) => { + expect(getUsersInRoomStub).to.have.returned; + expect(isUserReadyToVoteStub).to.have.returned; + expect(getStateStub).to.have.returned; + expect(startVotingStub).to.not.have.been.called; + expect(readyToVote).to.be.false; + }); + }); + + it('Should result in the room being ready to vote', function() { + requiredState = StateService.StateEnum.createIdeaCollections; + + isUserReadyToVoteStub = this.stub(VotingService, 'isUserReadyToVote') + .returns(Promise.resolve(true)); + + getStateStub = this.stub(StateService, 'getState') + .returns(Promise.resolve(requiredState)); + + return expect(VotingService.isRoomReady('start', BOARDID)).to.be.fulfilled + .then((readyToVote) => { + expect(getUsersInRoomStub).to.have.returned; + expect(isUserReadyToVoteStub).to.have.returned; + expect(getStateStub).to.have.returned; + expect(startVotingStub).to.have.been.called; + expect(readyToVote).to.be.true; + }); + }); + + it('Should result in the room not being ready to finish voting', function() { + requiredState = StateService.StateEnum.voteOnIdeaCollections; + + isUserDoneVotingStub = this.stub(VotingService, 'isUserDoneVoting') + .returns(Promise.resolve(false)); + + getStateStub = this.stub(StateService, 'getState') + .returns(Promise.resolve(requiredState)); + + return expect(VotingService.isRoomReady('finish', BOARDID)).to.be.fulfilled + .then((readyToVote) => { + expect(getUsersInRoomStub).to.have.returned; + expect(isUserDoneVotingStub).to.have.returned; + expect(getStateStub).to.have.returned; + expect(finishVotingStub).to.have.not.been.called; + expect(readyToVote).to.be.false; + }); + }); + + it('Should result in the room being ready to finish voting', function() { + requiredState = StateService.StateEnum.voteOnIdeaCollections; + + isUserDoneVotingStub = this.stub(VotingService, 'isUserDoneVoting') + .returns(Promise.resolve(true)); + + getStateStub = this.stub(StateService, 'getState') + .returns(Promise.resolve(requiredState)); + + return expect(VotingService.isRoomReady('finish', BOARDID)).to.be.fulfilled + .then((readyToVote) => { + expect(getUsersInRoomStub).to.have.returned; + expect(isUserDoneVotingStub).to.have.returned; + expect(getStateStub).to.have.returned; + expect(finishVotingStub).to.have.been.called; + expect(readyToVote).to.be.true; + }); + }); + }); + + describe('#isUserReady(votingAction, boardId, userId)', () => { + let getUsersReadyToVoteStub; + let getUsersDoneVotingStub; + + const users = ['user1', 'user2']; + + before(function() { + getUsersReadyToVoteStub = this.stub(KeyValService, 'getUsersReadyToVote') + .returns(Promise.resolve(users)); + getUsersDoneVotingStub = this.stub(KeyValService, 'getUsersDoneVoting') + .returns(Promise.resolve(users)); + }); + + it('Should not have the user be ready to vote', () => { + return expect(VotingService.isUserReady('start', BOARDID, 'user3')).to.be.fulfilled + .then((readyToVote) => { + expect(getUsersReadyToVoteStub).to.have.returned; + expect(readyToVote).to.be.false; + }); + }); + + it('Should have the user be ready to vote', () => { + return expect(VotingService.isUserReady('start', BOARDID, 'user2')).to.be.fulfilled + .then((readyToVote) => { + expect(getUsersReadyToVoteStub).to.have.returned; + expect(readyToVote).to.be.true; + }); + }); + + it('Should have the user not be ready to finish voting', () => { + return expect(VotingService.isUserReady('finish', BOARDID, 'user3')).to.be.fulfilled + .then((finishedVoting) => { + expect(getUsersDoneVotingStub).to.have.returned; + expect(finishedVoting).to.be.false; + }); + }); + + it('Should have the user be ready to finish voting', () => { + return expect(VotingService.isUserReady('finish', BOARDID, 'user2')).to.be.fulfilled + .then((finishedVoting) => { + expect(getUsersDoneVotingStub).to.have.returned; + expect(finishedVoting).to.be.true; + }); + }); + }); + + describe('#setUserReady(votingAction, boardId, userId)', () => { + const USERID = 'user1'; + let readyUserToVoteStub; + let readyUserDoneVotingStub; + let isRoomReadyStub; + + before(function() { + readyUserToVoteStub = this.stub(KeyValService, 'readyUserToVote') + .returns(Promise.resolve('readyUserToVote was called')); + readyUserDoneVotingStub = this.stub(KeyValService, 'readyUserDoneVoting') + .returns(Promise.resolve('readyUserDoneVoting was called')); + isRoomReadyStub = this.stub(VotingService, 'isRoomReady') + .returns(Promise.resolve('isRoomReady was called')); + }); + + afterEach((done) => { + resetRedis(USERID) + .then(() => { + done(); + }); + }); + + it('Should set the user ready to vote', () => { + return expect(VotingService.setUserReady('start', BOARDID, USERID)).to.be.fulfilled + .then(() => { + expect(readyUserToVoteStub).to.have.returned; + expect(isRoomReadyStub).to.have.returned; + }); + }); + + it('Should set the user ready to finish voting', () => { + return expect(VotingService.setUserReady('finish', BOARDID, USERID)).to.be.fulfilled + .then(() => { + expect(readyUserDoneVotingStub).to.have.returned; + expect(isRoomReadyStub).to.have.returned; + }); + }); + }); + + describe('#setUserReadyToVote(boardId, userId)', function() { + let checkUserVotingListExistsStub; + let isUserReadyToVoteStub; + const USERID = 'user1'; + + afterEach((done) => { + resetRedis(USERID) + .then(() => { + done(); + }); + }); + + it('Should reject the user from readying up to vote again', function() { + checkUserVotingListExistsStub = this.stub(KeyValService, 'checkUserVotingListExists') + .returns(Promise.resolve(false)); + + isUserReadyToVoteStub = this.stub(VotingService, 'isUserReadyToVote') + .returns(Promise.resolve(true)); + + return expect(VotingService.setUserReadyToVote(BOARDID)) + .to.be.rejectedWith(UnauthorizedError, + /User is already ready to vote./) + .then(() => { + expect(checkUserVotingListExistsStub).to.have.returned; + expect(isUserReadyToVoteStub).to.have.returned; + }); + }); + }); + + describe('#setUserReadyToFinishVoting(boardId, userId)', function() { + let isUserDoneVotingStub; + const USERID = 'user1'; + + it('Should reject the user from readying to finish voting again', function() { + isUserDoneVotingStub = this.stub(VotingService, 'isUserDoneVoting') + .returns(Promise.resolve(true)); + + return expect(VotingService.setUserReadyToFinishVoting(BOARDID, USERID)) + .to.be.rejectedWith(UnauthorizedError, + /User is already ready to finish voting/) + .then(() => { + expect(isUserDoneVotingStub).to.have.returned; + }); + }); + }); + + describe('#getVoteList(boardId, userId)', () => { + const USERID = 'user1'; + let checkUserVotingListExistsStub; + let isUserDoneVotingStub; + let getIdeaCollectionsStub; + let addToUserVotingListStub; + let getCollectionsToVoteOnStub; + let findByKeyStub; + + const collectionObjs = [ + {key: 'abc123'}, + {key: 'abc1234'}, + ]; + + const collectionKeys = ['abc123', 'abc1234']; + + const collectionPromises = [Promise.resolve({key: 'abc1234'}), + Promise.resolve({key: 'abc1234'})]; + + before(function() { + getIdeaCollectionsStub = this.stub(IdeaCollectionService, 'getIdeaCollections') + .returns(Promise.resolve(collectionObjs)); + addToUserVotingListStub = this.stub(KeyValService, 'addToUserVotingList') + .returns(Promise.resolve(collectionKeys)); + getCollectionsToVoteOnStub = this.stub(KeyValService, 'getCollectionsToVoteOn') + .returns(Promise.resolve(collectionKeys)); + findByKeyStub = this.stub(IdeaCollection, 'findByKey') + .returns(Promise.resolve(collectionPromises)); + }); + + afterEach((done) => { + resetRedis(USERID) + .then(() => { + done(); + }); + }); + + // Check to see if a user hasn't voted yet and generates the list of + // collections to vote on and stores them in Redis. + xit('Should create a new voting list with all the idea collections', function() { + checkUserVotingListExistsStub = this.stub(KeyValService, 'checkUserVotingListExists') + .returns(Promise.resolve(false)); + + isUserDoneVotingStub = this.stub(VotingService, 'isUserDoneVoting') + .returns(Promise.resolve(false)); + + return expect(VotingService.getVoteList(BOARDID, USERID)).to.be.fulfilled + .then((collectionsToVoteOn) => { + expect(checkUserVotingListExistsStub).to.have.returned; + expect(isUserDoneVotingStub).to.have.returned; + expect(getIdeaCollectionsStub).to.have.returned; + expect(addToUserVotingListStub).to.have.returned; + expect(collectionsToVoteOn).to.have.length(2); + }); + }); + + // In this case, the user has already voted and is done voting so we look + // to send back just an empty collection + it('Should return an empty collection since the user is done voting', function() { + checkUserVotingListExistsStub = this.stub(KeyValService, 'checkUserVotingListExists') + .returns(Promise.resolve(false)); + + isUserDoneVotingStub = this.stub(VotingService, 'isUserDoneVoting') + .returns(Promise.resolve(true)); + + return expect(VotingService.getVoteList(BOARDID, USERID)).to.be.fulfilled + .then((collectionsToVoteOn) => { + expect(checkUserVotingListExistsStub).to.have.returned; + expect(isUserDoneVotingStub).to.have.returned; + expect(getIdeaCollectionsStub).to.have.returned; + expect(addToUserVotingListStub).to.have.returned; + expect(collectionsToVoteOn).to.have.length(0); + }); + }); + + // In this case, the user has started voting, but hasn't finished yet so + // we get their remaining collection keys from Redis and generate them + it('Should return remaining idea collections to vote on', function() { + checkUserVotingListExistsStub = this.stub(KeyValService, 'checkUserVotingListExists') + .returns(Promise.resolve(true)); + + return expect(VotingService.getVoteList(BOARDID, USERID)).to.be.fulfilled + .then((collectionsToVoteOn) => { + expect(checkUserVotingListExistsStub).to.have.returned; + expect(getCollectionsToVoteOnStub).to.have.returned; + expect(findByKeyStub).to.have.returned; + expect(collectionsToVoteOn).to.have.length(2); + }); + }); + }); + + describe('#vote(boardId, userId, key, increment)', () => { + const USERID = 'user1'; + let findOneAndUpdateStub; + let removeFromUserVotingListStub; + let wasCollectionVotedOnStub; + let getCollectionsToVoteOnStub; + let setUserReadyToFinishVotingStub; + + const collectionKeys = ['abc123', 'abc1234']; + const emptyCollection = []; + + before(function() { + findOneAndUpdateStub = this.stub(IdeaCollection, 'findOneAndUpdate') + .returns(Promise.resolve('findOneAndUpdate was called')); + removeFromUserVotingListStub = this.stub(KeyValService, 'removeFromUserVotingList') + .returns(Promise.resolve('removeFromUserVotingList was called')); + setUserReadyToFinishVotingStub = this.stub(VotingService, 'setUserReadyToFinishVoting') + .returns(Promise.resolve(true)); + }); + + afterEach((done) => { + resetRedis(USERID) + .then(() => { + done(); + }); + }); + + it('Should vote on a collection, increment it and set user ready to finish', function() { + getCollectionsToVoteOnStub = this.stub(KeyValService, 'getCollectionsToVoteOn') + .returns(Promise.resolve(emptyCollection)); + + wasCollectionVotedOnStub = this.stub(VotingService, 'wasCollectionVotedOn') + .returns(Promise.resolve('wasCollectionVotedOn was called')); + + return expect(VotingService.vote(BOARDID, USERID, 'key', true)).to.be.fulfilled + .then((setUserReady) => { + expect(wasCollectionVotedOnStub).to.have.returned; + expect(findOneAndUpdateStub).to.have.returned; + expect(removeFromUserVotingListStub).to.have.returned; + expect(getCollectionsToVoteOnStub).to.have.returned; + expect(setUserReadyToFinishVotingStub).to.have.returned; + expect(setUserReady).to.be.true; + }); + }); + + it('Should vote on a collection and not increment it', function() { + getCollectionsToVoteOnStub = this.stub(KeyValService, 'getCollectionsToVoteOn') + .returns(Promise.resolve(collectionKeys)); + + wasCollectionVotedOnStub = this.stub(VotingService, 'wasCollectionVotedOn') + .returns(Promise.resolve('wasCollectionVotedOn was called')); + + return expect(VotingService.vote(BOARDID, USERID, 'key', true)).to.be.fulfilled + .then((setUserReady) => { + expect(findOneAndUpdateStub).to.have.returned; + expect(removeFromUserVotingListStub).to.have.returned; + expect(getCollectionsToVoteOnStub).to.have.returned; + expect(setUserReady).to.be.false; + }); + }); + + it('Should not allow a duplicate vote to occur', function() { + return expect(VotingService.vote(BOARDID, USERID, 'key', true)) + .to.be.rejectedWith(UnauthorizedError, + /Collection was already voted on or does not exist/); + }); + }); + + describe('#getResults(boardId)', () => { + + beforeEach((done) => { + return monky.create('Board', {boardId: BOARDID}) + .then(() => { + return Promise.all([ + monky.create('Idea', {boardId: BOARDID, content: 'idea1'}), + monky.create('Idea', {boardId: BOARDID, content: 'idea2'}), + ]); + }) + .then((allIdeas) => { + return Promise.all([ + monky.create('Result', {key: 'resultOne', boardId: BOARDID, ideas: allIdeas}), + monky.create('Result', {key: 'resultTwo', boardId: BOARDID, ideas: [allIdeas[0]]}), + ]); + }) + .then(() => { + done(); + }); + }); + + it('Should get all of the results on a board ', (done) => { + return VotingService.getResults(BOARDID) + .then((results) => { + expect(_.keys(results)).to.have.length(1); + expect(_.keys(results[0])).to.have.length(2); + done(); + }); + }); + }); +}); diff --git a/test/unit/setup.test.js b/test/unit/setup.test.js new file mode 100644 index 0000000..bc49134 --- /dev/null +++ b/test/unit/setup.test.js @@ -0,0 +1,22 @@ +import chai from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import chaiArray from 'chai-array'; +import sinomocha from 'sinomocha'; +import mochaMongoose from 'mocha-mongoose'; + +import CFG from '../../config'; +import {connectDB, setupFixtures} from '../fixtures'; + +// Global before block to setup everything +before((done) => { + chai.use(chaiAsPromised); + chai.use(sinonChai); + chai.use(chaiArray); + sinomocha(); + mochaMongoose(CFG.mongoURL); + + connectDB(function(err, mongoose) { + setupFixtures(err, mongoose, done); + }); +});