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, };