From 330169301aa8b7ded352951b8a78ab1bd033eb06 Mon Sep 17 00:00:00 2001 From: Dibya Singh Date: Fri, 16 Aug 2019 00:27:12 -0700 Subject: [PATCH 1/5] Update topics for works --- package-lock.json | 29 +++++-- src/events/index.js | 1 - src/events/projectPhases/index.js | 116 +++++++++++++++++-------- src/routes/phases/create.js | 4 +- src/routes/phases/create.spec.js | 117 +++++++++++++++++++++++--- src/routes/phases/delete.js | 5 +- src/routes/phases/delete.spec.js | 114 ++++++++++++++++++++++--- src/routes/phases/update.js | 4 +- src/routes/phases/update.spec.js | 127 +++++++++++++++++++++++++--- src/routes/works/create.js | 4 +- src/routes/works/create.spec.js | 119 ++++++++++++++++++++++---- src/routes/works/delete.js | 4 +- src/routes/works/delete.spec.js | 135 ++++++++++++++++++++++++++---- src/routes/works/update.js | 4 +- src/routes/works/update.spec.js | 131 +++++++++++++++++++++++++---- src/services/messageService.js | 12 +-- 16 files changed, 774 insertions(+), 152 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24db92ed..8b18977b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3308,6 +3308,11 @@ "object-assign": "^4.0.1" } }, + "file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" + }, "filename-regex": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", @@ -5598,18 +5603,26 @@ } }, "libpq": { - "version": "1.8.8", - "resolved": "https://registry.npmjs.org/libpq/-/libpq-1.8.8.tgz", - "integrity": "sha512-0TVzqkbAZZiM8JJy5sagRyXOkvU9zTBlgGX6YdzuWECobc5F81Tp6uuS+djMZrnB5YN4O/ff52hsvXYBRW2gdQ==", + "version": "1.8.9", + "resolved": "https://registry.npmjs.org/libpq/-/libpq-1.8.9.tgz", + "integrity": "sha512-herU0STiW3+/XBoYRycKKf49O9hBKK0JbdC2QmvdC5pyCSu8prb9idpn5bUSbxj8XwcEsWPWWWwTDZE9ZTwJ7g==", "requires": { - "bindings": "1.2.1", - "nan": "^2.10.0" + "bindings": "1.5.0", + "nan": "^2.14.0" }, "dependencies": { + "bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "requires": { + "file-uri-to-path": "1.0.0" + } + }, "nan": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz", - "integrity": "sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw==" + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==" } } }, diff --git a/src/events/index.js b/src/events/index.js index ce0921c1..1327a01e 100644 --- a/src/events/index.js +++ b/src/events/index.js @@ -44,7 +44,6 @@ export const rabbitHandlers = { [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_ADDED]: phaseProductAddedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_REMOVED]: phaseProductRemovedHandler, [EVENT.ROUTING_KEY.PROJECT_PHASE_PRODUCT_UPDATED]: phaseProductUpdatedHandler, - // Timeline and milestone 'timeline.initial': timelineAddedHandler, [EVENT.ROUTING_KEY.TIMELINE_ADDED]: timelineAddedHandler, diff --git a/src/events/projectPhases/index.js b/src/events/projectPhases/index.js index d9e8c218..6e74d270 100644 --- a/src/events/projectPhases/index.js +++ b/src/events/projectPhases/index.js @@ -7,6 +7,8 @@ import config from 'config'; import _ from 'lodash'; import Promise from 'bluebird'; import util from '../../util'; +import { TIMELINE_REFERENCES } from '../../constants'; + import messageService from '../../services/messageService'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -14,6 +16,30 @@ const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); const eClient = util.getElasticSearchClient(); +/** + * Get tags based on route parameter. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} phase event payload + * @param {String} route route value can be PHASE/WORK + * @returns {undefined} + */ +const getTags = function (logger, phase, route) { // eslint-disable-line func-names + if (route === TIMELINE_REFERENCES.WORK) { + return [{ + tag: `work#${phase.id}-details`, + title: `${phase.name} - Details`, + }, { + tag: `work#${phase.id}-requirements`, + title: `${phase.name} - Requirements`, + }]; + } + return [{ + tag: `phase#${phase.id}`, + title: phase.name, + }]; +}; + /** * Indexes the project phase in the elastic search. * @@ -63,20 +89,23 @@ const indexProjectPhase = Promise.coroutine(function* (logger, phase) { // eslin * * @param {Object} logger logger to log along with trace id * @param {Object} msg event payload + * @param {String} route route value can be PHASE/WORK * @returns {undefined} */ -const createPhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint-disable-line func-names +const createPhaseTopic = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names try { - logger.debug('Creating topic for phase with phase', phase); - const topic = yield messageService.createTopic({ + logger.debug('Creating topics for phase with phase', phase); + const tags = getTags(logger, phase, route); + const topics = yield Promise.all(_.map(tags, tag => messageService.createTopic({ reference: 'project', referenceId: `${phase.projectId}`, - tag: `phase#${phase.id}`, - title: phase.name, - body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line - }, logger); - logger.debug('topic for the phase created successfully'); - logger.debug('created topic', topic); + tag: tag.tag, + title: tag.title, + body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line + }, logger))); + + logger.debug('topics for the phase created successfully'); + logger.debug('created topics', topics); } catch (error) { logger.error('Error in creating topic for the project phase', error); // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase @@ -92,12 +121,14 @@ const createPhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint * @returns {undefined} */ const projectPhaseAddedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names - const phase = JSON.parse(msg.content.toString()); + const data = JSON.parse(msg.content.toString()); + const phase = _.get(data, 'added', {}); + const route = _.get(data, 'route', 'PHASE'); try { logger.debug('calling indexProjectPhase', phase); yield indexProjectPhase(logger, phase, channel); logger.debug('calling createPhaseTopic', phase); - yield createPhaseTopic(logger, phase); + yield createPhaseTopic(logger, phase, route); channel.ack(msg); } catch (error) { logger.error('Error handling project.phase.added event', error); @@ -134,6 +165,30 @@ const updateIndexProjectPhase = Promise.coroutine(function* (logger, data) { // } }); +/** + * Indexes the project phase in the elastic search. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} data event payload + * @returns {undefined} + */ +const updateTopicWithTag = Promise.coroutine(function* (logger, phase, tag) { // eslint-disable-line func-names + const topic = yield messageService.getPhaseTopic(phase.projectId, tag.tag, logger); + logger.trace('Topic', topic); + const title = tag.title; + const titleChanged = topic && topic.title !== title; + logger.trace('titleChanged', titleChanged); + const contentPost = topic && topic.posts && topic.posts.length > 0 ? topic.posts[0] : null; + logger.trace('contentPost', contentPost); + const postId = _.get(contentPost, 'id'); + const content = _.get(contentPost, 'body'); + if (postId && content && titleChanged) { + const updatedTopic = yield messageService.updateTopic(topic.id, { title, postId, content }, logger); + logger.debug('topic for the phase updated successfully'); + logger.trace('updated topic', updatedTopic); + } +}); + /** * Creates a new phase topic in message api. * @@ -141,23 +196,11 @@ const updateIndexProjectPhase = Promise.coroutine(function* (logger, data) { // * @param {Object} msg event payload * @returns {undefined} */ -const updatePhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint-disable-line func-names +const updatePhaseTopic = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names try { logger.debug('Updating topic for phase with phase', phase); - const topic = yield messageService.getPhaseTopic(phase.projectId, phase.id, logger); - logger.trace('Topic', topic); - const title = phase.name; - const titleChanged = topic && topic.title !== title; - logger.trace('titleChanged', titleChanged); - const contentPost = topic && topic.posts && topic.posts.length > 0 ? topic.posts[0] : null; - logger.trace('contentPost', contentPost); - const postId = _.get(contentPost, 'id'); - const content = _.get(contentPost, 'body'); - if (postId && content && titleChanged) { - const updatedTopic = yield messageService.updateTopic(topic.id, { title, postId, content }, logger); - logger.debug('topic for the phase updated successfully'); - logger.trace('updated topic', updatedTopic); - } + const tags = getTags(logger, phase, route); + yield Promise.all(_.map(tags, tag => updateTopicWithTag(logger, phase, tag))); } catch (error) { logger.error('Error in updating topic for the project phase', error); // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase @@ -175,10 +218,11 @@ const updatePhaseTopic = Promise.coroutine(function* (logger, phase) { // eslint const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names try { const data = JSON.parse(msg.content.toString()); + const route = _.get(data, 'route', 'PHASE'); logger.debug('calling updateIndexProjectPhase', data); yield updateIndexProjectPhase(logger, data, channel); logger.debug('calling updatePhaseTopic', data.updated); - yield updatePhaseTopic(logger, data.updated); + yield updatePhaseTopic(logger, data.updated, route); channel.ack(msg); } catch (error) { logger.error('Error handling project.phase.updated event', error); @@ -197,13 +241,14 @@ const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, cha const removePhaseFromIndex = Promise.coroutine(function* (logger, msg) { // eslint-disable-line func-names try { const data = JSON.parse(msg.content.toString()); - const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: data.projectId }); - const phases = _.filter(doc._source.phases, single => single.id !== data.id); // eslint-disable-line no-underscore-dangle + const phase = _.get(data, 'deleted', {}); + const doc = yield eClient.get({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, id: phase.projectId }); + const phases = _.filter(doc._source.phases, single => single.id !== phase.id); // eslint-disable-line no-underscore-dangle const merged = _.assign(doc._source, { phases }); // eslint-disable-line no-underscore-dangle yield eClient.update({ index: ES_PROJECT_INDEX, type: ES_PROJECT_TYPE, - id: data.projectId, + id: phase.projectId, body: { doc: merged, }, @@ -223,10 +268,9 @@ const removePhaseFromIndex = Promise.coroutine(function* (logger, msg) { // esli * @param {Object} msg event payload * @returns {undefined} */ -const removePhaseTopic = Promise.coroutine(function* (logger, msg) { // eslint-disable-line func-names +const removePhaseTopic = Promise.coroutine(function* (logger, phase, tag) { // eslint-disable-line func-names try { - const phase = JSON.parse(msg.content.toString()); - const phaseTopic = yield messageService.getPhaseTopic(phase.projectId, phase.id, logger); + const phaseTopic = yield messageService.getPhaseTopic(phase.projectId, tag, logger); yield messageService.deletePosts(phaseTopic.id, phaseTopic.postIds, logger); yield messageService.deleteTopic(phaseTopic.id, logger); logger.debug('topic for the phase removed successfully'); @@ -247,7 +291,11 @@ const removePhaseTopic = Promise.coroutine(function* (logger, msg) { // eslint-d const projectPhaseRemovedHandler = Promise.coroutine(function* (logger, msg, channel) { // eslint-disable-line func-names try { yield removePhaseFromIndex(logger, msg, channel); - yield removePhaseTopic(logger, msg); + const data = JSON.parse(msg.content.toString()); + const phase = _.get(data, 'deleted', {}); + const route = _.get(data, 'route'); + const tags = getTags(logger, phase, route); + yield Promise.all(_.map(tags, tag => removePhaseTopic(logger, phase, tag.tag))); channel.ack(msg); } catch (error) { logger.error('Error fetching project document from elasticsearch', error); diff --git a/src/routes/phases/create.js b/src/routes/phases/create.js index 23ea17eb..e4a9b250 100644 --- a/src/routes/phases/create.js +++ b/src/routes/phases/create.js @@ -5,7 +5,7 @@ import Sequelize from 'sequelize'; import models from '../../models'; import util from '../../util'; -import { EVENT } from '../../constants'; +import { EVENT, TIMELINE_REFERENCES } from '../../constants'; const permissions = require('tc-core-library-js').middleware.permissions; @@ -127,7 +127,7 @@ module.exports = [ // Send events to buses req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, - newProjectPhase, + { added: newProjectPhase, route: TIMELINE_REFERENCES.PHASE }, { correlationId: req.id }, ); req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js index f4b26e6b..c9ad3387 100644 --- a/src/routes/phases/create.spec.js +++ b/src/routes/phases/create.spec.js @@ -2,15 +2,21 @@ import _ from 'lodash'; import chai from 'chai'; import sinon from 'sinon'; +import config from 'config'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; import { BUS_API_EVENT, } from '../../constants'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const should = chai.should(); const body = { @@ -57,22 +63,23 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; let productTemplateId; beforeEach((done) => { // mocks testUtil.clearDb() - .then(() => models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { + .then(() => models.Project.create(project).then((p) => { projectId = p.id; projectName = p.name; // create members @@ -456,5 +463,91 @@ describe('Project Phases', () => { }); }); }); + + describe('RabbitMQ Message topic', () => { + let createMessageSpy; + let publishSpy; + let sandbox; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(async (done) => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: project, + }, + }); + + testUtil.wait(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + createMessageSpy = sandbox.spy(messageService, 'createTopic'); + done(); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message topic when phase added', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .post(`/v4/projects/${projectId}/phases/`) + .set({ + Authorization: `Bearer ${testUtil.jwts.copilot}`, + }) + .send({ param: body }) + .expect('Content-Type', /json/) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.added').should.be.true; + createMessageSpy.calledOnce.should.be.true; + createMessageSpy.calledWith(sinon.match({ reference: 'project', + referenceId: '1', + tag: 'phase#1', + title: 'test project phase', + })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phases/delete.js b/src/routes/phases/delete.js index afa440e0..bbe270eb 100644 --- a/src/routes/phases/delete.js +++ b/src/routes/phases/delete.js @@ -3,7 +3,7 @@ import _ from 'lodash'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; -import { EVENT } from '../../constants'; +import { EVENT, TIMELINE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -40,7 +40,7 @@ module.exports = [ // Send events to buses req.app.services.pubsub.publish( EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, - deleted, + { deleted, route: TIMELINE_REFERENCES.PHASE }, { correlationId: req.id }, ); req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, { req, deleted }); @@ -49,4 +49,3 @@ module.exports = [ }).catch(err => next(err)); }, ]; - diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index bea3d2be..3e6f3e95 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -3,15 +3,20 @@ import _ from 'lodash'; import request from 'supertest'; import sinon from 'sinon'; import chai from 'chai'; +import config from 'config'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; - +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; import { BUS_API_EVENT, } from '../../constants'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const expectAfterDelete = (projectId, id, err, next) => { if (err) throw err; setTimeout(() => @@ -70,22 +75,32 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + const topic = { + id: 1, + title: 'test project phase', + posts: + [{ id: 1, + type: 'post', + body: 'body', + }], + }; beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { + models.Project.create(project).then((p) => { projectId = p.id; projectName = p.name; // create members @@ -268,5 +283,78 @@ describe('Project Phases', () => { }); }); }); + + describe('RabbitMQ Message topic', () => { + let deleteTopicSpy; + let deletePostsSpy; + let publishSpy; + let sandbox; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(async (done) => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: _.assign(project, { phases: [_.assign(body, { id: phaseId, projectId })] }), + }, + }); + + testUtil.wait(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + deleteTopicSpy = sandbox.spy(messageService, 'deleteTopic'); + deletePostsSpy = sandbox.spy(messageService, 'deletePosts'); + sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + done(); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message topic when phase deleted', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + delete: () => Promise.resolve(true), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .delete(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.firstCall.calledWith('project.phase.removed').should.be.true; + deleteTopicSpy.calledOnce.should.be.true; + deleteTopicSpy.calledWith(topic.id).should.be.true; + deletePostsSpy.calledWith(topic.id).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/phases/update.js b/src/routes/phases/update.js index d5588fc3..9fcfcfba 100644 --- a/src/routes/phases/update.js +++ b/src/routes/phases/update.js @@ -6,7 +6,7 @@ import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { EVENT, ROUTES } from '../../constants'; +import { EVENT, ROUTES, TIMELINE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -156,7 +156,7 @@ module.exports = [ // emit original and updated project phase information req.app.services.pubsub.publish( EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, - { original: previousValue, updated, allPhases }, + { original: previousValue, updated, allPhases, route: TIMELINE_REFERENCES.PHASE }, { correlationId: req.id }, ); req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, { diff --git a/src/routes/phases/update.spec.js b/src/routes/phases/update.spec.js index fc7544fc..06d7dff2 100644 --- a/src/routes/phases/update.spec.js +++ b/src/routes/phases/update.spec.js @@ -2,16 +2,21 @@ import _ from 'lodash'; import sinon from 'sinon'; import chai from 'chai'; +import config from 'config'; import request from 'supertest'; import server from '../../app'; import models from '../../models'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; - +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; import { BUS_API_EVENT, } from '../../constants'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const should = chai.should(); const body = { @@ -75,22 +80,32 @@ describe('Project Phases', () => { lastName: 'lName', email: 'some@abc.com', }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + const topic = { + id: 1, + title: 'test project phase', + posts: + [{ id: 1, + type: 'post', + body: 'body', + }], + }; beforeEach((done) => { // mocks testUtil.clearDb() .then(() => { - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }).then((p) => { + models.Project.create(project).then((p) => { projectId = p.id; projectName = p.name; // create members @@ -635,5 +650,91 @@ describe('Project Phases', () => { }); }); }); + + describe('RabbitMQ Message topic', () => { + let updateMessageSpy; + let publishSpy; + let sandbox; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(async (done) => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: _.assign(project, { phases: [_.assign(body, { id: phaseId, projectId })] }), + }, + }); + + testUtil.wait(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + updateMessageSpy = sandbox.spy(messageService, 'updateTopic'); + sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + done(); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message topic when phase Updated', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .patch(`/v4/projects/${projectId}/phases/${phaseId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign(updateBody, { budget: 123 }) }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.updated').should.be.true; + updateMessageSpy.calledOnce.should.be.true; + updateMessageSpy.calledWith(topic.id, sinon.match({ + title: updateBody.name, + postId: topic.posts[0].id, + content: topic.posts[0].body })).should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/works/create.js b/src/routes/works/create.js index 44be86a0..fc0184a5 100644 --- a/src/routes/works/create.js +++ b/src/routes/works/create.js @@ -8,7 +8,7 @@ import Sequelize from 'sequelize'; import models from '../../models'; import util from '../../util'; -import { EVENT } from '../../constants'; +import { EVENT, TIMELINE_REFERENCES } from '../../constants'; const permissions = require('tc-core-library-js').middleware.permissions; @@ -138,7 +138,7 @@ module.exports = [ // Send events to buses req.log.debug('Sending event to RabbitMQ bus for project phase %d', newProjectPhase.id); req.app.services.pubsub.publish(EVENT.ROUTING_KEY.PROJECT_PHASE_ADDED, - newProjectPhase, + { added: newProjectPhase, route: TIMELINE_REFERENCES.WORK }, { correlationId: req.id }, ); req.log.debug('Sending event to Kafka bus for project phase %d', newProjectPhase.id); diff --git a/src/routes/works/create.spec.js b/src/routes/works/create.spec.js index 259dde28..ab009a8c 100644 --- a/src/routes/works/create.spec.js +++ b/src/routes/works/create.spec.js @@ -7,13 +7,18 @@ import _ from 'lodash'; import chai from 'chai'; import sinon from 'sinon'; import request from 'supertest'; - +import config from 'config'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; import { BUS_API_EVENT } from '../../constants'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const should = chai.should(); const validatePhase = (resJson, expectedPhase) => { @@ -46,6 +51,18 @@ describe('CREATE work', () => { lastName: 'lName', email: 'some@abc.com', }; + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; beforeEach((done) => { testUtil.clearDb() @@ -82,22 +99,10 @@ describe('CREATE work', () => { }) .then(() => { // Create projects - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - templateId: template.id, - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }) - .then((project) => { - projectId = project.id; - projectName = project.name; + models.Project.create(_.assign(project, { templateId: template.id })) + .then((_project) => { + projectId = _project.id; + projectName = _project.name; models.WorkStream.create({ name: 'Work Stream', type: 'generic', @@ -328,5 +333,85 @@ describe('CREATE work', () => { }); }); }); + + describe('RabbitMQ Message topic', () => { + let createMessageSpy; + let publishSpy; + let sandbox; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(async (done) => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: project, + }, + }); + + testUtil.wait(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + createMessageSpy = sandbox.spy(messageService, 'createTopic'); + done(); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message topic when work added', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .post(`/v4/projects/${projectId}/workstreams/${workStreamId}/works`) + .set({ + Authorization: `Bearer ${testUtil.jwts.connectAdmin}`, + }) + .send(body) + .expect(201) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.added').should.be.true; + createMessageSpy.calledTwice.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/works/delete.js b/src/routes/works/delete.js index 3aa82f73..6102db20 100644 --- a/src/routes/works/delete.js +++ b/src/routes/works/delete.js @@ -5,7 +5,7 @@ import validate from 'express-validation'; import Joi from 'joi'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; -import { EVENT } from '../../constants'; +import { EVENT, TIMELINE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -62,7 +62,7 @@ module.exports = [ // Send events to buses req.app.services.pubsub.publish( EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, - deleted, + { deleted, route: TIMELINE_REFERENCES.WORK }, { correlationId: req.id }, ); req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_REMOVED, { req, deleted }); diff --git a/src/routes/works/delete.spec.js b/src/routes/works/delete.spec.js index 3eb55dfb..c1c9de33 100644 --- a/src/routes/works/delete.spec.js +++ b/src/routes/works/delete.spec.js @@ -2,16 +2,22 @@ /** * Tests for delete.js */ +import _ from 'lodash'; import request from 'supertest'; import chai from 'chai'; import sinon from 'sinon'; - +import config from 'config'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; import { BUS_API_EVENT } from '../../constants'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + chai.should(); const expectAfterDelete = (workId, projectId, workStreamId, err, next) => { @@ -60,7 +66,27 @@ describe('DELETE work', () => { lastName: 'lName', email: 'some@abc.com', }; - + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + const topic = { + id: 1, + title: 'test project phase', + posts: + [{ id: 1, + type: 'post', + body: 'body', + }], + }; beforeEach((done) => { testUtil.clearDb() .then(() => { @@ -96,22 +122,10 @@ describe('DELETE work', () => { }) .then(() => { // Create projects - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - templateId: template.id, - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }) - .then((project) => { - projectId = project.id; - projectName = project.name; + models.Project.create(_.assign(project, { templateId: template.id })) + .then((_project) => { + projectId = _project.id; + projectName = _project.name; models.WorkStream.create({ name: 'Work Stream', type: 'generic', @@ -290,5 +304,90 @@ describe('DELETE work', () => { }); }); }); + + describe('RabbitMQ Message topic', () => { + let deleteTopicSpy; + let deletePostsSpy; + let publishSpy; + let sandbox; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(async (done) => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: _.assign(project, { phases: [_.assign({ + name: 'test project phase', + status: 'active', + startDate: '2018-05-15T00:00:00Z', + endDate: '2018-05-15T12:00:00Z', + budget: 20.0, + progress: 1.23456, + details: { + message: 'This can be any json', + }, + createdBy: 1, + updatedBy: 1, + projectId, + }, { id: workId, projectId })] }), + }, + }); + + testUtil.wait(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + deleteTopicSpy = sandbox.spy(messageService, 'deleteTopic'); + deletePostsSpy = sandbox.spy(messageService, 'deletePosts'); + sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + done(); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message topic when work deleted', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + delete: () => Promise.resolve(true), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .delete(`/v4/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .expect(204) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.removed').should.be.true; + deleteTopicSpy.calledTwice.should.be.true; + deletePostsSpy.calledTwice.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/routes/works/update.js b/src/routes/works/update.js index d828fdb6..82aaa08c 100644 --- a/src/routes/works/update.js +++ b/src/routes/works/update.js @@ -8,7 +8,7 @@ import Sequelize from 'sequelize'; import { middleware as tcMiddleware } from 'tc-core-library-js'; import models from '../../models'; import util from '../../util'; -import { EVENT, ROUTES } from '../../constants'; +import { EVENT, ROUTES, TIMELINE_REFERENCES } from '../../constants'; const permissions = tcMiddleware.permissions; @@ -175,7 +175,7 @@ module.exports = [ // emit original and updated project phase information req.app.services.pubsub.publish( EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, - { original: previousValue, updated, allPhases }, + { original: previousValue, updated, allPhases, route: TIMELINE_REFERENCES.WORK }, { correlationId: req.id }, ); req.app.emit(EVENT.ROUTING_KEY.PROJECT_PHASE_UPDATED, { diff --git a/src/routes/works/update.spec.js b/src/routes/works/update.spec.js index 56dbe231..8932f442 100644 --- a/src/routes/works/update.spec.js +++ b/src/routes/works/update.spec.js @@ -6,13 +6,18 @@ import _ from 'lodash'; import chai from 'chai'; import request from 'supertest'; import sinon from 'sinon'; - +import config from 'config'; import models from '../../models'; import server from '../../app'; import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; +import messageService from '../../services/messageService'; +import RabbitMQService from '../../services/rabbitmq'; import { BUS_API_EVENT } from '../../constants'; +const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); +const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); + const should = chai.should(); const body = { @@ -78,7 +83,27 @@ describe('UPDATE work', () => { lastName: 'lName', email: 'some@abc.com', }; - + const project = { + type: 'generic', + billingAccountId: 1, + name: 'test1', + description: 'test project1', + status: 'draft', + details: {}, + createdBy: 1, + updatedBy: 1, + lastActivityAt: 1, + lastActivityUserId: '1', + }; + const topic = { + id: 1, + title: 'test project phase', + posts: + [{ id: 1, + type: 'post', + body: 'body', + }], + }; beforeEach((done) => { testUtil.clearDb() .then(() => { @@ -114,22 +139,10 @@ describe('UPDATE work', () => { }) .then(() => { // Create projects - models.Project.create({ - type: 'generic', - billingAccountId: 1, - name: 'test1', - description: 'test project1', - status: 'draft', - templateId: template.id, - details: {}, - createdBy: 1, - updatedBy: 1, - lastActivityAt: 1, - lastActivityUserId: '1', - }) - .then((project) => { - projectId = project.id; - projectName = project.name; + models.Project.create(_.assign(project, { templateId: template.id })) + .then((_project) => { + projectId = _project.id; + projectName = _project.name; models.WorkStream.create({ name: 'Work Stream', type: 'generic', @@ -632,5 +645,87 @@ describe('UPDATE work', () => { }); }); }); + + describe('RabbitMQ Message topic', () => { + let updateMessageSpy; + let publishSpy; + let sandbox; + + before((done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + testUtil.wait(done); + }); + + beforeEach(async (done) => { + sandbox = sinon.sandbox.create(); + server.services.pubsub = new RabbitMQService(server.logger); + + // initialize RabbitMQ + server.services.pubsub.init( + config.get('rabbitmqURL'), + config.get('pubsubExchangeName'), + config.get('pubsubQueueName'), + ); + + // add project to ES index + await server.services.es.index({ + index: ES_PROJECT_INDEX, + type: ES_PROJECT_TYPE, + id: projectId, + body: { + doc: _.assign(project, { phases: [_.assign(body, { id: workId, projectId })] }), + }, + }); + + testUtil.wait(() => { + publishSpy = sandbox.spy(server.services.pubsub, 'publish'); + updateMessageSpy = sandbox.spy(messageService, 'updateTopic'); + sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + done(); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it('should send message topic when work updated', (done) => { + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: {}, + }, + }, + }), + }); + sandbox.stub(messageService, 'getClient', () => mockHttpClient); + request(server) + .patch(`/v4/projects/${projectId}/workstreams/${workStreamId}/works/${workId}`) + .set({ + Authorization: `Bearer ${testUtil.jwts.admin}`, + }) + .send({ param: _.assign(updateBody, { budget: 123 }) }) + .expect('Content-Type', /json/) + .expect(200) + .end((err) => { + if (err) { + done(err); + } else { + testUtil.wait(() => { + publishSpy.calledOnce.should.be.true; + publishSpy.calledWith('project.phase.updated').should.be.true; + updateMessageSpy.calledTwice.should.be.true; + done(); + }); + } + }); + }); + }); }); }); diff --git a/src/services/messageService.js b/src/services/messageService.js index 949c0134..db9ef140 100644 --- a/src/services/messageService.js +++ b/src/services/messageService.js @@ -1,6 +1,5 @@ import config from 'config'; import _ from 'lodash'; -// import util from '../util'; const Promise = require('bluebird'); const axios = require('axios'); @@ -141,15 +140,17 @@ function deletePosts(topicId, postIds, logger) { * Fetches the topic of given phase of the project. * * @param {Integer} projectId id of the project - * @param {Integer} phaseId id of the phase of the project + * @param {String} tag tag * @param {Object} logger object * @return {Promise} topic promise */ -function getPhaseTopic(projectId, phaseId, logger) { +function getPhaseTopic(projectId, tag, logger) { + const regex = new RegExp(/(\d+)/); + const phaseId = regex.exec(tag)[0]; logger.debug(`getPhaseTopic for projectId: ${projectId} phaseId: ${phaseId}`); return getClient(logger).then((msgClient) => { - logger.debug(`calling message service for fetching phaseId#${phaseId}`); - const encodedFilter = encodeURIComponent(`reference=project&referenceId=${projectId}&tag=phase#${phaseId}`); + logger.debug(`calling message service for fetching ${tag}`); + const encodedFilter = encodeURIComponent(`reference=project&referenceId=${projectId}&tag=${tag}`); return msgClient.get(`/topics/list/db?filter=${encodedFilter}`) .then((resp) => { logger.debug('Fetched phase topic', resp); @@ -183,4 +184,5 @@ module.exports = { deletePosts, getPhaseTopic, deleteTopic, + getClient, }; From 60dc97cc0f4a3d427547a6bbe79a25337dbc87f3 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Fri, 16 Aug 2019 18:05:42 +0800 Subject: [PATCH 2/5] fixed comments and method naming after f2f challenge for create new topics functionality --- src/events/projectPhases/index.js | 118 ++++++++++++++++++------------ src/services/messageService.js | 10 +-- 2 files changed, 76 insertions(+), 52 deletions(-) diff --git a/src/events/projectPhases/index.js b/src/events/projectPhases/index.js index 6e74d270..e8e33379 100644 --- a/src/events/projectPhases/index.js +++ b/src/events/projectPhases/index.js @@ -17,26 +17,35 @@ const ES_PROJECT_TYPE = config.get('elasticsearchConfig.docType'); const eClient = util.getElasticSearchClient(); /** - * Get tags based on route parameter. + * Build topics data based on route parameter. * * @param {Object} logger logger to log along with trace id - * @param {Object} phase event payload + * @param {Object} phase phase object * @param {String} route route value can be PHASE/WORK * @returns {undefined} */ -const getTags = function (logger, phase, route) { // eslint-disable-line func-names +const buildTopicsData = (logger, phase, route) => { if (route === TIMELINE_REFERENCES.WORK) { return [{ tag: `work#${phase.id}-details`, title: `${phase.name} - Details`, + reference: 'project', + referenceId: `${phase.projectId}`, + body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line }, { tag: `work#${phase.id}-requirements`, title: `${phase.name} - Requirements`, + reference: 'project', + referenceId: `${phase.projectId}`, + body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line }]; } return [{ tag: `phase#${phase.id}`, title: phase.name, + reference: 'project', + referenceId: `${phase.projectId}`, + body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line }]; }; @@ -85,29 +94,22 @@ const indexProjectPhase = Promise.coroutine(function* (logger, phase) { // eslin }); /** - * Creates a new phase topic in message api. + * Creates topics in message api * * @param {Object} logger logger to log along with trace id - * @param {Object} msg event payload - * @param {String} route route value can be PHASE/WORK + * @param {Object} phase phase object + * @param {String} route route value can be `phase`/`work` * @returns {undefined} */ -const createPhaseTopic = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names +const createTopics = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names try { - logger.debug('Creating topics for phase with phase', phase); - const tags = getTags(logger, phase, route); - const topics = yield Promise.all(_.map(tags, tag => messageService.createTopic({ - reference: 'project', - referenceId: `${phase.projectId}`, - tag: tag.tag, - title: tag.title, - body: 'This is the beginning of your phase discussion. During execution of this phase, all related communication will be conducted here - phase updates, questions and answers, suggestions, etc. If you haven\'t already, do please take a moment to review the form in the Specification tab above and fill in as much detail as possible. This will help get started faster. Thanks!', // eslint-disable-line - }, logger))); - - logger.debug('topics for the phase created successfully'); + logger.debug(`Creating topics for ${route} with phase`, phase); + const topicsData = buildTopicsData(logger, phase, route); + const topics = yield Promise.all(_.map(topicsData, topicData => messageService.createTopic(topicData, logger))); + logger.debug(`topics for the ${route} created successfully`); logger.debug('created topics', topics); } catch (error) { - logger.error('Error in creating topic for the project phase', error); + logger.error(`Error in creating topic for ${route}`, error); // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase // we can create topic for a phase manually, if somehow it fails } @@ -128,7 +130,7 @@ const projectPhaseAddedHandler = Promise.coroutine(function* (logger, msg, chann logger.debug('calling indexProjectPhase', phase); yield indexProjectPhase(logger, phase, channel); logger.debug('calling createPhaseTopic', phase); - yield createPhaseTopic(logger, phase, route); + yield createTopics(logger, phase, route); channel.ack(msg); } catch (error) { logger.error('Error handling project.phase.added event', error); @@ -166,16 +168,17 @@ const updateIndexProjectPhase = Promise.coroutine(function* (logger, data) { // }); /** - * Indexes the project phase in the elastic search. + * Update one topic * - * @param {Object} logger logger to log along with trace id - * @param {Object} data event payload + * @param {Object} logger logger to log along with trace id + * @param {Object} phase phase object + * @param {Object} topicUpdate updated topic data * @returns {undefined} */ -const updateTopicWithTag = Promise.coroutine(function* (logger, phase, tag) { // eslint-disable-line func-names - const topic = yield messageService.getPhaseTopic(phase.projectId, tag.tag, logger); +const updateOneTopic = Promise.coroutine(function* (logger, phase, topicUpdate) { // eslint-disable-line func-names + const topic = yield messageService.getTopicByTag(phase.projectId, topicUpdate.tag, logger); logger.trace('Topic', topic); - const title = tag.title; + const title = topicUpdate.title; const titleChanged = topic && topic.title !== title; logger.trace('titleChanged', titleChanged); const contentPost = topic && topic.posts && topic.posts.length > 0 ? topic.posts[0] : null; @@ -184,25 +187,27 @@ const updateTopicWithTag = Promise.coroutine(function* (logger, phase, tag) { // const content = _.get(contentPost, 'body'); if (postId && content && titleChanged) { const updatedTopic = yield messageService.updateTopic(topic.id, { title, postId, content }, logger); - logger.debug('topic for the phase updated successfully'); + logger.debug('topic updated successfully'); logger.trace('updated topic', updatedTopic); } }); /** - * Creates a new phase topic in message api. + * Update topics in message api. * * @param {Object} logger logger to log along with trace id - * @param {Object} msg event payload + * @param {Object} phase phase object + * @param {String} route route value can be `phase`/`work` * @returns {undefined} */ -const updatePhaseTopic = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names +const updateTopics = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names try { - logger.debug('Updating topic for phase with phase', phase); - const tags = getTags(logger, phase, route); - yield Promise.all(_.map(tags, tag => updateTopicWithTag(logger, phase, tag))); + logger.debug(`Updating topic for ${route} with phase`, phase); + const topicsData = buildTopicsData(logger, phase, route); + yield Promise.all(_.map(topicsData, topicData => updateOneTopic(logger, phase, topicData))); + logger.debug(`topics for the ${route} updated successfully`); } catch (error) { - logger.error('Error in updating topic for the project phase', error); + logger.error(`Error in updating topic for ${route}`, error); // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase // we can create topic for a phase manually, if somehow it fails } @@ -221,8 +226,8 @@ const projectPhaseUpdatedHandler = Promise.coroutine(function* (logger, msg, cha const route = _.get(data, 'route', 'PHASE'); logger.debug('calling updateIndexProjectPhase', data); yield updateIndexProjectPhase(logger, data, channel); - logger.debug('calling updatePhaseTopic', data.updated); - yield updatePhaseTopic(logger, data.updated, route); + logger.debug('calling updateTopics', data.updated); + yield updateTopics(logger, data.updated, route); channel.ack(msg); } catch (error) { logger.error('Error handling project.phase.updated event', error); @@ -262,25 +267,46 @@ const removePhaseFromIndex = Promise.coroutine(function* (logger, msg) { // esli }); /** - * Removes the phase topic from the message api. + * Removes one topic from the message api. * - * @param {Object} logger logger to log along with trace id - * @param {Object} msg event payload + * @param {Object} logger logger to log along with trace id + * @param {Object} phase phase object + * @param {Object} tag topic tag * @returns {undefined} */ -const removePhaseTopic = Promise.coroutine(function* (logger, phase, tag) { // eslint-disable-line func-names +const removeOneTopic = Promise.coroutine(function* (logger, phase, tag) { // eslint-disable-line func-names try { - const phaseTopic = yield messageService.getPhaseTopic(phase.projectId, tag, logger); + const phaseTopic = yield messageService.getTopicByTag(phase.projectId, tag, logger); yield messageService.deletePosts(phaseTopic.id, phaseTopic.postIds, logger); yield messageService.deleteTopic(phaseTopic.id, logger); - logger.debug('topic for the phase removed successfully'); } catch (error) { - logger.error('Error in removing topic for the project phase', error); + logger.error(`Error removing topic by tab ${tag}`, error); // don't throw the error back to nack the bus // we can delete topic for a phase manually, if somehow it fails } }); +/** + * Remove topics in message api. + * + * @param {Object} logger logger to log along with trace id + * @param {Object} phase phase object + * @param {String} route route value can be `phase`/`work` + * @returns {undefined} + */ +const removeTopics = Promise.coroutine(function* (logger, phase, route) { // eslint-disable-line func-names + try { + logger.debug(`Removing topic for ${route} with phase`, phase); + const topicsData = buildTopicsData(logger, phase, route); + yield Promise.all(_.map(topicsData, topicData => removeOneTopic(logger, phase, topicData.tag))); + logger.debug(`topics for the ${route} removed successfully`); + } catch (error) { + logger.error(`Error in removing topic for ${route}`, error); + // don't throw the error back to nack the bus, because we don't want to get multiple topics per phase + // we can create topic for a phase manually, if somehow it fails + } +}); + /** * Handler for project phase deleted event * @param {Object} logger logger to log along with trace id @@ -294,8 +320,8 @@ const projectPhaseRemovedHandler = Promise.coroutine(function* (logger, msg, cha const data = JSON.parse(msg.content.toString()); const phase = _.get(data, 'deleted', {}); const route = _.get(data, 'route'); - const tags = getTags(logger, phase, route); - yield Promise.all(_.map(tags, tag => removePhaseTopic(logger, phase, tag.tag))); + logger.debug('calling removeTopics'); + yield removeTopics(logger, phase, route); channel.ack(msg); } catch (error) { logger.error('Error fetching project document from elasticsearch', error); @@ -309,5 +335,5 @@ module.exports = { projectPhaseAddedHandler, projectPhaseRemovedHandler, projectPhaseUpdatedHandler, - createPhaseTopic, + createPhaseTopic: createTopics, }; diff --git a/src/services/messageService.js b/src/services/messageService.js index db9ef140..c596dddf 100644 --- a/src/services/messageService.js +++ b/src/services/messageService.js @@ -144,17 +144,15 @@ function deletePosts(topicId, postIds, logger) { * @param {Object} logger object * @return {Promise} topic promise */ -function getPhaseTopic(projectId, tag, logger) { - const regex = new RegExp(/(\d+)/); - const phaseId = regex.exec(tag)[0]; - logger.debug(`getPhaseTopic for projectId: ${projectId} phaseId: ${phaseId}`); +function getTopicByTag(projectId, tag, logger) { + logger.debug(`getTopicByTag for projectId: ${projectId} tag: ${tag}`); return getClient(logger).then((msgClient) => { logger.debug(`calling message service for fetching ${tag}`); const encodedFilter = encodeURIComponent(`reference=project&referenceId=${projectId}&tag=${tag}`); return msgClient.get(`/topics/list/db?filter=${encodedFilter}`) .then((resp) => { - logger.debug('Fetched phase topic', resp); const topics = _.get(resp.data, 'result.content', []); + logger.debug(`Fetched ${topics.length} topics`); if (topics && topics.length > 0) { return topics[0]; } @@ -182,7 +180,7 @@ module.exports = { createTopic, updateTopic, deletePosts, - getPhaseTopic, + getTopicByTag, deleteTopic, getClient, }; From 23e9442dd95c4a6cafa5c24ecab97b788498fa9d Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Sun, 18 Aug 2019 16:36:02 +0800 Subject: [PATCH 3/5] fix: unit test failed due to not restoring RabbitMQ mock after we need them work for real in new tests --- .eslintrc | 2 +- src/routes/phases/create.spec.js | 2 ++ src/routes/phases/delete.spec.js | 4 +++- src/routes/phases/update.spec.js | 4 +++- src/routes/works/create.spec.js | 2 ++ src/routes/works/delete.spec.js | 4 +++- src/routes/works/update.spec.js | 4 +++- src/tests/mockRabbitMQ.js | 18 ++++++++++++++++++ src/tests/serviceMocks.js | 9 +++------ 9 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 src/tests/mockRabbitMQ.js diff --git a/.eslintrc b/.eslintrc index b0115e3f..00dbe70b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,7 +8,7 @@ "mocha": true }, "rules": { - "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js", "**/*.spec.js", "**/serviceMocks.js"]}], + "import/no-extraneous-dependencies": ["error", {"devDependencies": ["**/*.test.js", "**/*.spec.js", "src/tests/*.js"]}], "max-len": ["error", { "ignoreComments": true, "code": 120 }], "valid-jsdoc": ["error", { "requireReturn": true, diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js index c9ad3387..e03dc6f3 100644 --- a/src/routes/phases/create.spec.js +++ b/src/routes/phases/create.spec.js @@ -10,6 +10,7 @@ import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; import messageService from '../../services/messageService'; import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT, } from '../../constants'; @@ -504,6 +505,7 @@ describe('Project Phases', () => { afterEach(() => { sandbox.restore(); + mockRabbitMQ(server); }); it('should send message topic when phase added', (done) => { diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index 3e6f3e95..297e056c 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -10,6 +10,7 @@ import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; import messageService from '../../services/messageService'; import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT, } from '../../constants'; @@ -320,13 +321,14 @@ describe('Project Phases', () => { publishSpy = sandbox.spy(server.services.pubsub, 'publish'); deleteTopicSpy = sandbox.spy(messageService, 'deleteTopic'); deletePostsSpy = sandbox.spy(messageService, 'deletePosts'); - sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + sandbox.stub(messageService, 'getTopicByTag', () => Promise.resolve(topic)); done(); }); }); afterEach(() => { sandbox.restore(); + mockRabbitMQ(server); }); it('should send message topic when phase deleted', (done) => { diff --git a/src/routes/phases/update.spec.js b/src/routes/phases/update.spec.js index 06d7dff2..3697f7d4 100644 --- a/src/routes/phases/update.spec.js +++ b/src/routes/phases/update.spec.js @@ -10,6 +10,7 @@ import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; import messageService from '../../services/messageService'; import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT, } from '../../constants'; @@ -685,13 +686,14 @@ describe('Project Phases', () => { testUtil.wait(() => { publishSpy = sandbox.spy(server.services.pubsub, 'publish'); updateMessageSpy = sandbox.spy(messageService, 'updateTopic'); - sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + sandbox.stub(messageService, 'getTopicByTag', () => Promise.resolve(topic)); done(); }); }); afterEach(() => { sandbox.restore(); + mockRabbitMQ(server); }); it('should send message topic when phase Updated', (done) => { diff --git a/src/routes/works/create.spec.js b/src/routes/works/create.spec.js index ab009a8c..55900f6c 100644 --- a/src/routes/works/create.spec.js +++ b/src/routes/works/create.spec.js @@ -14,6 +14,7 @@ import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; import messageService from '../../services/messageService'; import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT } from '../../constants'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -374,6 +375,7 @@ describe('CREATE work', () => { afterEach(() => { sandbox.restore(); + mockRabbitMQ(server); }); it('should send message topic when work added', (done) => { diff --git a/src/routes/works/delete.spec.js b/src/routes/works/delete.spec.js index c1c9de33..c23a7999 100644 --- a/src/routes/works/delete.spec.js +++ b/src/routes/works/delete.spec.js @@ -13,6 +13,7 @@ import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; import messageService from '../../services/messageService'; import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT } from '../../constants'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -354,13 +355,14 @@ describe('DELETE work', () => { publishSpy = sandbox.spy(server.services.pubsub, 'publish'); deleteTopicSpy = sandbox.spy(messageService, 'deleteTopic'); deletePostsSpy = sandbox.spy(messageService, 'deletePosts'); - sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + sandbox.stub(messageService, 'getTopicByTag', () => Promise.resolve(topic)); done(); }); }); afterEach(() => { sandbox.restore(); + mockRabbitMQ(server); }); it('should send message topic when work deleted', (done) => { diff --git a/src/routes/works/update.spec.js b/src/routes/works/update.spec.js index 8932f442..7335d527 100644 --- a/src/routes/works/update.spec.js +++ b/src/routes/works/update.spec.js @@ -13,6 +13,7 @@ import testUtil from '../../tests/util'; import busApi from '../../services/busApi'; import messageService from '../../services/messageService'; import RabbitMQService from '../../services/rabbitmq'; +import mockRabbitMQ from '../../tests/mockRabbitMQ'; import { BUS_API_EVENT } from '../../constants'; const ES_PROJECT_INDEX = config.get('elasticsearchConfig.indexName'); @@ -680,13 +681,14 @@ describe('UPDATE work', () => { testUtil.wait(() => { publishSpy = sandbox.spy(server.services.pubsub, 'publish'); updateMessageSpy = sandbox.spy(messageService, 'updateTopic'); - sandbox.stub(messageService, 'getPhaseTopic', () => Promise.resolve(topic)); + sandbox.stub(messageService, 'getTopicByTag', () => Promise.resolve(topic)); done(); }); }); afterEach(() => { sandbox.restore(); + mockRabbitMQ(server); }); it('should send message topic when work updated', (done) => { diff --git a/src/tests/mockRabbitMQ.js b/src/tests/mockRabbitMQ.js new file mode 100644 index 00000000..3913bd73 --- /dev/null +++ b/src/tests/mockRabbitMQ.js @@ -0,0 +1,18 @@ +/** + * Mock RabbitMQ service + */ +/* globals Promise */ + +import sinon from 'sinon'; +import _ from 'lodash'; + +module.exports = (app) => { + _.assign(app.services, { + pubsub: { + init: () => {}, + publish: () => {}, + }, + }); + sinon.stub(app.services.pubsub, 'init', () => Promise.resolve(true)); + sinon.stub(app.services.pubsub, 'publish', () => Promise.resolve(true)); +}; diff --git a/src/tests/serviceMocks.js b/src/tests/serviceMocks.js index 662bd2c4..060596df 100644 --- a/src/tests/serviceMocks.js +++ b/src/tests/serviceMocks.js @@ -7,16 +7,13 @@ import _ from 'lodash'; import config from 'config'; import elasticsearch from 'elasticsearch'; import util from '../util'; +import mockRabbitMQ from './mockRabbitMQ'; module.exports = (app) => { + mockRabbitMQ(app); + _.assign(app.services, { - pubsub: { - init: () => {}, - publish: () => {}, - }, es: new elasticsearch.Client(_.cloneDeep(config.elasticsearchConfig)), }); - sinon.stub(app.services.pubsub, 'init', () => Promise.resolve(true)); - sinon.stub(app.services.pubsub, 'publish', () => Promise.resolve(true)); sinon.stub(util, 'getM2MToken', () => Promise.resolve('MOCK_TOKEN')); }; From 6e1219df111b48f7d55f018cce69cb17bc08228f Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 19 Aug 2019 09:55:55 +0800 Subject: [PATCH 4/5] fix: some unit tests quite randomly fail most likely because of mocking/unmocking RabbitMQ --- src/routes/phases/create.spec.js | 7 +++++-- src/routes/phases/delete.spec.js | 5 ++++- src/routes/phases/update.spec.js | 5 ++++- src/routes/works/create.spec.js | 7 +++++-- src/routes/works/delete.spec.js | 5 ++++- src/routes/works/update.spec.js | 5 ++++- 6 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/routes/phases/create.spec.js b/src/routes/phases/create.spec.js index e03dc6f3..9b587ed6 100644 --- a/src/routes/phases/create.spec.js +++ b/src/routes/phases/create.spec.js @@ -470,8 +470,8 @@ describe('Project Phases', () => { let publishSpy; let sandbox; - before((done) => { - // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + before(async (done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete testUtil.wait(done); }); @@ -505,6 +505,9 @@ describe('Project Phases', () => { afterEach(() => { sandbox.restore(); + }); + + after(() => { mockRabbitMQ(server); }); diff --git a/src/routes/phases/delete.spec.js b/src/routes/phases/delete.spec.js index 297e056c..b6a0f042 100644 --- a/src/routes/phases/delete.spec.js +++ b/src/routes/phases/delete.spec.js @@ -291,7 +291,7 @@ describe('Project Phases', () => { let publishSpy; let sandbox; - before((done) => { + before(async (done) => { // Wait for 500ms in order to wait for createEvent calls from previous tests to complete testUtil.wait(done); }); @@ -328,6 +328,9 @@ describe('Project Phases', () => { afterEach(() => { sandbox.restore(); + }); + + after(() => { mockRabbitMQ(server); }); diff --git a/src/routes/phases/update.spec.js b/src/routes/phases/update.spec.js index 3697f7d4..2db36e68 100644 --- a/src/routes/phases/update.spec.js +++ b/src/routes/phases/update.spec.js @@ -657,7 +657,7 @@ describe('Project Phases', () => { let publishSpy; let sandbox; - before((done) => { + before(async (done) => { // Wait for 500ms in order to wait for createEvent calls from previous tests to complete testUtil.wait(done); }); @@ -693,6 +693,9 @@ describe('Project Phases', () => { afterEach(() => { sandbox.restore(); + }); + + after(() => { mockRabbitMQ(server); }); diff --git a/src/routes/works/create.spec.js b/src/routes/works/create.spec.js index 55900f6c..e2061431 100644 --- a/src/routes/works/create.spec.js +++ b/src/routes/works/create.spec.js @@ -340,8 +340,8 @@ describe('CREATE work', () => { let publishSpy; let sandbox; - before((done) => { - // Wait for 500ms in order to wait for createEvent calls from previous tests to complete + before(async (done) => { + // Wait for 500ms in order to wait for createEvent calls from previous tests to complete testUtil.wait(done); }); @@ -375,6 +375,9 @@ describe('CREATE work', () => { afterEach(() => { sandbox.restore(); + }); + + after(() => { mockRabbitMQ(server); }); diff --git a/src/routes/works/delete.spec.js b/src/routes/works/delete.spec.js index c23a7999..cffe9034 100644 --- a/src/routes/works/delete.spec.js +++ b/src/routes/works/delete.spec.js @@ -312,7 +312,7 @@ describe('DELETE work', () => { let publishSpy; let sandbox; - before((done) => { + before(async (done) => { // Wait for 500ms in order to wait for createEvent calls from previous tests to complete testUtil.wait(done); }); @@ -362,6 +362,9 @@ describe('DELETE work', () => { afterEach(() => { sandbox.restore(); + }); + + after(() => { mockRabbitMQ(server); }); diff --git a/src/routes/works/update.spec.js b/src/routes/works/update.spec.js index 7335d527..5fd1014a 100644 --- a/src/routes/works/update.spec.js +++ b/src/routes/works/update.spec.js @@ -652,7 +652,7 @@ describe('UPDATE work', () => { let publishSpy; let sandbox; - before((done) => { + before(async (done) => { // Wait for 500ms in order to wait for createEvent calls from previous tests to complete testUtil.wait(done); }); @@ -688,6 +688,9 @@ describe('UPDATE work', () => { afterEach(() => { sandbox.restore(); + }); + + after(() => { mockRabbitMQ(server); }); From 214f2d3c09ca228eff31fb36d87ad36578effb28 Mon Sep 17 00:00:00 2001 From: Maksym Mykhailenko Date: Mon, 19 Aug 2019 10:27:58 +0800 Subject: [PATCH 5/5] fix: added rabbitmq image to circle config for "test" job --- .circleci/config.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index df0c2ae9..84f1cf7c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -48,6 +48,7 @@ jobs: - POSTGRES_USER: circle_test - POSTGRES_DB: circle_test - image: elasticsearch:2.3 + - image: rabbitmq:3-management environment: DB_MASTER_URL: postgres://circle_test:@127.0.0.1:5432/circle_test AUTH_SECRET: secret