diff --git a/.gitignore b/.gitignore index d52442b57..acd1ba775 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist .DS_Store *.iml .env +gaba.cfg diff --git a/Dockerfile b/Dockerfile index 22ba7e318..cdb7652d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node +FROM node:7 # Create app directory RUN mkdir -p /usr/src/app @@ -6,6 +6,7 @@ WORKDIR /usr/src/app # Setup the environment ENV PATH /usr/src/app/bin:$PATH +ENV TALK_PORT 5000 EXPOSE 5000 # Install app dependencies diff --git a/README.md b/README.md index 70e2d1110..d295ef326 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Talk +# Talk [![CircleCI](https://circleci.com/gh/coralproject/talk.svg?style=svg)](https://circleci.com/gh/coralproject/talk) A commenting platform from The Coral Project. [https://coralproject.net](https://coralproject.net) ### Getting Started diff --git a/app.js b/app.js index 935990e9b..838819e35 100644 --- a/app.js +++ b/app.js @@ -1,15 +1,18 @@ -/* This is the entrypoint for the Talk Platform server. */ - -// Initialize application framework. const express = require('express'); +const bodyParser = require('body-parser'); +const morgan = require('morgan'); +const path = require('path'); const app = express(); -const bodyParser = require('body-parser'); -app.use(bodyParser.json()); // for parsing application/json -app.use(bodyParser.urlencoded({extended: true})); // for parsing application/x-www-form-urlencoded +// Middleware declarations. +app.use(morgan('dev')); +app.use(bodyParser.json()); +// API Routes. app.use('/api/v1', require('./routes/api')); -app.use('/client/', express.static('./dist')); + +// Static Routes. +app.use('/client/', express.static(path.join(__dirname, 'dist'))); module.exports = app; diff --git a/bin/init.js b/bin/init.js index 6ee35bfe4..d8be5a6fa 100644 --- a/bin/init.js +++ b/bin/init.js @@ -1,7 +1,12 @@ +const mongoose = require('../mongoose'); const Setting = require('../models/setting'); const defaults = {id: '1', moderation: 'pre'}; -Setting.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}).then(() => { - console.log('created settings object.'); -// process.exit(); // Removed to satisfy hooks -}).catch(console.error); +Setting.update({id: '1'}, {$setOnInsert: defaults}, {upsert: true}) + .then(() => { + console.log('Created settings object.'); + mongoose.disconnect(); + }).catch((err) => { + console.error(`failed to create the settings object ${JSON.stringify(err)}`); + throw new Error(err); // just to be safe + }); diff --git a/circle.yml b/circle.yml new file mode 100644 index 000000000..9de9eae4c --- /dev/null +++ b/circle.yml @@ -0,0 +1,21 @@ +machine: + node: + version: 7 + services: + - docker + +test: + override: + - MOCHA_FILE=$CIRCLE_TEST_REPORTS/junit/test-results.xml ./node_modules/.bin/mocha tests --reporter mocha-junit-reporter + - npm run lint + +deployment: + release: + tag: /[0-9]+(\.[0-9]+)*/ + commands: + - bash ./scripts/deploy.sh + + latest: + branch: master + commands: + - bash ./scripts/deploy.sh diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..ecfac00bc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '2' +services: + + talk: + image: coralproject/talk:latest + build: . + restart: always + ports: + - "5000:5000" + environment: + - "TALK_PORT=5000" + - "TALK_MONGO_URL=mongodb://mongo" + depends_on: + - mongo + + mongo: + image: mongo:3.2 + restart: always + volumes: + - mongo:/data/db + +volumes: + mongo: + external: false diff --git a/models/action.js b/models/action.js index 581108a71..cdbf92e8f 100644 --- a/models/action.js +++ b/models/action.js @@ -1,5 +1,3 @@ -'use strict'; - const mongoose = require('../mongoose'); const uuid = require('uuid'); const Schema = mongoose.Schema; @@ -15,7 +13,6 @@ const ActionSchema = new Schema({ item_id: String, user_id: String },{ - _id: false, timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' @@ -30,6 +27,16 @@ ActionSchema.statics.findById = function(id) { return Action.findOne({id}); }; +/** + * Finds users in an array of ids. + * @param {String} ids array of user identifiers (uuid) +*/ +ActionSchema.statics.findByItemIdArray = function(item_ids) { + return Action.find({ + 'item_id': {$in: item_ids} + }); +}; + const Action = mongoose.model('Action', ActionSchema); module.exports = Action; diff --git a/models/comment.js b/models/comment.js index 107708a3b..468478810 100644 --- a/models/comment.js +++ b/models/comment.js @@ -1,5 +1,3 @@ -'use strict'; - const mongoose = require('../mongoose'); const uuid = require('uuid'); const Schema = mongoose.Schema; @@ -13,7 +11,7 @@ const CommentSchema = new Schema({ body: { type: String, required: [true, 'The body is required.'], - minlength: 50 + minlength: 2 }, asset_id: String, author_id: String, @@ -24,7 +22,6 @@ const CommentSchema = new Schema({ }, parent_id: String },{ - _id: false, timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' @@ -33,12 +30,21 @@ const CommentSchema = new Schema({ /** * Finds a comment by the id. - * @param {String} id identifier of the comment (uuid) + * @param {String} asset_id identifier of comment (uuid) */ CommentSchema.statics.findById = function(id) { return Comment.findOne({id}); }; +/** + * Finds a comment by the asset_id. + * @param {String} asset_id identifier of the asset which owns this comment (uuid) +*/ +CommentSchema.statics.findByAssetId = function(asset_id) { + return Comment.find({asset_id}); +}; + + const Comment = mongoose.model('Comment', CommentSchema); module.exports = Comment; diff --git a/models/setting.js b/models/setting.js index dd82b3e42..b47b6e9e2 100644 --- a/models/setting.js +++ b/models/setting.js @@ -12,10 +12,19 @@ const SettingSchema = new Schema({ } }); +/** + * gets the entire settings record and sends it back + * @return {Promise} settings the whole settings record + */ SettingSchema.statics.getSettings = function () { - return this.findOne(); + return this.findOne({id: '1'}); }; +/** + * this will update the settings object with whatever you pass in + * @param {object} setting a hash of whatever settings you want to update + * @return {Promise} settings Promise that resolves to the entire (updated) settings object. + */ SettingSchema.statics.updateSettings = function (setting) { // there should only ever be one record unless something has gone wrong. return this.findOneAndUpdate({id: '1'}, {$set: setting}, {new: true}); diff --git a/models/user.js b/models/user.js index d6b174075..76635abfb 100644 --- a/models/user.js +++ b/models/user.js @@ -1,4 +1,3 @@ -'use strict'; const mongoose = require('../mongoose'); const uuid = require('uuid'); @@ -13,7 +12,6 @@ const UserProfileSchema = new Schema({ display_name: String, auth_user_id: String },{ - _id: false, timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' @@ -28,6 +26,16 @@ UserProfileSchema.statics.findById = function(id) { return UserProfile.findOne({id}); }; +/** + * Finds users in an array of idd. + * @param {String} idd array of user identifiers (uuid) +*/ +UserProfileSchema.statics.findByIdArray = function(ids) { + return UserProfile.find({ + 'id': {$in: ids} + }); +}; + // TO DO: methods // modifications to user as statics // find by auth user id diff --git a/package.json b/package.json index e581146ee..856db50c6 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "start": "./bin/www", "build": "webpack --config ./client/coral-embed-stream/webpack.config.js", "lint": "eslint .", - "test": "mocha tests", + "pretest": "npm install", + "test": "mocha tests --recursive", + "test-watch": "mocha tests --recursive -w", "embed-start": "node client/coral-embed-stream/dev-server.js" }, "config": { @@ -44,7 +46,9 @@ "chai-http": "^3.0.0", "debug": "^2.2.0", "express": "^4.14.0", - "mongoose": "^4.6.5" + "mongoose": "^4.6.5", + "uuid": "^2.0.3", + "morgan": "^1.7.0" }, "devDependencies": { "babel-core": "6.14.0", @@ -59,6 +63,7 @@ "babel-preset-es2015-minimal": "^2.1.0", "babel-preset-stage-0": "^6.16.0", "chai": "^3.5.0", + "chai-http": "^1.0.0", "copy-webpack-plugin": "^3.0.1", "eslint": "^3.9.1", "exports-loader": "^0.6.3", @@ -66,6 +71,7 @@ "imports-loader": "^0.6.5", "json-loader": "^0.5.4", "mocha": "^3.1.2", + "mocha-junit-reporter": "^1.12.1", "pre-git": "^3.10.0", "pym.js": "^1.1.1", "react": "15.3.2", @@ -82,5 +88,8 @@ "webpack-hot-middleware": "^2.12.2", "webpack-module-hot-accept": "^1.0.4", "whatwg-fetch": "^1.0.0" + }, + "engines": { + "node": ">=7.0.0" } } diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index dac53b755..df9bbc456 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -1,17 +1,39 @@ const express = require('express'); +const Comment = require('../../../models/comment'); const router = express.Router(); +//============================================================================== +// Routes +//============================================================================== + + router.get('/', (req, res) => { res.send('Read all of the comments ever'); }); -router.get('/:comment_id', (req, res) => { - res.send('Read a comment'); +router.get('/:comment_id', (req, res, next) => { + Comment.findById(req.params.comment_id).then((comment) => { + res.status(200).json(comment); + }).catch(error => { + next(error); + }); }); -router.post('/', (req, res) => { - res.send('Write a comment'); +router.post('/', (req, res, next) => { + let comment = new Comment({ + body: req.query.body, + author_id: req.query.author_id, + asset_id: req.query.asset_id, + parent_id: req.query.parent_id, + status: req.query.status + }); + comment.save().then(({id}) => { + res.status(201).send(id); + }).catch(error => { + next(error); + }); + }); router.put('/:comment_id', (req, res) => { diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index fa5d3a620..53bf53f3c 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -3,11 +3,11 @@ const router = express.Router(); const Setting = require('../../../models/setting'); router.get('/', (req, res, next) => { - Setting.getSettings().then(res.json).catch(next); + Setting.getSettings().then(settings => res.json(settings)).catch(next); }); router.put('/', (req, res, next) => { - Setting.updateSettings(req.body).then(res.json).catch(next); + Setting.updateSettings(req.body).then(() => res.status(204).end()).catch(next); }); module.exports = router; diff --git a/routes/api/stream/index.js b/routes/api/stream/index.js index b41e03dbb..c63bf871d 100644 --- a/routes/api/stream/index.js +++ b/routes/api/stream/index.js @@ -1,33 +1,26 @@ const express = require('express'); +const Comment = require('../../../models/comment'); +const User = require('../../../models/user'); +const Action = require('../../../models/action'); + const router = express.Router(); -router.get('/', (req, res) => { - console.log('Stream endpoint has been hit with asset_id ', req.query.asset_id); - res.json([ - { - 'id': 'abc', - 'type': 'comment', - 'body': 'Sample comment', - 'created_at': new Date().getTime(), - 'asset_id': 'assetTest' - }, - { - 'id': 'xyz', - 'type': 'comment', - 'body': 'Sample reply', - 'created_at': new Date().getTime() - 600000, - 'parent_id': 'abc', - 'asset_id': 'assetTest' - }, - { - 'id': 'def', - 'type': 'comment', - 'body': 'Another comment', - 'created_at': new Date().getTime() - 400000, - 'asset_id': 'assetTest' - } - ]); +router.get('/', (req, res, next) => { + + const commentsPromise = Comment.findByAssetId(req.query.asset_id); + + commentsPromise.then(comments => { + return Promise.all([ + comments, + User.findByIdArray(comments.map((comment) => comment.author_id)), + Action.findByItemIdArray(comments.map((comment) => comment.id)) + ]); + }).then(([comments, users, actions]) => { + res.json([...comments,...users,...actions]); + }).catch(error => { + next(error); + }); }); module.exports = router; diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 000000000..51336b44d --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +set -e + +docker login -e $DOCKER_EMAIL -u $DOCKER_USER -p $DOCKER_PASS + +# Sourced from https://segment.com/blog/ci-at-segment/ + +deploy_tag() { + # Find our individual versions from the tags + if [ -n "$(echo $CIRCLE_TAG | grep -E '.*\..*\..*')" ] + then + major=$(echo $CIRCLE_TAG | cut -d. -f1) + minor=$(echo $CIRCLE_TAG | cut -d. -f2) + patch=$(echo $CIRCLE_TAG | cut -d. -f3) + + major_version_tag=$major + minor_version_tag=$major.$minor + patch_version_tag=$major.$minor.$patch + + tag_list="$major_version_tag $minor_version_tag $patch_version_tag" + else + tag_list=$CIRCLE_TAG + fi + + # Tag the new image with major, minor and patch version tags. + for version in $tag_list + do + echo "==> tagging $version" + docker tag coralproject/talk:latest coralproject/talk:$version + done + + # Push each of the tags to docker hub, including latest + for version in $tag_list latest + do + echo "==> pushing $version" + docker push coralproject/talk:$version + done +} + +deploy_latest() { + echo "==> pushing latest" + docker push coralproject/talk:latest +} + +# build the repo +docker build -t coralproject/talk . + +# deploy based on the env +if [ -n "$CIRCLE_TAG" ] +then + deploy_tag +else + deploy_latest +fi diff --git a/swagger.yaml b/swagger.yaml index 36af7a4c5..ca7ef29d0 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -150,7 +150,11 @@ paths: description: Settings responses: 200: - description: OK + description: Get Setting + schema: + type: array + items: + $ref: '#/definitions/Setting' put: tags: - Settings @@ -192,7 +196,6 @@ paths: description: Not Found - definitions: Item: type: object @@ -244,6 +247,19 @@ definitions: type: string user_id: type: string + moderation: + type: string + enum: + - pre + - post + created_at: + type: string + format: date-time + description: Creation Date-Time + updated_at: + type: string + format: date-time + description: Updated Date-Time Asset: type: object properties: @@ -275,12 +291,3 @@ definitions: publication_date: type: date desctipion: When this asset was published. - - - - - - - - - diff --git a/tests/.eslintrc.json b/tests/.eslintrc.json new file mode 100644 index 000000000..97d8c197e --- /dev/null +++ b/tests/.eslintrc.json @@ -0,0 +1,10 @@ +{ + "env": { + "es6": true, + "node": true + }, + "extends": "eslint:recommended", + "rules": { + "no-undef": [0] + } +} diff --git a/tests/index.js b/tests/index.js index 0a212b7a4..51ccc54ee 100644 --- a/tests/index.js +++ b/tests/index.js @@ -1,10 +1,12 @@ /* eslint-env node, mocha */ 'use strict'; +// require('./utils/mongoose') const expect = require('chai').expect; + describe('Comment', () => { - describe.only('#add', () => { + describe('#add', () => { it('should add a comment', () => { expect(0).to.be.equal(0); }); diff --git a/tests/models/action.js b/tests/models/action.js new file mode 100644 index 000000000..8736edcab --- /dev/null +++ b/tests/models/action.js @@ -0,0 +1,40 @@ +/* eslint-env node, mocha */ + +require('../utils/mongoose'); +const Action = require('../../models/action'); +const expect = require('chai').expect; + +describe('Action: models', () => { + var mockActions; + beforeEach(() => { + return Action.create([{ + action_type: 'flag', + item_id: '123' + },{ + action_type: 'like', + item_id: '789' + },{ + action_type: 'flag', + item_id: '456' + }]).then((actions) => { + mockActions = actions; + }); + }); + + describe('#findById()', () => { + it('should find an action by id', () => { + return Action.findById(mockActions[0].id).then((result) => { + expect(result).to.have.property('action_type') + .and.to.equal('flag'); + }); + }); + }); + + describe('#findByItemIdArray()', () => { + it('should find an array of actions from an array of item_ids', () => { + return Action.findByItemIdArray(['123','456']).then((result) => { + expect(result).to.have.length(2); + }); + }); + }); +}); diff --git a/tests/models/comment.js b/tests/models/comment.js new file mode 100644 index 000000000..a8a543183 --- /dev/null +++ b/tests/models/comment.js @@ -0,0 +1,51 @@ +/* eslint-env node, mocha */ + +require('../utils/mongoose'); + +const Comment = require('../../models/comment'); +const expect = require('chai').expect; + +describe('Comment: models', () => { + var mockComments; + beforeEach(() => { + return Comment.create([{ + body: 'comment 10', + asset_id: '123' + },{ + body: 'comment 20', + asset_id: '123' + },{ + body: 'comment 30', + asset_id: '456' + }]).then((comments) => { + mockComments = comments; + }); + }); + + describe('#findById()', () => { + it('should find a comment by id', () => { + return Comment.findById(mockComments[0].id).then((result) => { + expect(result).to.have.property('body') + .and.to.equal('comment 10'); + }); + }); + }); + + describe('#findByAssetId()', () => { + it('should find an array of comments by asset id', () => { + return Comment.findByAssetId('123').then((result) => { + expect(result).to.have.length(2); + result.sort((a,b) => { + if (a.body < b.body) {return -1;} + else {return 1;} + }); + expect(result[0]).to.have.property('body') + .and.to.equal('comment 10'); + expect(result[1]).to.have.property('body') + .and.to.equal('comment 20'); + }); + }); + }); + + // }); +}); diff --git a/tests/models/user.js b/tests/models/user.js new file mode 100644 index 000000000..e1063e21c --- /dev/null +++ b/tests/models/user.js @@ -0,0 +1,40 @@ +/* eslint-env node, mocha */ + +require('../utils/mongoose'); +const User = require('../../models/user'); +const expect = require('chai').expect; + +describe('User: models', () => { + var mockUsers; + beforeEach(() => { + return User.create([{ + display_name: 'Stampi', + },{ + display_name: 'Sockmonster', + },{ + display_name: 'Marvel', + }]).then((users) => { + mockUsers = users; + }); + }); + + describe('#findById()', () => { + it('should find a user by id', () => { + return User.findById(mockUsers[0].id).then((result) => { + expect(result).to.have.property('display_name') + .and.to.equal('Stampi'); + }); + }); + }); + + describe('#findByIdArray()', () => { + it('should find an array of users from an array of ids', () => { + const ids = mockUsers.map((user) => user.id) + return User.findByIdArray(ids).then((result) => { + expect(result).to.have.length(3); + }); + }); + }); + + // }); +}); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js new file mode 100644 index 000000000..ad21e54ec --- /dev/null +++ b/tests/routes/api/comments/index.js @@ -0,0 +1,106 @@ +process.env.NODE_ENV = 'test'; + +require('../../../utils/mongoose'); + +const app = require('../../../../app'); +const chai = require('chai'); +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); +var expect = chai.expect; + + +const Comment = require('../../../../models/comment'); +const Action = require('../../../../models/action'); +const User = require('../../../../models/user'); + +describe('Post /comments', () => { + const users = [{ + id: '123', + display_name: 'John', + },{ + id: '456', + display_name: 'Paul', + }] + + const actions = [{ + action_type: 'flag', + item_id: 'abc' + },{ + action_type: 'like', + item_id: 'hij' + }] + + beforeEach(() => { + return User.create(users).then(() => { + return Action.create(actions) + }) + }) + + it('it should create a comment', function(done) { + chai.request(app) + .post('/api/v1/comments') + .query({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''}) + .end(function(err, res){ + expect(res).to.have.status(201) + done() + }) + }) + +}) + +describe('Get /:comment_id', () => { + const comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + author_id: '123' + },{ + id: 'def', + body: 'comment 20', + asset_id: 'asset', + author_id: '456' + },{ + id: 'hij', + body: 'comment 30', + asset_id: '456' + }] + + const users = [{ + id: '123', + display_name: 'John', + },{ + id: '456', + display_name: 'Paul', + }] + + const actions = [{ + action_type: 'flag', + item_id: 'abc' + },{ + action_type: 'like', + item_id: 'hij' + }] + + beforeEach(() => { + return Comment.create(comments).then(() => { + return User.create(users) + }).then(() => { + return Action.create(actions) + }) + }) + + it('should return the right comment for the comment_id', function(done){ + chai.request(app) + .get('/api/v1/comments') + .query({'comment_id': 'abc'}) + .end(function(err, res){ + expect(err).to.be.null; + expect(res).to.have.status(200); + if (err) return done(err); + done(); + }); + }) + + + +}) diff --git a/tests/routes/api/stream/index.js b/tests/routes/api/stream/index.js new file mode 100644 index 000000000..a4ca53228 --- /dev/null +++ b/tests/routes/api/stream/index.js @@ -0,0 +1,65 @@ +require('../../../utils/mongoose'); + +const app = require('../../../../app'); +const chai = require('chai'); +const chaiHttp = require('chai-http'); +chai.use(chaiHttp); +var expect = chai.expect; + +const Action = require('../../../../models/action'); +const User = require('../../../../models/user'); +const Comment = require('../../../../models/comment'); + +describe('api/stream: routes', () => { + const comments = [{ + id: 'abc', + body: 'comment 10', + asset_id: 'asset', + author_id: '123' + },{ + id: 'def', + body: 'comment 20', + asset_id: 'asset', + author_id: '456' + },{ + id: 'hij', + body: 'comment 30', + asset_id: '456' + }] + + const users = [{ + id: '123', + display_name: 'John', + },{ + id: '456', + display_name: 'Paul', + }] + + const actions = [{ + action_type: 'flag', + item_id: 'abc' + },{ + action_type: 'like', + item_id: 'hij' + }] + + beforeEach(() => { + return Comment.create(comments).then(() => { + return User.create(users) + }).then(() => { + return Action.create(actions) + }) + }) + + it('should return a stream with comments, users and actions', function(done){ + chai.request(app) + .get('/api/v1/stream') + .query({'asset_id': 'asset'}) + .end(function(err, res){ + expect(err).to.be.null; + expect(res).to.have.status(200); + if (err) return done(err); + done(); + }); + }) +}) diff --git a/tests/utils/mongoose.js b/tests/utils/mongoose.js new file mode 100644 index 000000000..35083a4b3 --- /dev/null +++ b/tests/utils/mongoose.js @@ -0,0 +1,31 @@ +var mongoose = require('mongoose'); + +// Ensure the NODE_ENV is set to 'test', +// this is helpful when you would like to change behavior when testing. +process.env.NODE_ENV = 'test'; + +beforeEach(function (done) { + function clearDB() { + for (var i in mongoose.connection.collections) { + mongoose.connection.collections[i].remove(function() {}); + } + return done(); + } + + + if (mongoose.connection.readyState === 0) { + mongoose.connect('coral-talk-test', function (err) { + if (err) { + throw err; + } + return clearDB(); + }); + } else { + return clearDB(); + } +}); + +after(function (done) { + mongoose.disconnect(); + return done(); +});